Просмотр исходного кода

822 bulk import of device components (#3711)

Closes #822: CSV import for device components

* Implement CSV import for netbox-community#822

* Comment out default_return_url until there is a proper target

* Fix the default value of `enabled` when not included in the import

* rear_port is definitely required here

* Power Ports don't have a type (yet)

* Add import for console-ports and console-server-ports

* Add import for device-bays
Sander Steffann 6 лет назад
Родитель
Сommit
adb25fd7d7

+ 0 - 7
README.md

@@ -36,13 +36,6 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
 instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
 instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
 and run `upgrade.sh`.
 and run `upgrade.sh`.
 
 
-## Alternative Installations
-
-* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
-* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
-* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
-* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
-
 # Providing Feedback
 # Providing Feedback
 
 
 Feature requests and bug reports must be submitted as GiHub issues. (Please be
 Feature requests and bug reports must be submitted as GiHub issues. (Please be

+ 1 - 1
docs/additional-features/custom-scripts.md

@@ -182,7 +182,7 @@ class NewBranchScript(Script):
     class Meta:
     class Meta:
         name = "New Branch"
         name = "New Branch"
         description = "Provision a new branch site"
         description = "Provision a new branch site"
-        fields = ['site_name', 'switch_count', 'switch_model']
+        field_order = ['site_name', 'switch_count', 'switch_model']
 
 
     site_name = StringVar(
     site_name = StringVar(
         description="Name of the new site"
         description="Name of the new site"

+ 0 - 1
docs/installation/3-http-daemon.md

@@ -32,7 +32,6 @@ server {
         proxy_set_header X-Forwarded-Host $server_name;
         proxy_set_header X-Forwarded-Host $server_name;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-Proto $scheme;
         proxy_set_header X-Forwarded-Proto $scheme;
-        add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
     }
     }
 }
 }
 ```
 ```

+ 15 - 0
docs/release-notes/version-2.6.md

@@ -1,3 +1,18 @@
+# v2.6.8 (FUTURE)
+
+## Enhancements
+
+* [#3139](https://github.com/netbox-community/netbox/issues/3139) - Disable password change form for LDAP-authenticated users
+* [#3457](https://github.com/netbox-community/netbox/issues/3457) - Display cable colors on device view
+* [#3329](https://github.com/netbox-community/netbox/issues/3329) - Remove obsolete P3P policy header
+* [#3663](https://github.com/netbox-community/netbox/issues/3663) - Add query filters for `created` and `last_updated` fields
+
+## Bug Fixes
+
+* [#3669](https://github.com/netbox-community/netbox/issues/3669) - Include `weight` field in prefix/VLAN role form
+* [#3674](https://github.com/netbox-community/netbox/issues/3674) - Include comments on PowerFeed view
+* [#3679](https://github.com/netbox-community/netbox/issues/3679) - Fix link for assigned ipaddress in interface page
+
 # v2.6.7 (2019-11-01)
 # v2.6.7 (2019-11-01)
 
 
 ## Enhancements
 ## Enhancements

+ 3 - 3
netbox/circuits/filters.py

@@ -2,14 +2,14 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from dcim.models import Region, Site
 from dcim.models import Region, Site
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from .constants import *
 from .constants import *
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
 
 
-class ProviderFilter(CustomFieldFilterSet):
+class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -54,7 +54,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
+class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 6 - 6
netbox/dcim/filters.py

@@ -2,7 +2,7 @@ import django_filters
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.db.models import Q
 from django.db.models import Q
 
 
-from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
+from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
 from utilities.constants import COLOR_CHOICES
@@ -39,7 +39,7 @@ class RegionFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
+class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -117,7 +117,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'color']
         fields = ['id', 'name', 'slug', 'color']
 
 
 
 
-class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
+class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -252,7 +252,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class DeviceTypeFilter(CustomFieldFilterSet):
+class DeviceTypeFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -424,7 +424,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver']
         fields = ['id', 'name', 'slug', 'napalm_driver']
 
 
 
 
-class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
+class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -1113,7 +1113,7 @@ class PowerPanelFilter(django_filters.FilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class PowerFeedFilter(CustomFieldFilterSet):
+class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 279 - 1
netbox/dcim/forms.py

@@ -25,7 +25,7 @@ from utilities.forms import (
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField,
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField,
     SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
     SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
-from virtualization.models import Cluster, ClusterGroup
+from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
@@ -2096,6 +2096,21 @@ class ConsolePortCreateForm(ComponentForm):
     )
     )
 
 
 
 
+class ConsolePortCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+
+    class Meta:
+        model = ConsolePort
+        fields = ConsolePort.csv_headers
+
+
 #
 #
 # Console server ports
 # Console server ports
 #
 #
@@ -2168,6 +2183,21 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
     )
     )
 
 
 
 
+class ConsoleServerPortCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+
+    class Meta:
+        model = ConsoleServerPort
+        fields = ConsoleServerPort.csv_headers
+
+
 #
 #
 # Power ports
 # Power ports
 #
 #
@@ -2215,6 +2245,21 @@ class PowerPortCreateForm(ComponentForm):
     )
     )
 
 
 
 
+class PowerPortCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+
+    class Meta:
+        model = PowerPort
+        fields = PowerPort.csv_headers
+
+
 #
 #
 # Power outlets
 # Power outlets
 #
 #
@@ -2280,6 +2325,56 @@ class PowerOutletCreateForm(ComponentForm):
         self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent)
         self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent)
 
 
 
 
+class PowerOutletCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    power_port = FlexibleModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of Power Port',
+        error_messages={
+            'invalid_choice': 'Power Port not found.',
+        }
+    )
+    feed_leg = CSVChoiceField(
+        choices=POWERFEED_LEG_CHOICES,
+        required=False,
+    )
+
+    class Meta:
+        model = PowerOutlet
+        fields = PowerOutlet.csv_headers
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit PowerPort choices to those belonging to this device (or VC master)
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['power_port'].queryset = PowerPort.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['power_port'].queryset = PowerPort.objects.none()
+
+
 class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
@@ -2531,6 +2626,73 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
         self.fields['tagged_vlans'].choices = vlan_choices
         self.fields['tagged_vlans'].choices = vlan_choices
 
 
 
 
+class InterfaceCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    virtual_machine = FlexibleModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of virtual machine',
+        error_messages={
+            'invalid_choice': 'Virtual machine not found.',
+        }
+    )
+    lag = FlexibleModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of LAG interface',
+        error_messages={
+            'invalid_choice': 'LAG interface not found.',
+        }
+    )
+    type = CSVChoiceField(
+        choices=IFACE_TYPE_CHOICES,
+    )
+    mode = CSVChoiceField(
+        choices=IFACE_MODE_CHOICES,
+        required=False,
+    )
+
+    class Meta:
+        model = Interface
+        fields = Interface.csv_headers
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces belonging to this device (or VC master)
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            device = self.instance.device
+
+        if device:
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG
+            )
+        else:
+            self.fields['lag'].queryset = Interface.objects.none()
+
+    def clean_enabled(self):
+        # Make sure enabled is True when it's not included in the uploaded data
+        if 'enabled' not in self.data:
+            return True
+        else:
+            return self.cleaned_data['enabled']
+
+
 class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
