Explorar o código

Merge pull request #1259 from digitalocean/develop

Release v2.0.6
Jeremy Stretch %!s(int64=8) %!d(string=hai) anos
pai
achega
5c63a499d5

+ 28 - 0
docs/configuration/optional-settings.md

@@ -83,6 +83,34 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni
 
 ---
 
+## LOGGING
+
+By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
+
+The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/1.11/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
+
+```
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'handlers': {
+        'file': {
+            'level': 'INFO',
+            'class': 'logging.FileHandler',
+            'filename': '/var/log/netbox.log',
+        },
+    },
+    'loggers': {
+        'django': {
+            'handlers': ['file'],
+            'level': 'INFO',
+        },
+    },
+}
+```
+
+---
+
 ## LOGIN_REQUIRED
 
 Default: False

+ 14 - 15
docs/installation/ldap.md

@@ -28,6 +28,9 @@ Create a file in the same directory as `configuration.py` (typically `netbox/net
 
 ## General Server Configuration
 
+!!! info
+    When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
+
 ```python
 import ldap
 
@@ -49,11 +52,11 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
 LDAP_IGNORE_CERT_ERRORS = True
 ```
 
-!!! info
-    When using Windows Server 2012 you may need to specify a port on AUTH_LDAP_SERVER_URI - 3269 for secure, 3268 for non-secure.
-
 ## User Authentication
 
+!!! info
+    When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
+
 ```python
 from django_auth_ldap.config import LDAPSearch
 
@@ -73,9 +76,6 @@ AUTH_LDAP_USER_ATTR_MAP = {
 }
 ```
 
-!!! info
-    When using Windows Server 2012 AUTH_LDAP_USER_DN_TEMPLATE should be set to None.
-
 # User Groups for Permissions
 
 ```python
@@ -109,12 +109,11 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
 * `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
 * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
 
-!!! info
-    It is also possible map user attributes to Django attributes:
-
-    ```no-highlight
-    AUTH_LDAP_USER_ATTR_MAP = {
-        "first_name": "givenName",
-        "last_name": "sn"
-    }
-    ```
+It is also possible map user attributes to Django attributes:
+
+```python
+AUTH_LDAP_USER_ATTR_MAP = {
+    "first_name": "givenName",
+    "last_name": "sn",
+}
+```

+ 7 - 0
docs/installation/upgrading.md

@@ -52,6 +52,13 @@ Once the new code is in place, run the upgrade script (which may need to be run
 # ./upgrade.sh
 ```
 
+!!! warning
+    The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below.
+
+```no-highlight
+# ./upgrade.sh -2
+```
+
 This script:
 
 * Installs or upgrades any new required Python packages

+ 4 - 0
netbox/circuits/models.py

@@ -52,6 +52,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
+    csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url']
+
     class Meta:
         ordering = ['name']
 
@@ -107,6 +109,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
+    csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
+
     class Meta:
         ordering = ['provider', 'cid']
         unique_together = ['provider', 'cid']

+ 20 - 0
netbox/dcim/models.py

@@ -280,6 +280,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 
     objects = SiteManager()
 
+    csv_headers = [
+        'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
+    ]
+
     class Meta:
         ordering = ['name']
 
@@ -402,6 +406,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 
     objects = RackManager()
 
+    csv_headers = [
+        'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
+    ]
+
     class Meta:
         ordering = ['site', 'name']
         unique_together = [
@@ -981,6 +989,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
     objects = DeviceManager()
 
+    csv_headers = [
+        'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
+        'site', 'rack_group', 'rack_name', 'position', 'face',
+    ]
+
     class Meta:
         ordering = ['name']
         unique_together = ['rack', 'position', 'face']
@@ -1096,6 +1109,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             self.asset_tag,
             self.get_status_display(),
             self.site.name,
+            self.rack.group.name if self.rack and self.rack.group else None,
             self.rack.name if self.rack else None,
             self.position,
             self.get_face_display(),
@@ -1162,6 +1176,8 @@ class ConsolePort(models.Model):
                                    verbose_name='Console server port', blank=True, null=True)
     connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
 
+    csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status']
+
     class Meta:
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
@@ -1231,6 +1247,8 @@ class PowerPort(models.Model):
                                         blank=True, null=True)
     connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
 
+    csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status']
+
     class Meta:
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
@@ -1392,6 +1410,8 @@ class InterfaceConnection(models.Model):
     connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED,
                                             verbose_name='Status')
 
+    csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
+
     def clean(self):
         try:
             if self.interface_a == self.interface_b:

+ 9 - 14
netbox/dcim/views.py

