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

Merge pull request #1614 from digitalocean/develop

Release v2.2.2
Jeremy Stretch 8 лет назад
Родитель
Сommit
7a64404299

+ 2 - 3
README.md

@@ -31,6 +31,5 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
 
 
 ## Alternative Installations
 ## Alternative Installations
 
 
-* [Docker container](https://github.com/digitalocean/netbox-docker)
-* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
-* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)
+* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
+* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))

+ 2 - 2
docs/installation/ldap.md

@@ -55,7 +55,7 @@ LDAP_IGNORE_CERT_ERRORS = True
 ## User Authentication
 ## User Authentication
 
 
 !!! info
 !!! info
-    When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
+    When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
 
 
 ```python
 ```python
 from django_auth_ldap.config import LDAPSearch
 from django_auth_ldap.config import LDAPSearch
@@ -79,7 +79,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
 
 
 # User Groups for Permissions
 # User Groups for Permissions
 !!! Info
 !!! Info
-    When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE.
+    When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
 
 
 ```python
 ```python
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

+ 20 - 2
netbox/dcim/forms.py

@@ -17,6 +17,7 @@ from utilities.forms import (
     ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
     ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
     SlugField, FilterTreeNodeMultipleChoiceField,
     SlugField, FilterTreeNodeMultipleChoiceField,
 )
 )
+from virtualization.models import Cluster
 from .formfields import MACAddressFormField
 from .formfields import MACAddressFormField
 from .models import (
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
@@ -900,11 +901,20 @@ class DeviceCSVForm(BaseDeviceCSVForm):
         required=False,
         required=False,
         help_text='Mounted rack face'
         help_text='Mounted rack face'
     )
     )
+    cluster = forms.ModelChoiceField(
+        queryset=Cluster.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Virtualization cluster',
+        error_messages={
+            'invalid_choice': 'Invalid cluster name.',
+        }
+    )
 
 
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'site', 'rack_group', 'rack_name', 'position', 'face',
+            'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster',
         ]
         ]
 
 
     def clean(self):
     def clean(self):
@@ -940,11 +950,19 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     device_bay_name = forms.CharField(
     device_bay_name = forms.CharField(
         help_text='Name of device bay',
         help_text='Name of device bay',
     )
     )
+    cluster = forms.ModelChoiceField(
+        queryset=Cluster.objects.all(),
+        to_field_name='name',
+        help_text='Virtualization cluster',
+        error_messages={
+            'invalid_choice': 'Invalid cluster name.',
+        }
+    )
 
 
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'parent', 'device_bay_name',
+            'parent', 'device_bay_name', 'cluster',
         ]
         ]
 
 
     def clean(self):
     def clean(self):

+ 0 - 26
netbox/dcim/views.py

@@ -34,32 +34,6 @@ from .models import (
 )
 )
 
 
 
 
-EXPANSION_PATTERN = '\[(\d+-\d+)\]'
-
-
-def xstr(s):
-    """
-    Replace None with an empty string (for CSV export)
-    """
-    return '' if s is None else str(s)
-
-
-def expand_pattern(string):
-    """
-    Expand a numeric pattern into a list of strings. Examples:
-      'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
-      'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7']
-    """
-    lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1)
-    x, y = pattern.split('-')
-    for i in range(int(x), int(y) + 1):
-        if remnant:
-            for string in expand_pattern(remnant):
-                yield "{0}{1}{2}".format(lead, i, string)
-        else:
-            yield "{0}{1}".format(lead, i)
-
-
 class BulkDisconnectView(View):
 class BulkDisconnectView(View):
     """
     """
     An extendable view for disconnection console/power/interface components in bulk.
     An extendable view for disconnection console/power/interface components in bulk.

+ 4 - 0
netbox/extras/models.py

@@ -274,6 +274,7 @@ class TopologyMap(models.Model):
         # Construct the graph
         # Construct the graph
         graph = graphviz.Graph()
         graph = graphviz.Graph()
         graph.graph_attr['ranksep'] = '1'
         graph.graph_attr['ranksep'] = '1'
+        seen = set()
         for i, device_set in enumerate(self.device_sets):
         for i, device_set in enumerate(self.device_sets):
 
 
             subgraph = graphviz.Graph(name='sg{}'.format(i))
             subgraph = graphviz.Graph(name='sg{}'.format(i))
