Parcourir la source

Merge pull request #106 from digitalocean/develop

Release v1.0.5
Jeremy Stretch il y a 8 ans
Parent
commit
c93bc40479

+ 8 - 2
CONTRIBUTING.md

@@ -49,8 +49,14 @@ Even if it's not quite right for NetBox, we may be able to point you to a tool b
     * A rough description of any changes necessary to the database schema (if applicable)
     * Any third-party libraries or other resources which would be involved
 
-# Submitting Pull Requests
+## Submitting Pull Requests
 
-When submitting a pull request, please be sure to work off of branch `develop`, rather than branch `master`.
+* When submitting a pull request, please be sure to work off of branch `develop`, rather than branch `master`.
 In NetBox, the `develop` branch is used for ongoing development, while `master` is used for tagging new
 stable releases.
+
+* All code submissions should meet the following criteria (CI will enforce these checks):
+
+    * Python syntax is valid
+    * All tests pass when run with `./manage.py test netbox/`
+    * PEP 8 compliance is enforced, with the exception that lines may be greater than 80 characters in length

+ 60 - 15
docs/getting-started.md

@@ -48,11 +48,12 @@ You can verify that authentication works using the following command:
 
 # NetBox
 
-## Dependencies
+## Installation
+
+NetBox requires following dependencies:
 
 * python2.7
 * python-dev
-* git
 * python-pip
 * libxml2-dev
 * libxslt1-dev
@@ -65,7 +66,21 @@ You can verify that authentication works using the following command:
 
 *graphviz is needed to render topology maps. If you have no need for this feature, graphviz is not required. 
 
-## Clone the Git Repository
+You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
+
+### Option A: Download a Release
+
+Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
+
+```
+# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
+# tar -xzf vX.Y.Z.tar.gz -C /opt
+# cd /opt/
+# ln -s netbox-1.0.4/ netbox
+# cd /opt/netbox/
+```
+
+### Option B: Clone the Git Repository
 
 Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
 
@@ -74,6 +89,12 @@ Create the base directory for the NetBox installation. For this guide, we'll use
 # cd /opt/netbox/
 ```
 
+If `git` is not already installed, install it:
+
+```
+# sudo apt-get install git
+```
+
 Next, clone the NetBox git repository into the current directory:
 
 ```
@@ -87,6 +108,8 @@ Resolving deltas: 100% (1495/1495), done.
 Checking connectivity... done.
 ```
 
+### Install Python Packages
+
 Install the necessary Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the required dependencies.)
 
 ```
@@ -264,38 +287,38 @@ Restart the nginx service to use the new configuration.
 ```
 ## Apache Configuration
 
-If you're feeling adventurous, or you already have Apache installed and can't run a dual-stack on your server, the following configuration should work for Apache:
+The following configuration should work for Apache. Be sure to modify the `ServerName` appropriately.
 
 ```
 <VirtualHost *:80>
     ProxyPreserveHost On
-    
+
     ServerName netbox.example.com
 
-    Alias /static/ /opt/netbox/netbox/static
+    Alias /static /opt/netbox/netbox/static
 
     <Directory /opt/netbox/netbox/static>
         Options Indexes FollowSymLinks MultiViews
         AllowOverride None
-        Order allow,deny
-        Allow from all
-        # Uncomment the line below if running Apache 2.4
-        #Require all granted
+        Require all granted
     </Directory>
 
     <Location /static>
         ProxyPass !
     </Location>
 
-    ProxyPass / http://127.0.0.1:8001
-    ProxyPassReverse / http://127.0.0.1:8001
+    ProxyPass / http://127.0.0.1:8001/
+    ProxyPassReverse / http://127.0.0.1:8001/
 </VirtualHost>
 ```
 
-Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf` and reload Apache:
+Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache:
 
 ```
-# a2ensite netbox; service apache2 restart
+# a2enmod proxy
+# a2enmod proxy_http
+# a2ensite netbox
+# service apache2 restart
 ```
 
 ## gunicorn Configuration
@@ -329,4 +352,26 @@ Finally, restart the supervisor service to detect and run the gunicorn service:
 
 At this point, you should be able to connect to the nginx HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
 
