Compare commits

..

55 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
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
58 changed files with 784 additions and 424 deletions

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

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

@@ -3,7 +3,6 @@ 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, FilterChoiceField, Livesearch, SmallTextarea,
@@ -57,6 +56,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
comments = CommentField()
class Meta:
nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
@@ -86,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')
)
@@ -178,11 +180,14 @@ 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()
class Meta:
nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit

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

@@ -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,
FilterChoiceField, 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,7 +90,10 @@ 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)
class Meta:
nullable_fields = ['tenant']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -234,14 +213,17 @@ 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()
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack
@@ -279,7 +261,7 @@ 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)
@@ -334,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')
@@ -583,17 +573,19 @@ 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')
class Meta:
nullable_fields = ['tenant', 'platform']
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
label='Rack Group')
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
@@ -631,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):
@@ -695,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')
@@ -762,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')
)
@@ -826,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):
@@ -891,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')
)
@@ -958,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')
)
@@ -1023,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
#
@@ -1033,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')
@@ -1087,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
@@ -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

@@ -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
)
@@ -48,15 +49,13 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
# Select
elif cf.type == CF_TYPE_SELECT:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required:
choices = [(0, 'None')] + choices
if bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
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:
@@ -72,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)
@@ -92,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):
@@ -125,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

@@ -71,9 +71,9 @@ class CustomFieldModel(object):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if not hasattr(self, 'custom_fields'):
if not hasattr(self, 'get_custom_fields'):
return dict()
return {field.name: value for field, value in self.custom_fields.items()}
return {field.name: value for field, value in self.get_custom_fields().items()}
def get_custom_fields(self):
"""
@@ -146,8 +146,10 @@ class CustomField(models.Model):
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CF_TYPE_SELECT:
# return CustomFieldChoice.objects.get(pk=int(serialized_value))
return self.choices.get(pk=int(serialized_value))
try:
return self.choices.get(pk=int(serialized_value))
except CustomFieldChoice.DoesNotExist:
return None
return serialized_value
@@ -198,6 +200,12 @@ class CustomFieldChoice(models.Model):
if self.field.type != CF_TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
def delete(self, using=None, keep_parents=False):
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
pk = self.pk
super(CustomFieldChoice, self).delete(using, keep_parents)
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
@@ -225,7 +233,8 @@ class Graph(models.Model):
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=200)
name = models.CharField(max_length=100)
description = models.CharField(max_length=200, blank=True)
template_code = models.TextField()
mime_type = models.CharField(max_length=15, blank=True)
file_extension = models.CharField(max_length=15, blank=True)

View File

@@ -3,7 +3,6 @@ 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 (
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
@@ -23,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
#
@@ -67,9 +54,12 @@ 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)
class Meta:
nullable_fields = ['tenant', 'description']
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
@@ -124,6 +114,9 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
date_added = forms.DateField(required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['date_added', 'description']
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
@@ -253,12 +246,15 @@ 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)
class Meta:
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
def prefix_status_choices():
status_counts = {}
@@ -294,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')
@@ -407,10 +404,13 @@ 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)
class Meta:
nullable_fields = ['vrf', 'tenant', 'description']
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress
@@ -509,11 +509,14 @@ 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)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
def vlan_status_choices():
status_counts = {}

View File

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

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.1'
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', '')
@@ -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
@@ -32,6 +46,11 @@ $(document).ready(function() {
})
}
// 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, FilterChoiceField, SlugField
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -89,11 +89,14 @@ 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)
class Meta:
nullable_fields = ['name']
class SecretFilterForm(forms.Form, BootstrapMixin):
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

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

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

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

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

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

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

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

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

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

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

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

@@ -7,30 +7,6 @@ from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDat
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,7 +47,10 @@ 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)
class Meta:
nullable_fields = ['group']
class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):

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

@@ -3,7 +3,9 @@ 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
@@ -32,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)
#
@@ -90,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:
@@ -253,6 +255,21 @@ class FilterChoiceField(forms.ModelMultipleChoiceField):
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
#
@@ -277,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))

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

@@ -280,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
@@ -326,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:
@@ -342,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:
@@ -400,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:
@@ -421,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()