@@ -2747,6 +2909,54 @@ class FrontPortCreateForm(ComponentForm):
         }
         }
 
 
 
 
+class FrontPortCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    rear_port = FlexibleModelChoiceField(
+        queryset=RearPort.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of Rear Port',
+        error_messages={
+            'invalid_choice': 'Rear Port not found.',
+        }
+    )
+    type = CSVChoiceField(
+        choices=PORT_TYPE_CHOICES,
+    )
+
+    class Meta:
+        model = FrontPort
+        fields = FrontPort.csv_headers
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit RearPort choices to those belonging to this device (or VC master)
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['rear_port'].queryset = RearPort.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['rear_port'].queryset = RearPort.objects.none()
+
+
 class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=FrontPort.objects.all(),
         queryset=FrontPort.objects.all(),
@@ -2821,6 +3031,24 @@ class RearPortCreateForm(ComponentForm):
     )
     )
 
 
 
 
+class RearPortCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    type = CSVChoiceField(
+        choices=PORT_TYPE_CHOICES,
+    )
+
+    class Meta:
+        model = RearPort
+        fields = RearPort.csv_headers
+
+
 class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RearPort.objects.all(),
         queryset=RearPort.objects.all(),
@@ -3389,6 +3617,56 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
         ).exclude(pk=device_bay.device.pk)
         ).exclude(pk=device_bay.device.pk)
 
 
 
 