-Please keep in mind that the configurations provided here are a bare minimum to get NetBox up and running. You will almost certainly want to make some changes to better suit your production environment.
+Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You will almost certainly want to make some changes to better suit your production environment.
+
+# Upgrading
+
+As with the initial installation, you can upgrade NetBox by either downloading the lastest release package or by cloning the `master` branch of the git repository. Several important steps are required before running the new code.
+
+First, apply any database migrations that were included with the release. Not all releases include database migrations (in fact, most don't), so don't worry if this command returns "No migrations to apply."
+
+```
+# ./manage.py migrate
+```
+
+Second, collect any static file that have changed into the root static path. As with database migrations, not all releases will include changes to static files.
+
+```
+# ./manage.py collectstatic
+```
+
+Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
+
+```
+# sudo supervisorctl restart netbox
+```

+ 2 - 4
docs/ipam.md

@@ -52,15 +52,13 @@ A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefix
 
 ### Statuses
 
-Each prefix is assigned an operational status. This may be one of the following:
+Each prefix is assigned an operational status. This is one of the following:
 
 * Container - A summary of child prefixes
 * Active - Provisioned and in use
 * Reserved - Earmarked for future use
 * Deprecated - No longer in use
 
-NetBox provides several statuses by default, but you are free to change them to suit the needs of your organization.
-
 ### Roles
 
 Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include:
@@ -71,7 +69,7 @@ Whereas a status describes a prefix's operational state, a role describes its fu
 * Lab
 * Out-of-band
 
-Role assignment is optional. And like statuses, you are free to create your own.
+Role assignment is optional and you are free to create as many as you'd like.
 
 ---
 

+ 5 - 2
netbox/dcim/forms.py

@@ -386,10 +386,13 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 
         # Rack position
         try:
+            pk = self.instance.pk if self.instance.pk else None
             if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
-                position_choices = Rack.objects.get(pk=self.data['rack']).get_rack_units(face=self.data.get('face'))
+                position_choices = Rack.objects.get(pk=self.data['rack'])\
+                    .get_rack_units(face=self.data.get('face'), exclude=pk)
             elif self.initial.get('rack') and str(self.initial.get('face')):
-                position_choices = Rack.objects.get(pk=self.initial['rack']).get_rack_units(face=self.initial.get('face'))
+                position_choices = Rack.objects.get(pk=self.initial['rack'])\
+                    .get_rack_units(face=self.initial.get('face'), exclude=pk)
             else:
                 position_choices = []
         except Rack.DoesNotExist:

+ 25 - 0
netbox/dcim/migrations/0003_auto_20160628_1721.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-06-28 17:21
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0002_auto_20160622_1821'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='interface',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (100BASE-TX)'], [1000, b'1GE (1000BASE-T)'], [1100, b'1GE (SFP)'], [1150, b'10GE (10GBASE-T)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (100BASE-TX)'], [1000, b'1GE (1000BASE-T)'], [1100, b'1GE (SFP)'], [1150, b'10GE (10GBASE-T)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200),
+        ),
+    ]

+ 62 - 16
netbox/dcim/models.py

@@ -45,14 +45,16 @@ IFACE_FF_VIRTUAL = 0
 IFACE_FF_100M_COPPER = 800
 IFACE_FF_1GE_COPPER = 1000
 IFACE_FF_SFP = 1100
