Przeglądaj źródła

Merge pull request #726 from digitalocean/develop

Release v1.7.2
Jeremy Stretch 9 lat temu
rodzic
commit
66be85a41f
40 zmienionych plików z 360 dodań i 213 usunięć
  1. 3 2
      CONTRIBUTING.md
  2. 4 4
      docs/installation/docker.md
  3. 3 3
      docs/installation/ldap.md
  4. 17 18
      docs/installation/netbox.md
  5. 6 6
      docs/installation/postgresql.md
  6. 6 6
      docs/installation/upgrading.md
  7. 11 11
      docs/installation/web-server.md
  8. 2 2
      netbox/circuits/forms.py
  9. 11 0
      netbox/dcim/filters.py
  10. 9 9
      netbox/dcim/fixtures/initial_data.json
  11. 2 1
      netbox/dcim/forms.py
  12. 57 0
      netbox/dcim/migrations/0022_color_names_to_rgb.py
  13. 11 37
      netbox/dcim/models.py
  14. 3 3
      netbox/dcim/tables.py
  15. 5 1
      netbox/extras/admin.py
  16. 2 2
      netbox/extras/models.py
  17. 1 1
      netbox/ipam/admin.py
  18. 2 2
      netbox/ipam/api/serializers.py
  19. 7 0
      netbox/ipam/filters.py
  20. 12 6
      netbox/ipam/fixtures/initial_data.json
  21. 9 1
      netbox/ipam/forms.py
  22. 20 0
      netbox/ipam/migrations/0011_rir_add_is_private.py
  23. 2 0
      netbox/ipam/models.py
  24. 3 1
      netbox/ipam/tables.py
  25. 2 0
      netbox/ipam/views.py
  26. 2 2
      netbox/netbox/settings.py
  27. 37 72
      netbox/project-static/css/base.css
  28. BIN
      netbox/project-static/img/tint_20.png
  29. 1 8
      netbox/templates/dcim/inc/_rack_elevation.html
  30. 2 1
      netbox/templates/dcim/ipaddress_assign.html
  31. 1 1
      netbox/templates/ipam/inc/prefix_header.html
  32. 1 1
      netbox/templates/ipam/ipaddress.html
  33. 18 2
      netbox/templates/ipam/ipaddress_assign.html
  34. 8 5
      netbox/templates/ipam/rir_list.html
  35. 8 2
      netbox/templates/tenancy/tenant.html
  36. 1 1
      netbox/templates/tenancy/tenant_import.html
  37. 1 1
      netbox/tenancy/models.py
  38. 19 0
      netbox/utilities/fields.py
  39. 50 1
      netbox/utilities/forms.py
  40. 1 0
      requirements.txt

+ 3 - 2
CONTRIBUTING.md

@@ -43,8 +43,9 @@ take some time for someone to address your issue.
 * First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're
 requesting is already listed. (Be sure to search closed issues as well, since some feature requests are rejected.) If
 the feature you'd like to see has already been requested, click "add a reaction" in the top right corner of the issue