@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 from copy import deepcopy
+from difflib import SequenceMatcher
 import re
 from natsort import natsorted
 from operator import attrgetter
@@ -776,20 +777,14 @@ class DeviceView(View):
         services = Service.objects.filter(device=device)
         secrets = device.secrets.all()
 
-        # Find any related devices for convenient linking in the UI
-        related_devices = []
-        if device.name:
-            if re.match('.+[0-9]+$', device.name):
-                # Strip 1 or more trailing digits (e.g. core-switch1)
-                base_name = re.match('(.*?)[0-9]+$', device.name).group(1)
-            elif re.match('.+\d[a-z]$', device.name.lower()):
-                # Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3)
-                base_name = re.match('(.*\d+)[a-z]$', device.name.lower()).group(1)
-            else:
-                base_name = None
-            if base_name:
-                related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\
-                    .select_related('rack', 'device_type__manufacturer')[:10]
+        # Find up to ten devices in the same site with the same functional role for quick reference.
+        related_devices = Device.objects.filter(
+            site=device.site, device_role=device.device_role
+        ).exclude(
+            pk=device.pk
+        ).select_related(
+            'rack', 'device_type__manufacturer'
+        )[:10]
 
         # Show graph button on interfaces only if at least one graph has been created.
         show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()

+ 62 - 0
netbox/extras/management/commands/nbshell.py

@@ -0,0 +1,62 @@
+from __future__ import unicode_literals
+
+import code
+import platform
+import sys
+
+from django import get_version
+from django.apps import apps
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from django.db.models import Model
+
+
+APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users']
+
+BANNER_TEXT = """### NetBox interactive shell ({node})
+### Python {python} | Django {django} | NetBox {netbox}
+### lsmodels() will show available models. Use help(<model>) for more info.""".format(
+    node=platform.node(),
+    python=platform.python_version(),
+    django=get_version(),
+    netbox=settings.VERSION
+)
+
+
+class Command(BaseCommand):
+    help = "Start the Django shell with all NetBox models already imported"
+    django_models = {}
+
+    def _lsmodels(self):
+        for app, models in self.django_models.items():
+            app_name = apps.get_app_config(app).verbose_name
+            print('{}:'.format(app_name))
+            for m in models:
+                print('  {}'.format(m))
+
+    def get_namespace(self):
+        namespace = {}
+
+        # Gather Django models from each app
+        for app in APPS:
+            self.django_models[app] = []
+            app_models = sys.modules['{}.models'.format(app)]
+            for name in dir(app_models):
+                model = getattr(app_models, name)
+                try:
+                    if issubclass(model, Model):
+                        namespace[name] = model
+                        self.django_models[app].append(name)
+                except TypeError:
+                    pass
+
+        # Load convenience commands
+        namespace.update({
+            'lsmodels': self._lsmodels,
+        })
+
+        return namespace
+
+    def handle(self, **options):
+        shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
+        return shell

+ 22 - 1
netbox/ipam/forms.py

@@ -180,6 +180,18 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         required=False,
         label='Site',
         widget=forms.Select(
+            attrs={'filter-for': 'vlan_group', 'nullable': 'true'}
+        )
+    )
+    vlan_group = ChainedModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        label='VLAN group',
+        widget=APISelect(
+            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
             attrs={'filter-for': 'vlan', 'nullable': 'true'}
         )
     )
@@ -187,11 +199,12 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         queryset=VLAN.objects.all(),
         chains=(
             ('site', 'site'),
+            ('group', 'vlan_group'),
         ),
         required=False,
         label='VLAN',
         widget=APISelect(
-            api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name'
         )
     )
 
@@ -200,6 +213,14 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
 
     def __init__(self, *args, **kwargs):
+
+        # Initialize helper selectors
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {})
+        if instance and instance.vlan is not None:
+            initial['vlan_group'] = instance.vlan.group
+        kwargs['initial'] = initial
+
         super(PrefixForm, self).__init__(*args, **kwargs)
 
         self.fields['vrf'].empty_label = 'Global'

+ 31 - 8
netbox/ipam/models.py

@@ -89,6 +89,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
     description = models.CharField(max_length=100, blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
+    csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
+
     class Meta:
         ordering = ['name']
         verbose_name = 'VRF'
@@ -146,6 +148,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
     description = models.CharField(max_length=100, blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
+    csv_headers = ['prefix', 'rir', 'date_added', 'description']
+
     class Meta:
         ordering = ['family', 'prefix']
 
@@ -200,7 +204,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
 
     def get_utilization(self):
         """
-        Determine the utilization rate of the aggregate prefix and return it as a percentage.
+        Determine the prefix utilization of the aggregate and return it as a percentage.
         """
         child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
         # Remove overlapping prefixes from list of children
@@ -297,6 +301,10 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
 
     objects = PrefixQuerySet.as_manager()
 
+    csv_headers = [
+        'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
+    ]
+
     class Meta:
         ordering = ['vrf', 'family', 'prefix']
         verbose_name_plural = 'prefixes'
@@ -307,9 +315,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     def get_absolute_url(self):
         return reverse('ipam:prefix', args=[self.pk])
 
-    def get_duplicates(self):
-        return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
-
     def clean(self):
 
         if self.prefix:
@@ -357,6 +362,22 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
             self.description,
         ])
 