@@ -288,6 +289,9 @@ class TopologyMap(models.Model):
             devices = []
             devices = []
             for query in device_set.strip(';').split(';'):  # Split regexes on semicolons
             for query in device_set.strip(';').split(';'):  # Split regexes on semicolons
                 devices += Device.objects.filter(name__regex=query).select_related('device_role')
                 devices += Device.objects.filter(name__regex=query).select_related('device_role')
+            # Remove duplicate devices
+            devices = [d for d in devices if d.id not in seen]
+            seen.update([d.id for d in devices])
             for d in devices:
             for d in devices:
                 bg_color = '#{}'.format(d.device_role.color)
                 bg_color = '#{}'.format(d.device_role.color)
                 fg_color = '#{}'.format(foreground_color(d.device_role.color))
                 fg_color = '#{}'.format(foreground_color(d.device_role.color))

+ 12 - 1
netbox/ipam/api/serializers.py

@@ -240,12 +240,22 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
 # IP addresses
 # IP addresses
 #
 #
 
 
+class IPAddressInterfaceSerializer(InterfaceSerializer):
+    virtual_machine = NestedVirtualMachineSerializer()
+
+    class Meta(InterfaceSerializer.Meta):
+        fields = [
+            'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
+            'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
+        ]
+
+
 class IPAddressSerializer(CustomFieldModelSerializer):
 class IPAddressSerializer(CustomFieldModelSerializer):
     vrf = NestedVRFSerializer()
     vrf = NestedVRFSerializer()
     tenant = NestedTenantSerializer()
     tenant = NestedTenantSerializer()
     status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
     status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
     role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
     role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
-    interface = InterfaceSerializer()
+    interface = IPAddressInterfaceSerializer()
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -262,6 +272,7 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
         model = IPAddress
         model = IPAddress
         fields = ['id', 'url', 'family', 'address']
         fields = ['id', 'url', 'family', 'address']
 
 
+
 IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
 IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
 IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
 IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
 
 

+ 1 - 1
netbox/ipam/api/views.py

@@ -151,7 +151,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = IPAddress.objects.select_related(
     queryset = IPAddress.objects.select_related(
         'vrf__tenant', 'tenant', 'nat_inside'
         'vrf__tenant', 'tenant', 'nat_inside'
     ).prefetch_related(
     ).prefetch_related(
-        'interface__device'
+        'interface__device', 'interface__virtual_machine'
     )
     )
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
     write_serializer_class = serializers.WritableIPAddressSerializer
     write_serializer_class = serializers.WritableIPAddressSerializer

+ 7 - 1
netbox/ipam/models.py

@@ -440,7 +440,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             self.get_status_display(),
             self.get_status_display(),
             self.get_role_display(),
             self.get_role_display(),
             self.device.identifier if self.device else None,
             self.device.identifier if self.device else None,
-            self.virtual_machine.name if self.device else None,
+            self.virtual_machine.name if self.virtual_machine else None,
             self.interface.name if self.interface else None,
             self.interface.name if self.interface else None,
             is_primary,
             is_primary,
             self.description,
             self.description,
@@ -452,6 +452,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             return self.interface.device
             return self.interface.device
         return None
         return None
 
 
+    @property
+    def virtual_machine(self):
+        if self.interface:
+            return self.interface.virtual_machine
+        return None
+
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
         return STATUS_CHOICE_CLASSES[self.status]
 
 

+ 4 - 0
netbox/netbox/forms.py

@@ -30,6 +30,10 @@ OBJ_TYPE_CHOICES = (
     ('Tenancy', (
     ('Tenancy', (
         ('tenant', 'Tenants'),
         ('tenant', 'Tenants'),
     )),
     )),
+    ('Virtualization', (
+        ('cluster', 'Clusters'),
+        ('virtualmachine', 'Virtual machines'),
+    )),
 )
 )
 
 
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.2.1'
+VERSION = '2.2.2'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

+ 5 - 3
netbox/netbox/views.py

@@ -27,7 +27,7 @@ from tenancy.models import Tenant
 from tenancy.tables import TenantTable
 from tenancy.tables import TenantTable
 from virtualization.filters import ClusterFilter, VirtualMachineFilter
 from virtualization.filters import ClusterFilter, VirtualMachineFilter
 from virtualization.models import Cluster, VirtualMachine
 from virtualization.models import Cluster, VirtualMachine
-from virtualization.tables import ClusterTable, VirtualMachineTable
+from virtualization.tables import ClusterTable, VirtualMachineDetailTable
 from .forms import SearchForm
 from .forms import SearchForm
 
 
 
 
