diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 046f6cd79..85119102b 100644 --- a/CONTRIBUTING.md +++ b/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: diff --git a/docs/installation/docker.md b/docs/installation/docker.md index efc9685a9..00551a096 100644 --- a/docs/installation/docker.md +++ b/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. diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index 5a90ec5e3..6a4994a5c 100644 --- a/docs/installation/ldap.md +++ b/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 ``` diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index 042943cce..5b673e845 100644 --- a/docs/installation/netbox.md +++ b/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... diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index a4c898bad..e1d9a49ea 100644 --- a/docs/installation/postgresql.md +++ b/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 ``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 303915dc7..afb36f464 100644 --- a/docs/installation/upgrading.md +++ b/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 ``` diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 559928888..10cc4992f 100644 --- a/docs/installation/web-server.md +++ b/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 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 ``` diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 288f2255f..c66a8bc40 100644 --- a/netbox/circuits/forms.py +++ b/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'] diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 65831a974..69bccd253 100644 --- a/netbox/dcim/filters.py +++ b/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( diff --git a/netbox/dcim/fixtures/initial_data.json b/netbox/dcim/fixtures/initial_data.json index a26cbfcc5..e765de227 100644 --- a/netbox/dcim/fixtures/initial_data.json +++ b/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" } }, { diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 44e30e964..23da437a7 100644 --- a/netbox/dcim/forms.py +++ b/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') # diff --git a/netbox/dcim/migrations/0022_color_names_to_rgb.py b/netbox/dcim/migrations/0022_color_names_to_rgb.py new file mode 100644 index 000000000..97e5de9ca --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 93cdb95d5..e8967b0c0 100644 --- a/netbox/dcim/models.py +++ b/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): diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 6c138b446..c81c24f82 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -11,7 +11,7 @@ from .models import ( COLOR_LABEL = """ - + """ DEVICE_LINK = """ @@ -34,7 +34,7 @@ RACKROLE_ACTIONS = """ RACK_ROLE = """ {% if record.role %} - + {% else %} — {% endif %} @@ -59,7 +59,7 @@ PLATFORM_ACTIONS = """ """ DEVICE_ROLE = """ - + """ STATUS_ICON = """ diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index b7ed94c21..2a06b3f2f 100644 --- a/netbox/extras/admin.py +++ b/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) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 609e878e9..a65e90834 100644 --- a/netbox/extras/models.py +++ b/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): diff --git a/netbox/ipam/admin.py b/netbox/ipam/admin.py index 56113a44e..f3f914129 100644 --- a/netbox/ipam/admin.py +++ b/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) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f7cf20636..742eba9ea 100644 --- a/netbox/ipam/api/serializers.py +++ b/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'] # diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index e7e150b34..bb04ca78e 100644 --- a/netbox/ipam/filters.py +++ b/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', diff --git a/netbox/ipam/fixtures/initial_data.json b/netbox/ipam/fixtures/initial_data.json index 5e7659422..da6b4a9ca 100644 --- a/netbox/ipam/fixtures/initial_data.json +++ b/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 } }, { diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1a9e25db3..08bb5db04 100644 --- a/netbox/ipam/forms.py +++ b/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'), + ])) # diff --git a/netbox/ipam/migrations/0011_rir_add_is_private.py b/netbox/ipam/migrations/0011_rir_add_is_private.py new file mode 100644 index 000000000..ad7732653 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 1538251cf..5f28acaed 100644 --- a/netbox/ipam/models.py +++ b/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'] diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index f58dc6673..81953a348 100644 --- a/netbox/ipam/tables.py +++ b/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') # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 78bb1c148..0be5b225e 100644 --- a/netbox/ipam/views.py +++ b/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' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ccc94488b..ee8c475ce 100644 --- a/netbox/netbox/settings.py +++ b/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), } diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 635aa7e94..ff9eb98c1 100644 --- a/netbox/project-static/css/base.css +++ b/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 { diff --git a/netbox/project-static/img/tint_20.png b/netbox/project-static/img/tint_20.png new file mode 100644 index 000000000..a03a1f9ac Binary files /dev/null and b/netbox/project-static/img/tint_20.png differ diff --git a/netbox/templates/dcim/inc/_rack_elevation.html b/netbox/templates/dcim/inc/_rack_elevation.html index 1313df8ea..368092166 100644 --- a/netbox/templates/dcim/inc/_rack_elevation.html +++ b/netbox/templates/dcim/inc/_rack_elevation.html @@ -6,13 +6,6 @@
- - -
{% render_field form.interface %} + {% render_field form.set_as_primary %}
diff --git a/netbox/templates/ipam/inc/prefix_header.html b/netbox/templates/ipam/inc/prefix_header.html index 7dc7a35a1..105c41a0f 100644 --- a/netbox/templates/ipam/inc/prefix_header.html +++ b/netbox/templates/ipam/inc/prefix_header.html @@ -3,7 +3,7 @@ diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 2392e462b..3135b60be 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -9,7 +9,7 @@ diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html index 4143dc3ee..10a582a2f 100644 --- a/netbox/templates/ipam/ipaddress_assign.html +++ b/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 %} @@ -19,9 +19,25 @@ {% endif %}
- Assign IP Address {{ ipaddress }} ({% if ipaddress.vrf %}VRF {{ ipaddress.vrf }}{% else %}Global Table{% endif %}) + Assign an IP Address
+
+ +
+

{{ ipaddress }}

+
+ +
+

+ {% if ipaddress.vrf %} + {{ ipaddress.vrf }} ({{ ipaddress.vrf.rd }}) + {% else %} + Global + {% endif %} +

+
+

RIRs

-
+
{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %} + {% if request.GET.family == '6' %} +
Note: Numbers shown indicate /64 prefixes.
+ {% endif %}
+
+ {% include 'inc/filter_panel.html' %} +
-{% if request.GET.family == '6' %} -
Note: Numbers shown indicate /64 prefixes.
-{% endif %} {% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index c3e640de1..a1c9f0992 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -8,7 +8,9 @@
@@ -50,7 +52,11 @@ Group - {{ tenant.group }} + {% if tenant.group %} + {{ tenant.group }} + {% else %} + None + {% endif %} diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html index ef76cacb1..eb0c62c99 100644 --- a/netbox/templates/tenancy/tenant_import.html +++ b/netbox/templates/tenancy/tenant_import.html @@ -40,7 +40,7 @@ Group - Tenant group + Tenant group (optional) Customers diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 8c1bf5fa4..b80a40382 100644 --- a/netbox/tenancy/models.py +++ b/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, ]) diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 017ceb275..14d1c7d8f 100644 --- a/netbox/utilities/fields.py +++ b/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) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 74e0749db..979706ace 100644 --- a/netbox/utilities/forms.py +++ b/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, 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 = ' '\ ''\ @@ -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): diff --git a/requirements.txt b/requirements.txt index 40b0b707f..a7fef0b9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +cffi>=1.8 cryptography==1.4 Django==1.10 django-debug-toolbar==1.4