+    def get_status_class(self):
+        return STATUS_CHOICE_CLASSES[self.status]
+
+    def get_duplicates(self):
+        return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
+
+    def get_utilization(self):
+        """
+        Determine the utilization of the prefix and return it as a percentage.
+        """
+        child_count = IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf).count()
+        prefix_size = self.prefix.size
+        if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
+            prefix_size -= 2
+        return int(float(child_count) / prefix_size * 100)
+
     @property
     def new_subnet(self):
         if self.family == 4:
@@ -368,9 +389,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
                 return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
             return None
 
-    def get_status_class(self):
-        return STATUS_CHOICE_CLASSES[self.status]
-
 
 class IPAddressManager(models.Manager):
 
@@ -414,6 +432,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
 
     objects = IPAddressManager()
 
+    csv_headers = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
+
     class Meta:
         ordering = ['family', 'address']
         verbose_name = 'IP address'
@@ -452,11 +472,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     def to_csv(self):
 
         # Determine if this IP is primary for a Device
-        is_primary = False
         if self.family == 4 and getattr(self, 'primary_ip4_for', False):
             is_primary = True
         elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
             is_primary = True
+        else:
+            is_primary = False
 
         return csv_format([
             self.address,
@@ -527,6 +548,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
     description = models.CharField(max_length=100, blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
+    csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+
     class Meta:
         ordering = ['site', 'group', 'vid']
         unique_together = [

+ 3 - 2
netbox/ipam/tables.py

@@ -34,7 +34,7 @@ RIR_ACTIONS = """
 
 UTILIZATION_GRAPH = """
 {% load helpers %}
-{% utilization_graph value %}
+{% if record.pk %}{% utilization_graph value %}{% else %}&mdash;{% endif %}
 """
 
 ROLE_ACTIONS = """
@@ -241,6 +241,7 @@ class PrefixTable(BaseTable):
     prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
     status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
+    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='IP Usage')
     tenant = tables.TemplateColumn(TENANT_LINK)
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
@@ -248,7 +249,7 @@ class PrefixTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Prefix
-        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
         row_attrs = {
             'class': lambda record: 'success' if not record.pk else '',
         }

+ 9 - 0
netbox/netbox/configuration.example.py

@@ -58,6 +58,11 @@ CORS_ORIGIN_REGEX_WHITELIST = [
     # r'^(https?://)?(\w+\.)?example\.com$',
 ]
 
+# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal
+# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
+# on a production system.
+DEBUG = False
+
 # Email settings
 EMAIL = {
     'SERVER': 'localhost',
@@ -72,6 +77,10 @@ EMAIL = {
 # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
 ENFORCE_GLOBAL_UNIQUE = False
 
+# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
+#   https://docs.djangoproject.com/en/1.11/topics/logging/
+LOGGING = {}
+
 # Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
 # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 LOGIN_REQUIRED = False

+ 21 - 19
netbox/netbox/settings.py

@@ -13,9 +13,9 @@ except ImportError:
     )
 
 
-VERSION = '2.0.5'
+VERSION = '2.0.6'
 
-# Import local configuration
+# Import required configuration parameters
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
     try:
@@ -25,33 +25,35 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
             "Mandatory setting {} is missing from configuration.py.".format(setting)
         )
 
-# Default configurations
+# Import optional configuration parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
-DEBUG = getattr(configuration, 'DEBUG', False)
-EMAIL = getattr(configuration, 'EMAIL', {})
-LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
+BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
+BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
+CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
+CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
+CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
+DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
+DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
+DEBUG = getattr(configuration, 'DEBUG', False)
+ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
+EMAIL = getattr(configuration, 'EMAIL', {})
+LOGGING = getattr(configuration, 'LOGGING', {})
+LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
+MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
+PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '')
 NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '')
-TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
-DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
-TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
-SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
-DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
-BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
-BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
-PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
-ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
-MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
-CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
-CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
-CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
+SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
+TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
+TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
+
 CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 
 # Attempt to import LDAP configuration if it has been defined

+ 1 - 0
netbox/secrets/models.py

@@ -291,6 +291,7 @@ class Secret(CreatedUpdatedModel):
     hash = models.CharField(max_length=128, editable=False)
 
     plaintext = None
+    csv_headers = ['device', 'role', 'name', 'plaintext']
 
     class Meta:
         ordering = ['device', 'role', 'name']

+ 6 - 0
netbox/templates/ipam/aggregate.html

@@ -57,6 +57,12 @@
                         <a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a>
                     </td>
                 </tr>
+                <tr>
+                    <td>Utilization</td>
+                    <td>
+                        {{ aggregate.get_utilization }}%
+                    </td>
+                </tr>
                 <tr>
                     <td>Date Added</td>
                     <td>

+ 2 - 2
netbox/templates/ipam/prefix.html

@@ -121,8 +121,8 @@
                     </td>
                 </tr>
                 <tr>
-                    <td>IP Addresses</td>
-                    <td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }}</a></td>
+                    <td>Utilization</td>
+                    <td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }} IP addresses</a> ({{ prefix.get_utilization }}%)</td>
                 </tr>
             </table>
         </div>