+class DeviceBayCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    installed_device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Child device not found.',
+        }
+    )
+
+    class Meta:
+        model = DeviceBay
+        fields = DeviceBay.csv_headers
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit installed device choices to devices of the correct type and location
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['installed_device'].queryset = Device.objects.filter(
+                site=device.site,
+                rack=device.rack,
+                parent_bay__isnull=True,
+                device_type__u_height=0,
+                device_type__subdevice_role=SUBDEVICE_ROLE_CHILD
+            ).exclude(pk=device.pk)
+        else:
+            self.fields['installed_device'].queryset = Interface.objects.none()
+
+
 class DeviceBayBulkRenameForm(BulkRenameForm):
 class DeviceBayBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceBay.objects.all(),
         queryset=DeviceBay.objects.all(),

+ 74 - 0
netbox/dcim/tables.py

@@ -426,6 +426,15 @@ class ConsolePortTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class ConsolePortImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+
+    class Meta(BaseTable.Meta):
+        model = ConsolePort
+        fields = ('device', 'name', 'description')
+        empty_text = False
+
+
 class ConsoleServerPortTemplateTable(BaseTable):
 class ConsoleServerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -440,6 +449,15 @@ class ConsoleServerPortTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class ConsoleServerPortImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+
+    class Meta(BaseTable.Meta):
+        model = ConsoleServerPort
+        fields = ('device', 'name', 'description')
+        empty_text = False
+
+
 class PowerPortTemplateTable(BaseTable):
 class PowerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -454,6 +472,15 @@ class PowerPortTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class PowerPortImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+
+    class Meta(BaseTable.Meta):
+        model = PowerPort
+        fields = ('device', 'name', 'description', 'maximum_draw', 'allocated_draw')
+        empty_text = False
+
+
 class PowerOutletTemplateTable(BaseTable):
 class PowerOutletTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -468,6 +495,15 @@ class PowerOutletTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class PowerOutletImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+
+    class Meta(BaseTable.Meta):
+        model = PowerOutlet
+        fields = ('device', 'name', 'description', 'power_port', 'feed_leg')
+        empty_text = False
+
+
 class InterfaceTemplateTable(BaseTable):
 class InterfaceTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}")
     mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}")
