Compare commits

..

82 Commits

Author SHA1 Message Date
Jeremy Stretch
c171547037 Merge pull request #625 from digitalocean/develop
Release v1.6.3
2016-10-19 16:25:50 -04:00
Jeremy Stretch
493b7d594d Release v1.6.3 2016-10-19 16:21:01 -04:00
Jeremy Stretch
4d40c015e4 Added instance count to DeviceType view 2016-10-19 15:56:15 -04:00
Jeremy Stretch
4405bc4182 Closes #608: Add "toggle all" button to device and device type components 2016-10-19 15:45:26 -04:00
Jeremy Stretch
54a0639a6e Merge pull request #623 from jsenecal/patch-1
Removed superfluous "is" in error message
2016-10-19 14:51:08 -04:00
Jonathan Senecal
334b286ebf Removed superfluous "is" in error message 2016-10-19 14:22:34 -04:00
Jeremy Stretch
c09cb5df3d #353: Added bulk editing for InterfaceTemplates 2016-10-19 12:15:54 -04:00
Jeremy Stretch
0da3661ff0 #353: Allow bulk editing of interfaces 2016-10-14 16:38:46 -04:00
Jeremy Stretch
5a4ccbc066 Fixes #616: Correct display of custom URL fields 2016-10-14 11:08:09 -04:00
Jeremy Stretch
49cbdc22da Fixes #615: Account for BASE_PATH in static URLs and during login 2016-10-13 16:27:09 -04:00
Jeremy Stretch
579ed0a985 Redirect user to previous page after logging in 2016-10-13 16:12:27 -04:00
Jeremy Stretch
464797858f Fixes #604: Correct display of unnamed devices in form selection fields 2016-10-13 15:21:36 -04:00
Jeremy Stretch
0ff46bf5d0 Fixes #611: Power/console/interface connection import: status field should be case-insensitive 2016-10-13 12:18:32 -04:00
Jeremy Stretch
330abe5a2d Fixes #602: Correct display of custom integer fields with value of 0 or 1 2016-10-05 15:29:16 -04:00
Jeremy Stretch
73945899fe Fixes #527: Support for nullifying custom fields during bulk editing 2016-10-05 15:17:17 -04:00
Jeremy Stretch
8227a9ff9c Merge pull request #592 from lf-/patch-2
Allow multiple ALLOWED_HOSTS on docker
2016-10-04 15:01:31 -04:00
Jeremy Stretch
f1c70cd896 Fixes #591: Correct display of device type component creation buttons 2016-10-04 14:46:27 -04:00
Jeremy Stretch
7055292803 Merge pull request #596 from digitalocean/bugfix-591
Fixes #591: Correct display of device type component creation buttons
2016-10-04 10:22:04 -04:00
Jeremy Stretch
3503c77699 Fixes #591: Correct display of component creation buttons in device type view 2016-10-04 10:17:56 -04:00
lf
b68c64041e Allow multiple ALLOWED_HOSTS on docker
Change ALLOWED_HOSTS to be a space delimited list.
2016-10-02 21:01:29 -06:00
Jeremy Stretch
36066068d4 #527: Initial work to allow nullifying fields during bulk edit 2016-09-30 16:17:41 -04:00
Jeremy Stretch
8ed174e7af Post-release version bump 2016-09-30 11:26:08 -04:00
Jeremy Stretch
7336fdf162 Merge pull request #587 from digitalocean/develop
Release v1.6.2
2016-09-30 11:24:16 -04:00
Jeremy Stretch
b5a7dd7d6d Reformatted URLs list to make pep8 happy 2016-09-30 11:19:50 -04:00
Jeremy Stretch
35918ae966 Release v1.6.2 2016-09-30 11:06:31 -04:00
Jeremy Stretch
ce01bb59a3 Fixed tests 2016-09-29 16:56:30 -04:00
Jeremy Stretch
18a5a966e3 Fixes #212: Tweak APISelect widget to inject BASE_PATH in API URL 2016-09-29 16:41:02 -04:00
Jeremy Stretch
833499ffe8 #212: Introduced BASE_PATH configuration setting 2016-09-29 16:32:16 -04:00
Jeremy Stretch
5b7f350ded Added headers to all bulk edit tables 2016-09-28 17:20:16 -04:00
Jeremy Stretch
d5fc0e9ce7 Closes #345: Bulk edit: allow user to select all objects on page or all matching query 2016-09-28 16:56:17 -04:00
Jeremy Stretch
c6592faeb2 Fixes #466: Validate available free space for all instances when increasing the U height of a device type 2016-09-28 14:19:52 -04:00
Jeremy Stretch
dec00cdb55 Closes #481: Require interface creation before trying to assign an IP to a device 2016-09-28 12:24:33 -04:00
Jeremy Stretch
30c7c2d359 Closes #475: Display add buttons at top and bottom of all device/device type panels 2016-09-28 12:06:00 -04:00
Jeremy Stretch
118bb5ea73 Fixed DCIM API test 2016-09-28 10:02:18 -04:00
Jeremy Stretch
35b3d8e33a Fixes #581: Corrected initialization of custom boolean and select fields 2016-09-28 09:58:59 -04:00
Jeremy Stretch
187a6dee17 Closes #579: Add a description field to ExportTemplate 2016-09-27 16:31:18 -04:00
Jeremy Stretch
0900a6bf49 Added subdevice_role to DeviceTypeSerializer 2016-09-27 16:04:14 -04:00
Jeremy Stretch
6cba2e92f2 Merge pull request #574 from rfdrake/mobile
viewport change for mobile
2016-09-27 13:58:57 -04:00
Jeremy Stretch
796b131f73 Fixes #577: Correct initialization of custom boolean fields 2016-09-27 13:42:10 -04:00
Jeremy Stretch
bdb8d62cef Closes #575: Allow all valid URL schemes in custom fields 2016-09-27 11:42:20 -04:00
Jeremy Stretch
d049c1c244 Fixes #576: Delete all relevant CustomFieldValues ehen deleting a CustomFieldChoice 2016-09-27 10:51:33 -04:00
Robert Drake
45432a6f29 viewport change for mobile 2016-09-26 23:43:05 -04:00
Jeremy Stretch
a803bd8033 Simplified web server installation doc 2016-09-26 14:21:10 -04:00
Jeremy Stretch
0001bbc966 #447: Correcting CentOS installation docs 2016-09-26 12:50:44 -04:00
Jeremy Stretch
1ebba3ee26 Merge pull request #529 from chagara/develop
Added centos/rhel installation
2016-09-26 12:05:19 -04:00
Jeremy Stretch
fde24258e3 Fixes #571: Correct rack group filter on device list 2016-09-22 10:06:59 -04:00
Jeremy Stretch
59c6d5b1ec Fixed typo 2016-09-21 11:58:04 -04:00
Jeremy Stretch
33694030b7 Tweaked ExportTemplate admin display 2016-09-21 11:57:05 -04:00
Jeremy Stretch
f8f973dac2 Post-release version bump 2016-09-21 11:45:12 -04:00
Jeremy Stretch
bffabef556 Merge pull request #570 from digitalocean/develop
Release v1.6.1-r1
2016-09-21 11:44:45 -04:00
Jeremy Stretch
325d96dabb Quick fix for v1.6.1 related to #561 2016-09-21 11:43:22 -04:00
Jeremy Stretch
b7b1682f42 Added link to the mailing list 2016-09-21 11:02:19 -04:00
Jeremy Stretch
aa2612aeba Post-release version bump 2016-09-21 10:15:03 -04:00
Jeremy Stretch
b99704082b Merge pull request #569 from digitalocean/develop
Release v1.6.1
2016-09-21 10:14:40 -04:00
Jeremy Stretch
75d8852bf7 Release v1.6.1 2016-09-21 09:55:57 -04:00
Jeremy Stretch
0444ac7db9 Introduced NullableModelMultipleChoiceField to allow null filtering without causing introspection issues during database migrations 2016-09-20 15:48:58 -04:00
Jeremy Stretch
b2684aeefc status filter fields should not be required 2016-09-20 11:29:30 -04:00
Jeremy Stretch
6ccc6244dd Corrected PrefixFilterForm 2016-09-20 11:25:16 -04:00
Jeremy Stretch
e618bf40ec Reimplemented FilterChoiceField 2016-09-20 11:08:25 -04:00
Jeremy Stretch
e3f0a12313 PEP8 fix 2016-09-19 16:21:42 -04:00
Jeremy Stretch
687e68db69 Fixes #564: Display custom fields for all applicable objects 2016-09-19 16:13:02 -04:00
Jeremy Stretch
b10e29aaac Closes #561: Make custom fields accessible from within export templates 2016-09-19 16:11:37 -04:00
Jeremy Stretch
d0c92b4f8a Removed obsolete dependency 2016-09-19 10:32:38 -04:00
Jeremy Stretch
513408f16a Fixes #562: Fixed bulk interface creation 2016-09-19 10:31:40 -04:00
Jeremy Stretch
64326e7c9d Closes #552: Added a None filter option for custom select fields 2016-09-16 13:42:07 -04:00
Jeremy Stretch
ce9d853883 Closes #415: Added an expand/collapse toggle button to the prefix list 2016-09-16 11:50:02 -04:00
Jeremy Stretch
814a0e7344 Tweak to #493 2016-09-16 10:31:42 -04:00
Jeremy Stretch
2c7c0ce29d Merge pull request #493 from stianvi/csv_reader_unicode
Fixed csv reader to handle special characters
2016-09-16 10:30:27 -04:00
Jeremy Stretch
2015d08407 Merge pull request #555 from rfdrake/develop
Fix for Docker ldap
2016-09-16 09:59:43 -04:00
Jeremy Stretch
9dea5656ad Added 'none' options to filters for optional fields 2016-09-15 17:12:53 -04:00
Jeremy Stretch
daadf7a49b Fixes #557: Add 'global' choice to VRF filter for prefixes and IP addresses 2016-09-15 16:03:53 -04:00
Jeremy Stretch
2567412121 Fixes #531: Order prefixes by VRF assignment 2016-09-15 12:09:54 -04:00
Jeremy Stretch
5e4fce248c Fixes #558: Update slug field when name is populated without a key press 2016-09-15 11:36:45 -04:00
Jeremy Stretch
824d2d8205 Implemented FilterChoiceField and get_filter_choices() to reduce filter form boilerplate 2016-09-14 16:27:26 -04:00
Robert Drake
9718895ff9 add django-auth-ldap to Dockerfile 2016-09-14 13:32:54 -04:00
Robert Drake
9eec975800 change ldap.py to ldap_config.py 2016-09-14 13:32:54 -04:00
Jeremy Stretch
5601be87f7 Merge pull request #551 from digitalocean/docker-build-perms
Fix permissions on docker-build.sh
2016-09-13 13:15:57 -04:00
Zach Moody
440610836a fixes permissions on docker-build.sh 2016-09-13 11:27:04 -05:00
Jeremy Stretch
4fa536b940 Post-release version bump 2016-09-13 12:16:42 -04:00
Chagara
af519b93b7 added more changes for centos/rhel installation 2016-09-02 16:54:47 -04:00
Chagara
2213e3e0cf added centos/rhel installation on netbox.md postgresql.md and web-server.md 2016-09-02 16:38:58 -04:00
Stian Vikan
0708942ab8 Fixed csv reader to handle special characters 2016-08-19 12:09:40 +02:00
80 changed files with 1174 additions and 756 deletions