-and add a thumbs up (+1). This ensures that the issue has a better chance of making it onto the roadmap. Also feel free
-to add a comment with any additional justification for the feature.
+and add a thumbs up. This ensures that the issue has a better chance of making it onto the roadmap. Also feel free
+to add a comment with any additional justification for the feature. (However, note that comments with no substance
+other than a "+1" will be deleted as spam. Please use GitHub's reactions feature to indicate your support.)
 
 * While suggestions for new features are welcome, it's important to limit the scope of NetBox's feature set to avoid
 feature creep. For example, the following features would be firmly out of scope for NetBox:

+ 4 - 4
docs/installation/docker.md

@@ -4,10 +4,10 @@ This guide demonstrates how to build and run NetBox as a Docker container. It as
 
 To get NetBox up and running:
 
-```
-git clone -b master https://github.com/digitalocean/netbox.git
-cd netbox
-docker-compose up -d
+```no-highlight
+# git clone -b master https://github.com/digitalocean/netbox.git
+# cd netbox
+# docker-compose up -d
 ```
 
 The application will be available on http://localhost/ after a few minutes.

+ 3 - 3
docs/installation/ldap.md

@@ -7,19 +7,19 @@ built-in Django users in the event of a failure.
 
 On Ubuntu:
 
-```
+```no-highlight
 sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev
 ```
 
 On CentOS:
 
-```
+```no-highlight
 sudo yum install -y python-devel openldap-devel
 ```
 
 ## Install django-auth-ldap
 
-```
+```no-highlight
 sudo pip install django-auth-ldap
 ```
 

+ 17 - 18
docs/installation/netbox.md

@@ -2,13 +2,13 @@
 
 **Debian/Ubuntu**
 
-```
+```no-highlight
 # apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
 ```
 
 **CentOS/RHEL**
 
-```
+```no-highlight
 # yum install -y epel-release
 # yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
 ```
@@ -19,7 +19,7 @@ You may opt to install NetBox either from a numbered release or by cloning the m
 
 Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
 
-```
+```no-highlight
 # wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
 # tar -xzf vX.Y.Z.tar.gz -C /opt
 # cd /opt/
@@ -31,28 +31,27 @@ Download the [latest stable release](https://github.com/digitalocean/netbox/rele
 
 Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
 
-```
-# mkdir -p /opt/netbox/
-# cd /opt/netbox/
+```no-highlight
+# mkdir -p /opt/netbox/ && cd /opt/netbox/
 ```
 
 If `git` is not already installed, install it:
 
 **Debian/Ubuntu**
 
-```
+```no-highlight
 # apt-get install -y git
 ```
 
 **CentOS/RHEL**
 
-```
+```no-highlight
 # yum install -y git
 ```
 
 Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
 
-```
+```no-highlight
 # git clone -b master https://github.com/digitalocean/netbox.git .
 Cloning into '.'...
 remote: Counting objects: 1994, done.
@@ -67,7 +66,7 @@ Checking connectivity... done.
 
 Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
 
-```
+```no-highlight
 # pip install -r requirements.txt
 ```
 
@@ -75,7 +74,7 @@ Install the required Python packages using pip. (If you encounter any compilatio
 
 Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
 
-```
+```no-highlight
 # cd netbox/netbox/
 # cp configuration.example.py configuration.py
 ```
@@ -92,7 +91,7 @@ This is a list of the valid hostnames by which this server can be reached. You m
 
 Example:
 
-```
+```python
 ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
 ```
 
@@ -102,7 +101,7 @@ This parameter holds the database configuration details. You must define the use
 
 Example:
 
-```
+```python
 DATABASE = {
     'NAME': 'netbox',               # Database name
     'USER': 'netbox',               # PostgreSQL username
@@ -125,7 +124,7 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
 
 Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
 
-```
+```no-highlight
 # cd /opt/netbox/netbox/
 # ./manage.py migrate
 Operations to perform:
@@ -144,7 +143,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
 
 NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
 
-```
+```no-highlight
 # ./manage.py createsuperuser
 Username: admin
 Email address: admin@example.com
@@ -155,7 +154,7 @@ Superuser created successfully.
 
 # Collect Static Files
 
-```
+```no-highlight
 # ./manage.py collectstatic
 
 You have requested to collect static files at the destination
@@ -176,7 +175,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
 !!! note
     This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
 
-```
+```no-highlight
 # ./manage.py loaddata initial_data
 Installed 43 object(s) from 4 fixture(s)
 ```
@@ -185,7 +184,7 @@ Installed 43 object(s) from 4 fixture(s)
 
 At this point, NetBox should be able to run. We can verify this by starting a development instance:
 
-```
+```no-highlight
 # ./manage.py runserver 0.0.0.0:8000 --insecure
 Performing system checks...
 

+ 6 - 6
docs/installation/postgresql.md

@@ -4,27 +4,27 @@ NetBox requires a PostgreSQL database to store data. MySQL is not supported, as
 
 **Debian/Ubuntu**
 
-```
+```no-highlight
 # apt-get install -y postgresql libpq-dev python-psycopg2
 ```
 
 **CentOS/RHEL**
 
-```
+```no-highlight
 # yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
 # postgresql-setup initdb
 ```
 
 If using CentOS, modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
 
-```
+```no-highlight
 host    all             all             127.0.0.1/32            md5
 host    all             all             ::1/128                 md5
 ```
 
 Then, start the service:
 
-```
+```no-highlight
 # systemctl start postgresql
 ```
 
@@ -35,7 +35,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
 !!! danger
     DO NOT USE THE PASSWORD FROM THE EXAMPLE.
 
-```
+```no-highlight
 # sudo -u postgres psql
 psql (9.3.13)
 Type "help" for help.
@@ -51,7 +51,7 @@ postgres=# \q
 
 You can verify that authentication works issuing the following command and providing the configured password:
 
-```
+```no-highlight
 # psql -U netbox -h localhost -W
 ```
 

+ 6 - 6
docs/installation/upgrading.md

@@ -8,7 +8,7 @@ Download the [latest stable release](https://github.com/digitalocean/netbox/rele
 
 Download and extract the latest version:
 
-```
+```no-highlight
 # wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
 # tar -xzf vX.Y.Z.tar.gz -C /opt
 # cd /opt/
@@ -17,13 +17,13 @@ Download and extract the latest version:
 
 Copy the 'configuration.py' you created when first installing to the new version:
 
-```
+```no-highlight
 # cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
 ```
 
 If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
 
-```
+```no-highlight
 # cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
 ```
 
@@ -31,7 +31,7 @@ If you followed the original installation guide to set up gunicorn, be sure to c
 
 This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
 
-```
+```no-highlight
 # cd /opt/netbox
 # git checkout master
 # git pull origin master
@@ -42,7 +42,7 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most
 
 Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured).
 
-```
+```no-highlight
 # ./upgrade.sh
 ```
 
@@ -56,6 +56,6 @@ This script:
 
 Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
 
-```
+```no-highlight
 # sudo supervisorctl restart netbox
 ```

+ 11 - 11
docs/installation/web-server.md

@@ -5,7 +5,7 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for
 !!! info
     Only Debian/Ubuntu instructions are provided here, but the installation process for CentOS/RHEL does not differ much. Please consult the documentation for those distributions for details.
 
-```
+```no-highlight
 # apt-get install -y gunicorn supervisor
 ```
 
@@ -13,13 +13,13 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for
 
 The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
 
-```
+```no-highlight
 # apt-get install -y nginx
 ```
 
 Once nginx is installed, save the following configuration to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
 
-```
+```nginx
 server {
     listen 80;
 
@@ -43,7 +43,7 @@ server {
 
 Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
 
-```
+```no-highlight
 # cd /etc/nginx/sites-enabled/
 # rm default
 # ln -s /etc/nginx/sites-available/netbox
@@ -51,7 +51,7 @@ Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sit
 
 Restart the nginx service to use the new configuration.
 
-```
+```no-highlight
 # service nginx restart
 ```
 
@@ -59,13 +59,13 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
 
 ## Option B: Apache
 
-```
+```no-highlight
 # apt-get install -y apache2
 ```
 
 Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
 
-```
+```apache
 <VirtualHost *:80>
     ProxyPreserveHost On
 
@@ -90,7 +90,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
 
 Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache:
 
-```
+```no-highlight
 # a2enmod proxy
 # a2enmod proxy_http
 # a2ensite netbox
@@ -103,7 +103,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https
 
 Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL change the username from `www-data` to `nginx` or `apache`.
 
-```
+```no-highlight
 command = '/usr/bin/gunicorn'
 pythonpath = '/opt/netbox/netbox'
 bind = '127.0.0.1:8001'
@@ -115,7 +115,7 @@ user = 'www-data'
 
 Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
 
-```
+```no-highlight
 [program:netbox]
 command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
 directory = /opt/netbox/netbox/
@@ -124,7 +124,7 @@ user = www-data
 
 Then, restart the supervisor service to detect and run the gunicorn service:
 
-```
+```no-highlight
 # service supervisor restart
 ```
 

+ 2 - 2
netbox/circuits/forms.py

@@ -54,7 +54,7 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     portal_url = forms.URLField(required=False, label='Portal')
     noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact')
     admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
-    comments = CommentField()
+    comments = CommentField(widget=SmallTextarea)
 
     class Meta:
         nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
@@ -183,7 +183,7 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
-    comments = CommentField()
+    comments = CommentField(widget=SmallTextarea)
 
     class Meta:
         nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']

+ 11 - 0
netbox/dcim/filters.py

@@ -1,4 +1,5 @@
 import django_filters
+from netaddr.core import AddrFormatError
 
 from django.db.models import Q
 
@@ -146,6 +147,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search',
         label='Search',
     )
+    mac_address = django_filters.MethodFilter(
+        action='_mac_address',
+        label='MAC address',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='rack__site',
         queryset=Site.objects.all(),
@@ -254,6 +259,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
             Q(comments__icontains=value)
         ).distinct()
 
+    def _mac_address(self, queryset, value):
+        try:
+            return queryset.filter(interfaces__mac_address=value.strip()).distinct()
+        except AddrFormatError:
+            return queryset.none()
+
 
 class ConsolePortFilter(django_filters.FilterSet):
     device_id = django_filters.ModelMultipleChoiceFilter(

+ 9 - 9
netbox/dcim/fixtures/initial_data.json

@@ -5,7 +5,7 @@
     "fields": {
         "name": "Console Server",
         "slug": "console-server",
-        "color": "teal"
+        "color": "009688"
     }
 },
 {
@@ -14,7 +14,7 @@
     "fields": {
         "name": "Core Switch",
         "slug": "core-switch",
-        "color": "blue"
+        "color": "2196f3"
     }
 },
 {
@@ -23,7 +23,7 @@
     "fields": {
         "name": "Distribution Switch",
         "slug": "distribution-switch",
-        "color": "blue"
+        "color": "2196f3"
     }
 },
 {
@@ -32,7 +32,7 @@
     "fields": {
         "name": "Access Switch",
         "slug": "access-switch",
-        "color": "blue"
+        "color": "2196f3"
     }
 },
 {
@@ -41,7 +41,7 @@
     "fields": {
         "name": "Management Switch",
         "slug": "management-switch",
-        "color": "orange"
+        "color": "ff9800"
     }
 },
 {
@@ -50,7 +50,7 @@
     "fields": {
         "name": "Firewall",
         "slug": "firewall",
-        "color": "red"
+        "color": "f44336"
     }
 },
 {
@@ -59,7 +59,7 @@
     "fields": {
         "name": "Router",
         "slug": "router",
-        "color": "purple"
+        "color": "9c27b0"
     }
 },
 {
@@ -68,7 +68,7 @@
     "fields": {
         "name": "Server",
         "slug": "server",
-        "color": "medium_gray"
+        "color": "9e9e9e"
     }
 },
 {
@@ -77,7 +77,7 @@
     "fields": {
         "name": "PDU",
         "slug": "pdu",
-        "color": "dark_gray"
+        "color": "607d8b"
     }
 },
 {

+ 2 - 1
netbox/dcim/forms.py

@@ -221,7 +221,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
     width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
     u_height = forms.IntegerField(required=False, label='Height (U)')
-    comments = CommentField()
+    comments = CommentField(widget=SmallTextarea)
 
     class Meta:
         nullable_fields = ['group', 'tenant', 'role', 'comments']
@@ -612,6 +612,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
                                  to_field_name='slug', null_option=(0, 'None'))
     status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
+    mac_address = forms.CharField(label='MAC address')
 
 
 #

+ 57 - 0
netbox/dcim/migrations/0022_color_names_to_rgb.py

@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-06 16:35
+from __future__ import unicode_literals
+
+from django.db import migrations
+import utilities.fields
+
+
+COLOR_CONVERSION = {
+    'teal': '009688',
+    'green': '4caf50',
+    'blue': '2196f3',
+    'purple': '9c27b0',
+    'yellow': 'ffeb3b',
+    'orange': 'ff9800',
+    'red': 'f44336',
+    'light_gray': 'c0c0c0',
+    'medium_gray': '9e9e9e',
+    'dark_gray': '607d8b',
+}
+
+
+def color_names_to_rgb(apps, schema_editor):
+    RackRole = apps.get_model('dcim', 'RackRole')
+    DeviceRole = apps.get_model('dcim', 'DeviceRole')
+    for color_name, color_rgb in COLOR_CONVERSION.items():
+        RackRole.objects.filter(color=color_name).update(color=color_rgb)
+        DeviceRole.objects.filter(color=color_name).update(color=color_rgb)
+
+
+def color_rgb_to_name(apps, schema_editor):
+    RackRole = apps.get_model('dcim', 'RackRole')
+    DeviceRole = apps.get_model('dcim', 'DeviceRole')
+    for color_name, color_rgb in COLOR_CONVERSION.items():
+        RackRole.objects.filter(color=color_rgb).update(color=color_name)
+        DeviceRole.objects.filter(color=color_rgb).update(color=color_name)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0021_add_ff_flexstack'),
+    ]
+
+    operations = [
+        migrations.RunPython(color_names_to_rgb, color_rgb_to_name),
+        migrations.AlterField(
+            model_name='devicerole',
+            name='color',
+            field=utilities.fields.ColorField(max_length=6),
+        ),
+        migrations.AlterField(
+            model_name='rackrole',
+            name='color',
+            field=utilities.fields.ColorField(max_length=6),
+        ),
+    ]

+ 11 - 37
netbox/dcim/models.py

@@ -3,7 +3,7 @@ from collections import OrderedDict
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.fields import GenericRelation
-from django.core.exceptions import MultipleObjectsReturned, ValidationError
+from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -12,7 +12,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
-from utilities.fields import NullableCharField
+from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
 
@@ -54,29 +54,6 @@ SUBDEVICE_ROLE_CHOICES = (
     (SUBDEVICE_ROLE_CHILD, 'Child'),
 )
 
-COLOR_TEAL = 'teal'
-COLOR_GREEN = 'green'
-COLOR_BLUE = 'blue'
-COLOR_PURPLE = 'purple'
-COLOR_YELLOW = 'yellow'
-COLOR_ORANGE = 'orange'
-COLOR_RED = 'red'
-COLOR_GRAY1 = 'light_gray'
-COLOR_GRAY2 = 'medium_gray'
-COLOR_GRAY3 = 'dark_gray'
-ROLE_COLOR_CHOICES = [
-    [COLOR_TEAL, 'Teal'],
-    [COLOR_GREEN, 'Green'],
-    [COLOR_BLUE, 'Blue'],
-    [COLOR_PURPLE, 'Purple'],
-    [COLOR_YELLOW, 'Yellow'],
-    [COLOR_ORANGE, 'Orange'],
-    [COLOR_RED, 'Red'],
-    [COLOR_GRAY1, 'Light Gray'],
-    [COLOR_GRAY2, 'Medium Gray'],
-    [COLOR_GRAY3, 'Dark Gray'],
-]
-
 # Virtual
 IFACE_FF_VIRTUAL = 0
 # Ethernet
@@ -345,7 +322,7 @@ class RackRole(models.Model):
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
-    color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
+    color = ColorField()
 
     class Meta:
         ordering = ['name']
@@ -761,7 +738,7 @@ class DeviceRole(models.Model):
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
-    color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
+    color = ColorField()
 
     class Meta:
         ordering = ['name']
@@ -1173,16 +1150,13 @@ class Interface(models.Model):
         return None
 
     def get_connected_interface(self):
-        try:
-            connection = InterfaceConnection.objects.select_related().get(Q(interface_a=self) | Q(interface_b=self))
-            if connection.interface_a == self:
-                return connection.interface_b
-            else:
-                return connection.interface_a
-        except InterfaceConnection.DoesNotExist:
-            return None
-        except InterfaceConnection.MultipleObjectsReturned:
-            raise MultipleObjectsReturned("Multiple connections found for {} interface {}!".format(self.device, self))
+        connection = InterfaceConnection.objects.select_related().filter(Q(interface_a=self) | Q(interface_b=self))\
+            .first()
+        if connection and connection.interface_a == self:
+            return connection.interface_b
+        elif connection:
+            return connection.interface_a
+        return None
 
 
 class InterfaceConnection(models.Model):

+ 3 - 3
netbox/dcim/tables.py

@@ -11,7 +11,7 @@ from .models import (
 
 
 COLOR_LABEL = """
-<label class="label {{ record.color }}">{{ record }}</label>
+<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
 """
 
 DEVICE_LINK = """
@@ -34,7 +34,7 @@ RACKROLE_ACTIONS = """
 
 RACK_ROLE = """
 {% if record.role %}
-    <label class="label {{ record.role.color }}">{{ value }}</label>
+    <label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
 {% else %}
     &mdash;
 {% endif %}
@@ -59,7 +59,7 @@ PLATFORM_ACTIONS = """
 """
 
 DEVICE_ROLE = """
-<label class="label {{ record.device_role.color }}">{{ value }}</label>
+<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
 """
 
 STATUS_ICON = """

+ 5 - 1
netbox/extras/admin.py

@@ -1,5 +1,6 @@
 from django import forms
 from django.contrib import admin
+from django.utils.safestring import mark_safe
 
 from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
 
@@ -54,4 +55,7 @@ class TopologyMapAdmin(admin.ModelAdmin):
 @admin.register(UserAction)
 class UserActionAdmin(admin.ModelAdmin):
     actions = None
-    list_display = ['user', 'action', 'content_type', 'object_id', 'message']
+    list_display = ['user', 'action', 'content_type', 'object_id', '_message']
+
+    def _message(self, obj):
+        return mark_safe(obj.message)

+ 2 - 2
netbox/extras/models.py

@@ -130,7 +130,7 @@ class CustomField(models.Model):
         if self.type == CF_TYPE_SELECT:
             # Could be ModelChoiceField or TypedChoiceField
             return str(value.id) if hasattr(value, 'id') else str(value)
-        return str(value)
+        return value
 
     def deserialize_value(self, serialized_value):
         """
@@ -165,7 +165,7 @@ class CustomFieldValue(models.Model):
         unique_together = ['field', 'obj_type', 'obj_id']
 
     def __unicode__(self):
-        return '{} {}'.format(self.obj, self.field)
+        return u'{} {}'.format(self.obj, self.field)
 
     @property
     def value(self):

+ 1 - 1
netbox/ipam/admin.py

@@ -28,7 +28,7 @@ class RIRAdmin(admin.ModelAdmin):
     prepopulated_fields = {
         'slug': ['name'],
     }
-    list_display = ['name', 'slug']
+    list_display = ['name', 'slug', 'is_private']
 
 
 @admin.register(Aggregate)

+ 2 - 2
netbox/ipam/api/serializers.py

@@ -58,13 +58,13 @@ class RIRSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = RIR
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'is_private']
 
 
 class RIRNestedSerializer(RIRSerializer):
 
     class Meta(RIRSerializer.Meta):
-        pass
+        fields = ['id', 'name', 'slug']
 
 
 #

+ 7 - 0
netbox/ipam/filters.py

@@ -46,6 +46,13 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         fields = ['name', 'rd']
 
 
+class RIRFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = RIR
+        fields = ['is_private']
+
+
 class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
     q = django_filters.MethodFilter(
         action='search',

+ 12 - 6
netbox/ipam/fixtures/initial_data.json

@@ -43,7 +43,8 @@
     "pk": 1,
     "fields": {
         "name": "ARIN",
-        "slug": "arin"
+        "slug": "arin",
+        "is_private": false
     }
 },
 {
@@ -51,7 +52,8 @@
     "pk": 2,
     "fields": {
         "name": "RIPE",
-        "slug": "ripe"
+        "slug": "ripe",
+        "is_private": false
     }
 },
 {
@@ -59,7 +61,8 @@
     "pk": 3,
     "fields": {
         "name": "APNIC",
-        "slug": "apnic"
+        "slug": "apnic",
+        "is_private": false
     }
 },
 {
@@ -67,7 +70,8 @@
     "pk": 4,
     "fields": {
         "name": "LACNIC",
-        "slug": "lacnic"
+        "slug": "lacnic",
+        "is_private": false
     }
 },
 {
@@ -75,7 +79,8 @@
     "pk": 5,
     "fields": {
         "name": "AFRINIC",
-        "slug": "afrinic"
+        "slug": "afrinic",
+        "is_private": false
     }
 },
 {
@@ -83,7 +88,8 @@
     "pk": 6,
     "fields": {
         "name": "RFC 1918",
-        "slug": "rfc-1918"
+        "slug": "rfc-1918",
+        "is_private": true
     }
 },
 {

+ 9 - 1
netbox/ipam/forms.py

@@ -75,7 +75,15 @@ class RIRForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = RIR
-        fields = ['name', 'slug']
+        fields = ['name', 'slug', 'is_private']
+
+
+class RIRFilterForm(forms.Form, BootstrapMixin):
+    is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
+        ('', '---------'),
+        ('True', 'Yes'),
+        ('False', 'No'),
+    ]))
 
 
 #

+ 20 - 0
netbox/ipam/migrations/0011_rir_add_is_private.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-06 18:27
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0010_ipaddress_help_texts'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rir',
+            name='is_private',
+            field=models.BooleanField(default=False, help_text=b'IP space managed by this RIR is considered private', verbose_name=b'Private'),
+        ),
+    ]

+ 2 - 0
netbox/ipam/models.py

@@ -103,6 +103,8 @@ class RIR(models.Model):
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
+    is_private = models.BooleanField(default=False, verbose_name='Private',
+                                     help_text='IP space managed by this RIR is considered private')
 
     class Meta:
         ordering = ['name']

+ 3 - 1
netbox/ipam/tables.py

@@ -126,6 +126,7 @@ class VRFTable(BaseTable):
 class RIRTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
+    is_private = tables.BooleanColumn(verbose_name='Private')
     aggregate_count = tables.Column(verbose_name='Aggregates')
     stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
                                 footer=lambda table: sum(r.stats['total'] for r in table.data))
@@ -142,7 +143,8 @@ class RIRTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = RIR
-        fields = ('pk', 'name', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', 'stats_deprecated', 'stats_available', 'utilization', 'actions')
+        fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
+                  'stats_deprecated', 'stats_available', 'utilization', 'actions')
 
 
 #

+ 2 - 0
netbox/ipam/views.py

@@ -154,6 +154,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 class RIRListView(ObjectListView):
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
+    filter = filters.RIRFilter
+    filter_form = forms.RIRFilterForm
     table = tables.RIRTable
     edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
     template_name = 'ipam/rir_list.html'

+ 2 - 2
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
 
 
-VERSION = '1.7.1'
+VERSION = '1.7.2'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -188,7 +188,7 @@ REST_FRAMEWORK = {
 
 # Swagger settings (API docs)
 SWAGGER_SETTINGS = {
-    'base_path': '{}/api/docs'.format(ALLOWED_HOSTS[0]),
+    'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
 }
 
 

+ 37 - 72
netbox/project-static/css/base.css

@@ -98,7 +98,7 @@ nav ul.pagination {
 div.rack_header {
     margin-left: 36px;
     text-align: center;
-    width: 200px;
+    width: 230px;
 }
 ul.rack_legend {
     float: left;
@@ -126,29 +126,16 @@ ul.rack {
     list-style-type: none;
     padding: 0;
     position: absolute;
-    width: 200px;
+    width: 230px;
 }
 ul.rack li {
+    border-top: 1px solid #e0e0e0;
     display: block;
     font-size: 13px;
     height: 20px;
     overflow: hidden;
     text-align: center;
 }
-ul.rack_empty li {
-    background-color: #f7f7f7;
-    border-bottom: 1px solid #dddddd;
-    height: 20px;
-}
-ul.rack li.empty:last-child {
-    border-bottom: 0;
-}
-ul.rack_far_face {
-    z-index: 100;
-}
-ul.rack_near_face {
-    z-index: 200;
-}
 ul.rack li.h2u { height: 40px; }
 ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; }
 ul.rack li.h3u { height: 60px; }
@@ -247,22 +234,9 @@ ul.rack li.h49u { height: 980px; }
 ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; }
 ul.rack li.h50u { height: 1000px; }
 ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; }
-ul.rack li.occupied a {
-    color: #ffffff;
-    display: block;
-    font-weight: bold;
-}
-ul.rack li.occupied a:hover {
-    text-decoration: none;
-}
-ul.rack li.occupied span {
-    display: block;
-}
-ul.rack_near_face li.empty {
-    border-bottom: 1px solid #e0e0e0;
-}
-ul.rack_near_face li.occupied {
-    color: #474747;
+ul.rack_far_face {
+    background-color: #f7f7f7;
+    z-index: 100;
 }
 ul.rack_far_face li.occupied {
     background: repeating-linear-gradient(
@@ -272,7 +246,6 @@ ul.rack_far_face li.occupied {
         #f0f0f0 7px,
         #f0f0f0 14px
     );
-    color: #303030;
 }
 ul.rack_far_face li.blocked {
     background: repeating-linear-gradient(
@@ -282,54 +255,46 @@ ul.rack_far_face li.blocked {
         #ffc7c7 7px,
         #ffc7c7 14px
     );
-    border-bottom: 1px solid #e0e0e0;
-    color: #303030;
 }
-ul.rack_near_face li.empty a {
+ul.rack_near_face {
+    z-index: 200;
+}
+ul.rack_near_face li.occupied {
+    border-top: 1px solid #474747;
+    color: #474747;
+}
+ul.rack_near_face li.occupied:hover {
+    background-image: url('../img/tint_20.png');
+}
+ul.rack_near_face li:first-child {
+    border-top: 0;
+}
+ul.rack_near_face li.available a {
     color: #0000ff;
     display: none;
     text-decoration: none;
 }
-ul.rack_near_face li.empty:hover {
+ul.rack_near_face li.available:hover {
     background-color: #ffffff;
 }
-ul.rack_near_face li.empty:hover a {
+ul.rack_near_face li.available:hover a {
     display: block;
 }
-
-/* Colors (from http://flatuicolors.com) */
-.teal { background-color: #1abc9c; }
-.green { background-color: #2ecc71; }
-.blue { background-color: #3498db; }
-.purple { background-color: #9b59b6; }
-.yellow { background-color: #f1c40f; }
-.orange { background-color: #e67e22; }
-.red { background-color: #e74c3c; }
-.light_gray { background-color: #dce2e3; }
-.medium_gray { background-color: #95a5a6; }
-.dark_gray { background-color: #34495e; }
-
-/* Rack elevation coloring */
-ul.rack .teal { border-bottom: 1px solid #16a085; }
-ul.rack .teal:hover { background-color: #16a085; }
-ul.rack .green { border-bottom: 1px solid #27ae60; }
-ul.rack .green:hover { background-color: #27ae60; }
-ul.rack .blue { border-bottom: 1px solid #2980b9; }
-ul.rack .blue:hover { background-color: #2980b9; }
-ul.rack .purple { border-bottom: 1px solid #8e44ad; }
-ul.rack .purple:hover { background-color: #8e44ad; }
-ul.rack .yellow { border-bottom: 1px solid #f39c12; }
-ul.rack .yellow:hover { background-color: #f39c12; }
-ul.rack .orange { border-bottom: 1px solid #d35400; }
-ul.rack .orange:hover { background-color: #d35400; }
-ul.rack .red { border-bottom: 1px solid #c0392b; }
-ul.rack .red:hover { background-color: #c0392b; }
-ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; }
-ul.rack .light_gray:hover { background-color: #bdc3c7; }
-ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; }
-ul.rack .medium_gray:hover { background-color: #7f8c8d; }
-ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; }
-ul.rack .dark_gray:hover { background-color: #2c3e50; }
+ul.rack li.occupied a {
+    color: #ffffff;
+    display: block;
+    font-weight: bold;
+}
+ul.rack li.occupied a:hover {
+    text-decoration: none;
+}
+ul.rack li.occupied span {
+    cursor: default;
+    display: block;
+}
+li.occupied + li.available {
+    border-top: 1px solid #474747;
+}
 
 /* Misc */
 .banner-bottom {

BIN
netbox/project-static/img/tint_20.png


+ 1 - 8
netbox/templates/dcim/inc/_rack_elevation.html

@@ -6,13 +6,6 @@
 
 <div class="rack_frame">
 
-    <!-- Render all slots empty -->
-    <ul class="rack rack_empty">
-        {% for u in rack.units %}
-            <li></li>
-        {% endfor %}
-    </ul>
-
     <!-- Render rear view of devices on far face -->
     <ul class="rack rack_far_face">
         {% for u in secondary_face %}
@@ -42,7 +35,7 @@
                     {% endifequal %}
                 </li>
             {% else %}
-                <li class="empty">
+                <li class="available">
                     {% if perms.dcim.add_device %}
                         <a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}&face={{ face_id }}&position={{ u.id }}" class="add_device" >add device</a>
                     {% endif %}

+ 2 - 1
netbox/templates/dcim/ipaddress_assign.html

@@ -1,7 +1,7 @@
 {% extends '_base.html' %}
 {% load form_helpers %}
 
-{% block title %}Assign an IP Address{% endblock %}
+{% block title %}Assign a New IP Address{% endblock %}
 
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
@@ -40,6 +40,7 @@
                         </div>
                     </div>
                     {% render_field form.interface %}
+                    {% render_field form.set_as_primary %}
                 </div>
             </div>
             <div class="panel panel-default">

+ 1 - 1
netbox/templates/ipam/inc/prefix_header.html

@@ -3,7 +3,7 @@
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
             {% if prefix.vrf %}
-                <li><a href="{% url 'ipam:prefix_list' %}?vrf={{ prefix.vrf.pk }}">{{ prefix.vrf }}</a></li>
+                <li><a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a></li>
             {% endif %}
             <li>{{ prefix }}</li>
         </ol>

+ 1 - 1
netbox/templates/ipam/ipaddress.html

@@ -9,7 +9,7 @@
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
             {% if ipaddress.vrf %}
-                <li><a href="{% url 'ipam:ipaddress_list' %}?vrf={{ ipaddress.vrf.pk }}">{{ ipaddress.vrf }}</a></li>
+                <li><a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a></li>
             {% endif %}
             <li>{{ ipaddress }}</li>
         </ol>

+ 18 - 2
netbox/templates/ipam/ipaddress_assign.html

@@ -2,7 +2,7 @@
 {% load static from staticfiles %}
 {% load form_helpers %}
 
-{% block title %}Assign IP Address{% endblock %}
+{% block title %}Assign an IP Address{% endblock %}
 
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
@@ -19,9 +19,25 @@
             {% endif %}
             <div class="panel panel-default">
                 <div class="panel-heading">
-                    <strong>Assign IP Address {{ ipaddress }} ({% if ipaddress.vrf %}VRF {{ ipaddress.vrf }}{% else %}Global Table{% endif %})</strong>
+                    <strong>Assign an IP Address</strong>
                 </div>
                 <div class="panel-body">
+                    <div class="form-group">
+                        <label class="col-md-3 control-label">IP Address</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ ipaddress }}</p>
+                        </div>
+                        <label class="col-md-3 control-label">VRF</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">
+                                {% if ipaddress.vrf %}
+                                    <a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a> ({{ ipaddress.vrf.rd }})
+                                {% else %}
+                                    <span>Global</span>
+                                {% endif %}
+                            </p>
+                        </div>
+                    </div>
                     <ul class="nav nav-tabs" role="tablist">
                         <li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
                         <li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>

+ 8 - 5
netbox/templates/ipam/rir_list.html

@@ -12,7 +12,7 @@
             IPv4 Stats
         </a>
     {% else %}
-        <a href="{% url 'ipam:rir_list' %}?family=6" class="btn btn-default">
+        <a href="{% url 'ipam:rir_list' %}?family=6{% if request.GET %}&{{ request.GET.urlencode }}{% endif %}" class="btn btn-default">
             <span class="fa fa-table" aria-hidden="true"></span>
             IPv6 Stats
         </a>
@@ -26,11 +26,14 @@
 </div>
 <h1>RIRs</h1>
 <div class="row">
-	<div class="col-md-12">
+	<div class="col-md-9">
         {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %}
+        {% if request.GET.family == '6' %}
+            <div class="alert alert-info pull-right"><strong>Note:</strong> Numbers shown indicate /64 prefixes.</div>
+        {% endif %}
     </div>
+	<div class="col-md-3">
+		{% include 'inc/filter_panel.html' %}
+	</div>
 </div>
-{% if request.GET.family == '6' %}
-    <div class="pull-right text-muted"><strong>Note:</strong> Numbers shown indicate /64 prefixes.</div>
-{% endif %}
 {% endblock %}

+ 8 - 2
netbox/templates/tenancy/tenant.html

@@ -8,7 +8,9 @@
     <div class="col-md-9">
         <ol class="breadcrumb">
             <li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
-            <li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
+            {% if tenant.group %}
+                <li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
+            {% endif %}
             <li>{{ tenant }}</li>
         </ol>
     </div>
@@ -50,7 +52,11 @@
                 <tr>
                     <td>Group</td>
                     <td>
-                        <a href="{{ tenant.group.get_absolute_url }}">{{ tenant.group }}</a>
+                        {% if tenant.group %}
+                            <a href="{{ tenant.group.get_absolute_url }}">{{ tenant.group }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
                     </td>
                 </tr>
                 <tr>

+ 1 - 1
netbox/templates/tenancy/tenant_import.html

@@ -40,7 +40,7 @@
 				</tr>
 				<tr>
 					<td>Group</td>
-					<td>Tenant group</td>
+					<td>Tenant group (optional)</td>
 					<td>Customers</td>
 				</tr>
 				<tr>

+ 1 - 1
netbox/tenancy/models.py

@@ -48,6 +48,6 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
         return ','.join([
             self.name,
             self.slug,
-            self.group.name,
+            self.group.name if self.group else '',
             self.description,
         ])

+ 19 - 0
netbox/utilities/fields.py

@@ -1,5 +1,11 @@
+from django.core.validators import RegexValidator
 from django.db import models
 
+from .forms import ColorSelect
+
+
+validate_color = RegexValidator('^[0-9a-f]{6}$', 'Enter a valid hexadecimal RGB color code.', 'invalid')
+
 
 class NullableCharField(models.CharField):
     description = "Stores empty values as NULL rather than ''"
@@ -11,3 +17,16 @@ class NullableCharField(models.CharField):
 
     def get_prep_value(self, value):
         return value or None
+
+
+class ColorField(models.CharField):
+    default_validators = [validate_color]
+    description = "A hexadecimal RGB color code"
+
+    def __init__(self, *args, **kwargs):
+        kwargs['max_length'] = 6
+        super(ColorField, self).__init__(*args, **kwargs)
+
+    def formfield(self, **kwargs):
+        kwargs['widget'] = ColorSelect
+        return super(ColorField, self).formfield(**kwargs)

+ 50 - 1
netbox/utilities/forms.py

@@ -11,6 +11,32 @@ from django.utils.html import format_html
 from django.utils.safestring import mark_safe
 
 
+COLOR_CHOICES = (
+    ('aa1409', 'Dark red'),
+    ('f44336', 'Red'),
+    ('e91e63', 'Pink'),
+    ('ff66ff', 'Fuschia'),
+    ('9c27b0', 'Purple'),
+    ('673ab7', 'Dark purple'),
+    ('3f51b5', 'Indigo'),
+    ('2196f3', 'Blue'),
+    ('03a9f4', 'Light blue'),
+    ('00bcd4', 'Cyan'),
+    ('009688', 'Teal'),
+    ('2f6a31', 'Dark green'),
+    ('4caf50', 'Green'),
+    ('8bc34a', 'Light green'),
+    ('cddc39', 'Lime'),
+    ('ffeb3b', 'Yellow'),
+    ('ffc107', 'Amber'),
+    ('ff9800', 'Orange'),
+    ('ff5722', 'Dark orange'),
+    ('795548', 'Brown'),
+    ('c0c0c0', 'Light grey'),
+    ('9e9e9e', 'Grey'),
+    ('607d8b', 'Dark grey'),
+    ('111111', 'Black'),
+)
 NUMERIC_EXPANSION_PATTERN = '\[(\d+-\d+)\]'
 IP4_EXPANSION_PATTERN = '\[([0-9]{1,3}-[0-9]{1,3})\]'
 IP6_EXPANSION_PATTERN = '\[([0-9a-f]{1,4}-[0-9a-f]{1,4})\]'
@@ -71,6 +97,27 @@ class SmallTextarea(forms.Textarea):
     pass
 
 
+class ColorSelect(forms.Select):
+
+    def __init__(self, *args, **kwargs):
+        kwargs['choices'] = COLOR_CHOICES
+        super(ColorSelect, self).__init__(*args, **kwargs)
+
+    def render_option(self, selected_choices, option_value, option_label):
+        if option_value is None:
+            option_value = ''
+        option_value = force_text(option_value)
+        if option_value in selected_choices:
+            selected_html = mark_safe(' selected')
+            if not self.allow_multiple_selected:
+                # Only allow for a single selection.
+                selected_choices.remove(option_value)
+        else:
+            selected_html = ''
+        return format_html('<option value="{}"{} style="background-color: #{}">{}</option>',
+                           option_value, selected_html, option_value, force_text(option_label))
+
+
 class SelectWithDisabled(forms.Select):
     """
     Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
@@ -234,6 +281,7 @@ class CommentField(forms.CharField):
     A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text.
     """
     widget = forms.Textarea
+    default_label = 'Comments'
     # TODO: Port GFM syntax cheat sheet to internal documentation
     default_helptext = '<i class="fa fa-info-circle"></i> '\
                        '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
@@ -241,8 +289,9 @@ class CommentField(forms.CharField):
 
     def __init__(self, *args, **kwargs):
         required = kwargs.pop('required', False)
+        label = kwargs.pop('label', self.default_label)
         help_text = kwargs.pop('help_text', self.default_helptext)
-        super(CommentField, self).__init__(required=required, help_text=help_text, *args, **kwargs)
+        super(CommentField, self).__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
 
 
 class FlexibleModelChoiceField(forms.ModelChoiceField):

+ 1 - 0
requirements.txt

@@ -1,3 +1,4 @@
+cffi>=1.8
 cryptography==1.4
 Django==1.10
 django-debug-toolbar==1.4