@@ -483,6 +519,16 @@ class InterfaceTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class InterfaceImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    virtual_machine = tables.LinkColumn('virtualization:virtualmachine', args=[Accessor('virtual_machine.pk')], verbose_name='Virtual Machine')
+
+    class Meta(BaseTable.Meta):
+        model = Interface
+        fields = ('device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode')
+        empty_text = False
+
+
 class FrontPortTemplateTable(BaseTable):
 class FrontPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     rear_port_position = tables.Column(
     rear_port_position = tables.Column(
@@ -500,6 +546,15 @@ class FrontPortTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class FrontPortImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+
+    class Meta(BaseTable.Meta):
+        model = FrontPort
+        fields = ('device', 'name', 'description', 'type', 'rear_port', 'rear_port_position')
+        empty_text = False
+
+
 class RearPortTemplateTable(BaseTable):
 class RearPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -514,6 +569,15 @@ class RearPortTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class RearPortImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+
+    class Meta(BaseTable.Meta):
+        model = RearPort
+        fields = ('device', 'name', 'description', 'type', 'position')
+        empty_text = False
+
+
 class DeviceBayTemplateTable(BaseTable):
 class DeviceBayTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -701,6 +765,16 @@ class DeviceBayTable(BaseTable):
         fields = ('name',)
         fields = ('name',)
 
 
 
 
+class DeviceBayImportTable(BaseTable):
+    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    installed_device = tables.LinkColumn('dcim:device', args=[Accessor('installed_device.pk')], verbose_name='Installed Device')
+
+    class Meta(BaseTable.Meta):
+        model = DeviceBay
+        fields = ('device', 'name', 'installed_device', 'description')
+        empty_text = False
+
+
 #
 #
 # Cables
 # Cables
 #
 #

+ 8 - 0
netbox/dcim/urls.py

@@ -175,6 +175,7 @@ urlpatterns = [
     path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
     path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
+    path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
 
 
     # Console server ports
     # Console server ports
     path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
     path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
@@ -187,6 +188,7 @@ urlpatterns = [
     path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
     path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
     path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
     path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
     path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
     path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
+    path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
 
 
     # Power ports
     # Power ports
     path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
     path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
@@ -196,6 +198,7 @@ urlpatterns = [
     path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
     path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
     path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
     path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
+    path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
 
 
     # Power outlets
     # Power outlets
     path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
     path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
@@ -208,6 +211,7 @@ urlpatterns = [
     path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
     path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
     path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
     path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
     path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
     path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
+    path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
 
 
     # Interfaces
     # Interfaces
     path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
     path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
@@ -222,6 +226,7 @@ urlpatterns = [
     path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
     path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
     path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
     path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
     path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
     path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
+    path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
 
 
     # Front ports
     # Front ports
     # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
     # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
@@ -234,6 +239,7 @@ urlpatterns = [
     path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
     path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
     path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
     path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
     path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
     path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
+    path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
 
 
     # Rear ports
     # Rear ports
     # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
     # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
@@ -246,6 +252,7 @@ urlpatterns = [
     path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
     path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
     path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
     path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
     path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
     path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
+    path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
 
 
     # Device bays
     # Device bays
     path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
     path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
@@ -256,6 +263,7 @@ urlpatterns = [
     path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
     path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
     path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
     path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
     path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
     path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
+    path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
 
 
     # Inventory items
     # Inventory items
     path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
     path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),

+ 64 - 0
netbox/dcim/views.py

@@ -1218,6 +1218,14 @@ class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = ConsolePort
     model = ConsolePort
 
 
 
 
+class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_consoleport'
+    model_form = forms.ConsolePortCSVForm
+    table = tables.ConsolePortImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:consoleport_list'
+
+
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
@@ -1250,6 +1258,14 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = ConsoleServerPort
     model = ConsoleServerPort
 
 
 
 
+class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_consoleserverport'
+    model_form = forms.ConsoleServerPortCSVForm
+    table = tables.ConsoleServerPortImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:consoleserverport_list'
+
+
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleserverport'
     permission_required = 'dcim.change_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
@@ -1302,6 +1318,14 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = PowerPort
     model = PowerPort
 
 
 
 
+class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_powerport'
+    model_form = forms.PowerPortCSVForm
+    table = tables.PowerPortImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:powerport_list'
+
+
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
@@ -1334,6 +1358,14 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = PowerOutlet
     model = PowerOutlet
 
 
 
 
+class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_poweroutlet'
+    model_form = forms.PowerOutletCSVForm
+    table = tables.PowerOutletImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:poweroutlet_list'
+
+
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_poweroutlet'
     permission_required = 'dcim.change_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
@@ -1423,6 +1455,14 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = Interface
     model = Interface
 
 
 
 
+class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_interface'
+    model_form = forms.InterfaceCSVForm
+    table = tables.InterfaceImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:interface_list'
+
+
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
@@ -1475,6 +1515,14 @@ class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = FrontPort
     model = FrontPort
 
 
 
 
+class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_frontport'
+    model_form = forms.FrontPortCSVForm
+    table = tables.FrontPortImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:frontport_list'
+
+
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_frontport'
     permission_required = 'dcim.change_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
@@ -1527,6 +1575,14 @@ class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = RearPort
     model = RearPort
 
 
 
 
+class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_rearport'
+    model_form = forms.RearPortCSVForm
+    table = tables.RearPortImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:rearport_list'
+
+
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rearport'
     permission_required = 'dcim.change_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
@@ -1648,6 +1704,14 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
         })
         })
 
 
 
 
+class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_devicebay'
+    model_form = forms.DeviceBayCSVForm
+    table = tables.DeviceBayImportTable
+    # TODO: change after netbox-community#3564 has been implemented
+    # default_return_url = 'dcim:devicebay_list'
+
+
 class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
     permission_required = 'dcim.change_devicebay'
     permission_required = 'dcim.change_devicebay'
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()

+ 1 - 1
netbox/extras/api/urls.py

@@ -18,7 +18,7 @@ router.APIRootView = ExtrasRootView
 router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 
 
 # Custom field choices
 # Custom field choices
-router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
+router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
 
 
 # Graphs
 # Graphs
 router.register(r'graphs', views.GraphViewSet)
 router.register(r'graphs', views.GraphViewSet)

+ 21 - 0
netbox/extras/filters.py

@@ -223,3 +223,24 @@ class ObjectChangeFilter(django_filters.FilterSet):
             Q(user_name__icontains=value) |
             Q(user_name__icontains=value) |
             Q(object_repr__icontains=value)
             Q(object_repr__icontains=value)
         )
         )
+
+
+class CreatedUpdatedFilterSet(django_filters.FilterSet):
+    created = django_filters.DateFilter()
+    created__gte = django_filters.DateFilter(
+        field_name='created',
+        lookup_expr='gte'
+    )
+    created__lte = django_filters.DateFilter(
+        field_name='created',
+        lookup_expr='lte'
+    )
+    last_updated = django_filters.DateTimeFilter()
+    last_updated__gte = django_filters.DateTimeFilter(
+        field_name='last_updated',
+        lookup_expr='gte'
+    )
+    last_updated__lte = django_filters.DateTimeFilter(
+        field_name='last_updated',
+        lookup_expr='lte'
+    )

+ 7 - 7
netbox/ipam/filters.py

@@ -5,7 +5,7 @@ from django.db.models import Q
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
@@ -13,7 +13,7 @@ from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
 
 
-class VRFFilter(TenancyFilterSet, CustomFieldFilterSet):
+class VRFFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -49,7 +49,7 @@ class RIRFilter(NameSlugSearchFilterSet):
         fields = ['name', 'slug', 'is_private']
         fields = ['name', 'slug', 'is_private']
 
 
 
 
-class AggregateFilter(CustomFieldFilterSet):
+class AggregateFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -110,7 +110,7 @@ class RoleFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
+class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -247,7 +247,7 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
         return queryset.filter(prefix__net_mask_length=value)
         return queryset.filter(prefix__net_mask_length=value)
 
 
 
 
-class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
+class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -384,7 +384,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
+class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -444,7 +444,7 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class ServiceFilter(django_filters.FilterSet):
+class ServiceFilter(CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 1 - 1
netbox/ipam/forms.py

@@ -240,7 +240,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = Role
         model = Role
         fields = [
         fields = [
-            'name', 'slug',
+            'name', 'slug', 'weight',
         ]
         ]
 
 
 
 

+ 2 - 2
netbox/ipam/tables.py

@@ -85,7 +85,7 @@ IPADDRESS_LINK = """
 """
 """
 
 
 IPADDRESS_ASSIGN_LINK = """
 IPADDRESS_ASSIGN_LINK = """
-<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
+<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
 """
 """
 
 
 IPADDRESS_PARENT = """
 IPADDRESS_PARENT = """
@@ -292,7 +292,7 @@ class RoleTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Role
         model = Role
-        fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions')
+        fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'weight', 'actions')
 
 
 
 
 #
 #

+ 8 - 0
netbox/project-static/css/base.css

@@ -457,6 +457,14 @@ table.report th a {
     width: 80px;
     width: 80px;
     border: 1px solid grey;
     border: 1px solid grey;
 }
 }
+.inline-color-block {
+    display: inline-block;
+    width: 1.5em;
+    height: 1.5em;
+    border: 1px solid grey;
+    border-radius: .25em;
+    vertical-align: middle;
+}
 .text-nowrap {
 .text-nowrap {
     white-space: nowrap;
     white-space: nowrap;
 }
 }

+ 2 - 2
netbox/secrets/filters.py

@@ -2,7 +2,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from dcim.models import Device
 from dcim.models import Device
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .models import Secret, SecretRole
 from .models import Secret, SecretRole
 
 
@@ -14,7 +14,7 @@ class SecretRoleFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class SecretFilter(CustomFieldFilterSet):
+class SecretFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 3 - 0
netbox/templates/dcim/inc/interface.html

@@ -48,6 +48,9 @@
     <td class="text-nowrap">
     <td class="text-nowrap">
         {% if iface.cable %}
         {% if iface.cable %}
             <a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a>
             <a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a>
+            {% if iface.cable.color %}
+            <span class="inline-color-block" style="background-color: #{{ iface.cable.color }}">&nbsp;</span>
+            {% endif %}
             <a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace">
             <a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace">
                 <i class="fa fa-share-alt" aria-hidden="true"></i>
                 <i class="fa fa-share-alt" aria-hidden="true"></i>
             </a>
             </a>

+ 12 - 0
netbox/templates/dcim/powerfeed.html

@@ -121,6 +121,18 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body rendered-markdown">
+                {% if powerfeed.comments %}
+                    {{ powerfeed.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
     </div>
     </div>
     <div class="col-md-6">
     <div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 5 - 3
netbox/templates/users/_user.html

@@ -12,9 +12,11 @@
             <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
             <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
                 <a href="{% url 'user:profile' %}">Profile</a>
                 <a href="{% url 'user:profile' %}">Profile</a>
             </li>
             </li>
-            <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
-                <a href="{% url 'user:change_password' %}">Change Password</a>
-            </li>
+            {% if not request.user.ldap_username %}
+                <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
+                    <a href="{% url 'user:change_password' %}">Change Password</a>
+                </li>
+            {% endif %}
             <li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
             <li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
                 <a href="{% url 'user:token_list' %}">API Tokens</a>
                 <a href="{% url 'user:token_list' %}">API Tokens</a>
             </li>
             </li>

+ 2 - 2
netbox/tenancy/filters.py

@@ -1,7 +1,7 @@
 import django_filters
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
@@ -13,7 +13,7 @@ class TenantGroupFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class TenantFilter(CustomFieldFilterSet):
+class TenantFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 5 - 0
netbox/users/views.py

@@ -95,6 +95,11 @@ class ChangePasswordView(LoginRequiredMixin, View):
     template_name = 'users/change_password.html'
     template_name = 'users/change_password.html'
 
 
     def get(self, request):
     def get(self, request):
+        # LDAP users cannot change their password here
+        if getattr(request.user, 'ldap_username'):
+            messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
+            return redirect('user:profile')
+
         form = PasswordChangeForm(user=request.user)
         form = PasswordChangeForm(user=request.user)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {

+ 4 - 4
netbox/virtualization/filters.py

@@ -4,9 +4,9 @@ from netaddr import EUI
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
-from tenancy.models import Tenant
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
+from tenancy.models import Tenant
 from utilities.filters import (
 from utilities.filters import (
     MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
     MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
 )
 )
@@ -28,7 +28,7 @@ class ClusterGroupFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class ClusterFilter(CustomFieldFilterSet):
+class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -86,7 +86,7 @@ class ClusterFilter(CustomFieldFilterSet):
         )
         )
 
 
 
 
-class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet):
+class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'