diff --git a/Dockerfile b/Dockerfile index 2cf8b9294..63562e2ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,10 @@ WORKDIR /opt/netbox ARG BRANCH=master ARG URL=https://github.com/digitalocean/netbox.git RUN git clone --depth 1 $URL -b $BRANCH . && \ - pip install gunicorn==17.5 && pip install -r requirements.txt + apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \ + pip install gunicorn==17.5 && \ + pip install django-auth-ldap && \ + pip install -r requirements.txt ADD docker/docker-entrypoint.sh /docker-entrypoint.sh ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index d6c863983..9d69af40a 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -35,6 +35,8 @@ Each export template is associated with a certain type of object. For instance, Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop. +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. ## Example diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index f719290ed..152588c5a 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -5,6 +5,8 @@ from django.db.models import Q from dcim.models import Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter + from .models import Provider, Circuit, CircuitType @@ -64,12 +66,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Circuit type (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 0cb7a3016..f3c210dc2 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -6,7 +6,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField, + APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, + SlugField, ) from .models import Circuit, CircuitType, Provider @@ -57,15 +58,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def provider_site_choices(): - site_choices = Site.objects.all() - return [(s.slug, s.name) for s in site_choices] - - class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider - site = forms.MultipleChoiceField(required=False, choices=provider_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug') # @@ -189,32 +184,12 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def circuit_type_choices(): - type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits')) - return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices] - - -def circuit_provider_choices(): - provider_choices = Provider.objects.annotate(circuit_count=Count('circuits')) - return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices] - - -def circuit_tenant_choices(): - tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits')) - return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices] - - -def circuit_site_choices(): - site_choices = Site.objects.annotate(circuit_count=Count('circuits')) - return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices] - - class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit - type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) - provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug') + provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug') + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', + null_option=(0, 'None')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug') diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index eac47445e..65831a974 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, @@ -15,12 +16,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -75,34 +76,34 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = NullableModelMultipleChoiceFilter( name='group', queryset=RackGroup.objects.all(), label='Group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( + group = NullableModelMultipleChoiceFilter( name='group', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = NullableModelMultipleChoiceFilter( name='role', queryset=RackRole.objects.all(), label='Role (ID)', ) - role = django_filters.ModelMultipleChoiceFilter( + role = NullableModelMultipleChoiceFilter( name='role', queryset=RackRole.objects.all(), to_field_name='slug', @@ -177,12 +178,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -210,12 +211,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Device model (slug)', ) - platform_id = django_filters.ModelMultipleChoiceFilter( + platform_id = NullableModelMultipleChoiceFilter( name='platform', queryset=Platform.objects.all(), label='Platform (ID)', ) - platform = django_filters.ModelMultipleChoiceFilter( + platform = NullableModelMultipleChoiceFilter( name='platform', queryset=Platform.objects.all(), to_field_name='slug', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8d2e48430..01fd8abb4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -9,7 +9,7 @@ from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, - FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, ) from .models import ( @@ -117,15 +117,10 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') -def site_tenant_choices(): - tenant_choices = Tenant.objects.annotate(site_count=Count('sites')) - return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices] - - class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug', + null_option=(0, 'None')) # @@ -140,14 +135,8 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -def rackgroup_site_choices(): - site_choices = Site.objects.annotate(rack_count=Count('rack_groups')) - return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices] - - class RackGroupFilterForm(forms.Form, BootstrapMixin): - site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') # @@ -254,36 +243,15 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def rack_site_choices(): - site_choices = Site.objects.annotate(rack_count=Count('racks')) - return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices] - - -def rack_group_choices(): - group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) - return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices] - - -def rack_tenant_choices(): - tenant_choices = Tenant.objects.annotate(rack_count=Count('racks')) - return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices] - - -def rack_role_choices(): - role_choices = RackRole.objects.annotate(rack_count=Count('racks')) - return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices] - - class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack - site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - role = forms.MultipleChoiceField(required=False, choices=rack_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug') + group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site') + .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug', + null_option=(0, 'None')) + role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug', + null_option=(0, 'None')) # @@ -317,14 +285,9 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): u_height = forms.IntegerField(min_value=1, required=False) -def devicetype_manufacturer_choices(): - manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) - return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices] - - class DeviceTypeFilterForm(forms.Form, BootstrapMixin): - manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), + to_field_name='slug') # @@ -627,49 +590,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): serial = forms.CharField(max_length=50, required=False, label='Serial Number') -def device_site_choices(): - site_choices = Site.objects.annotate(device_count=Count('racks__devices')) - return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices] - - -def device_rack_group_choices(): - group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices')) - return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices] - - -def device_role_choices(): - role_choices = DeviceRole.objects.annotate(device_count=Count('devices')) - return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices] - - -def device_tenant_choices(): - tenant_choices = Tenant.objects.annotate(device_count=Count('devices')) - return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices] - - -def device_type_choices(): - type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances')) - return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices] - - -def device_platform_choices(): - platform_choices = Platform.objects.annotate(device_count=Count('devices')) - return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices] - - class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device - site = forms.MultipleChoiceField(required=False, choices=device_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - role = forms.MultipleChoiceField(required=False, choices=device_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type', - widget=forms.SelectMultiple(attrs={'size': 8})) - platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug') + rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), + label='Rack Group') + role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug') + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', + null_option=(0, 'None')) + device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer') + .annotate(filter_count=Count('instances')), label='Type') + platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')), + to_field_name='slug', null_option=(0, 'None')) status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES)) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f3b2f4bf1..44b39573c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1399,7 +1399,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/interface_add_multi.html' default_redirect_url = 'dcim:device_list' - def update_objects(self, pk_list, form): + def update_objects(self, pk_list, form, fields): selected_devices = Device.objects.filter(pk__in=pk_list) interfaces = [] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d8ccbf986..bcd9f175f 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -2,7 +2,7 @@ import django_filters from django.contrib.contenttypes.models import ContentType -from .models import CustomField +from .models import CF_TYPE_SELECT, CustomField class CustomFieldFilter(django_filters.Filter): @@ -10,9 +10,22 @@ class CustomFieldFilter(django_filters.Filter): Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. """ + def __init__(self, cf_type, *args, **kwargs): + self.cf_type = cf_type + super(CustomFieldFilter, self).__init__(*args, **kwargs) + def filter(self, queryset, value): + # Skip filter on empty value if not value.strip(): return queryset + # Treat 0 as None for Select fields + try: + if self.cf_type == CF_TYPE_SELECT and int(value) == 0: + return queryset.exclude( + custom_field_values__field__name=self.name, + ) + except ValueError: + pass return queryset.filter( custom_field_values__field__name=self.name, custom_field_values__serialized_value=value, @@ -30,4 +43,4 @@ class CustomFieldFilterSet(django_filters.FilterSet): obj_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name) + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 8ec70d2e8..06c5403c6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -47,14 +47,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F # Select elif cf.type == CF_TYPE_SELECT: - if bulk_edit: - choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] - if not cf.required: - choices = [(0, 'None')] + choices + choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] + if not cf.required: + choices = [(0, 'None')] + choices + if bulk_edit or filterable_only: choices = [(None, '---------')] + choices - field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) - else: - field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required) + field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) # URL elif cf.type == CF_TYPE_URL: diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ce5b1d43f..4bc9f3550 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -67,7 +67,18 @@ ACTION_CHOICES = ( class CustomFieldModel(object): - def custom_fields(self): + def cf(self): + """ + Name-based CustomFieldValue accessor for use in templates + """ + if not hasattr(self, 'custom_fields'): + return dict() + return {field.name: value for field, value in self.custom_fields.items()} + + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ # Find all custom fields applicable to this type of object content_type = ContentType.objects.get_for_model(self) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index be2d127b5..a998bb2a0 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -7,6 +7,7 @@ from django.db.models import Q from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role @@ -21,12 +22,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): lookup_type='icontains', label='Name', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -85,29 +86,34 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search_by_parent', label='Parent prefix', ) - vrf = django_filters.MethodFilter( - action='_vrf', + vrf_id = NullableModelMultipleChoiceFilter( + name='vrf_id', + queryset=VRF.objects.all(), label='VRF', ) - # Duplicate of `vrf` for backward-compatibility - vrf_id = django_filters.MethodFilter( - action='_vrf', - label='VRF', + vrf = NullableModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', ) - tenant_id = django_filters.MethodFilter( - action='_tenant_id', + tenant_id = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.MethodFilter( - action='_tenant', - label='Tenant', + tenant = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', ) - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( + site = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), to_field_name='slug', @@ -122,12 +128,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): name='vlan__vid', label='VLAN number (1-4095)', ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), label='Role (ID)', ) - role = django_filters.ModelMultipleChoiceFilter( + role = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), to_field_name='slug', @@ -136,7 +142,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Prefix - fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role'] + fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -157,17 +163,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _vrf(self, queryset, value): - if str(value) == '': - return queryset - try: - vrf_id = int(value) - except ValueError: - return queryset.none() - if vrf_id == 0: - return queryset.filter(vrf__isnull=True) - return queryset.filter(vrf__pk=value) - def _tenant(self, queryset, value): if str(value) == '': return queryset @@ -196,22 +191,27 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search_by_parent', label='Parent prefix', ) - vrf = django_filters.MethodFilter( - action='_vrf', + vrf_id = NullableModelMultipleChoiceFilter( + name='vrf_id', + queryset=VRF.objects.all(), label='VRF', ) - # Duplicate of `vrf` for backward-compatibility - vrf_id = django_filters.MethodFilter( - action='_vrf', - label='VRF', + vrf = NullableModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', ) - tenant_id = django_filters.MethodFilter( - action='_tenant_id', + tenant_id = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.MethodFilter( - action='_tenant', - label='Tenant', + tenant = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', ) device_id = django_filters.ModelMultipleChoiceFilter( name='interface__device', @@ -232,7 +232,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = IPAddress - fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id'] + fields = ['q', 'family', 'device_id', 'device', 'interface_id'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -253,35 +253,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _vrf(self, queryset, value): - if str(value) == '': - return queryset - try: - vrf_id = int(value) - except ValueError: - return queryset.none() - if vrf_id == 0: - return queryset.filter(vrf__isnull=True) - return queryset.filter(vrf__pk=value) - - def _tenant(self, queryset, value): - if str(value) == '': - return queryset - return queryset.filter( - Q(tenant__slug=value) | - Q(tenant__isnull=True, vrf__tenant__slug=value) - ) - - def _tenant_id(self, queryset, value): - try: - value = int(value) - except ValueError: - return queryset.none() - return queryset.filter( - Q(tenant__pk=value) | - Q(tenant__isnull=True, vrf__tenant__pk=value) - ) - class VLANGroupFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( @@ -317,12 +288,12 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = NullableModelMultipleChoiceFilter( name='group', queryset=VLANGroup.objects.all(), label='Group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( + group = NullableModelMultipleChoiceFilter( name='group', queryset=VLANGroup.objects.all(), to_field_name='slug', @@ -337,23 +308,23 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): name='vid', label='VLAN number (1-4095)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), label='Role (ID)', ) - role = django_filters.ModelMultipleChoiceFilter( + role = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), to_field_name='slug', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 5e08ca854..6382895f8 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,7 +5,9 @@ from dcim.models import Site, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant -from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField +from utilities.forms import ( + APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, +) from .models import ( Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, @@ -69,15 +71,10 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def vrf_tenant_choices(): - tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs')) - return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] - - class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug', + null_option=(0, None)) # @@ -128,16 +125,11 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def aggregate_rir_choices(): - rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates')) - return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices] - - class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR', - widget=forms.SelectMultiple(attrs={'size': 8})) + rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug', + label='RIR') # @@ -268,21 +260,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def prefix_vrf_choices(): - vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes')) - return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices] - - -def tenant_choices(): - tenant_choices = Tenant.objects.all() - return [(t.slug, t.name) for t in tenant_choices] - - -def prefix_site_choices(): - site_choices = Site.objects.annotate(prefix_count=Count('prefixes')) - return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices] - - def prefix_status_choices(): status_counts = {} for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'): @@ -290,27 +267,21 @@ def prefix_status_choices(): return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES] -def prefix_role_choices(): - role_choices = Role.objects.annotate(prefix_count=Count('prefixes')) - return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices] - - class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Network', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF', - widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', - widget=forms.SelectMultiple(attrs={'size': 6})) - status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) - site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) - role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) + vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd', + label='VRF', null_option=(0, 'Global')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + null_option=(0, 'None')) + status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + null_option=(0, 'None')) + role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + null_option=(0, 'None')) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -441,21 +412,16 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def ipaddress_vrf_choices(): - vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses')) - return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices] - - class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF', - widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', - widget=forms.SelectMultiple(attrs={'size': 6})) + vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd', + label='VRF', null_option=(0, 'Global')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), + to_field_name='slug', null_option=(0, 'None')) # @@ -470,14 +436,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -def vlangroup_site_choices(): - site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups')) - return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices] - - class VLANGroupFilterForm(forms.Form, BootstrapMixin): - site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') # @@ -555,21 +515,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def vlan_site_choices(): - site_choices = Site.objects.annotate(vlan_count=Count('vlans')) - return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices] - - -def vlan_group_choices(): - group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) - return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices] - - -def vlan_tenant_choices(): - tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans')) - return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] - - def vlan_status_choices(): status_counts = {} for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): @@ -577,19 +522,13 @@ def vlan_status_choices(): return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES] -def vlan_role_choices(): - role_choices = Role.objects.annotate(vlan_count=Count('vlans')) - return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices] - - class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN - site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices) - role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug') + group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', + null_option=(0, 'None')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', + null_option=(0, 'None')) + status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) + role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', + null_option=(0, 'None')) diff --git a/netbox/ipam/migrations/0008_prefix_change_order.py b/netbox/ipam/migrations/0008_prefix_change_order.py new file mode 100644 index 000000000..3ad3eb9e3 --- /dev/null +++ b/netbox/ipam/migrations/0008_prefix_change_order.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-09-15 16:08 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0007_prefix_ipaddress_add_tenant'), + ] + + operations = [ + migrations.AlterModelOptions( + name='prefix', + options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'}, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 0b8b09049..c602fa3e0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -12,6 +12,7 @@ from dcim.models import Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel +from utilities.sql import NullsFirstQuerySet from .fields import IPNetworkField, IPAddressField @@ -192,7 +193,7 @@ class Role(models.Model): return self.vlans.count() -class PrefixQuerySet(models.QuerySet): +class PrefixQuerySet(NullsFirstQuerySet): def annotate_depth(self, limit=None): """ @@ -249,7 +250,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): objects = PrefixQuerySet.as_manager() class Meta: - ordering = ['family', 'prefix'] + ordering = ['vrf', 'family', 'prefix'] verbose_name_plural = 'prefixes' def __unicode__(self): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8ce0a5c6a..b64f5e4b3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.6.0' +VERSION = '1.6.1' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -71,7 +71,7 @@ if LDAP_CONFIGURED: logger.setLevel(logging.DEBUG) except ImportError: raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. " - "You can remove netbox/ldap.py to disable LDAP.") + "You can remove netbox/ldap_config.py to disable LDAP.") BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 4f0592b34..6647046d7 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -25,7 +25,7 @@ $(document).ready(function() { }); if (slug_field) { var slug_source = $('#id_' + slug_field.attr('slug-source')); - slug_source.keyup(function() { + slug_source.on('keyup change', function() { if (slug_field && !slug_field.attr('_changed')) { slug_field.val(slugify($(this).val(), 50)); } diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 1e45fe163..d8368cc32 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -5,7 +5,7 @@ from django import forms from django.db.models import Count from dcim.models import Device -from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField +from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField from .models import Secret, SecretRole, UserKey @@ -95,13 +95,8 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin): name = forms.CharField(max_length=100, required=False) -def secret_role_choices(): - role_choices = SecretRole.objects.annotate(secret_count=Count('secrets')) - return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices] - - class SecretFilterForm(forms.Form, BootstrapMixin): - role = forms.MultipleChoiceField(required=False, choices=secret_role_choices) + role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug') # diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 8844dc43a..46f37c44f 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -104,6 +104,9 @@ + {% with circuit.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=circuit %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 742dbcc3e..c59858626 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -105,7 +105,7 @@
- {% with provider.custom_fields as custom_fields %} + {% with provider.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 754c9c2a8..551241715 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -144,7 +144,7 @@
- {% with device.custom_fields as custom_fields %} + {% with device.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} {% if request.user.is_authenticated %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 11d7fb411..4c2aef15d 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -132,7 +132,7 @@ - {% with rack.custom_fields as custom_fields %} + {% with rack.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index ffcf382ab..ccf2c9673 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -111,7 +111,7 @@
- {% with site.custom_fields as custom_fields %} + {% with site.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index e2b4086b1..d95494c67 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -82,7 +82,7 @@ {% include 'inc/created_updated.html' with obj=aggregate %}
- {% with aggregate.custom_fields as custom_fields %} + {% with aggregate.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 56f95479b..7524b00fe 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -121,6 +121,9 @@ + {% with ipaddress.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=ipaddress %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 24a40ed66..89f06bb47 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -101,6 +101,9 @@
+ {% with prefix.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=prefix %}
diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 14ac861a2..df790f9c6 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -6,6 +6,15 @@ {% block content %}
+ + {% if 'expand' in request.GET %} + + Collapse all + {% else %} + + Expand all + {% endif %} + {% if perms.ipam.add_prefix %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 5b9d4cd3c..f25de0295 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -110,6 +110,9 @@
+ {% with vlan.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=vlan %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 899b14f54..dc8903418 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -82,6 +82,9 @@
+ {% with vrf.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=vrf %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 6fe140e53..c3e640de1 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -65,7 +65,7 @@
- {% with tenant.custom_fields as custom_fields %} + {% with tenant.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 01d2d578d..6eeeb1e7b 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -3,6 +3,7 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet +from utilities.filters import NullableModelMultipleChoiceFilter from .models import Tenant, TenantGroup @@ -11,12 +12,12 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = NullableModelMultipleChoiceFilter( name='group', queryset=TenantGroup.objects.all(), label='Group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( + group = NullableModelMultipleChoiceFilter( name='group', queryset=TenantGroup.objects.all(), to_field_name='slug', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 40b5b5b1d..b49e972a7 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,7 +2,7 @@ from django import forms from django.db.models import Count from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField +from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField from .models import Tenant, TenantGroup @@ -74,12 +74,7 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group') -def tenant_group_choices(): - group_choices = TenantGroup.objects.annotate(tenant_count=Count('tenants')) - return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices] - - class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant - group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), + to_field_name='slug', null_option=(0, 'None')) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py new file mode 100644 index 000000000..d1dbf39b8 --- /dev/null +++ b/netbox/utilities/filters.py @@ -0,0 +1,93 @@ +import django_filters +import itertools + +from django import forms +from django.db.models import Q +from django.utils.encoding import force_text + + +class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is + used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null + choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar + to defining a MultipleChoiceField with: + + choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()] + + However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating + database migrations. + """ + iterator = forms.models.ModelChoiceIterator + + def __init__(self, null_value=0, null_label='None', *args, **kwargs): + self.null_value = null_value + self.null_label = null_label + super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs) + + def _get_choices(self): + if hasattr(self, '_choices'): + return self._choices + # Prepend the null choice to the queryset iterator + return itertools.chain( + [(self.null_value, self.null_label)], + self.iterator(self), + ) + choices = property(_get_choices, forms.ChoiceField._set_choices) + + def clean(self, value): + # Strip all instances of the null value before cleaning + if value is not None: + stripped_value = [x for x in value if x != force_text(self.null_value)] + else: + stripped_value = value + super(NullableModelMultipleChoiceField, self).clean(stripped_value) + return value + + +class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): + """ + This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default + queryset filter argument is: + + .filter(fieldname=value) + + When filtering by the value representing "is null" ('0' by default) the argument is modified to: + + .filter(fieldname__isnull=True) + """ + field_class = NullableModelMultipleChoiceField + + def __init__(self, *args, **kwargs): + self.null_value = kwargs.get('null_value', 0) + super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs) + + def filter(self, qs, value): + value = value or () # Make sure we have an iterable + + if self.is_noop(qs, value): + return qs + + # Even though not a noop, no point filtering if empty + if not value: + return qs + + q = Q() + for v in set(value): + # Filtering by "is null" + if v == force_text(self.null_value): + arg = {'{}__isnull'.format(self.name): True} + # Filtering by a related field (e.g. slug) + elif self.field.to_field_name is not None: + arg = {'{}__{}'.format(self.name, self.field.to_field_name): v} + # Filtering by primary key (default) + else: + arg = {self.name: v} + if self.conjoined: + qs = self.get_method(qs)(**arg) + else: + q |= Q(**arg) + if self.distinct: + return self.get_method(qs)(q).distinct() + + return self.get_method(qs)(q) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 4cbc1028b..1df25891b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,4 +1,5 @@ import csv +import itertools import re from django import forms @@ -142,10 +143,14 @@ class CSVDataField(forms.CharField): if not self.help_text: self.help_text = 'Enter one line per record in CSV format.' + def utf_8_encoder(self, unicode_csv_data): + for line in unicode_csv_data: + yield line.encode('utf-8') + def to_python(self, value): # Return a list of dictionaries, each representing an individual record records = [] - reader = csv.reader(value.splitlines()) + reader = csv.reader(self.utf_8_encoder(value.splitlines())) for i, row in enumerate(reader, start=1): if row: if len(row) < len(self.columns): @@ -222,6 +227,32 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source +class FilterChoiceField(forms.ModelMultipleChoiceField): + iterator = forms.models.ModelChoiceIterator + + def __init__(self, null_option=None, *args, **kwargs): + self.null_option = null_option + if 'required' not in kwargs: + kwargs['required'] = False + if 'widget' not in kwargs: + kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) + super(FilterChoiceField, self).__init__(*args, **kwargs) + + def label_from_instance(self, obj): + if hasattr(obj, 'filter_count'): + return u'{} ({})'.format(obj, obj.filter_count) + return force_text(obj) + + def _get_choices(self): + if hasattr(self, '_choices'): + return self._choices + if self.null_option is not None: + return itertools.chain([self.null_option], self.iterator(self)) + return self.iterator(self) + + choices = property(_get_choices, forms.ChoiceField._set_choices) + + # # Forms # diff --git a/netbox/utilities/sql.py b/netbox/utilities/sql.py new file mode 100644 index 000000000..617586ab8 --- /dev/null +++ b/netbox/utilities/sql.py @@ -0,0 +1,32 @@ +from django.db import connections, models +from django.db.models.sql.compiler import SQLCompiler + + +class NullsFirstSQLCompiler(SQLCompiler): + + def get_order_by(self): + result = super(NullsFirstSQLCompiler, self).get_order_by() + if result: + return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result] + return result + + +class NullsFirstQuery(models.sql.query.Query): + + def get_compiler(self, using=None, connection=None): + if using is None and connection is None: + raise ValueError("Need either using or connection") + if using: + connection = connections[using] + return NullsFirstSQLCompiler(self, connection, using) + + +class NullsFirstQuerySet(models.QuerySet): + """ + Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global + table before those assigned to a VRF. + """ + + def __init__(self, model=None, query=None, using=None, hints=None): + super(NullsFirstQuerySet, self).__init__(model, query, using, hints) + self.query = query or NullsFirstQuery(self.model) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a719d3fbf..5e8b01d6f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from django_tables2 import RequestConfig from django.contrib import messages @@ -10,18 +11,30 @@ from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, TypedCho from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError -from django.utils.decorators import method_decorator from django.utils.http import is_safe_url from django.views.generic import View from extras.forms import CustomFieldForm -from extras.models import CustomFieldValue, ExportTemplate, UserAction +from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction from .error_handlers import handle_protectederror from .forms import ConfirmationForm from .paginator import EnhancedPaginator +class annotate_custom_fields: + + def __init__(self, queryset, custom_fields): + self.queryset = queryset + self.custom_fields = custom_fields + + def __iter__(self): + for obj in self.queryset: + values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} + obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) + yield obj + + class ObjectListView(View): queryset = None filter = None @@ -39,19 +52,26 @@ class ObjectListView(View): if self.filter: self.queryset = self.filter(request.GET, self.queryset).qs + # If this type of object has one or more custom fields, prefetch any relevant custom field values + custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\ + .prefetch_related('choices') + if custom_fields: + self.queryset = self.queryset.prefetch_related('custom_field_values') + # Check for export template rendering if request.GET.get('export'): et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) + queryset = annotate_custom_fields(self.queryset, custom_fields) if custom_fields else self.queryset try: - response = et.to_response(context_dict={'queryset': self.queryset.all()}, - filename='netbox_{}'.format(self.queryset.model._meta.verbose_name_plural)) + response = et.to_response(context_dict={'queryset': queryset}, + filename='netbox_{}'.format(model._meta.verbose_name_plural)) return response except TemplateSyntaxError: messages.error(request, "There was an error rendering the selected export template ({})." .format(et.name)) # Fall back to built-in CSV export elif 'export' in request.GET and hasattr(model, 'to_csv'): - output = '\n'.join([obj.to_csv() for obj in self.queryset.all()]) + output = '\n'.join([obj.to_csv() for obj in self.queryset]) response = HttpResponse( output, content_type='text/csv' diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh old mode 100644 new mode 100755