@@ -126,9 +126,11 @@ SEARCH_TYPES = OrderedDict((
         'url': 'virtualization:cluster_list',
         'url': 'virtualization:cluster_list',
     }),
     }),
     ('virtualmachine', {
     ('virtualmachine', {
-        'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'),
+        'queryset': VirtualMachine.objects.select_related(
+            'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+        ),
         'filter': VirtualMachineFilter,
         'filter': VirtualMachineFilter,
-        'table': VirtualMachineTable,
+        'table': VirtualMachineDetailTable,
         'url': 'virtualization:virtualmachine_list',
         'url': 'virtualization:virtualmachine_list',
     }),
     }),
 ))
 ))

+ 6 - 1
netbox/templates/virtualization/cluster_add_devices.html

@@ -59,6 +59,7 @@
 <script type="text/javascript">
 <script type="text/javascript">
     $(document).ready(function() {
     $(document).ready(function() {
         var device_list = $('#id_devices');
         var device_list = $('#id_devices');
+        var disabled_indicator = device_list.attr('disabled-indicator');
         $('#id_search').autocomplete({
         $('#id_search').autocomplete({
             source: function(request, response) {
             source: function(request, response) {
                 $.ajax({
                 $.ajax({
@@ -70,7 +71,11 @@
                     },
                     },
                     success: function(data) {
                     success: function(data) {
                         response($.map(data.results, function(item) {
                         response($.map(data.results, function(item) {
-                            device_list.append('<option value="' + item['id'] + '">' + item['display_name'] + '</option>');
+                            var option = $("<option></option>").attr("value", item['id']).text(item['display_name']);
+                            if (disabled_indicator && item[disabled_indicator]) {
+                                option.attr("disabled", "disabled");
+                            }
+                            device_list.append(option);
                         }));
                         }));
                     }
                     }
                 });
                 });

+ 1 - 4
netbox/virtualization/filters.py

@@ -78,7 +78,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         label='Cluster group (ID)',
         label='Cluster group (ID)',
     )
     )
     cluster_group = NullableModelMultipleChoiceFilter(
     cluster_group = NullableModelMultipleChoiceFilter(
-        name='cluster__group__slug',
+        name='cluster__group',
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Cluster group (slug)',
         label='Cluster group (slug)',
@@ -88,12 +88,10 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         label='Cluster (ID)',
         label='Cluster (ID)',
     )
     )
     role_id = NullableModelMultipleChoiceFilter(
     role_id = NullableModelMultipleChoiceFilter(
-        name='role_id',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         label='Role (ID)',
         label='Role (ID)',
     )
     )
     role = NullableModelMultipleChoiceFilter(
     role = NullableModelMultipleChoiceFilter(
-        name='role__slug',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
@@ -112,7 +110,6 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         label='Platform (ID)',
         label='Platform (ID)',
     )
     )
     platform = NullableModelMultipleChoiceFilter(
     platform = NullableModelMultipleChoiceFilter(
-        name='platform',
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Platform (slug)',
         label='Platform (slug)',

+ 1 - 1
netbox/virtualization/forms.py

@@ -210,7 +210,7 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
 
 
         # If the Cluster is assigned to a Site, all Devices must be assigned to that Site.
         # If the Cluster is assigned to a Site, all Devices must be assigned to that Site.
         if self.cluster.site is not None:
         if self.cluster.site is not None:
-            for device in self.cleaned_data.get('devices'):
+            for device in self.cleaned_data.get('devices', []):
                 if device.site != self.cluster.site:
                 if device.site != self.cluster.site:
                     raise ValidationError({
                     raise ValidationError({
                         'devices': "{} belongs to a different site ({}) than the cluster ({})".format(
                         'devices': "{} belongs to a different site ({}) than the cluster ({})".format(

+ 5 - 0
netbox/virtualization/tables.py

@@ -24,6 +24,10 @@ VIRTUALMACHINE_STATUS = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
 """
 
 
+VIRTUALMACHINE_ROLE = """
+<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
+"""
+
 VIRTUALMACHINE_PRIMARY_IP = """
 VIRTUALMACHINE_PRIMARY_IP = """
 {{ record.primary_ip6.address.ip|default:"" }}
 {{ record.primary_ip6.address.ip|default:"" }}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -93,6 +97,7 @@ class VirtualMachineTable(BaseTable):
     name = tables.LinkColumn()
     name = tables.LinkColumn()
     status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
     status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
     cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
     cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
+    role = tables.TemplateColumn(VIRTUALMACHINE_ROLE)
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):