+IFACE_FF_10GE_COPPER = 1150
 IFACE_FF_SFP_PLUS = 1200
 IFACE_FF_XFP = 1300
 IFACE_FF_QSFP_PLUS = 1400
 IFACE_FF_CHOICES = [
     [IFACE_FF_VIRTUAL, 'Virtual'],
-    [IFACE_FF_100M_COPPER, '10/100M (Copper)'],
-    [IFACE_FF_1GE_COPPER, '1GE (Copper)'],
+    [IFACE_FF_100M_COPPER, '10/100M (100BASE-TX)'],
+    [IFACE_FF_1GE_COPPER, '1GE (1000BASE-T)'],
     [IFACE_FF_SFP, '1GE (SFP)'],
+    [IFACE_FF_10GE_COPPER, '10GE (10GBASE-T)'],
     [IFACE_FF_SFP_PLUS, '10GE (SFP+)'],
     [IFACE_FF_XFP, '10GE (XFP)'],
     [IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'],
@@ -83,6 +85,48 @@ RPC_CLIENT_CHOICES = [
 ]
 
 
+def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
+    """
+    Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the
+    following pattern:
+
+        {a}/{b}/{c}:{d}
+
+    Interfaces are ordered first by field a, then b, then c, and finally d. Leading text (which typically indicates the
+    interface's type) is ignored. If any fields are not contained by an interface name, those fields are treated as
+    None. 'None' is ordered after all other values. For example:
+
+        et-0/0/0
+        et-0/0/1
+        et-0/1/0
+        xe-0/1/1:0
+        xe-0/1/1:1
+        xe-0/1/1:2
+        xe-0/1/1:3
+        et-0/1/2
+        ...
+        et-0/1/9
+        et-0/1/10
+        et-0/1/11
+        et-1/0/0
+        et-1/0/1
+        ...
+        vlan1
+        vlan10
+
+    :param queryset: The base queryset to be ordered
+    :param sql_col: Table and name of the SQL column which contains the interface name (ex: ''dcim_interface.name')
+    :param primary_ordering: A tuple of fields which take ordering precedence before the interface name (optional)
+    """
+    ordering = primary_ordering + ('_id1', '_id2', '_id3', '_id4')
+    return queryset.extra(select={
+        '_id1': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
+        '_id2': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
+        '_id3': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
+        '_id4': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
+    }).order_by(*ordering)
+
+
 class Site(CreatedUpdatedModel):
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -213,12 +257,13 @@ class Rack(CreatedUpdatedModel):
             return "{} ({})".format(self.name, self.facility_id)
         return self.name
 
-    def get_rack_units(self, face=RACK_FACE_FRONT, remove_redundant=False):
+    def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
         """
         Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
         Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
 
         :param face: Rack face (front or rear)
+        :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
         :param remove_redundant: If True, rack units occupied by a device already listed will be omitted
         """
 
@@ -229,7 +274,9 @@ class Rack(CreatedUpdatedModel):
         # Add devices to rack units list
         if self.pk:
             for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
-                    .filter(rack=self, position__gt=0).filter(Q(face=face) | Q(device_type__is_full_depth=True)):
+                    .exclude(pk=exclude)\
+                    .filter(rack=self, position__gt=0)\
+                    .filter(Q(face=face) | Q(device_type__is_full_depth=True)):
                 if remove_redundant:
                     elevation[device.position]['device'] = device
                     for u in range(device.position + 1, device.position + device.device_type.u_height):
@@ -408,6 +455,13 @@ class PowerOutletTemplate(models.Model):
         return self.name
 
 
+class InterfaceTemplateManager(models.Manager):
+
+    def get_queryset(self):
+        qs = super(InterfaceTemplateManager, self).get_queryset()
+        return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',))
+
+
 class InterfaceTemplate(models.Model):
     """
     A template for a physical data interface on a new Device.
@@ -417,6 +471,8 @@ class InterfaceTemplate(models.Model):
     form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
     mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
 
+    objects = InterfaceTemplateManager()
+
     class Meta:
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
@@ -708,18 +764,8 @@ class PowerOutlet(models.Model):
 class InterfaceManager(models.Manager):
 
     def get_queryset(self):
-        """
-        Cast up to three interface slot/position IDs as independent integers and order appropriately. This ensures that
-        interfaces are ordered numerically without regard to type. For example:
-            xe-0/0/0, xe-0/0/1, xe-0/0/2 ... et-0/0/47, et-0/0/48, et-0/0/49 ...
-        instead of:
-            et-0/0/48, et-0/0/49, et-0/0/50 ... et-0/0/53, xe-0/0/0, xe-0/0/1 ...
-        """
-        return super(InterfaceManager, self).get_queryset().extra(select={
-            '_id1': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)\/([0-9]+)$') AS integer)",
-            '_id2': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)$') AS integer)",
-            '_id3': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)$') AS integer)",
-        }).order_by('device', '_id1', '_id2', '_id3')
+        qs = super(InterfaceManager, self).get_queryset()
+        return order_interfaces(qs, 'dcim_interface.name', ('device',))
 
     def virtual(self):
         return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL)

+ 6 - 1
netbox/dcim/views.py

@@ -75,11 +75,13 @@ def site(request, slug):
         'vlan_count': VLAN.objects.filter(site=site).count(),
         'circuit_count': Circuit.objects.filter(site=site).count(),
     }
+    rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
     topology_maps = TopologyMap.objects.filter(site=site)
 
     return render(request, 'dcim/site.html', {
         'site': site,
         'stats': stats,
+        'rack_groups': rack_groups,
         'topology_maps': topology_maps,
     })
 
@@ -1514,7 +1516,10 @@ def module_add(request, pk):
             module.device = device
             module.save()
             messages.success(request, "Added module {} to {}".format(module.name, module.device.name))
-            return redirect('dcim:device_inventory', pk=module.device.pk)
+            if '_addanother' in request.POST:
+                return redirect('dcim:module_add', pk=module.device.pk)
+            else:
+                return redirect('dcim:device_inventory', pk=module.device.pk)
 
     else:
         form = forms.ModuleForm()

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

@@ -1,7 +1,7 @@
 from rest_framework import generics
 
 from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
-from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter
+from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
 
 from . import serializers
 
@@ -12,6 +12,7 @@ class VRFListView(generics.ListAPIView):
     """
     queryset = VRF.objects.all()
     serializer_class = serializers.VRFSerializer
+    filter_class = VRFFilter
 
 
 class VRFDetailView(generics.RetrieveAPIView):

+ 28 - 8
netbox/ipam/filters.py

@@ -46,9 +46,14 @@ class PrefixFilter(django_filters.FilterSet):
         action='search_by_parent',
         label='Parent prefix',
     )
+    vrf = django_filters.MethodFilter(
+        action='_vrf',
+        label='VRF',
+    )
+    # Duplicate of `vrf` for backward-compatibility
     vrf_id = django_filters.MethodFilter(
-        action='vrf',
-        label='VRF (ID)',
+        action='_vrf',
+        label='VRF',
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
@@ -84,7 +89,7 @@ class PrefixFilter(django_filters.FilterSet):
 
     class Meta:
         model = Prefix
-        fields = ['family', 'site_id', 'site', 'vrf_id', 'vrf', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
+        fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
 
     def search(self, queryset, value):
         value = value.strip()
@@ -104,7 +109,7 @@ class PrefixFilter(django_filters.FilterSet):
         except AddrFormatError:
             return queryset.none()
 
-    def vrf(self, queryset, value):
+    def _vrf(self, queryset, value):
         if str(value) == '':
             return queryset
         try:
@@ -121,10 +126,14 @@ class IPAddressFilter(django_filters.FilterSet):
         action='search',
         label='Search',
     )
-    vrf_id = django_filters.ModelMultipleChoiceFilter(
-        name='vrf',
-        queryset=VRF.objects.all(),
-        label='VRF (ID)',
+    vrf = django_filters.MethodFilter(
+        action='_vrf',
+        label='VRF',
+    )
+    # Duplicate of `vrf` for backward-compatibility
+    vrf_id = django_filters.MethodFilter(
+        action='_vrf',
+        label='VRF',
     )
     device_id = django_filters.ModelMultipleChoiceFilter(
         name='interface__device',
@@ -155,6 +164,17 @@ class IPAddressFilter(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)
+
 
 class VLANFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(

+ 20 - 1
netbox/templates/dcim/site.html

@@ -126,13 +126,32 @@
         </div>
         <div class="panel panel-default">
             <div class="panel-heading">
+                <strong>Rack Groups</strong>
+            </div>
+            {% if rack_groups %}
+                <table class="table table-hover panel-body">
+                    {% for rg in rack_groups %}
+                        <tr>
+                            <td><i class="fa fa-fw fa-folder"></i> <a href="{{ rg.get_absolute_url }}">{{ rg.name }}</a></td>
+                            <td>{{ rg.rack_count }}</td>
+                        </tr>
+                    {% endfor %}
+                </table>
+            {% else %}
+                <div class="panel-body text-muted">
+                    None
+                </div>
+            {% endif %}
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
                 <strong>Topology Maps</strong>
             </div>
             {% if topology_maps %}
                 <table class="table table-hover panel-body">
                     {% for tm in topology_maps %}
                         <tr>
-                            <td><i class="fa fa-fw fa-map text-success"></i> <a href="{% url 'dcim-api:topology_map' slug=tm.slug %}" target="_blank">{{ tm }}</a></td>
+                            <td><i class="fa fa-fw fa-map"></i> <a href="{% url 'dcim-api:topology_map' slug=tm.slug %}" target="_blank">{{ tm }}</a></td>
                             <td>{{ tm.description }}</td>
                         </tr>
                     {% endfor %}