diff --git a/README.md b/README.md index f97d27394..e4ba912a3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/). -Questions? Comments? Please subscribe to [the netbox-disucss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**! +Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**! ### Build Status diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index fc8c13ea0..9e466ddc1 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -26,6 +26,18 @@ BANNER_BOTTOM = BANNER_TOP --- +## BASE_PATH + +Default: None + +The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set: + +``` +BASE_PATH = 'netbox/' +``` + +--- + ## DEBUG Default: False diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index b5b96d034..042943cce 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -1,19 +1,16 @@ # Installation -NetBox requires following system dependencies: - -* python2.7 -* python-dev -* python-pip -* libxml2-dev -* libxslt1-dev -* libffi-dev -* graphviz -* libpq-dev -* libssl-dev +**Debian/Ubuntu** ``` -# sudo apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev +# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev +``` + +**CentOS/RHEL** + +``` +# yum install -y epel-release +# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel ``` You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. @@ -41,8 +38,16 @@ Create the base directory for the NetBox installation. For this guide, we'll use If `git` is not already installed, install it: +**Debian/Ubuntu** + ``` -# sudo apt-get install -y git +# apt-get install -y git +``` + +**CentOS/RHEL** + +``` +# yum install -y git ``` Next, clone the **master** branch of the NetBox GitHub repository into the current directory: @@ -63,7 +68,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.) ``` -# sudo pip install -r requirements.txt +# pip install -r requirements.txt ``` # Configuration @@ -76,7 +81,7 @@ Move into the NetBox configuration directory and make a copy of `configuration.e ``` Open `configuration.py` with your preferred editor and set the following variables: - + * ALLOWED_HOSTS * DATABASE * SECRET_KEY @@ -143,8 +148,8 @@ NetBox does not come with any predefined user accounts. You'll need to create a # ./manage.py createsuperuser Username: admin Email address: admin@example.com -Password: -Password (again): +Password: +Password (again): Superuser created successfully. ``` diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index c4ad03b6a..a4c898bad 100644 --- a/docs/installation/postgresql.md +++ b/docs/installation/postgresql.md @@ -2,17 +2,33 @@ NetBox requires a PostgreSQL database to store data. MySQL is not supported, as # Installation -The following packages are needed to install PostgreSQL with Python support: - -* postgresql -* libpq-dev -* python-psycopg2 +**Debian/Ubuntu** ``` -# sudo apt-get install -y postgresql libpq-dev python-psycopg2 +# apt-get install -y postgresql libpq-dev python-psycopg2 ``` -# Configuration +**CentOS/RHEL** + +``` +# 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: + +``` +host all all 127.0.0.1/32 md5 +host all all ::1/128 md5 +``` + +Then, start the service: + +``` +# systemctl start postgresql +``` + +# Database Creation At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands. diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index b30e2659c..559928888 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -1,9 +1,12 @@ # Web Server Installation -We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence. +We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence. + +!!! 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. ``` -# sudo apt-get install -y gunicorn supervisor +# apt-get install -y gunicorn supervisor ``` ## Option A: nginx @@ -11,10 +14,10 @@ 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. ``` -# sudo apt-get install -y nginx +# apt-get install -y nginx ``` -Once nginx is installed, proceed with the following configuration: +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`.) ``` server { @@ -38,19 +41,18 @@ server { } ``` -Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created. +Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created. ``` # cd /etc/nginx/sites-enabled/ # rm default -# ln -s /etc/nginx/sites-available/netbox +# ln -s /etc/nginx/sites-available/netbox ``` Restart the nginx service to use the new configuration. ``` # service nginx restart - * Restarting nginx nginx ``` To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04). @@ -58,7 +60,7 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https: ## Option B: Apache ``` -# sudo apt-get install -y apache2 +# apt-get install -y apache2 ``` Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately): @@ -99,7 +101,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https # gunicorn Installation -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. +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`. ``` command = '/usr/bin/gunicorn' @@ -120,7 +122,7 @@ directory = /opt/netbox/netbox/ user = www-data ``` -Finally, restart the supervisor service to detect and run the gunicorn service: +Then, restart the supervisor service to detect and run the gunicorn service: ``` # service supervisor restart diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 583f63d8b..1f3ced50a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -5,6 +5,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, + SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -131,11 +132,19 @@ class ManufacturerNestedSerializer(ManufacturerSerializer): class DeviceTypeSerializer(serializers.ModelSerializer): manufacturer = ManufacturerNestedSerializer() + subdevice_role = serializers.SerializerMethodField() class Meta: model = DeviceType fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'is_console_server', 'is_pdu', 'is_network_device'] + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role'] + + def get_subdevice_role(self, obj): + return { + SUBDEVICE_ROLE_PARENT: 'parent', + SUBDEVICE_ROLE_CHILD: 'child', + None: None, + }[obj.subdevice_role] class DeviceTypeNestedSerializer(DeviceTypeSerializer): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 01fd8abb4..b65916267 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -593,7 +593,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug') - rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), + rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')), label='Rack Group') role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug') tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5627ebde1..2cfbbcc70 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -576,11 +576,29 @@ class DeviceType(models.Model): def __unicode__(self): return u'{} {}'.format(self.manufacturer, self.model) + def __init__(self, *args, **kwargs): + super(DeviceType, self).__init__(*args, **kwargs) + + # Save a copy of u_height for validation in clean() + self._original_u_height = self.u_height + def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) def clean(self): + # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have + # room to expand within their racks. This validation will impose a very high performance penalty when there are + # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. + if self.pk is not None and self.u_height > self._original_u_height: + for d in Device.objects.filter(device_type=self, position__isnull=False): + face_required = None if self.is_full_depth else d.face + u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required, + exclude=[d.pk]) + if d.position not in u_available: + raise ValidationError("Device {} in rack {} does not have sufficient space to accommodate a height " + "of {}U".format(d, d.rack, self.u_height)) + if not self.is_console_server and self.cs_port_templates.count(): raise ValidationError("Must delete all console server port templates associated with this device before " "declassifying it as a console server.") diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index f7f63c555..5f52776c3 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -2,6 +2,8 @@ import json from rest_framework import status from rest_framework.test import APITestCase +from django.conf import settings + class SiteTest(APITestCase): @@ -57,7 +59,7 @@ class SiteTest(APITestCase): 'embed_link', ] - def test_get_list(self, endpoint='/api/dcim/sites/'): + def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -67,7 +69,7 @@ class SiteTest(APITestCase): sorted(self.standard_fields), ) - def test_get_detail(self, endpoint='/api/dcim/sites/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -76,7 +78,7 @@ class SiteTest(APITestCase): sorted(self.standard_fields), ) - def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'): + def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -91,7 +93,7 @@ class SiteTest(APITestCase): sorted(self.nested_fields), ) - def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'): + def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -149,7 +151,7 @@ class RackTest(APITestCase): 'rear_units' ] - def test_get_list(self, endpoint='/api/dcim/racks/'): + def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -163,7 +165,7 @@ class RackTest(APITestCase): sorted(SiteTest.nested_fields), ) - def test_get_detail(self, endpoint='/api/dcim/racks/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -192,7 +194,7 @@ class ManufacturersTest(APITestCase): nested_fields = standard_fields - def test_get_list(self, endpoint='/api/dcim/manufacturers/'): + def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -202,7 +204,7 @@ class ManufacturersTest(APITestCase): sorted(self.standard_fields), ) - def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -227,6 +229,7 @@ class DeviceTypeTest(APITestCase): 'is_console_server', 'is_pdu', 'is_network_device', + 'subdevice_role', ] nested_fields = [ @@ -236,7 +239,7 @@ class DeviceTypeTest(APITestCase): 'slug' ] - def test_get_list(self, endpoint='/api/dcim/device-types/'): + def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -246,7 +249,7 @@ class DeviceTypeTest(APITestCase): sorted(self.standard_fields), ) - def test_detail_list(self, endpoint='/api/dcim/device-types/1/'): + def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)): # TODO: details returns list view. # response = self.client.get(endpoint) # content = json.loads(response.content) @@ -270,7 +273,7 @@ class DeviceRolesTest(APITestCase): nested_fields = ['id', 'name', 'slug'] - def test_get_list(self, endpoint='/api/dcim/device-roles/'): + def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -280,7 +283,7 @@ class DeviceRolesTest(APITestCase): sorted(self.standard_fields), ) - def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -298,7 +301,7 @@ class PlatformsTest(APITestCase): nested_fields = ['id', 'name', 'slug'] - def test_get_list(self, endpoint='/api/dcim/platforms/'): + def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -308,7 +311,7 @@ class PlatformsTest(APITestCase): sorted(self.standard_fields), ) - def test_get_detail(self, endpoint='/api/dcim/platforms/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -346,7 +349,7 @@ class DeviceTest(APITestCase): nested_fields = ['id', 'name', 'display_name'] - def test_get_list(self, endpoint='/api/dcim/devices/'): + def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -373,7 +376,7 @@ class DeviceTest(APITestCase): sorted(RackTest.nested_fields), ) - def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'): + def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)): flat_fields = [ 'asset_tag', @@ -421,7 +424,7 @@ class DeviceTest(APITestCase): sorted(flat_fields), ) - def test_get_detail(self, endpoint='/api/dcim/devices/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -439,7 +442,7 @@ class ConsoleServerPortsTest(APITestCase): nested_fields = ['id', 'device', 'name'] - def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'): + def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -461,7 +464,7 @@ class ConsolePortsTest(APITestCase): nested_fields = ['id', 'device', 'name'] - def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'): + def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -479,7 +482,7 @@ class ConsolePortsTest(APITestCase): sorted(ConsoleServerPortsTest.nested_fields), ) - def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -500,7 +503,7 @@ class PowerPortsTest(APITestCase): nested_fields = ['id', 'device', 'name'] - def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'): + def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -514,7 +517,7 @@ class PowerPortsTest(APITestCase): sorted(DeviceTest.nested_fields), ) - def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -535,7 +538,7 @@ class PowerOutletsTest(APITestCase): nested_fields = ['id', 'device', 'name'] - def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'): + def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -585,7 +588,7 @@ class InterfaceTest(APITestCase): 'connection_status', ] - def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'): + def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -599,7 +602,7 @@ class InterfaceTest(APITestCase): sorted(DeviceTest.nested_fields), ) - def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'): + def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -612,7 +615,7 @@ class InterfaceTest(APITestCase): sorted(DeviceTest.nested_fields), ) - def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'): + def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -622,7 +625,8 @@ class InterfaceTest(APITestCase): sorted(SiteTest.graph_fields), ) - def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'): + def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/' + .format(settings.BASE_PATH)): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -643,9 +647,8 @@ class RelatedConnectionsTest(APITestCase): 'interfaces', ] - def test_get_list(self, endpoint=( - '/api/dcim/related-connections/' - '?peer-device=test1-edge1&peer-interface=xe-0/0/3')): + def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3' + .format(settings.BASE_PATH))): response = self.client.get(endpoint) content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 402cd6093..b7ed94c21 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -40,7 +40,7 @@ class GraphAdmin(admin.ModelAdmin): @admin.register(ExportTemplate) class ExportTemplateAdmin(admin.ModelAdmin): - list_display = ['content_type', 'name', 'mime_type', 'file_extension'] + list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension'] @admin.register(TopologyMap) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 06c5403c6..540651a01 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django import forms from django.contrib.contenttypes.models import ContentType +from utilities.forms import LaxURLField from .models import ( CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue ) @@ -56,7 +57,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F # URL elif cf.type == CF_TYPE_URL: - field = forms.URLField(required=cf.required, initial=cf.default) + field = LaxURLField(required=cf.required, initial=cf.default) # Text else: @@ -92,7 +93,7 @@ class CustomFieldForm(forms.ModelForm): existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\ .select_related('field') for cfv in existing_values: - self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value + self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value def _save_custom_fields(self): diff --git a/netbox/extras/migrations/0003_exporttemplate_add_description.py b/netbox/extras/migrations/0003_exporttemplate_add_description.py new file mode 100644 index 000000000..6355955b5 --- /dev/null +++ b/netbox/extras/migrations/0003_exporttemplate_add_description.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-09-27 20:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0002_custom_fields'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='exporttemplate', + name='name', + field=models.CharField(max_length=100), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 64e84ef66..40ce4a1f5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -146,8 +146,10 @@ class CustomField(models.Model): # Read date as YYYY-MM-DD return date(*[int(n) for n in serialized_value.split('-')]) if self.type == CF_TYPE_SELECT: - # return CustomFieldChoice.objects.get(pk=int(serialized_value)) - return self.choices.get(pk=int(serialized_value)) + try: + return self.choices.get(pk=int(serialized_value)) + except CustomFieldChoice.DoesNotExist: + return None return serialized_value @@ -198,6 +200,12 @@ class CustomFieldChoice(models.Model): if self.field.type != CF_TYPE_SELECT: raise ValidationError("Custom field choices can only be assigned to selection fields.") + def delete(self, using=None, keep_parents=False): + # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it + pk = self.pk + super(CustomFieldChoice, self).delete(using, keep_parents) + CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() + class Graph(models.Model): type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) @@ -225,7 +233,8 @@ class Graph(models.Model): class ExportTemplate(models.Model): content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}) - name = models.CharField(max_length=200) + name = models.CharField(max_length=100) + description = models.CharField(max_length=200, blank=True) template_code = models.TextField() mime_type = models.CharField(max_length=15, blank=True) file_extension = models.CharField(max_length=15, blank=True) diff --git a/netbox/netbox/configuration.docker.py b/netbox/netbox/configuration.docker.py index 6906ab1b4..81993ee21 100644 --- a/netbox/netbox/configuration.docker.py +++ b/netbox/netbox/configuration.docker.py @@ -52,6 +52,10 @@ EMAIL = { # are permitted to access most data in NetBox (excluding secrets) but not make any changes. LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False) +# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: +# BASE_PATH = 'netbox/' +BASE_PATH = os.environ.get('BASE_PATH', '') + # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 603327c6e..b85fcafbb 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -52,6 +52,10 @@ EMAIL = { # are permitted to access most data in NetBox (excluding secrets) but not make any changes. LOGIN_REQUIRED = False +# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: +# BASE_PATH = 'netbox/' +BASE_PATH = '' + # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a7de9c47c..c1f3bcae3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.6.1-r1' +VERSION = '1.6.2' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -27,6 +27,9 @@ ADMINS = getattr(configuration, 'ADMINS', []) DEBUG = getattr(configuration, 'DEBUG', False) EMAIL = getattr(configuration, 'EMAIL', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) +BASE_PATH = getattr(configuration, 'BASE_PATH', '') +if BASE_PATH: + BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index b67f04cfd..41b71546e 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.conf.urls import include, url from django.contrib import admin from django.views.defaults import page_not_found @@ -8,7 +9,7 @@ from users.views import login, logout handler500 = handle_500 -urlpatterns = [ +_patterns = [ # Default page url(r'^$', home, name='home'), @@ -42,3 +43,8 @@ urlpatterns = [ url(r'^admin/', include(admin.site.urls)), ] + +# Prepend BASE_PATH +urlpatterns = [ + url(r'^{}'.format(settings.BASE_PATH), include(_patterns)) +] diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 6647046d7..d437c3e4a 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -1,13 +1,18 @@ $(document).ready(function() { - // "Select all" checkbox in a table header - $('th input:checkbox[name=_all]').click(function (event) { - $(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked')); + // "Toggle all" checkbox in a table header + $('#toggle_all').click(function (event) { + $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); + if ($(this).is(':checked')) { + $('#select_all_box').removeClass('hidden'); + } else { + $('#select_all').prop('checked', false); + } }); - // Uncheck the "select all" checkbox if an item is unchecked + // Uncheck the "toggle all" checkbox if an item is unchecked $('input:checkbox[name=pk]').click(function (event) { if (!$(this).attr('checked')) { - $(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false); + $('#select_all, #toggle_all').prop('checked', false); } }); diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index d8368cc32..cd2a77ae3 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -91,7 +91,7 @@ class SecretImportForm(BulkImportForm, BootstrapMixin): class SecretBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) - role = forms.ModelChoiceField(queryset=SecretRole.objects.all()) + role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) name = forms.CharField(max_length=100, required=False) diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 26f005f3b..ba2ebf94f 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -26,7 +26,7 @@
{{ exception }}
{{ error }}
- Home Page + Home Page
diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 0a9cb20d4..9ef5ef4eb 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -9,6 +9,7 @@ +