+ 8 - 2
netbox/templates/ipam/prefix_edit.html

@@ -8,13 +8,19 @@
             {% render_field form.prefix %}
             {% render_field form.status %}
             {% render_field form.vrf %}
-            {% render_field form.site %}
-            {% render_field form.vlan %}
             {% render_field form.role %}
             {% render_field form.description %}
             {% render_field form.is_pool %}
         </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Site/VLAN Assignment</strong></div>
+        <div class="panel-body">
+            {% render_field form.site %}
+            {% render_field form.vlan_group %}
+            {% render_field form.vlan %}
+        </div>
+    </div>
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Tenancy</strong></div>
         <div class="panel-body">

+ 2 - 0
netbox/tenancy/models.py

@@ -41,6 +41,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
+    csv_headers = ['name', 'slug', 'group', 'description']
+
     class Meta:
         ordering = ['group', 'name']
 

+ 2 - 2
netbox/utilities/forms.py

@@ -478,8 +478,8 @@ class ChainedFieldsMixin(forms.BaseForm):
 
                 filters_dict = {}
                 for (db_field, parent_field) in field.chains:
-                    if self.is_bound and self.data.get(parent_field):
-                        filters_dict[db_field] = self.data[parent_field]
+                    if self.is_bound and parent_field in self.data:
+                        filters_dict[db_field] = self.data[parent_field] or None
                     elif self.initial.get(parent_field):
                         filters_dict[db_field] = self.initial[parent_field]
                     elif self.fields[parent_field].widget.attrs.get('nullable'):

+ 3 - 1
netbox/utilities/views.py

@@ -102,7 +102,9 @@ class ObjectListView(View):
                                .format(et.name))
         # Fall back to built-in CSV export
         elif 'export' in request.GET and hasattr(model, 'to_csv'):
-            output = '\n'.join([obj.to_csv() for obj in self.queryset])
+            headers = getattr(model, 'csv_headers', None)
+            output = ','.join(headers) + '\n' if headers else ''
+            output += '\n'.join([obj.to_csv() for obj in self.queryset])
             response = HttpResponse(
                 output,
                 content_type='text/csv'

+ 19 - 6
upgrade.sh

@@ -5,6 +5,25 @@
 # Once the script completes, remember to restart the WSGI service (e.g.
 # gunicorn or uWSGI).
 
+# Determine which version of Python/pip to use. Default to v3 (if available)
+# but allow the user to force v2.
+PYTHON="python3"
+PIP="pip3"
+type $PYTHON >/dev/null 2>&1 && type $PIP >/dev/null 2>&1 || PYTHON="python" PIP="pip"
+while getopts ":2" opt; do
+    case $opt in
+        2)
+            PYTHON="python"
+            PIP="pip"
+            echo "Forcing Python/pip v2"
+            ;;
+        \?)
+            echo "Invalid option: -$OPTARG" >&2
+            exit
+            ;;
+    esac
+done
+
 # Optionally use sudo if not already root, and always prompt for password
 # before running the command
 PREFIX="sudo -k "
@@ -20,12 +39,6 @@ COMMAND="${PREFIX}find . -name \"*.pyc\" -delete"
 echo "Cleaning up stale Python bytecode ($COMMAND)..."
 eval $COMMAND
 
-# Prefer python3/pip3
-PYTHON="python3"
-type $PYTHON >/dev/null 2>&1 || PYTHON="python"
-PIP="pip3"
-type $PIP >/dev/null 2>&1 || PIP="pip"
-
 # Install any new Python packages
 COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade"
 echo "Updating required Python packages ($COMMAND)..."