View File

@@ -5,7 +5,10 @@ WORKDIR /opt/netbox
ARG BRANCH=master
ARG URL=https://github.com/digitalocean/netbox.git
RUN git clone --depth 1 $URL -b $BRANCH . && \
pip install gunicorn==17.5 && pip install -r requirements.txt
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \
pip install gunicorn==17.5 && \
pip install django-auth-ldap && \
pip install -r requirements.txt
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py

View File

@@ -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 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

View File

@@ -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

View File

@@ -35,6 +35,8 @@ Each export template is associated with a certain type of object. For instance,
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## Example

View File

@@ -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.
```

View File

@@ -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.

View File

@@ -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

View File

@@ -5,6 +5,8 @@ from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import Provider, Circuit, CircuitType
@@ -64,12 +66,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Circuit type (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',

View File

@@ -3,10 +3,10 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
SlugField,
)
from .models import Circuit, CircuitType, Provider
@@ -56,16 +56,13 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
comments = CommentField()
def provider_site_choices():
site_choices = Site.objects.all()
return [(s.slug, s.name) for s in site_choices]
class Meta:
nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
#
@@ -91,7 +88,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'interface'}))
display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
@@ -183,38 +180,21 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
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()
def circuit_type_choices():
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
def circuit_provider_choices():
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
def circuit_tenant_choices():
tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
class Meta:
nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')

View File

@@ -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):

View File

@@ -4,6 +4,7 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
@@ -15,12 +16,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search',
label='Search',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -75,34 +76,34 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=RackRole.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=RackRole.objects.all(),
to_field_name='slug',
@@ -177,12 +178,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -210,12 +211,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Device model (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
platform_id = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = django_filters.ModelMultipleChoiceFilter(
platform = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',

View File

@@ -1,23 +1,24 @@
import re
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField,
)
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole,
Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
)
@@ -42,37 +43,12 @@ def get_device_by_name_or_pk(name):
return device
def bulkedit_platform_choices():
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(p.pk, p.name) for p in Platform.objects.all()]
return choices
def bulkedit_rackgroup_choices():
def validate_connection_status(value):
"""
Include an option to remove the currently assigned group from a rack.
Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(r.pk, r) for r in RackGroup.objects.all()]
return choices
def bulkedit_rackrole_choices():
"""
Include an option to remove the currently assigned role from a rack.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(r.pk, r.name) for r in RackRole.objects.all()]
return choices
if value.lower() not in ['planned', 'connected']:
raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
#
@@ -114,18 +90,16 @@ class SiteImportForm(BulkImportForm, BootstrapMixin):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
def site_tenant_choices():
tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
class Meta:
nullable_fields = ['tenant']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
null_option=(0, 'None'))
#
@@ -140,14 +114,8 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
def rackgroup_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
class RackGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
#
@@ -245,45 +213,27 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
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()
def rack_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('racks'))
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
def rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
def rack_tenant_choices():
tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
def rack_role_choices():
role_choices = RackRole.objects.annotate(rack_count=Count('racks'))
return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices]
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=rack_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
#
@@ -311,20 +261,15 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
'is_pdu', 'is_network_device', 'subdevice_role']
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False)
def devicetype_manufacturer_choices():
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug')
#
@@ -371,6 +316,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
fields = ['name_pattern', 'form_factor', 'mgmt_only']
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
class Meta:
nullable_fields = []
class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
@@ -620,56 +573,27 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False,
label='Platform')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
def device_site_choices():
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices]
def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
def device_tenant_choices():
tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
def device_platform_choices():
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
class Meta:
nullable_fields = ['tenant', 'platform']
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
widget=forms.SelectMultiple(attrs={'size': 8}))
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
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',
null_option=(0, 'None'))
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
.annotate(filter_count=Count('instances')), label='Type')
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))
@@ -699,7 +623,7 @@ class ConsoleConnectionCSVForm(forms.Form):
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
console_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
status = forms.CharField(validators=[validate_connection_status])
def clean(self):
@@ -763,6 +687,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'console_server'}))
console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
display_field='display_name',
attrs={'filter-for': 'cs_port'}))
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
@@ -830,7 +755,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'}))
display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
@@ -894,7 +819,7 @@ class PowerConnectionCSVForm(forms.Form):
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
power_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
status = forms.CharField(validators=[validate_connection_status])
def clean(self):
@@ -959,7 +884,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'pdu'}))
pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
attrs={'filter-for': 'power_outlet'}))
display_field='display_name', attrs={'filter-for': 'power_outlet'}))
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
)
@@ -1026,7 +951,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'}))
display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
@@ -1091,6 +1016,15 @@ class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['description']
#
# Interface connections
#
@@ -1101,6 +1035,7 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'device_b'}))
device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
display_field='display_name',
attrs={'filter-for': 'interface_b'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
@@ -1155,7 +1090,7 @@ class InterfaceConnectionCSVForm(forms.Form):
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'})
interface_b = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
status = forms.CharField(validators=[validate_connection_status])
def clean(self):

View File

@@ -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.")

View File

@@ -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)

View File

@@ -3,10 +3,6 @@ from django.conf.urls import url
from secrets.views import secret_add
from . import views
from .models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
InterfaceTemplate,
)
urlpatterns = [
@@ -75,6 +71,7 @@ urlpatterns = [
# Interface templates
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
# Device bay templates
@@ -159,6 +156,7 @@ urlpatterns = [
# Interfaces
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),

View File

@@ -457,6 +457,14 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
form = forms.InterfaceTemplateForm
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interfacetemplate'
cls = InterfaceTemplate
parent_cls = DeviceType
form = forms.InterfaceTemplateBulkEditForm
template_name = 'dcim/interfacetemplate_bulk_edit.html'
class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
cls = InterfaceTemplate
@@ -1399,7 +1407,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/interface_add_multi.html'
default_redirect_url = 'dcim:device_list'
def update_objects(self, pk_list, form):
def update_objects(self, pk_list, form, fields):
selected_devices = Device.objects.filter(pk__in=pk_list)
interfaces = []
@@ -1425,6 +1433,14 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
len(selected_devices)))
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
cls = Interface
parent_cls = Device
form = forms.InterfaceBulkEditForm
template_name = 'dcim/interface_bulk_edit.html'
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
cls = Interface

View File

@@ -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)

View File

@@ -2,7 +2,7 @@ import django_filters
from django.contrib.contenttypes.models import ContentType
from .models import CustomField
from .models import CF_TYPE_SELECT, CustomField
class CustomFieldFilter(django_filters.Filter):
@@ -10,9 +10,22 @@ class CustomFieldFilter(django_filters.Filter):
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
"""
def __init__(self, cf_type, *args, **kwargs):
self.cf_type = cf_type
super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value):
# Skip filter on empty value
if not value.strip():
return queryset
# Treat 0 as None for Select fields
try:
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.name,
)
except ValueError:
pass
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value,
@@ -30,4 +43,4 @@ class CustomFieldFilterSet(django_filters.FilterSet):
obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name)
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)

View File

@@ -3,6 +3,7 @@ from collections import OrderedDict
from django import forms
from django.contrib.contenttypes.models import ContentType
from utilities.forms import BulkEditForm, LaxURLField
from .models import (
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
)
@@ -47,18 +48,14 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
# Select
elif cf.type == CF_TYPE_SELECT:
if bulk_edit:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required:
choices = [(0, 'None')] + choices
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
else:
field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required)
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
# 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:
@@ -74,10 +71,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
class CustomFieldForm(forms.ModelForm):
custom_fields = []
def __init__(self, *args, **kwargs):
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
super(CustomFieldForm, self).__init__(*args, **kwargs)
@@ -94,7 +91,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):
@@ -127,22 +124,25 @@ class CustomFieldForm(forms.ModelForm):
return obj
class CustomFieldBulkEditForm(forms.Form):
custom_fields = []
def __init__(self, model, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(model)
class CustomFieldBulkEditForm(BulkEditForm):
def __init__(self, *args, **kwargs):
super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = []
for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items():
custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
for name, field in custom_fields:
# Annotate non-required custom fields as nullable
if not field.required:
self.nullable_fields.append(name)
field.required = False
self.fields[name] = field
custom_fields.append(name)
self.custom_fields = custom_fields
# Annotate this as a custom field
self.custom_fields.append(name)
print(self.nullable_fields)
class CustomFieldFilterForm(forms.Form):

View File

@@ -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),
),
]

View File

@@ -67,7 +67,18 @@ ACTION_CHOICES = (
class CustomFieldModel(object):
def custom_fields(self):
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if not hasattr(self, 'get_custom_fields'):
return dict()
return {field.name: value for field, value in self.get_custom_fields().items()}
def get_custom_fields(self):
"""
Return a dictionary of custom fields for a single object in the form {<field>: value}.
"""
# Find all custom fields applicable to this type of object
content_type = ContentType.objects.get_for_model(self)
@@ -135,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
@@ -187,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)
@@ -214,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)

View File

@@ -7,6 +7,7 @@ from django.db.models import Q
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
@@ -21,12 +22,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
lookup_type='icontains',
label='Name',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -85,29 +86,34 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = django_filters.MethodFilter(
action='_tenant_id',
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.MethodFilter(
action='_tenant',
label='Tenant',
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
@@ -122,12 +128,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='vlan__vid',
label='VLAN number (1-4095)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
@@ -136,7 +142,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -157,17 +163,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
def _tenant(self, queryset, value):
if str(value) == '':
return queryset
@@ -196,22 +191,27 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = django_filters.MethodFilter(
action='_tenant_id',
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.MethodFilter(
action='_tenant',
label='Tenant',
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
@@ -232,7 +232,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = IPAddress
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
fields = ['q', 'family', 'device_id', 'device', 'interface_id']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -253,35 +253,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
def _tenant(self, queryset, value):
if str(value) == '':
return queryset
return queryset.filter(
Q(tenant__slug=value) |
Q(tenant__isnull=True, vrf__tenant__slug=value)
)
def _tenant_id(self, queryset, value):
try:
value = int(value)
except ValueError:
return queryset.none()
return queryset.filter(
Q(tenant__pk=value) |
Q(tenant__isnull=True, vrf__tenant__pk=value)
)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -317,12 +288,12 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
to_field_name='slug',
@@ -337,23 +308,23 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='vid',
label='VLAN number (1-4095)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',

View File

@@ -3,9 +3,10 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
from utilities.forms import (
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
)
from .models import (
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -21,18 +22,6 @@ IP_FAMILY_CHOICES = [
]
def bulkedit_vrf_choices():
"""
Include an option to assign the object to the global table.
"""
choices = [
(None, '---------'),
(0, 'Global'),
]
choices += [(v.pk, v.name) for v in VRF.objects.all()]
return choices
#
# VRFs
#
@@ -65,19 +54,17 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def vrf_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
class Meta:
nullable_fields = ['tenant', 'description']
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
#
@@ -127,17 +114,15 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
date_added = forms.DateField(required=False)
description = forms.CharField(max_length=100, required=False)
def aggregate_rir_choices():
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
class Meta:
nullable_fields = ['date_added', 'description']
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
widget=forms.SelectMultiple(attrs={'size': 8}))
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
label='RIR')
#
@@ -261,26 +246,14 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def prefix_vrf_choices():
vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
def tenant_choices():
tenant_choices = Tenant.objects.all()
return [(t.slug, t.name) for t in tenant_choices]
def prefix_site_choices():
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
class Meta:
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
def prefix_status_choices():
@@ -290,27 +263,21 @@ def prefix_status_choices():
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
def prefix_role_choices():
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
widget=forms.SelectMultiple(attrs={'size': 6}))
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
widget=forms.SelectMultiple(attrs={'size': 6}))
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
@@ -323,6 +290,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
@@ -436,14 +404,12 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def ipaddress_vrf_choices():
vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
class Meta:
nullable_fields = ['vrf', 'tenant', 'description']
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -452,10 +418,10 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
widget=forms.SelectMultiple(attrs={'size': 6}))
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
widget=forms.SelectMultiple(attrs={'size': 6}))
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug', null_option=(0, 'None'))
#
@@ -470,14 +436,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
def vlangroup_site_choices():
site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
#
@@ -549,25 +509,13 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def vlan_site_choices():
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_group_choices():
group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
def vlan_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
def vlan_status_choices():
@@ -577,19 +525,13 @@ def vlan_status_choices():
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
def vlan_role_choices():
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-09-15 16:08
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0007_prefix_ipaddress_add_tenant'),
]
operations = [
migrations.AlterModelOptions(
name='prefix',
options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
),
]

View File

@@ -12,6 +12,7 @@ from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from .fields import IPNetworkField, IPAddressField
@@ -138,7 +139,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
if self.pk:
covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates:
raise ValidationError("{} is overlaps with an existing aggregate ({})"
raise ValidationError("{} overlaps with an existing aggregate ({})"
.format(self.prefix, covered_aggregates[0]))
def save(self, *args, **kwargs):
@@ -192,7 +193,7 @@ class Role(models.Model):
return self.vlans.count()
class PrefixQuerySet(models.QuerySet):
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
@@ -249,7 +250,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
objects = PrefixQuerySet.as_manager()
class Meta:
ordering = ['family', 'prefix']
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):

View File

@@ -9,7 +9,7 @@ import os
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
#
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')]
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
# PostgreSQL database configuration.
DATABASE = {
@@ -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)

View File

@@ -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

View File

@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
VERSION = '1.6.0'
VERSION = '1.6.3'
# 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', '')
@@ -71,7 +74,7 @@ if LDAP_CONFIGURED:
logger.setLevel(logging.DEBUG)
except ImportError:
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
"You can remove netbox/ldap.py to disable LDAP.")
"You can remove netbox/ldap_config.py to disable LDAP.")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -159,7 +162,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_ROOT = BASE_DIR + '/static/'
STATIC_URL = '/static/'
STATIC_URL = '/{}static/'.format(BASE_PATH)
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"),
)
@@ -173,8 +176,7 @@ MESSAGE_TAGS = {
}
# Authentication URLs
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048

View File

@@ -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))
]

View File

@@ -1,16 +1,30 @@
$(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 (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);
}
});
// Simple "Toggle all" button (panel)
$('button.toggle').click(function (event) {
var selected = $(this).attr('selected');
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
return false;
});
// Slugify
function slugify(s, num_chars) {
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
@@ -25,13 +39,18 @@ $(document).ready(function() {
});
if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source'));
slug_source.keyup(function() {
slug_source.on('keyup change', function() {
if (slug_field && !slug_field.attr('_changed')) {
slug_field.val(slugify($(this).val(), 50));
}
})
}
// Bulk edit nullification
$('input:checkbox[name=_nullify]').click(function (event) {
$('#id_' + this.value).toggle('disabled');
});
// API select widget
$('select[filter-for]').change(function () {

View File

@@ -5,7 +5,7 @@ from django import forms
from django.db.models import Count
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -89,19 +89,17 @@ class SecretImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
class SecretBulkEditForm(forms.Form, BootstrapMixin):
class SecretBulkEditForm(BulkEditForm, 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)
def secret_role_choices():
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
class Meta:
nullable_fields = ['name']
class SecretFilterForm(forms.Form, BootstrapMixin):
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
#

View File

@@ -26,7 +26,7 @@
<pre><strong>{{ exception }}</strong><br />
{{ error }}</pre>
<div class="text-right">
<a href="/" class="btn btn-primary">Home Page</a>
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}">
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
</head>
<body>
<nav class="navbar navbar-default navbar-fixed-top">
@@ -20,7 +21,7 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">
<a class="navbar-brand" href="{% url 'home' %}">
<img src="{% static 'img/netbox_logo.png' %}" />
</a>
</div>
@@ -288,7 +289,7 @@
<div class="col-xs-4 text-right">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="/api/docs/">API</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a>
</p>
</div>

View File

@@ -104,6 +104,9 @@
</tr>
</table>
</div>
{% with circuit.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=circuit %}
</div>
<div class="col-md-6">

View File

@@ -3,14 +3,21 @@
{% block title %}Circuit Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Circuit</th>
<th>Type</th>
<th>Provider</th>
<th>Port speed</th>
<th>Commit rate</th>
</tr>
{% for circuit in selected_objects %}
<tr>
<td><a href="{% url 'circuits:circuit' pk=circuit.pk %}">{{ circuit }}</a></td>
<td>{{ circuit.type }}</td>
<td>{{ circuit.provider }}</td>
<td>{{ circuit.port_speed }} Kbps</td>
<td>{{ circuit.commit_rate }}</td>
<td>{{ circuit.port_speed_human }}</td>
<td>{{ circuit.commit_rate_human }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -105,7 +105,7 @@
</tr>
</table>
</div>
{% with provider.custom_fields as custom_fields %}
{% with provider.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">

View File

@@ -3,7 +3,12 @@
{% block title %}Provider Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Provider</th>
<th>Account</th>
<th>ASN</th>
</tr>
{% for provider in selected_objects %}
<tr>
<td><a href="{% url 'circuits:provider' slug=provider.slug %}">{{ provider }}</a></td>

View File

@@ -144,7 +144,7 @@
</tr>
</table>
</div>
{% with device.custom_fields as custom_fields %}
{% with device.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% if request.user.is_authenticated %}
@@ -186,18 +186,23 @@
{% include 'dcim/inc/_ipaddress.html' %}
{% endfor %}
</table>
{% else %}
{% elif interfaces or mgmt_interfaces %}
<div class="panel-body text-muted">
None found
None assigned
</div>
{% else %}
<div class="panel-body">
<a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
</div>
{% endif %}
{% if perms.ipam.add_ipaddress %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Assign IP address
</a>
</div>
{% if interfaces or mgmt_interfaces %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
</a>
</div>
{% endif %}
{% endif %}
</div>
<div class="panel panel-default">
@@ -210,7 +215,7 @@
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No management interfaces defined!
<i class="fa fa-fw fa-warning"></i> No management interfaces defined
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
{% endif %}
@@ -222,7 +227,7 @@
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No console ports defined!
<i class="fa fa-fw fa-warning"></i> No console ports defined
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
{% endif %}
@@ -235,7 +240,7 @@
{% if not device.device_type.is_pdu %}
<tr>
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No power ports defined!
<i class="fa fa-fw fa-warning"></i> No power ports defined
{% if perms.dcim.add_powerport %}
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
{% endif %}
@@ -248,20 +253,17 @@
<div class="panel-footer text-right">
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add interface
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interface
</a>
{% endif %}
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add console
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a>
{% endif %}
{% if perms.dcim.add_powerport and not device.device_type.is_pdu %}
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add power
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a>
{% endif %}
</div>
@@ -312,6 +314,16 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for devicebay in device_bays %}
@@ -324,23 +336,19 @@
</table>
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_devicebay %}
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add device bay
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -350,12 +358,22 @@
{% endif %}
{% if interfaces or device.device_type.is_network_device %}
{% if perms.dcim.delete_interface %}
<form method="post" action="{% url 'dcim:interface_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_interface and interfaces|length > 10 %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for iface in interfaces %}
@@ -368,23 +386,24 @@
</table>
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit selected
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add interface
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -400,6 +419,16 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Ports</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for csp in cs_ports %}
@@ -412,23 +441,19 @@
</table>
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_consoleserverport %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add console server ports
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -444,6 +469,16 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlets</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for po in power_outlets %}
@@ -456,23 +491,19 @@
</table>
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_poweroutlet %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add power outlets
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -531,13 +562,13 @@ function toggleConnection(elem, api_url) {
return false;
}
$(".consoleport-toggle").click(function() {
return toggleConnection($(this), "/api/dcim/console-ports/");
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
});
$(".powerport-toggle").click(function() {
return toggleConnection($(this), "/api/dcim/power-ports/");
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
});
$(".interface-toggle").click(function() {
return toggleConnection($(this), "/api/dcim/interface-connections/");
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
});
</script>
<script src="{% static 'js/graphs.js' %}"></script>

View File

@@ -3,7 +3,14 @@
{% block title %}Device Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
<th>Tenant</th>
<th>Serial</th>
</tr>
{% for device in selected_objects %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>

View File

@@ -72,6 +72,10 @@
{% endif %}
</td>
</tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
</tr>
</table>
</div>
<div class="panel panel-default">
@@ -143,14 +147,14 @@
</div>
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' delete_url='dcim:devicetype_delete_interface' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
</div>
<div class="col-md-6">
{% if devicetype.is_parent_device %}
{% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
{% endif %}
{% if devicetype.is_network_device %}
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
{% endif %}
{% if devicetype.is_console_server %}
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}

View File

@@ -3,11 +3,15 @@
{% block title %}Device Type Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Device type</th>
<th>Manufacturer</th>
<th>Height</th>
</tr>
{% for devicetype in selected_objects %}
<tr>
<td><a href="{% url 'dcim:devicetype' pk=devicetype.pk %}">{{ devicetype }}</a></td>
<td>{{ devicetype.model }}</td>
<td><a href="{% url 'dcim:devicetype' pk=devicetype.pk %}">{{ devicetype.model }}</a></td>
<td>{{ devicetype.manufacturer }}</td>
<td>{{ devicetype.u_height }}U</td>
</tr>

View File

@@ -1,30 +1,9 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% if table.model|user_can_change:request.user or table.model|user_can_delete:request.user %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
<input type="hidden" name="pk_all" value="{% for row in table.rows %}{{ row.record.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
{% render_table table table_template|default:'table.html' %}
{% if perms.dcim.add_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_add_multi' %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add Interfaces
</button>
{% endif %}
{% if bulk_edit_url and table.model|user_can_change:request.user %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}" class="btn btn-warning btn-sm">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and table.model|user_can_delete:request.user %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}" class="btn btn-danger btn-sm">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Delete Selected
</button>
{% endif %}
</form>
{% else %}
{% render_table table table_template|default:'table.html' %}
{% endif %}
{% extends 'utilities/obj_table.html' %}
{% block extra_actions %}
{% if perms.dcim.add_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_add_multi' %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces
</button>
{% endif %}
{% endblock %}

View File

@@ -1,23 +1,46 @@
{% load render_table from django_tables2 %}
{% if perms.dcim.change_devicetype %}
<form method="post" action="{% url delete_url pk=devicetype.pk %}">
<form method="post">
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs pull-right">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }}
</a>
<strong>{{ title }}</strong>
<div class="pull-right">
{% if table.rows|length > 3 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if table.rows|length > 10 %}
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }}
</a>
{% endif %}
</div>
</div>
{% render_table table 'table.html' %}
{% if table.rows %}
<div class="panel-footer">
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
<div class="panel-footer">
{% if table.rows %}
{% if edit_url %}
<button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}" class="btn btn-xs btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if delete_url %}
<button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% endif %}
<div class="pull-right">
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }}
</a>
</div>
{% endif %}
<div class="clearfix"></div>
</div>
</div>
</form>
{% else %}

View File

@@ -7,7 +7,12 @@
{% block form_title %}Interface(s) to Add{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
</tr>
{% for device in selected_objects %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>

View File

@@ -0,0 +1,17 @@
{% extends 'utilities/bulk_edit_form.html' %}
{% load form_helpers %}
{% block title %}Interface Bulk Edit{% endblock %}
{% block selected_objects_table %}
<tr>
<th>Name</th>
<th>Form Factor</th>
</tr>
{% for iface in selected_objects %}
<tr>
<td>{{ iface.name }}</td>
<td>{{ iface.get_form_factor_display }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends 'utilities/bulk_edit_form.html' %}
{% load form_helpers %}
{% block title %}Interface Template Bulk Edit{% endblock %}
{% block selected_objects_table %}
<tr>
<th>Name</th>
<th>Form Factor</th>
<th>Management</th>
</tr>
{% for iface in selected_objects %}
<tr>
<td>{{ iface.name }}</td>
<td>{{ iface.get_form_factor_display }}</td>
<td>
{% if iface.mgmt_only %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -132,7 +132,7 @@
</tr>
</table>
</div>
{% with rack.custom_fields as custom_fields %}
{% with rack.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">

View File

@@ -3,12 +3,13 @@
{% block title %}Rack Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Name</th>
<th>Site</th>
<th>Group</th>
<th>Tenant</th>
<th>Role</th>
<th>Type</th>
<th>Width</th>
<th>Height</th>
@@ -19,6 +20,7 @@
<td>{{ rack.site }}</td>
<td>{{ rack.group }}</td>
<td>{{ rack.tenant }}</td>
<td>{{ rack.role }}</td>
<td>{{ rack.get_type_display }}</td>
<td>{{ rack.get_width_display }}</td>
<td>{{ rack.u_height }}U</td>

View File

@@ -111,7 +111,7 @@
</tr>
</table>
</div>
{% with site.custom_fields as custom_fields %}
{% with site.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">

View File

@@ -3,7 +3,11 @@
{% block title %}Site Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Site</th>
<th>Tenant</th>
</tr>
{% for site in selected_objects %}
<tr>
<td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site }}</a></td>

View File

@@ -8,13 +8,13 @@
<tr>
<td>{{ field }}</td>
<td>
{% if value == True %}
{% if field.type == 300 and value == True %}
<i class="glyphicon glyphicon-ok text-success" title="True"></i>
{% elif value == False %}
{% elif field.type == 300 and value == False %}
<i class="glyphicon glyphicon-remove text-danger" title="False"></i>
{% elif field.type == 500 and value %}
{{ value|urlizetrunc:75 }}
{% elif value %}
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% elif field.type == 200 or value %}
{{ value }}
{% elif field.required %}
<span class="text-warning">Not defined</span>

View File

@@ -8,7 +8,7 @@
<li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export">CSV (default)</a></li>
<li class="divider"></li>
{% for et in export_templates %}
<li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export={{ et.name }}">{{ et.name }}</a></li>
<li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
{% endfor %}
</ul>
</div>

View File

@@ -82,7 +82,7 @@
{% include 'inc/created_updated.html' with obj=aggregate %}
</div>
<div class="col-md-6">
{% with aggregate.custom_fields as custom_fields %}
{% with aggregate.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
</div>

View File

@@ -3,7 +3,13 @@
{% block title %}Aggregate Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Aggregate</th>
<th>RIR</th>
<th>Date Added</th>
<th>Description</th>
</tr>
{% for aggregate in selected_objects %}
<tr>
<td><a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate }}</a></td>

View File

@@ -121,6 +121,9 @@
</tr>
</table>
</div>
{% with ipaddress.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=ipaddress %}
</div>
<div class="col-md-6">

View File

@@ -3,14 +3,20 @@
{% block title %}IP Address Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>IP Address</th>
<th>VRF</th>
<th>Tenant</th>
<th>Assigned</th>
<th>Description</th>
</tr>
{% for ipaddress in selected_objects %}
<tr>
<td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
<td>{{ ipaddress.vrf|default:"Global" }}</td>
<td>{{ ipaddress.tenant }}</td>
<td>{{ ipaddress.interface.device }}</td>
<td>{{ ipaddress.interface }}</td>
<td>{% if ipaddress.interface %}<i class="glyphicon glyphicon-ok text-success" title="{{ ipaddress.interface.device }} {{ ipaddress.interface }}"></i>{% endif %}</td>
<td>{{ ipaddress.description }}</td>
</tr>
{% endfor %}

View File

@@ -101,6 +101,9 @@
</tr>
</table>
</div>
{% with prefix.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=prefix %}
<br />
</div>

View File

@@ -3,16 +3,23 @@
{% block title %}Prefix Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Prefix</th>
<th>Site</th>
<th>VRF</th>
<th>Tenant</th>
<th>Status</th>
<th>Role</th>
</tr>
{% for prefix in selected_objects %}
<tr>
<td><a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a></td>
<td>{{ prefix.site }}</td>
<td>{{ prefix.vrf|default:"Global" }}</td>
<td>{{ prefix.tenant }}</td>
<td>{{ prefix.site }}</td>
<td>{{ prefix.status }}</td>
<td>{{ prefix.get_status_display }}</td>
<td>{{ prefix.role }}</td>
<td>{{ prefix.description }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -6,6 +6,15 @@
{% block content %}
<div class="pull-right">
<a href="{% url 'ipam:prefix_list' %}{% querystring_toggle request expand='on' %}" class="btn btn-default">
{% if 'expand' in request.GET %}
<span class="fa fa-chevron-right" aria-hidden="true"></span>
Collapse all
{% else %}
<span class="fa fa-chevron-down" aria-hidden="true"></span>
Expand all
{% endif %}
</a>
{% if perms.ipam.add_prefix %}
<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>

View File

@@ -110,6 +110,9 @@
</tr>
</table>
</div>
{% with vlan.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=vlan %}
</div>
<div class="col-md-6">

View File

@@ -3,16 +3,23 @@
{% block title %}VLAN Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>VLAN</th>
<th>Site</th>
<th>Group</th>
<th>Tenant</th>
<th>Status</th>
<th>Role</th>
</tr>
{% for vlan in selected_objects %}
<tr>
<td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td>
<td>{{ vlan.name }}</td>
<td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan }}</a></td>
<td>{{ vlan.site }}</td>
<td>{{ vlan.group }}</td>
<td>{{ vlan.tenant }}</td>
<td>{{ vlan.get_status_display }}</td>
<td>{{ vlan.role }}</td>
<td>{{ vlan.description }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -82,6 +82,9 @@
</tr>
</table>
</div>
{% with vrf.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=vrf %}
</div>
<div class="col-md-6">

View File

@@ -3,7 +3,13 @@
{% block title %}VRF Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>VRF</th>
<th>RD</th>
<th>Tenant</th>
<th>Description</th>
</tr>
{% for vrf in selected_objects %}
<tr>
<td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td>

View File

@@ -3,11 +3,15 @@
{% block title %}Secret Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Device</th>
<th>Role</th>
<th>Name</th>
</tr>
{% for secret in selected_objects %}
<tr>
<td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret }}</a></td>
<td>{{ secret.device }}</td>
<td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret.device }}</a></td>
<td>{{ secret.role }}</td>
<td>{{ secret.name }}</td>
</tr>

View File

@@ -65,7 +65,7 @@
</tr>
</table>
</div>
{% with tenant.custom_fields as custom_fields %}
{% with tenant.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">

View File

@@ -3,7 +3,11 @@
{% block title %}Tenant Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Tenant</th>
<th>Group</th>
</tr>
{% for tenant in selected_objects %}
<tr>
<td><a href="{% url 'tenancy:tenant' slug=tenant.slug %}">{{ tenant }}</a></td>

View File

@@ -8,12 +8,15 @@
{% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% endif %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading"><strong>{% block selected_objects_title %}Selected For Editing{% endblock %}</strong></div>
<div class="panel-heading"><strong>{% block selected_objects_title %}{{ selected_objects|length }} Selected For Editing{% endblock %}</strong></div>
<table class="panel-body table table-hover">
{% block select_objects_table %}{% endblock %}
{% block selected_objects_table %}{% endblock %}
</table>
</div>
</div>
@@ -29,7 +32,13 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>{% block form_title %}Attributes{% endblock %}</strong></div>
<div class="panel-body">
{% render_form form %}
{% for field in form.visible_fields %}
{% if field.name in form.nullable_fields %}
{% render_field field bulk_nullable=True %}
{% else %}
{% render_field field %}
{% endif %}
{% endfor %}
</div>
</div>
<div class="form-group text-right">

View File

@@ -5,17 +5,26 @@
{% csrf_token %}
<input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
<input type="hidden" name="pk_all" value="{% for row in table.rows %}{{ row.record.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
{% if table.paginator.num_pages > 1 %}
<div id="select_all_box" class="hidden alert alert-info">
<div class="checkbox-inline">
<label for="select_all">
<input type="checkbox" id="select_all" name="_all" />
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
</div>
{% endif %}
{% render_table table table_template|default:'table.html' %}
{% block extra_actions %}{% endblock %}
{% if bulk_edit_url and table.model|user_can_change:request.user %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}" class="btn btn-warning btn-sm">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit Selected
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and table.model|user_can_delete:request.user %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}" class="btn btn-danger btn-sm">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Delete Selected
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
</form>

View File

@@ -5,26 +5,26 @@
<div class="col-md-9 col-md-offset-3">
<div class="checkbox{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">
{{ field }}
{{ field.label }}
{{ field }} {{ field.label }}
</label>
{% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span>
{% endif %}
</div>
</div>
{% elif field|widget_type == 'radioselect' %}
<div class="col-md-9 col-md-offset-3">
<div class="radio{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">
{{ field }}
{{ field.label }}
{% if bulk_nullable %}
<label class="checkbox-inline">
<input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
</label>
</div>
{% endif %}
</div>
{% elif field|widget_type == 'textarea' %}
<div class="col-md-12">
{{ field }}
{% if bulk_nullable %}
<label class="checkbox-inline">
<input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
</label>
{% endif %}
{% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span>
{% endif %}
@@ -40,6 +40,11 @@
<label class="col-md-3 control-label{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="col-md-9">
{{ field }}
{% if bulk_nullable %}
<label class="checkbox-inline">
<input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
</label>
{% endif %}
{% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span>
{% endif %}

View File

@@ -3,6 +3,7 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import Tenant, TenantGroup
@@ -11,12 +12,12 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search',
label='Search',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=TenantGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=TenantGroup.objects.all(),
to_field_name='slug',

View File

@@ -2,35 +2,11 @@ from django import forms
from django.db.models import Count
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField
from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField
from .models import Tenant, TenantGroup
def bulkedit_tenantgroup_choices():
"""
Include an option to remove the currently assigned TenantGroup from a Tenant.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(g.pk, g.name) for g in TenantGroup.objects.all()]
return choices
def bulkedit_tenant_choices():
"""
Include an option to remove the currently assigned Tenant from an object.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(t.pk, t.name) for t in Tenant.objects.all()]
return choices
#
# Tenant groups
#
@@ -71,15 +47,13 @@ class TenantImportForm(BulkImportForm, BootstrapMixin):
class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group')
group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
def tenant_group_choices():
group_choices = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices]
class Meta:
nullable_fields = ['group']
class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Tenant
group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
to_field_name='slug', null_option=(0, 'None'))

View File

@@ -1,10 +1,9 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render, resolve_url
from django.shortcuts import redirect, render
from django.utils.http import is_safe_url
from secrets.forms import UserKeyForm
@@ -26,7 +25,7 @@ def login(request):
# Determine where to direct user after successful login
redirect_to = request.POST.get('next', '')
if not is_safe_url(url=redirect_to, host=request.get_host()):
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
redirect_to = reverse('home')
# Authenticate user
auth_login(request, form.get_user())

View File

@@ -0,0 +1,93 @@
import django_filters
import itertools
from django import forms
from django.db.models import Q
from django.utils.encoding import force_text
class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is
used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null
choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar
to defining a MultipleChoiceField with:
choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()]
However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating
database migrations.
"""
iterator = forms.models.ModelChoiceIterator
def __init__(self, null_value=0, null_label='None', *args, **kwargs):
self.null_value = null_value
self.null_label = null_label
super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs)
def _get_choices(self):
if hasattr(self, '_choices'):
return self._choices
# Prepend the null choice to the queryset iterator
return itertools.chain(
[(self.null_value, self.null_label)],
self.iterator(self),
)
choices = property(_get_choices, forms.ChoiceField._set_choices)
def clean(self, value):
# Strip all instances of the null value before cleaning
if value is not None:
stripped_value = [x for x in value if x != force_text(self.null_value)]
else:
stripped_value = value
super(NullableModelMultipleChoiceField, self).clean(stripped_value)
return value
class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
"""
This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default
queryset filter argument is:
.filter(fieldname=value)
When filtering by the value representing "is null" ('0' by default) the argument is modified to:
.filter(fieldname__isnull=True)
"""
field_class = NullableModelMultipleChoiceField
def __init__(self, *args, **kwargs):
self.null_value = kwargs.get('null_value', 0)
super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs)
def filter(self, qs, value):
value = value or () # Make sure we have an iterable
if self.is_noop(qs, value):
return qs
# Even though not a noop, no point filtering if empty
if not value:
return qs
q = Q()
for v in set(value):
# Filtering by "is null"
if v == force_text(self.null_value):
arg = {'{}__isnull'.format(self.name): True}
# Filtering by a related field (e.g. slug)
elif self.field.to_field_name is not None:
arg = {'{}__{}'.format(self.name, self.field.to_field_name): v}
# Filtering by primary key (default)
else:
arg = {self.name: v}
if self.conjoined:
qs = self.get_method(qs)(**arg)
else:
q |= Q(**arg)
if self.distinct:
return self.get_method(qs)(q).distinct()
return self.get_method(qs)(q)

View File

@@ -1,8 +1,11 @@
import csv
import itertools
import re
from django import forms
from django.conf import settings
from django.core.urlresolvers import reverse_lazy
from django.core.validators import URLValidator
from django.utils.encoding import force_text
from django.utils.html import format_html
from django.utils.safestring import mark_safe
@@ -31,7 +34,7 @@ def add_blank_choice(choices):
"""
Add a blank choice to the beginning of a choices list.
"""
return ((None, '---------'),) + choices
return ((None, '---------'),) + tuple(choices)
#
@@ -89,7 +92,7 @@ class APISelect(SelectWithDisabled):
super(APISelect, self).__init__(*args, **kwargs)
self.attrs['class'] = 'api-select'
self.attrs['api-url'] = api_url
self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
if display_field:
self.attrs['display-field'] = display_field
if disabled_indicator:
@@ -142,10 +145,14 @@ class CSVDataField(forms.CharField):
if not self.help_text:
self.help_text = 'Enter one line per record in CSV format.'
def utf_8_encoder(self, unicode_csv_data):
for line in unicode_csv_data:
yield line.encode('utf-8')
def to_python(self, value):
# Return a list of dictionaries, each representing an individual record
records = []
reader = csv.reader(value.splitlines())
reader = csv.reader(self.utf_8_encoder(value.splitlines()))
for i, row in enumerate(reader, start=1):
if row:
if len(row) < len(self.columns):
@@ -222,6 +229,47 @@ class SlugField(forms.SlugField):
self.widget.attrs['slug-source'] = slug_source
class FilterChoiceField(forms.ModelMultipleChoiceField):
iterator = forms.models.ModelChoiceIterator
def __init__(self, null_option=None, *args, **kwargs):
self.null_option = null_option
if 'required' not in kwargs:
kwargs['required'] = False
if 'widget' not in kwargs:
kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
super(FilterChoiceField, self).__init__(*args, **kwargs)
def label_from_instance(self, obj):
if hasattr(obj, 'filter_count'):
return u'{} ({})'.format(obj, obj.filter_count)
return force_text(obj)
def _get_choices(self):
if hasattr(self, '_choices'):
return self._choices
if self.null_option is not None:
return itertools.chain([self.null_option], self.iterator(self))
return self.iterator(self)
choices = property(_get_choices, forms.ChoiceField._set_choices)
class LaxURLField(forms.URLField):
"""
Custom URLField which allows any valid URL scheme
"""
class AnyURLScheme(object):
# A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
def __contains__(self, item):
if not item or not re.match('^[a-z][0-9a-z+\-.]*$', item.lower()):
return False
return True
default_validators = [URLValidator(schemes=AnyURLScheme())]
#
# Forms
#
@@ -246,6 +294,18 @@ class ConfirmationForm(forms.Form, BootstrapMixin):
confirm = forms.BooleanField(required=True)
class BulkEditForm(forms.Form):
def __init__(self, model, *args, **kwargs):
super(BulkEditForm, self).__init__(*args, **kwargs)
self.model = model
# Copy any nullable fields defined in Meta
if hasattr(self.Meta, 'nullable_fields'):
self.nullable_fields = [field for field in self.Meta.nullable_fields]
else:
self.nullable_fields = []
class BulkImportForm(forms.Form):
def clean(self):

View File

@@ -12,4 +12,4 @@ class LoginRequiredMiddleware:
def process_request(self, request):
if LOGIN_REQUIRED and not request.user.is_authenticated():
if request.path_info != settings.LOGIN_URL:
return HttpResponseRedirect(settings.LOGIN_URL)
return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info))

32
netbox/utilities/sql.py Normal file
View File

@@ -0,0 +1,32 @@
from django.db import connections, models
from django.db.models.sql.compiler import SQLCompiler
class NullsFirstSQLCompiler(SQLCompiler):
def get_order_by(self):
result = super(NullsFirstSQLCompiler, self).get_order_by()
if result:
return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result]
return result
class NullsFirstQuery(models.sql.query.Query):
def get_compiler(self, using=None, connection=None):
if using is None and connection is None:
raise ValueError("Need either using or connection")
if using:
connection = connections[using]
return NullsFirstSQLCompiler(self, connection, using)
class NullsFirstQuerySet(models.QuerySet):
"""
Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global
table before those assigned to a VRF.
"""
def __init__(self, model=None, query=None, using=None, hints=None):
super(NullsFirstQuerySet, self).__init__(model, query, using, hints)
self.query = query or NullsFirstQuery(self.model)

View File

@@ -27,4 +27,4 @@ class ToggleColumn(tables.CheckBoxColumn):
@property
def header(self):
return mark_safe('<input type="checkbox" name="_all" title="Select all" />')
return mark_safe('<input type="checkbox" id="toggle_all" title="Toggle all" />')

View File

@@ -5,12 +5,13 @@ register = template.Library()
@register.inclusion_tag('utilities/render_field.html')
def render_field(field):
def render_field(field, bulk_nullable=False):
"""
Render a single form field from template
"""
return {
'field': field,
'bulk_nullable': bulk_nullable,
}

View File

@@ -1,3 +1,4 @@
from collections import OrderedDict
from django_tables2 import RequestConfig
from django.contrib import messages
@@ -10,18 +11,30 @@ from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, TypedCho
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError
from django.utils.decorators import method_decorator
from django.utils.http import is_safe_url
from django.views.generic import View
from extras.forms import CustomFieldForm
from extras.models import CustomFieldValue, ExportTemplate, UserAction
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm
from .paginator import EnhancedPaginator
class annotate_custom_fields:
def __init__(self, queryset, custom_fields):
self.queryset = queryset
self.custom_fields = custom_fields
def __iter__(self):
for obj in self.queryset:
values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
yield obj
class ObjectListView(View):
queryset = None
filter = None
@@ -39,19 +52,26 @@ class ObjectListView(View):
if self.filter:
self.queryset = self.filter(request.GET, self.queryset).qs
# If this type of object has one or more custom fields, prefetch any relevant custom field values
custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\
.prefetch_related('choices')
if custom_fields:
self.queryset = self.queryset.prefetch_related('custom_field_values')
# Check for export template rendering
if request.GET.get('export'):
et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export'))
queryset = annotate_custom_fields(self.queryset, custom_fields) if custom_fields else self.queryset
try:
response = et.to_response(context_dict={'queryset': self.queryset.all()},
filename='netbox_{}'.format(self.queryset.model._meta.verbose_name_plural))
response = et.to_response(context_dict={'queryset': queryset},
filename='netbox_{}'.format(model._meta.verbose_name_plural))
return response
except TemplateSyntaxError:
messages.error(request, "There was an error rendering the selected export template ({})."
.format(et.name))
# Fall back to built-in CSV export
elif 'export' in request.GET and hasattr(model, 'to_csv'):
output = '\n'.join([obj.to_csv() for obj in self.queryset.all()])
output = '\n'.join([obj.to_csv() for obj in self.queryset])
response = HttpResponse(
output,
content_type='text/csv'
@@ -260,42 +280,59 @@ class BulkImportView(View):
class BulkEditView(View):
cls = None
parent_cls = None
form = None
template_name = None
default_redirect_url = None
def get(self, request, *args, **kwargs):
def get(self):
return redirect(self.default_redirect_url)
def post(self, request, *args, **kwargs):
def post(self, request, **kwargs):
# Attempt to derive parent object if a parent class has been given
if self.parent_cls:
parent_obj = get_object_or_404(self.parent_cls, **kwargs)
else:
parent_obj = None
# Determine URL to redirect users upon modification of objects
posted_redirect_url = request.POST.get('redirect_url')
if posted_redirect_url and is_safe_url(url=posted_redirect_url, host=request.get_host()):
redirect_url = posted_redirect_url
else:
elif parent_obj:
redirect_url = parent_obj.get_absolute_url()
elif self.default_redirect_url:
redirect_url = reverse(self.default_redirect_url)
else:
raise ImproperlyConfigured('No redirect URL has been provided.')
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST:
if hasattr(self.form, 'custom_fields'):
form = self.form(self.cls, request.POST)
else:
form = self.form(request.POST)
form = self.form(self.cls, request.POST)
if form.is_valid():
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
# Update objects
updated_count = self.update_objects(pk_list, form, standard_fields)
# Update standard fields. If a field is listed in _nullify, delete its value.
nullified_fields = request.POST.getlist('_nullify')
fields_to_update = {}
for field in standard_fields:
if field in form.nullable_fields and field in nullified_fields:
fields_to_update[field] = ''
elif form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
# Update custom fields for objects
if custom_fields:
objs_updated = self.update_custom_fields(pk_list, form, custom_fields)
objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
if objs_updated and not updated_count:
updated_count = objs_updated
@@ -306,10 +343,7 @@ class BulkEditView(View):
return redirect(redirect_url)
else:
if hasattr(self.form, 'custom_fields'):
form = self.form(self.cls, initial={'pk': pk_list})
else:
form = self.form(initial={'pk': pk_list})
form = self.form(self.cls, initial={'pk': pk_list})
selected_objects = self.cls.objects.filter(pk__in=pk_list)
if not selected_objects:
@@ -322,26 +356,23 @@ class BulkEditView(View):
'cancel_url': redirect_url,
})
def update_objects(self, pk_list, form, fields):
fields_to_update = {}
for name in fields:
# Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
fields_to_update[name] = None
elif form.cleaned_data[name]:
fields_to_update[name] = form.cleaned_data[name]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
def update_custom_fields(self, pk_list, form, fields):
def update_custom_fields(self, pk_list, form, fields, nullified_fields):
obj_type = ContentType.objects.get_for_model(self.cls)
objs_updated = False
for name in fields:
if form.cleaned_data[name] not in [None, u'']:
field = form.fields[name].model
field = form.fields[name].model
# Setting the field to null
if name in form.nullable_fields and name in nullified_fields:
# Delete all CustomFieldValues for instances of this field belonging to the selected objects.
CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
objs_updated = True
# Updating the value of the field
elif form.cleaned_data[name] not in [None, u'']:
# Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
@@ -380,7 +411,7 @@ class BulkDeleteView(View):
template_name = 'utilities/confirm_bulk_delete.html'
default_redirect_url = None
def post(self, request, *args, **kwargs):
def post(self, request, **kwargs):
# Attempt to derive parent object if a parent class has been given
if self.parent_cls:
@@ -401,9 +432,9 @@ class BulkDeleteView(View):
# Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [x for x in request.POST.get('pk_all').split(',') if x]
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else:
pk_list = request.POST.getlist('pk')
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
form_cls = self.get_form()

0
scripts/docker-build.sh Normal file → Executable file
View File