Compare commits

...

69 Commits

Author SHA1 Message Date
Jeremy Stretch
88239e0b0d Merge pull request #1278 from digitalocean/develop
Release v2.0.7
2017-06-15 14:26:38 -04:00
Jeremy Stretch
9930e2745f Release v2.0.7 2017-06-15 14:22:16 -04:00
Jeremy Stretch
da3879e928 Fixes #1275: Raise validation error on prefix import when multiple VLANs are found 2017-06-15 14:13:20 -04:00
Jeremy Stretch
7195b7c803 Closes #626: Added bulk disconnect function for console/power/interface connections on device view 2017-06-15 14:01:49 -04:00
Jeremy Stretch
9b082eea14 Fixes #1274: Exclude unterminated circuits from topology maps 2017-06-15 10:05:14 -04:00
Jeremy Stretch
a16218b311 Fixes #1273: Corrected status choices in IP address import form 2017-06-14 16:22:49 -04:00
Jeremy Stretch
29a71fd903 #1265: Improved livesearch UI Javascript 2017-06-14 14:50:12 -04:00
Jeremy Stretch
fcacac7c6f Fixes #1265: Fix console/power/interface connection validation when selecting a device via live search 2017-06-14 13:00:36 -04:00
Jeremy Stretch
78d74261e9 Fixes #1266: Prevent termination a circuit to an already-connected interface 2017-06-14 10:57:43 -04:00
Jeremy Stretch
16d694734b Fixes #1268: Fix CSV import error under Python 3 2017-06-14 09:55:52 -04:00
Jeremy Stretch
252ab0fbab Fixes #1238: Fix error when editing an IP with a NAT assignment which has no assigned device 2017-06-13 16:57:25 -04:00
Jeremy Stretch
8eb9c451a1 Renamed AddViews to CreateViews for consistency 2017-06-13 16:48:21 -04:00
Jeremy Stretch
469c52be28 Fixes #1263: Differentiate add and edit permissions for objects 2017-06-13 16:41:57 -04:00
Jeremy Stretch
54fa51eeff Post-release version bump 2017-06-13 15:55:58 -04:00
Jeremy Stretch
5456af6867 Removed 'update-alternatives' from Python3 instructions 2017-06-13 14:28:38 -04:00
Jeremy Stretch
180446c34d Removed Debian/RHEL references from installation docs 2017-06-12 10:06:19 -04:00
Jeremy Stretch
5c63a499d5 Merge pull request #1259 from digitalocean/develop
Release v2.0.6
2017-06-12 09:51:15 -04:00
Jeremy Stretch
3a2c5b318a Release v2.0.6 2017-06-12 09:44:09 -04:00
Jeremy Stretch
cfff69a715 Closes #1180: Simplified the process of finding related devices when viewing a device 2017-06-09 17:04:09 -04:00
Jeremy Stretch
08883d86ef Closes #913: Added headers to object CSV exports 2017-06-09 16:24:59 -04:00
Jeremy Stretch
8a849ebeff Closes #990: Enable logging configuration in configuration.py 2017-06-09 15:03:10 -04:00
Jeremy Stretch
05a796faf1 Closes #704: Allow filtering VLANs by group when editing prefixes 2017-06-09 14:15:12 -04:00
Jeremy Stretch
9e1d03b383 Formatting cleanup 2017-06-09 12:19:32 -04:00
Jeremy Stretch
0a929f2971 Fixes #1253: Improved upgrade.sh to allow forcing Python2 2017-06-09 12:13:47 -04:00
Jeremy Stretch
7878992570 First stab at an interactive shell which pre-imports all models 2017-06-08 16:38:25 -04:00
Jeremy Stretch
4f95926cbd Added utilization percetange to aggregate and prefix views 2017-06-08 12:48:49 -04:00
Jeremy Stretch
f3e997ea39 Closes #40: Added IP utilization graph to prefix list 2017-06-08 12:37:25 -04:00
Jeremy Stretch
2b921c21ff Post-release version bump 2017-06-08 10:12:39 -04:00
Jeremy Stretch
50496b1a59 Merge pull request #1251 from digitalocean/develop
Release v2.0.5
2017-06-08 10:10:41 -04:00
Jeremy Stretch
9736d63577 Release v2.0.5 2017-06-08 10:05:26 -04:00
Jeremy Stretch
13add414c4 Fixed formatting and typos 2017-06-08 10:02:16 -04:00
Jeremy Stretch
b032bc13db Merge pull request #1080 from bellwood/patch-2
Enhance LDAP documentation
2017-06-08 09:55:17 -04:00
Jeremy Stretch
aaad428438 Merge pull request #1249 from feuerrot/patch-1
upgrading.md: fix typo
2017-06-08 09:17:52 -04:00
Markus Witt
203895fc7e upgrading.md: fix typo 2017-06-08 10:58:30 +02:00
Jeremy Stretch
aab1fab445 PEP8 fix 2017-06-07 15:56:59 -04:00
Jeremy Stretch
e06221bc89 Merge branch 'import_headers' into develop 2017-06-07 15:54:59 -04:00
Jeremy Stretch
26a13edcf3 Layout tweaks 2017-06-07 15:54:48 -04:00
Jeremy Stretch
65b6fe576f Converted device fields to use FlexibleModelChoiceField; misc cleanup 2017-06-07 15:51:11 -04:00
Jeremy Stretch
4671829ad8 Removed obsolete validation from InterfaceConnectionCSVForm 2017-06-07 15:33:10 -04:00
Jeremy Stretch
293be752ca Form cleanup and fixed child device import 2017-06-07 15:30:28 -04:00
Jeremy Stretch
0a6e4f31d5 Updated CSVForm validation 2017-06-07 14:19:08 -04:00
Jeremy Stretch
e6c4ce51f7 Replaced all CSVForm ChoiceFields with CSVChoiceField 2017-06-07 13:22:06 -04:00
Jeremy Stretch
3924063060 Converted ConnectionStatusCSVField to a ChoiceField 2017-06-06 22:35:41 -04:00
Jeremy Stretch
d122f9f700 Added dynamic examples for CSV form fields 2017-06-06 17:27:26 -04:00
Brian Ellwood
d0649ba815 Update ldap.md
Wrap code in code block
2017-06-05 20:37:09 -04:00
Brian Ellwood
1ec09270a7 Update ldap.md
Capitalization
2017-06-05 20:35:05 -04:00
Jeremy Stretch
1ddd7415cb Replaced old CSVDataField 2017-06-05 16:16:23 -04:00
Jeremy Stretch
ec9d0d4008 Implemented ConnectionStatusCSVField 2017-06-05 16:12:51 -04:00
Jeremy Stretch
08c8bd3049 Renamed new import view to BulkImportView 2017-06-05 15:53:41 -04:00
Jeremy Stretch
2520d9f400 Converted console/power import views to new scheme 2017-06-05 15:53:03 -04:00
Jeremy Stretch
0e863ff9ca Converted interface connections import view to new scheme 2017-06-05 15:04:23 -04:00
Jeremy Stretch
1b78f54c6b Merge pull request #1245 from digitalocean/fix-secret-device-filter
Fixes #1244
2017-06-03 00:03:27 -04:00
Zach Moody
b732c24ec4 Fixes #1244 2017-06-02 16:33:49 -05:00
Jeremy Stretch
af604aba31 Converted secrets import view to new scheme 2017-06-02 17:23:41 -04:00
Jeremy Stretch
c82658440f Converted IPAM import views to new scheme 2017-06-02 16:07:11 -04:00
Jeremy Stretch
7e660d4d8e Converted site/rack/device import views to new scheme 2017-06-02 14:49:25 -04:00
Jeremy Stretch
4a8147f8a5 Converted circuits import views to new scheme 2017-06-02 13:40:52 -04:00
Jeremy Stretch
583830c652 #1190: Allow partial string matching when searching on custom fields 2017-06-01 16:57:32 -04:00
Jeremy Stretch
95fdb549d7 Fixes #1243: Catch ValueError in IP-based object filters 2017-06-01 16:13:07 -04:00
Jeremy Stretch
a598f0e632 Initial work on #655: CSV import headers 2017-05-31 17:40:11 -04:00
Jeremy Stretch
293dbd8a8b Fixes #1226: Improve validation for custom field values submitted via the API 2017-05-31 14:09:57 -04:00
Jeremy Stretch
f03a378ce0 Fixes #1239: Fix server error when creating VLANGroup via API 2017-05-31 11:50:03 -04:00
Jeremy Stretch
6aae8aee5b Closes #1237: Enabled setting limit=0 to disable pagination in API requests; added MAX_PAGE_SIZE configuration setting 2017-05-30 23:24:21 -04:00
Jeremy Stretch
6d908d3e79 Fixes #1236: Truncate rack names in elevations list; add facility ID 2017-05-30 13:13:01 -04:00
Jeremy Stretch
d5016c7133 Fixes #1235: Fix permission name for adding/editing inventory items 2017-05-30 13:03:25 -04:00
Jeremy Stretch
b5a1b692bd Fixes #1225: Fixed border on empty circuits table on provider view 2017-05-26 10:08:03 -04:00
Jeremy Stretch
834c396a22 Tweaked upgrade script to prefer pip3/python3 if present 2017-05-26 09:55:22 -04:00
Jeremy Stretch
bc18d241e8 Post-release version bump 2017-05-25 14:46:34 -04:00
bellwood
5ff4e3b194 Enhance LDAP documentation
Incorporating @marvnrawley's enhancements from #518
2017-04-13 17:03:58 -04:00
70 changed files with 1611 additions and 1729 deletions

View File

@@ -136,3 +136,8 @@ The response will return devices 1 through 100. The URL provided in the `next` a
"results": [...]
}
```
The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings/#max_page_size) setting, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
!!! warning
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.

View File

@@ -83,6 +83,34 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni
---
## LOGGING
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/1.11/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
```
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': '/var/log/netbox.log',
},
},
'loggers': {
'django': {
'handlers': ['file'],
'level': 'INFO',
},
},
}
```
---
## LOGIN_REQUIRED
Default: False
@@ -99,6 +127,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
---
## MAX_PAGE_SIZE
Default: 1000
An API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This setting defines the maximum limit. Setting it to `0` or `None` will allow an API consumer to request all objects by specifying `?limit=0`.
---
## NETBOX_USERNAME
## NETBOX_PASSWORD

View File

@@ -1,5 +1,4 @@
This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to
built-in Django users in the event of a failure.
This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to built-in Django users in the event of a failure.
# Requirements
@@ -29,6 +28,9 @@ Create a file in the same directory as `configuration.py` (typically `netbox/net
## General Server Configuration
!!! info
When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
```python
import ldap
@@ -52,6 +54,9 @@ LDAP_IGNORE_CERT_ERRORS = True
## User Authentication
!!! info
When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
```python
from django_auth_ldap.config import LDAPSearch
@@ -99,3 +104,16 @@ AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_CACHE_GROUPS = True
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
```
* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
It is also possible map user attributes to Django attributes:
```python
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn",
}
```

View File

@@ -1,12 +1,11 @@
# Installation
**Debian/Ubuntu**
**Ubuntu**
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
```
Python 2:
@@ -15,7 +14,7 @@ Python 2:
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
```
**CentOS/RHEL**
**CentOS**
Python 3:
@@ -57,13 +56,13 @@ Create the base directory for the NetBox installation. For this guide, we'll use
If `git` is not already installed, install it:
**Debian/Ubuntu**
**Ubuntu**
```no-highlight
# apt-get install -y git
```
**CentOS/RHEL**
**CentOS**
```no-highlight
# yum install -y git
@@ -150,11 +149,14 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
# Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
!!! warning
The examples on the rest of this page call the `python` executable, which will be Python2 on most systems. Replace this with `python3` if you're running NetBox on Python3.
Before NetBox can run, we need to install the database schema. This is done by running `python manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```no-highlight
# cd /opt/netbox/netbox/
# ./manage.py migrate
# python manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
@@ -172,7 +174,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```no-highlight
# ./manage.py createsuperuser
# python manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
@@ -183,7 +185,7 @@ Superuser created successfully.
# Collect Static Files
```no-highlight
# ./manage.py collectstatic --no-input
# python manage.py collectstatic --no-input
You have requested to collect static files at the destination
location as specified in your settings:
@@ -204,7 +206,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
```no-highlight
# ./manage.py loaddata initial_data
# python manage.py loaddata initial_data
Installed 43 object(s) from 4 fixture(s)
```
@@ -213,7 +215,7 @@ Installed 43 object(s) from 4 fixture(s)
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```no-highlight
# ./manage.py runserver 0.0.0.0:8000 --insecure
# python manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
System check identified no issues (0 silenced).

View File

@@ -1,15 +1,18 @@
NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
!!! note
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
# Installation
**Debian/Ubuntu**
**Ubuntu**
```no-highlight
# apt-get update
# apt-get install -y postgresql libpq-dev
```
**CentOS/RHEL**
**CentOS**
```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel

View File

@@ -52,6 +52,13 @@ Once the new code is in place, run the upgrade script (which may need to be run
# ./upgrade.sh
```
!!! warning
The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below.
```no-highlight
# ./upgrade.sh -2
```
This script:
* Installs or upgrades any new required Python packages
@@ -64,7 +71,7 @@ This script:
Your models have changes that are not yet reflected in a migration, and so won't be applied.
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are inentionally modifying the database schema.
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema.
# Restart the WSGI Service

View File

@@ -3,7 +3,7 @@
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.
For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
```no-highlight
# apt-get install -y gunicorn supervisor

View File

@@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
FilterChoiceField, Livesearch, SmallTextarea, SlugField,
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
SmallTextarea, SlugField,
)
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -39,15 +39,18 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
}
class ProviderFromCSVForm(forms.ModelForm):
class ProviderCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url']
class ProviderImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ProviderFromCSVForm)
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments']
help_texts = {
'name': 'Provider name',
'asn': '32-bit autonomous system number',
'portal_url': 'Portal URL',
'comments': 'Free-form comments',
}
class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -102,21 +105,36 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class CircuitCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
help_text='Name of parent provider',
error_messages={
'invalid_choice': 'Provider not found.'
}
)
type = forms.ModelChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='name',
help_text='Type of circuit',
error_messages={
'invalid_choice': 'Invalid circuit type.'
}
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.'
}
)
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -202,7 +220,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
label='Interface',
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
disabled_indicator='is_connected'
disabled_indicator='connection'
)
)

View File

@@ -52,6 +52,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url']
class Meta:
ordering = ['name']
@@ -107,6 +109,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class Meta:
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']

View File

@@ -10,7 +10,7 @@ urlpatterns = [
# Providers
url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'),
url(r'^providers/add/$', views.ProviderEditView.as_view(), name='provider_add'),
url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'),
url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@@ -20,13 +20,13 @@ urlpatterns = [
# Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/add/$', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
# Circuits
url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
url(r'^circuits/add/$', views.CircuitEditView.as_view(), name='circuit_add'),
url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'),
url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@@ -36,7 +36,7 @@ urlpatterns = [
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),

View File

@@ -49,14 +49,18 @@ class ProviderView(View):
})
class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_provider'
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_provider'
model = Provider
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list'
class ProviderEditView(ProviderCreateView):
permission_required = 'circuits.change_provider'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
model = Provider
@@ -65,9 +69,8 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_provider'
form = forms.ProviderImportForm
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
template_name = 'circuits/provider_import.html'
default_return_url = 'circuits:provider_list'
@@ -97,8 +100,8 @@ class CircuitTypeListView(ObjectListView):
template_name = 'circuits/circuittype_list.html'
class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype'
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
@@ -106,6 +109,10 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('circuits:circuittype_list')
class CircuitTypeEditView(CircuitTypeCreateView):
permission_required = 'circuits.change_circuittype'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
@@ -147,14 +154,18 @@ class CircuitView(View):
})
class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit'
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuit'
model = Circuit
form_class = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
class CircuitEditView(CircuitCreateView):
permission_required = 'circuits.change_circuit'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
model = Circuit
@@ -163,9 +174,8 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuit'
form = forms.CircuitImportForm
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
template_name = 'circuits/circuit_import.html'
default_return_url = 'circuits:circuit_list'
@@ -234,8 +244,8 @@ def circuit_terminations_swap(request, pk):
# Circuit terminations
#
class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittermination'
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittermination'
model = CircuitTermination
form_class = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
@@ -249,6 +259,10 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url()
class CircuitTerminationEditView(CircuitTerminationCreateView):
permission_required = 'circuits.change_circuittermination'
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
model = CircuitTermination

View File

@@ -5,7 +5,6 @@ import re
from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@@ -14,18 +13,18 @@ from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
)
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate,
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES,
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface,
IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES,
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN,
Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
)
@@ -50,14 +49,6 @@ def get_device_by_name_or_pk(name):
return device
def validate_connection_status(value):
"""
Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
"""
if value.lower() not in ['planned', 'connected']:
raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
class DeviceComponentForm(BootstrapMixin, forms.Form):
"""
Allow inclusion of the parent device as context for limiting field choices.
@@ -107,27 +98,37 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
class SiteFromCSVForm(forms.ModelForm):
class SiteCSVForm(forms.ModelForm):
region = forms.ModelChoiceField(
Region.objects.all(), to_field_name='name', required=False, error_messages={
'invalid_choice': 'Tenant not found.'
queryset=Region.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned region',
error_messages={
'invalid_choice': 'Region not found.',
}
)
tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False, error_messages={
'invalid_choice': 'Tenant not found.'
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
class Meta:
model = Site
fields = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments',
]
class SiteImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=SiteFromCSVForm)
help_texts = {
'name': 'Site name',
'slug': 'URL-friendly slug',
'asn': '32-bit autonomous system number',
}
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -217,49 +218,73 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
group_name = forms.CharField(required=False)
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Role not found.'})
type = forms.CharField(required=False)
class RackCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
)
group_name = forms.CharField(
help_text='Name of rack group',
required=False
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
role = forms.ModelChoiceField(
queryset=RackRole.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned role',
error_messages={
'invalid_choice': 'Role not found.',
}
)
type = CSVChoiceField(
choices=RACK_TYPE_CHOICES,
required=False,
help_text='Rack type'
)
width = forms.ChoiceField(
choices=(
(RACK_WIDTH_19IN, '19'),
(RACK_WIDTH_23IN, '23'),
),
help_text='Rail-to-rail width (in inches)'
)
class Meta:
model = Rack
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units']
fields = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
]
help_texts = {
'name': 'Rack name',
'u_height': 'Height in rack units',
}
def clean(self):
super(RackCSVForm, self).clean()
site = self.cleaned_data.get('site')
group = self.cleaned_data.get('group_name')
group_name = self.cleaned_data.get('group_name')
# Validate rack group
if site and group:
if group_name:
try:
self.instance.group = RackGroup.objects.get(site=site, name=group)
self.instance.group = RackGroup.objects.get(site=site, name=group_name)
except RackGroup.DoesNotExist:
self.add_error('group_name', "Invalid rack group ({})".format(group))
def clean_type(self):
rack_type = self.cleaned_data['type']
if not rack_type:
return None
try:
choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES}
return choices[rack_type.lower()]
except KeyError:
raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format(
rack_type,
', '.join({v: k for k, v in RACK_TYPE_CHOICES}),
))
class RackImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=RackFromCSVForm)
raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site))
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -663,32 +688,60 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.initial['rack'] = self.instance.parent_bay.device.rack_id
class BaseDeviceFromCSVForm(forms.ModelForm):
class BaseDeviceCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid device role.'}
queryset=DeviceRole.objects.all(),
to_field_name='name',
help_text='Name of assigned role',
error_messages={
'invalid_choice': 'Invalid device role.',
}
)
tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid manufacturer.'}
queryset=Manufacturer.objects.all(),
to_field_name='name',
help_text='Device type manufacturer',
error_messages={
'invalid_choice': 'Invalid manufacturer.',
}
)
model_name = forms.CharField(
help_text='Device type model name'
)
model_name = forms.CharField()
platform = forms.ModelChoiceField(
queryset=Platform.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid platform.'}
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned platform',
error_messages={
'invalid_choice': 'Invalid platform.',
}
)
status = CSVChoiceField(
choices=STATUS_CHOICES,
help_text='Operational status of device'
)
status = forms.CharField()
class Meta:
fields = []
model = Device
help_texts = {
'name': 'Device name',
}
def clean(self):
super(BaseDeviceCSVForm, self).clean()
manufacturer = self.cleaned_data.get('manufacturer')
model_name = self.cleaned_data.get('model_name')
@@ -697,70 +750,73 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
try:
self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name))
class DeviceFromCSVForm(BaseDeviceFromCSVForm):
class DeviceCSVForm(BaseDeviceCSVForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(), to_field_name='name', error_messages={
queryset=Site.objects.all(),
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Invalid site name.',
}
)
rack_name = forms.CharField(required=False)
face = forms.CharField(required=False)
rack_group = forms.CharField(
required=False,
help_text='Parent rack\'s group (if any)'
)
rack_name = forms.CharField(
required=False,
help_text='Name of parent rack'
)
face = CSVChoiceField(
choices=RACK_FACE_CHOICES,
required=False,
help_text='Mounted rack face'
)
class Meta(BaseDeviceFromCSVForm.Meta):
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_name', 'position', 'face',
'site', 'rack_group', 'rack_name', 'position', 'face',
]
def clean(self):
super(DeviceFromCSVForm, self).clean()
super(DeviceCSVForm, self).clean()
site = self.cleaned_data.get('site')
rack_group = self.cleaned_data.get('rack_group')
rack_name = self.cleaned_data.get('rack_name')
# Validate rack
if site and rack_name:
if site and rack_group and rack_name:
try:
self.instance.rack = Rack.objects.get(site=site, name=rack_name)
self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
except Rack.DoesNotExist:
self.add_error('rack_name', "Invalid rack ({})".format(rack_name))
def clean_face(self):
face = self.cleaned_data['face']
if not face:
return None
try:
return {
'front': 0,
'rear': 1,
}[face.lower()]
except KeyError:
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group))
elif site and rack_name:
try:
self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name)
except Rack.DoesNotExist:
raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site))
class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
class ChildDeviceCSVForm(BaseDeviceCSVForm):
parent = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text='Name or ID of parent device',
error_messages={
'invalid_choice': 'Parent device not found.'
'invalid_choice': 'Parent device not found.',
}
)
device_bay_name = forms.CharField(required=False)
device_bay_name = forms.CharField(
help_text='Name of device bay',
)
class Meta(BaseDeviceFromCSVForm.Meta):
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay_name',
@@ -768,7 +824,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
def clean(self):
super(ChildDeviceFromCSVForm, self).clean()
super(ChildDeviceCSVForm, self).clean()
parent = self.cleaned_data.get('parent')
device_bay_name = self.cleaned_data.get('device_bay_name')
@@ -776,22 +832,12 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
# Validate device bay
if parent and device_bay_name:
try:
device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
if device_bay.installed_device:
self.add_error('device_bay_name',
"Device bay ({} {}) is already occupied".format(parent, device_bay_name))
else:
self.instance.parent_bay = device_bay
self.instance.parent_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
# Inherit site and rack from parent device
self.instance.site = parent.site
self.instance.rack = parent.rack
except DeviceBay.DoesNotExist:
self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
class DeviceImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=DeviceFromCSVForm)
class ChildDeviceImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name))
class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -889,75 +935,84 @@ class ConsolePortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class ConsoleConnectionCSVForm(forms.Form):
class ConsoleConnectionCSVForm(forms.ModelForm):
console_server = FlexibleModelChoiceField(
queryset=Device.objects.filter(device_type__is_console_server=True),
to_field_name='name',
help_text='Console server name or ID',
error_messages={
'invalid_choice': 'Console server not found',
}
)
cs_port = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
console_port = forms.CharField()
status = forms.CharField(validators=[validate_connection_status])
cs_port = forms.CharField(
help_text='Console server port name'
)
device = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Device name or ID',
error_messages={
'invalid_choice': 'Device not found',
}
)
console_port = forms.CharField(
help_text='Console port name'
)
connection_status = CSVChoiceField(
choices=CONNECTION_STATUS_CHOICES,
help_text='Connection status'
)
def clean(self):
class Meta:
model = ConsolePort
fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status']
# Validate console server port
if self.cleaned_data.get('console_server'):
try:
cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'],
name=self.cleaned_data['cs_port'])
if ConsolePort.objects.filter(cs_port=cs_port):
raise forms.ValidationError("Console server port is already occupied (by {} {})"
.format(cs_port.connected_console.device, cs_port.connected_console))
except ConsoleServerPort.DoesNotExist:
raise forms.ValidationError("Invalid console server port ({} {})"
.format(self.cleaned_data['console_server'], self.cleaned_data['cs_port']))
def clean_console_port(self):
# Validate console port
if self.cleaned_data.get('device'):
try:
console_port = ConsolePort.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['console_port'])
if console_port.cs_port:
raise forms.ValidationError("Console port is already connected (to {} {})"
.format(console_port.cs_port.device, console_port.cs_port))
except ConsolePort.DoesNotExist:
raise forms.ValidationError("Invalid console port ({} {})"
.format(self.cleaned_data['device'], self.cleaned_data['console_port']))
console_port_name = self.cleaned_data.get('console_port')
if not self.cleaned_data.get('device') or not console_port_name:
return None
try:
# Retrieve console port by name
consoleport = ConsolePort.objects.get(
device=self.cleaned_data['device'], name=console_port_name
)
# Check if the console port is already connected
if consoleport.cs_port is not None:
raise forms.ValidationError("{} {} is already connected".format(
self.cleaned_data['device'], console_port_name
))
except ConsolePort.DoesNotExist:
raise forms.ValidationError("Invalid console port ({} {})".format(
self.cleaned_data['device'], console_port_name
))
class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
self.instance = consoleport
return consoleport
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
def clean_cs_port(self):
connection_list = []
cs_port_name = self.cleaned_data.get('cs_port')
if not self.cleaned_data.get('console_server') or not cs_port_name:
return None
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
console_port = ConsolePort.objects.get(device=form.cleaned_data['device'],
name=form.cleaned_data['console_port'])
console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'],
name=form.cleaned_data['cs_port'])
if form.cleaned_data['status'] == 'planned':
console_port.connection_status = CONNECTION_STATUS_PLANNED
else:
console_port.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(console_port)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
try:
# Retrieve console server port by name
cs_port = ConsoleServerPort.objects.get(
device=self.cleaned_data['console_server'], name=cs_port_name
)
# Check if the console server port is already connected
if ConsolePort.objects.filter(cs_port=cs_port).count():
raise forms.ValidationError("{} {} is already connected".format(
self.cleaned_data['console_server'], cs_port_name
))
except ConsoleServerPort.DoesNotExist:
raise forms.ValidationError("Invalid console server port ({} {})".format(
self.cleaned_data['console_server'], cs_port_name
))
self.cleaned_data['csv'] = connection_list
return cs_port
class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
@@ -1119,6 +1174,10 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
}
class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
#
# Power ports
#
@@ -1137,76 +1196,84 @@ class PowerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class PowerConnectionCSVForm(forms.Form):
class PowerConnectionCSVForm(forms.ModelForm):
pdu = FlexibleModelChoiceField(
queryset=Device.objects.filter(device_type__is_pdu=True),
to_field_name='name',
help_text='PDU name or ID',
error_messages={
'invalid_choice': 'PDU not found.',
}
)
power_outlet = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
power_port = forms.CharField()
status = forms.CharField(validators=[validate_connection_status])
power_outlet = forms.CharField(
help_text='Power outlet name'
)
device = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Device name or ID',
error_messages={
'invalid_choice': 'Device not found',
}
)
power_port = forms.CharField(
help_text='Power port name'
)
connection_status = CSVChoiceField(
choices=CONNECTION_STATUS_CHOICES,
help_text='Connection status'
)
def clean(self):
class Meta:
model = PowerPort
fields = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status']
# Validate power outlet
if self.cleaned_data.get('pdu'):
try:
power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'],
name=self.cleaned_data['power_outlet'])
if PowerPort.objects.filter(power_outlet=power_outlet):
raise forms.ValidationError("Power outlet is already occupied (by {} {})"
.format(power_outlet.connected_port.device,
power_outlet.connected_port))
except PowerOutlet.DoesNotExist:
raise forms.ValidationError("Invalid PDU port ({} {})"
.format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet']))
def clean_power_port(self):
# Validate power port
if self.cleaned_data.get('device'):
try:
power_port = PowerPort.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['power_port'])
if power_port.power_outlet:
raise forms.ValidationError("Power port is already connected (to {} {})"
.format(power_port.power_outlet.device, power_port.power_outlet))
except PowerPort.DoesNotExist:
raise forms.ValidationError("Invalid power port ({} {})"
.format(self.cleaned_data['device'], self.cleaned_data['power_port']))
power_port_name = self.cleaned_data.get('power_port')
if not self.cleaned_data.get('device') or not power_port_name:
return None
try:
# Retrieve power port by name
powerport = PowerPort.objects.get(
device=self.cleaned_data['device'], name=power_port_name
)
# Check if the power port is already connected
if powerport.power_outlet is not None:
raise forms.ValidationError("{} {} is already connected".format(
self.cleaned_data['device'], power_port_name
))
except PowerPort.DoesNotExist:
raise forms.ValidationError("Invalid power port ({} {})".format(
self.cleaned_data['device'], power_port_name
))
class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=PowerConnectionCSVForm)
self.instance = powerport
return powerport
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
def clean_power_outlet(self):
connection_list = []
power_outlet_name = self.cleaned_data.get('power_outlet')
if not self.cleaned_data.get('pdu') or not power_outlet_name:
return None
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
power_port = PowerPort.objects.get(device=form.cleaned_data['device'],
name=form.cleaned_data['power_port'])
power_port.power_outlet = PowerOutlet.objects.get(device=form.cleaned_data['pdu'],
name=form.cleaned_data['power_outlet'])
if form.cleaned_data['status'] == 'planned':
power_port.connection_status = CONNECTION_STATUS_PLANNED
else:
power_port.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(power_port)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
try:
# Retrieve power outlet by name
power_outlet = PowerOutlet.objects.get(
device=self.cleaned_data['pdu'], name=power_outlet_name
)
# Check if the power outlet is already connected
if PowerPort.objects.filter(power_outlet=power_outlet).count():
raise forms.ValidationError("{} {} is already connected".format(
self.cleaned_data['pdu'], power_outlet_name
))
except PowerOutlet.DoesNotExist:
raise forms.ValidationError("Invalid power outlet ({} {})".format(
self.cleaned_data['pdu'], power_outlet_name
))
self.cleaned_data['csv'] = connection_list
return power_outlet
class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
@@ -1368,6 +1435,10 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
}
class PowerOutletBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
#
# Interfaces
#
@@ -1445,6 +1516,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
self.fields['lag'].choices = []
class InterfaceBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
#
# Interface connections
#
@@ -1531,99 +1606,85 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
]
# Mark connected interfaces as disabled
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
]
if self.data.get('device_b'):
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
]
class InterfaceConnectionCSVForm(forms.Form):
class InterfaceConnectionCSVForm(forms.ModelForm):
device_a = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Name or ID of device A',
error_messages={'invalid_choice': 'Device A not found.'}
)
interface_a = forms.CharField()
interface_a = forms.CharField(
help_text='Name of interface A'
)
device_b = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Name or ID of device B',
error_messages={'invalid_choice': 'Device B not found.'}
)
interface_b = forms.CharField()
status = forms.CharField(
validators=[validate_connection_status]
interface_b = forms.CharField(
help_text='Name of interface B'
)
connection_status = CSVChoiceField(
choices=CONNECTION_STATUS_CHOICES,
help_text='Connection status'
)
def clean(self):
class Meta:
model = InterfaceConnection
fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
# Validate interface A
if self.cleaned_data.get('device_a'):
try:
interface_a = Interface.objects.get(device=self.cleaned_data['device_a'],
name=self.cleaned_data['interface_a'])
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})"
.format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
try:
InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a))
raise forms.ValidationError("{} {} is already connected"
.format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
except InterfaceConnection.DoesNotExist:
pass
def clean_interface_a(self):
# Validate interface B
if self.cleaned_data.get('device_b'):
try:
interface_b = Interface.objects.get(device=self.cleaned_data['device_b'],
name=self.cleaned_data['interface_b'])
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})"
.format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
try:
InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b))
raise forms.ValidationError("{} {} is already connected"
.format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
except InterfaceConnection.DoesNotExist:
pass
interface_name = self.cleaned_data.get('interface_a')
if not interface_name:
return None
try:
# Retrieve interface by name
interface = Interface.objects.get(
device=self.cleaned_data['device_a'], name=interface_name
)
# Check for an existing connection to this interface
if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count():
raise forms.ValidationError("{} {} is already connected".format(
self.cleaned_data['device_a'], interface_name
))
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})".format(
self.cleaned_data['device_a'], interface_name
))
class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
return interface
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
def clean_interface_b(self):
connection_list = []
occupied_interfaces = []
interface_name = self.cleaned_data.get('interface_b')
if not interface_name:
return None
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
interface_a = Interface.objects.get(device=form.cleaned_data['device_a'],
name=form.cleaned_data['interface_a'])
if interface_a in occupied_interfaces:
raise forms.ValidationError("{} {} found in multiple connections"
.format(interface_a.device.name, interface_a.name))
interface_b = Interface.objects.get(device=form.cleaned_data['device_b'],
name=form.cleaned_data['interface_b'])
if interface_b in occupied_interfaces:
raise forms.ValidationError("{} {} found in multiple connections"
.format(interface_b.device.name, interface_b.name))
connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b)
if form.cleaned_data['status'] == 'planned':
connection.connection_status = CONNECTION_STATUS_PLANNED
else:
connection.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(connection)
occupied_interfaces.append(interface_a)
occupied_interfaces.append(interface_b)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
try:
# Retrieve interface by name
interface = Interface.objects.get(
device=self.cleaned_data['device_b'], name=interface_name
)
# Check for an existing connection to this interface
if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count():
raise forms.ValidationError("{} {} is already connected".format(
self.cleaned_data['device_b'], interface_name
))
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})".format(
self.cleaned_data['device_b'], interface_name
))
self.cleaned_data['csv'] = connection_list
return interface
class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):

View File

@@ -280,6 +280,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
objects = SiteManager()
csv_headers = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
]
class Meta:
ordering = ['name']
@@ -346,7 +350,7 @@ class RackGroup(models.Model):
]
def __str__(self):
return '{} - {}'.format(self.site.name, self.name)
return self.name
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@@ -402,6 +406,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
objects = RackManager()
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
]
class Meta:
ordering = ['site', 'name']
unique_together = [
@@ -981,6 +989,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
objects = DeviceManager()
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face',
]
class Meta:
ordering = ['name']
unique_together = ['rack', 'position', 'face']
@@ -1096,6 +1109,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.asset_tag,
self.get_status_display(),
self.site.name,
self.rack.group.name if self.rack and self.rack.group else None,
self.rack.name if self.rack else None,
self.position,
self.get_face_display(),
@@ -1162,6 +1176,8 @@ class ConsolePort(models.Model):
verbose_name='Console server port', blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
@@ -1231,6 +1247,8 @@ class PowerPort(models.Model):
blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
@@ -1392,11 +1410,16 @@ class InterfaceConnection(models.Model):
connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED,
verbose_name='Status')
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
def clean(self):
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
try:
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
except ObjectDoesNotExist:
pass
# Used for connections export
def to_csv(self):

View File

@@ -247,7 +247,7 @@ class RackImportTable(BaseTable):
class Meta(BaseTable.Meta):
model = Rack
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height')
#

View File

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ImageAttachmentEditView
from ipam.views import ServiceEditView
from ipam.views import ServiceCreateView
from secrets.views import secret_add
from .models import Device, Rack, Site
from . import views
@@ -14,13 +14,13 @@ urlpatterns = [
# Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'),
url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
# Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'),
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
@@ -30,13 +30,13 @@ urlpatterns = [
# Rack groups
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
url(r'^rack-groups/add/$', views.RackGroupEditView.as_view(), name='rackgroup_add'),
url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
# Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'),
url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@@ -56,18 +56,18 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
url(r'^manufacturers/add/$', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
# Device types
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
url(r'^device-types/add/$', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
@@ -75,45 +75,45 @@ urlpatterns = [
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
# Console port templates
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateAddView.as_view(), name='devicetype_add_consoleport'),
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
# Console server port templates
url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateAddView.as_view(), name='devicetype_add_consoleserverport'),
url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
# Power port templates
url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateAddView.as_view(), name='devicetype_add_powerport'),
url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
# Power outlet templates
url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateAddView.as_view(), name='devicetype_add_poweroutlet'),
url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
# 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/add/$', views.InterfaceTemplateCreateView.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
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(), name='devicetype_add_devicebay'),
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
# Device roles
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
url(r'^device-roles/add/$', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
# Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
url(r'^platforms/add/$', views.PlatformEditView.as_view(), name='platform_add'),
url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
# Devices
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'),
url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'),
url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@@ -124,12 +124,12 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'),
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
@@ -138,7 +138,8 @@ urlpatterns = [
# Console server ports
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
@@ -147,7 +148,7 @@ urlpatterns = [
# Power ports
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
@@ -156,7 +157,8 @@ urlpatterns = [
# Power outlets
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
@@ -165,8 +167,9 @@ urlpatterns = [
# Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), 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/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
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'),
@@ -175,7 +178,7 @@ urlpatterns = [
# Device bays
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'),
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),

View File

@@ -8,7 +8,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db.models import Count
from django.db.models import Count, Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -29,8 +29,8 @@ from . import filters, forms, tables
from .models import (
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, Region, Site,
)
@@ -141,6 +141,44 @@ class ComponentDeleteView(ObjectDeleteView):
return obj.device.get_absolute_url()
class BulkDisconnectView(View):
"""
An extendable view for disconnection console/power/interface components in bulk.
"""
model = None
form = None
template_name = 'dcim/bulk_disconnect.html'
def disconnect_objects(self, objects):
raise NotImplementedError()
def post(self, request, pk):
device = get_object_or_404(Device, pk=pk)
selected_objects = []
if '_confirm' in request.POST:
form = self.form(request.POST)
if form.is_valid():
count = self.disconnect_objects(form.cleaned_data['pk'])
messages.success(request, "Disconnected {} {} on {}".format(
count, self.model._meta.verbose_name_plural, device
))
return redirect(device.get_absolute_url())
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'form': form,
'device': device,
'obj_type_plural': self.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': device.get_absolute_url(),
})
#
# Regions
#
@@ -151,8 +189,8 @@ class RegionListView(ObjectListView):
template_name = 'dcim/region_list.html'
class RegionEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_region'
class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_region'
model = Region
form_class = forms.RegionForm
@@ -160,6 +198,10 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:region_list')
class RegionEditView(RegionCreateView):
permission_required = 'dcim.change_region'
class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region'
cls = Region
@@ -203,14 +245,18 @@ class SiteView(View):
})
class SiteEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_site'
class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_site'
model = Site
form_class = forms.SiteForm
template_name = 'dcim/site_edit.html'
default_return_url = 'dcim:site_list'
class SiteEditView(SiteCreateView):
permission_required = 'dcim.change_site'
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site'
model = Site
@@ -219,9 +265,8 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_site'
form = forms.SiteImportForm
model_form = forms.SiteCSVForm
table = tables.SiteTable
template_name = 'dcim/site_import.html'
default_return_url = 'dcim:site_list'
@@ -246,8 +291,8 @@ class RackGroupListView(ObjectListView):
template_name = 'dcim/rackgroup_list.html'
class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup'
class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackgroup'
model = RackGroup
form_class = forms.RackGroupForm
@@ -255,6 +300,10 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:rackgroup_list')
class RackGroupEditView(RackGroupCreateView):
permission_required = 'dcim.change_rackgroup'
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup'
cls = RackGroup
@@ -272,8 +321,8 @@ class RackRoleListView(ObjectListView):
template_name = 'dcim/rackrole_list.html'
class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole'
class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackrole'
model = RackRole
form_class = forms.RackRoleForm
@@ -281,6 +330,10 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:rackrole_list')
class RackRoleEditView(RackRoleCreateView):
permission_required = 'dcim.change_rackrole'
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole'
cls = RackRole
@@ -374,14 +427,18 @@ class RackView(View):
})
class RackEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rack'
class RackCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rack'
model = Rack
form_class = forms.RackForm
template_name = 'dcim/rack_edit.html'
default_return_url = 'dcim:rack_list'
class RackEditView(RackCreateView):
permission_required = 'dcim.change_rack'
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack'
model = Rack
@@ -390,9 +447,8 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rack'
form = forms.RackImportForm
model_form = forms.RackCSVForm
table = tables.RackImportTable
template_name = 'dcim/rack_import.html'
default_return_url = 'dcim:rack_list'
@@ -424,8 +480,8 @@ class RackReservationListView(ObjectListView):
template_name = 'dcim/rackreservation_list.html'
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackreservation'
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackreservation'
model = RackReservation
form_class = forms.RackReservationForm
@@ -439,6 +495,10 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
return obj.rack.get_absolute_url()
class RackReservationEditView(RackReservationCreateView):
permission_required = 'dcim.change_rackreservation'
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rackreservation'
model = RackReservation
@@ -463,8 +523,8 @@ class ManufacturerListView(ObjectListView):
template_name = 'dcim/manufacturer_list.html'
class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer'
class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_manufacturer'
model = Manufacturer
form_class = forms.ManufacturerForm
@@ -472,6 +532,10 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:manufacturer_list')
class ManufacturerEditView(ManufacturerCreateView):
permission_required = 'dcim.change_manufacturer'
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_manufacturer'
cls = Manufacturer
@@ -543,14 +607,18 @@ class DeviceTypeView(View):
})
class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicetype'
class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicetype'
model = DeviceType
form_class = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeEditView(DeviceTypeCreateView):
permission_required = 'dcim.change_devicetype'
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype'
model = DeviceType
@@ -577,7 +645,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device type components
#
class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
@@ -594,7 +662,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
parent_cls = DeviceType
class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
@@ -609,7 +677,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
parent_cls = DeviceType
class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
@@ -624,7 +692,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = DeviceType
class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlettemplate'
parent_model = DeviceType
parent_field = 'device_type'
@@ -639,7 +707,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
parent_cls = DeviceType
class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interfacetemplate'
parent_model = DeviceType
parent_field = 'device_type'
@@ -662,7 +730,7 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = DeviceType
class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebaytemplate'
parent_model = DeviceType
parent_field = 'device_type'
@@ -687,8 +755,8 @@ class DeviceRoleListView(ObjectListView):
template_name = 'dcim/devicerole_list.html'
class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole'
class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicerole'
model = DeviceRole
form_class = forms.DeviceRoleForm
@@ -696,6 +764,10 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:devicerole_list')
class DeviceRoleEditView(DeviceRoleCreateView):
permission_required = 'dcim.change_devicerole'
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole'
cls = DeviceRole
@@ -712,8 +784,8 @@ class PlatformListView(ObjectListView):
template_name = 'dcim/platform_list.html'
class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform'
class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_platform'
model = Platform
form_class = forms.PlatformForm
@@ -721,6 +793,10 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:platform_list')
class PlatformEditView(PlatformCreateView):
permission_required = 'dcim.change_platform'
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform'
cls = Platform
@@ -778,20 +854,14 @@ class DeviceView(View):
services = Service.objects.filter(device=device)
secrets = device.secrets.all()
# Find any related devices for convenient linking in the UI
related_devices = []
if device.name:
if re.match('.+[0-9]+$', device.name):
# Strip 1 or more trailing digits (e.g. core-switch1)
base_name = re.match('(.*?)[0-9]+$', device.name).group(1)
elif re.match('.+\d[a-z]$', device.name.lower()):
# Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3)
base_name = re.match('(.*\d+)[a-z]$', device.name.lower()).group(1)
else:
base_name = None
if base_name:
related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\
.select_related('rack', 'device_type__manufacturer')[:10]
# Find up to ten devices in the same site with the same functional role for quick reference.
related_devices = Device.objects.filter(
site=device.site, device_role=device.device_role
).exclude(
pk=device.pk
).select_related(
'rack', 'device_type__manufacturer'
)[:10]
# Show graph button on interfaces only if at least one graph has been created.
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
@@ -850,14 +920,18 @@ class DeviceLLDPNeighborsView(View):
})
class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_device'
class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_device'
model = Device
form_class = forms.DeviceForm
template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list'
class DeviceEditView(DeviceCreateView):
permission_required = 'dcim.change_device'
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device'
model = Device
@@ -866,7 +940,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device'
form = forms.DeviceImportForm
model_form = forms.DeviceCSVForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import.html'
default_return_url = 'dcim:device_list'
@@ -874,23 +948,22 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device'
form = forms.ChildDeviceImportForm
model_form = forms.ChildDeviceCSVForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
default_return_url = 'dcim:device_list'
def save_obj(self, obj):
def _save_obj(self, obj_form):
# Inherit site and rack from parent device
obj.site = obj.parent_bay.device.site
obj.rack = obj.parent_bay.device.rack
obj.save()
obj = obj_form.save()
# Save the reverse relation
# Save the reverse relation to the parent device bay
device_bay = obj.parent_bay
device_bay.installed_device = obj
device_bay.save()
return obj
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device'
@@ -912,7 +985,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Console ports
#
class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView):
class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleport'
parent_model = Device
parent_field = 'device'
@@ -1016,9 +1089,8 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.change_consoleport'
form = forms.ConsoleConnectionImportForm
model_form = forms.ConsoleConnectionCSVForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/console_connections_import.html'
default_return_url = 'dcim:console_connections_list'
@@ -1026,7 +1098,7 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
# Console server ports
#
class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView):
class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverport'
parent_model = Device
parent_field = 'device'
@@ -1125,6 +1197,15 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
model = ConsoleServerPort
class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
form = forms.ConsoleServerPortBulkDisconnectForm
def disconnect_objects(self, cs_ports):
return ConsolePort.objects.filter(cs_port__in=cs_ports).update(cs_port=None, connection_status=None)
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverport'
cls = ConsoleServerPort
@@ -1135,7 +1216,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power ports
#
class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView):
class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerport'
parent_model = Device
parent_field = 'device'
@@ -1239,9 +1320,8 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.change_powerport'
form = forms.PowerConnectionImportForm
model_form = forms.PowerConnectionCSVForm
table = tables.PowerConnectionTable
template_name = 'dcim/power_connections_import.html'
default_return_url = 'dcim:power_connections_list'
@@ -1249,7 +1329,7 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
# Power outlets
#
class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView):
class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlet'
parent_model = Device
parent_field = 'device'
@@ -1348,6 +1428,17 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
model = PowerOutlet
class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
form = forms.PowerOutletBulkDisconnectForm
def disconnect_objects(self, power_outlets):
return PowerPort.objects.filter(power_outlet__in=power_outlets).update(
power_outlet=None, connection_status=None
)
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlet'
cls = PowerOutlet
@@ -1358,7 +1449,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Interfaces
#
class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = Device
parent_field = 'device'
@@ -1378,6 +1469,18 @@ class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
model = Interface
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_interface'
model = Interface
form = forms.InterfaceBulkDisconnectForm
def disconnect_objects(self, interfaces):
count, _ = InterfaceConnection.objects.filter(
Q(interface_a__in=interfaces) | Q(interface_b__in=interfaces)
).delete()
return count
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
cls = Interface
@@ -1396,7 +1499,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device bays
#
class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebay'
parent_model = Device
parent_field = 'device'
@@ -1676,9 +1779,8 @@ def interfaceconnection_delete(request, pk):
class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.change_interface'
form = forms.InterfaceConnectionImportForm
model_form = forms.InterfaceConnectionCSVForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/interface_connections_import.html'
default_return_url = 'dcim:interface_connections_list'

View File

@@ -1,4 +1,5 @@
from __future__ import unicode_literals
from datetime import datetime
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -6,7 +7,9 @@ from rest_framework.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
from extras.models import (
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue,
)
#
@@ -25,16 +28,30 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
for field_name, value in data.items():
cf = custom_fields[field_name]
# Validate custom field name
if field_name not in custom_fields:
raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
# Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value))
# Validate date
if cf.type == CF_TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(
field_name, value
))
# Validate selected choice
cf = custom_fields[field_name]
if cf.type == CF_TYPE_SELECT:
valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError("Invalid choice ({}) for field {}".format(value, field_name))
raise ValidationError("Invalid choice for field {}: {}".format(field_name, value))
# Check for missing required fields
missing_fields = []
@@ -87,7 +104,7 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
field=custom_field,
obj_type=content_type,
obj_id=instance.pk,
defaults={'serialized_value': value},
defaults={'serialized_value': custom_field.serialize_value(value)},
)
def create(self, validated_data):

View File

@@ -32,7 +32,7 @@ class CustomFieldFilter(django_filters.Filter):
pass
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value,
custom_field_values__serialized_value__icontains=value,
)

View File

@@ -0,0 +1,62 @@
from __future__ import unicode_literals
import code
import platform
import sys
from django import get_version
from django.apps import apps
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import Model
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users']
BANNER_TEXT = """### NetBox interactive shell ({node})
### Python {python} | Django {django} | NetBox {netbox}
### lsmodels() will show available models. Use help(<model>) for more info.""".format(
node=platform.node(),
python=platform.python_version(),
django=get_version(),
netbox=settings.VERSION
)
class Command(BaseCommand):
help = "Start the Django shell with all NetBox models already imported"
django_models = {}
def _lsmodels(self):
for app, models in self.django_models.items():
app_name = apps.get_app_config(app).verbose_name
print('{}:'.format(app_name))
for m in models:
print(' {}'.format(m))
def get_namespace(self):
namespace = {}
# Gather Django models from each app
for app in APPS:
self.django_models[app] = []
app_models = sys.modules['{}.models'.format(app)]
for name in dir(app_models):
model = getattr(app_models, name)
try:
if issubclass(model, Model):
namespace[name] = model
self.django_models[app].append(name)
except TypeError:
pass
# Load convenience commands
namespace.update({
'lsmodels': self._lsmodels,
})
return namespace
def handle(self, **options):
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
return shell

View File

@@ -139,7 +139,11 @@ class CustomField(models.Model):
if self.type == CF_TYPE_BOOLEAN:
return str(int(bool(value)))
if self.type == CF_TYPE_DATE:
return value.strftime('%Y-%m-%d')
# Could be date/datetime object or string
try:
return value.strftime('%Y-%m-%d')
except AttributeError:
return value
if self.type == CF_TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
@@ -367,7 +371,8 @@ class TopologyMap(models.Model):
# Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination()
if peer_termination is not None and peer_termination.interface.device in devices:
if (peer_termination is not None and peer_termination.interface is not None and
peer_termination.interface.device in devices):
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
return graph.pipe(format=img_format)

View File

@@ -137,7 +137,7 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer):
# Validate uniqueness of name and slug if a site has been assigned.
if data.get('site', None):
for field in ['name', 'slug']:
validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('site', field))
validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field))
validator.set_context(self)
validator(data)

View File

@@ -85,7 +85,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
try:
prefix = str(IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
except AddrFormatError:
except (AddrFormatError, ValueError):
pass
return queryset.filter(qs_filter)
@@ -172,7 +172,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
try:
prefix = str(IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
except AddrFormatError:
except (AddrFormatError, ValueError):
pass
return queryset.filter(qs_filter)
@@ -183,7 +183,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(prefix__net_contained_or_equal=query)
except AddrFormatError:
except (AddrFormatError, ValueError):
return queryset.none()
def filter_mask_length(self, queryset, name, value):
@@ -259,7 +259,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
try:
ipaddress = str(IPNetwork(value.strip()))
qs_filter |= Q(address__net_host=ipaddress)
except AddrFormatError:
except (AddrFormatError, ValueError):
pass
return queryset.filter(qs_filter)
@@ -270,7 +270,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
try:
query = str(IPNetwork(value.strip()).cidr)
return queryset.filter(address__net_host_contained=query)
except AddrFormatError:
except (AddrFormatError, ValueError):
return queryset.none()
def filter_mask_length(self, queryset, name, value):

View File

@@ -1,7 +1,7 @@
from __future__ import unicode_literals
from django import forms
from django.core.exceptions import ValidationError
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count
from dcim.models import Site, Rack, Device, Interface
@@ -9,8 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
add_blank_choice,
)
from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
@@ -48,17 +49,23 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
class VRFFromCSVForm(forms.ModelForm):
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class VRFCSVForm(forms.ModelForm):
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
class Meta:
model = VRF
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class VRFImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VRFFromCSVForm)
help_texts = {
'name': 'VRF name',
}
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -116,19 +123,21 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
}
class AggregateFromCSVForm(forms.ModelForm):
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'RIR not found.'})
class AggregateCSVForm(forms.ModelForm):
rir = forms.ModelChoiceField(
queryset=RIR.objects.all(),
to_field_name='name',
help_text='Name of parent RIR',
error_messages={
'invalid_choice': 'RIR not found.',
}
)
class Meta:
model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description']
class AggregateImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=AggregateFromCSVForm)
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
@@ -172,6 +181,18 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'vlan_group', 'nullable': 'true'}
)
)
vlan_group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='VLAN group',
widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
attrs={'filter-for': 'vlan', 'nullable': 'true'}
)
)
@@ -179,11 +200,12 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name'
)
)
@@ -192,74 +214,108 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance and instance.vlan is not None:
initial['vlan_group'] = instance.vlan.group
kwargs['initial'] = initial
super(PrefixForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'VRF not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
vlan_group_name = forms.CharField(required=False)
vlan_vid = forms.IntegerField(required=False)
status = forms.CharField()
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
class PrefixCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
help_text='Route distinguisher of parent VRF',
error_messages={
'invalid_choice': 'VRF not found.',
}
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
)
vlan_group = forms.CharField(
help_text='Group name of assigned VLAN',
required=False
)
vlan_vid = forms.IntegerField(
help_text='Numeric ID of assigned VLAN',
required=False
)
status = CSVChoiceField(
choices=IPADDRESS_STATUS_CHOICES,
help_text='Operational status'
)
role = forms.ModelChoiceField(
queryset=Role.objects.all(),
required=False,
to_field_name='name',
help_text='Functional role',
error_messages={
'invalid_choice': 'Invalid role.',
}
)
class Meta:
model = Prefix
fields = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool',
'description',
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
def clean(self):
super(PrefixFromCSVForm, self).clean()
super(PrefixCSVForm, self).clean()
site = self.cleaned_data.get('site')
vlan_group_name = self.cleaned_data.get('vlan_group_name')
vlan_group = self.cleaned_data.get('vlan_group')
vlan_vid = self.cleaned_data.get('vlan_vid')
vlan_group = None
# Validate VLAN group
if vlan_group_name:
try:
vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
except VLANGroup.DoesNotExist:
if site:
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
else:
self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
# Validate VLAN
if vlan_vid:
if vlan_group and vlan_vid:
try:
self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid)
self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid)
except VLAN.DoesNotExist:
if site:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
elif vlan_group:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name))
elif not vlan_group_name:
self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid))
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
class PrefixImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=PrefixFromCSVForm)
raise forms.ValidationError("VLAN {} not found in site {} group {}".format(
vlan_vid, site, vlan_group
))
else:
raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group))
except MultipleObjectsReturned:
raise forms.ValidationError(
"Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group)
)
elif vlan_vid:
try:
self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid)
except VLAN.DoesNotExist:
if site:
raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site))
else:
raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid))
except MultipleObjectsReturned:
raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid))
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -441,7 +497,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
initial['interface_site'] = instance.interface.device.site
initial['interface_rack'] = instance.interface.device.rack
initial['interface_device'] = instance.interface.device
if instance and instance.nat_inside is not None:
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
@@ -513,16 +569,46 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global'
class IPAddressFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'VRF not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
status = forms.CharField()
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False)
is_primary = forms.BooleanField(required=False)
class IPAddressCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
help_text='Route distinguisher of the assigned VRF',
error_messages={
'invalid_choice': 'VRF not found.',
}
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text='Name of the assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
status = CSVChoiceField(
choices=IPADDRESS_STATUS_CHOICES,
help_text='Operational status'
)
device = FlexibleModelChoiceField(
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text='Name or ID of assigned device',
error_messages={
'invalid_choice': 'Device not found.',
}
)
interface_name = forms.CharField(
help_text='Name of assigned interface',
required=False
)
is_primary = forms.BooleanField(
help_text='Make this the primary IP for the assigned device',
required=False
)
class Meta:
model = IPAddress
@@ -530,6 +616,8 @@ class IPAddressFromCSVForm(forms.ModelForm):
def clean(self):
super(IPAddressCSVForm, self).clean()
device = self.cleaned_data.get('device')
interface_name = self.cleaned_data.get('interface_name')
is_primary = self.cleaned_data.get('is_primary')
@@ -537,24 +625,17 @@ class IPAddressFromCSVForm(forms.ModelForm):
# Validate interface
if device and interface_name:
try:
Interface.objects.get(device=device, name=interface_name)
self.instance.interface = Interface.objects.get(device=device, name=interface_name)
except Interface.DoesNotExist:
self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device))
raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device))
elif device and not interface_name:
self.add_error('interface_name', "Device set ({}) but interface missing".format(device))
raise forms.ValidationError("Device set ({}) but interface missing".format(device))
elif interface_name and not device:
self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name))
raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name))
# Validate is_primary
if is_primary and not device:
self.add_error('is_primary', "No device specified; cannot set as primary IP")
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
raise forms.ValidationError("No device specified; cannot set as primary IP")
def save(self, *args, **kwargs):
@@ -569,11 +650,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
elif self.instance.address.version == 6:
self.instance.primary_ip6_for = self.cleaned_data['device']
return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
class IPAddressImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
return super(IPAddressCSVForm, self).save(*args, **kwargs)
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -673,60 +750,67 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
class VLANFromCSVForm(forms.ModelForm):
class VLANCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'}
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
)
group_name = forms.CharField(
help_text='Name of VLAN group',
required=False
)
group_name = forms.CharField(required=False)
tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
)
status = CSVChoiceField(
choices=VLAN_STATUS_CHOICES,
help_text='Operational status'
)
status = forms.CharField()
role = forms.ModelChoiceField(
queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'}
queryset=Role.objects.all(),
required=False,
to_field_name='name',
help_text='Functional role',
error_messages={
'invalid_choice': 'Invalid role.',
}
)
class Meta:
model = VLAN
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
help_texts = {
'vid': 'Numeric VLAN ID (1-4095)',
'name': 'VLAN name',
}
def clean(self):
super(VLANFromCSVForm, self).clean()
super(VLANCSVForm, self).clean()
# Validate VLANGroup
site = self.cleaned_data.get('site')
group_name = self.cleaned_data.get('group_name')
# Validate VLAN group
if group_name:
try:
VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
self.instance.group = VLANGroup.objects.get(site=site, name=group_name)
except VLANGroup.DoesNotExist:
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
def save(self, *args, **kwargs):
vlan = super(VLANFromCSVForm, self).save(commit=False)
# Assign VLANGroup by site and name
if self.cleaned_data['group_name']:
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
if kwargs.get('commit'):
vlan.save()
return vlan
class VLANImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VLANFromCSVForm)
if site:
raise forms.ValidationError("VLAN group {} not found for site {}".format(group_name, site))
else:
raise forms.ValidationError("Global VLAN group {} not found".format(group_name))
class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

View File

@@ -89,6 +89,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta:
ordering = ['name']
verbose_name = 'VRF'
@@ -146,6 +148,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['prefix', 'rir', 'date_added', 'description']
class Meta:
ordering = ['family', 'prefix']
@@ -200,7 +204,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
def get_utilization(self):
"""
Determine the utilization rate of the aggregate prefix and return it as a percentage.
Determine the prefix utilization of the aggregate and return it as a percentage.
"""
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
# Remove overlapping prefixes from list of children
@@ -297,6 +301,10 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
objects = PrefixQuerySet.as_manager()
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
class Meta:
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
@@ -307,9 +315,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def get_duplicates(self):
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
def clean(self):
if self.prefix:
@@ -357,6 +362,22 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
self.description,
])
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
def get_duplicates(self):
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
def get_utilization(self):
"""
Determine the utilization of the prefix and return it as a percentage.
"""
child_count = IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf).count()
prefix_size = self.prefix.size
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
prefix_size -= 2
return int(float(child_count) / prefix_size * 100)
@property
def new_subnet(self):
if self.family == 4:
@@ -368,9 +389,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
class IPAddressManager(models.Manager):
@@ -414,6 +432,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
objects = IPAddressManager()
csv_headers = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
class Meta:
ordering = ['family', 'address']
verbose_name = 'IP address'
@@ -452,11 +472,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
def to_csv(self):
# Determine if this IP is primary for a Device
is_primary = False
if self.family == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True
elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
else:
is_primary = False
return csv_format([
self.address,
@@ -498,9 +519,7 @@ class VLANGroup(models.Model):
verbose_name_plural = 'VLAN groups'
def __str__(self):
if self.site is None:
return self.name
return '{} - {}'.format(self.site.name, self.name)
return self.name
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@@ -529,6 +548,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
class Meta:
ordering = ['site', 'group', 'vid']
unique_together = [

View File

@@ -34,7 +34,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph value %}
{% if record.pk %}{% utilization_graph value %}{% else %}&mdash;{% endif %}
"""
ROLE_ACTIONS = """
@@ -241,6 +241,7 @@ class PrefixTable(BaseTable):
prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='IP Usage')
tenant = tables.TemplateColumn(TENANT_LINK)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
@@ -248,7 +249,7 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}

View File

@@ -10,7 +10,7 @@ urlpatterns = [
# VRFs
url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'),
url(r'^vrfs/add/$', views.VRFEditView.as_view(), name='vrf_add'),
url(r'^vrfs/add/$', views.VRFCreateView.as_view(), name='vrf_add'),
url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@@ -20,13 +20,13 @@ urlpatterns = [
# RIRs
url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
url(r'^rirs/add/$', views.RIREditView.as_view(), name='rir_add'),
url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'),
url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
# Aggregates
url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
url(r'^aggregates/add/$', views.AggregateEditView.as_view(), name='aggregate_add'),
url(r'^aggregates/add/$', views.AggregateCreateView.as_view(), name='aggregate_add'),
url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@@ -36,13 +36,13 @@ urlpatterns = [
# Roles
url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
url(r'^roles/add/$', views.RoleEditView.as_view(), name='role_add'),
url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'),
url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
# Prefixes
url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
url(r'^prefixes/add/$', views.PrefixEditView.as_view(), name='prefix_add'),
url(r'^prefixes/add/$', views.PrefixCreateView.as_view(), name='prefix_add'),
url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@@ -53,8 +53,8 @@ urlpatterns = [
# IP addresses
url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'),
url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'),
url(r'^ip-addresses/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
@@ -64,13 +64,13 @@ urlpatterns = [
# VLAN groups
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
# VLANs
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),
url(r'^vlans/add/$', views.VLANCreateView.as_view(), name='vlan_add'),
url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),

View File

@@ -13,7 +13,7 @@ from django.views.generic import View
from dcim.models import Device
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import (
@@ -114,14 +114,18 @@ class VRFView(View):
})
class VRFEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vrf'
class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vrf'
model = VRF
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list'
class VRFEditView(VRFCreateView):
permission_required = 'ipam.change_vrf'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
model = VRF
@@ -130,9 +134,8 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vrf'
form = forms.VRFImportForm
model_form = forms.VRFCSVForm
table = tables.VRFTable
template_name = 'ipam/vrf_import.html'
default_return_url = 'ipam:vrf_list'
@@ -240,8 +243,8 @@ class RIRListView(ObjectListView):
}
class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir'
class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_rir'
model = RIR
form_class = forms.RIRForm
@@ -249,6 +252,10 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
return reverse('ipam:rir_list')
class RIREditView(RIRCreateView):
permission_required = 'ipam.change_rir'
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
@@ -325,14 +332,18 @@ class AggregateView(View):
})
class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_aggregate'
class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_aggregate'
model = Aggregate
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list'
class AggregateEditView(AggregateCreateView):
permission_required = 'ipam.change_aggregate'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
model = Aggregate
@@ -341,9 +352,8 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_aggregate'
form = forms.AggregateImportForm
model_form = forms.AggregateCSVForm
table = tables.AggregateTable
template_name = 'ipam/aggregate_import.html'
default_return_url = 'ipam:aggregate_list'
@@ -373,8 +383,8 @@ class RoleListView(ObjectListView):
template_name = 'ipam/role_list.html'
class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role'
class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_role'
model = Role
form_class = forms.RoleForm
@@ -382,6 +392,10 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('ipam:role_list')
class RoleEditView(RoleCreateView):
permission_required = 'ipam.change_role'
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
@@ -521,14 +535,18 @@ class PrefixIPAddressesView(View):
})
class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_prefix'
class PrefixCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_prefix'
model = Prefix
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list'
class PrefixEditView(PrefixCreateView):
permission_required = 'ipam.change_prefix'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
@@ -538,9 +556,8 @@ class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_prefix'
form = forms.PrefixImportForm
model_form = forms.PrefixCSVForm
table = tables.PrefixTable
template_name = 'ipam/prefix_import.html'
default_return_url = 'ipam:prefix_list'
@@ -615,21 +632,25 @@ class IPAddressView(View):
})
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_ipaddress'
class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_ipaddress'
model = IPAddress
form_class = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
default_return_url = 'ipam:ipaddress_list'
class IPAddressEditView(IPAddressCreateView):
permission_required = 'ipam.change_ipaddress'
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
model = IPAddress
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
permission_required = 'ipam.add_ipaddress'
pattern_form = forms.IPAddressPatternForm
model_form = forms.IPAddressBulkAddForm
@@ -640,9 +661,8 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressImportForm
model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable
template_name = 'ipam/ipaddress_import.html'
default_return_url = 'ipam:ipaddress_list'
def save_obj(self, obj):
@@ -687,8 +707,8 @@ class VLANGroupListView(ObjectListView):
template_name = 'ipam/vlangroup_list.html'
class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
@@ -696,6 +716,10 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('ipam:vlangroup_list')
class VLANGroupEditView(VLANGroupCreateView):
permission_required = 'ipam.change_vlangroup'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
@@ -732,14 +756,18 @@ class VLANView(View):
})
class VLANEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlan'
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlan'
model = VLAN
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list'
class VLANEditView(VLANCreateView):
permission_required = 'ipam.change_vlan'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
model = VLAN
@@ -748,9 +776,8 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlan'
form = forms.VLANImportForm
model_form = forms.VLANCSVForm
table = tables.VLANTable
template_name = 'ipam/vlan_import.html'
default_return_url = 'ipam:vlan_list'
@@ -774,8 +801,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services
#
class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_service'
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_service'
model = Service
form_class = forms.ServiceForm
template_name = 'ipam/service_edit.html'
@@ -789,6 +816,10 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
return obj.device.get_absolute_url()
class ServiceEditView(ServiceCreateView):
permission_required = 'ipam.change_service'
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
model = Service

View File

@@ -58,6 +58,11 @@ CORS_ORIGIN_REGEX_WHITELIST = [
# r'^(https?://)?(\w+\.)?example\.com$',
]
# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal
# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
# on a production system.
DEBUG = False
# Email settings
EMAIL = {
'SERVER': 'localhost',
@@ -72,6 +77,10 @@ EMAIL = {
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
ENFORCE_GLOBAL_UNIQUE = False
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
# https://docs.djangoproject.com/en/1.11/topics/logging/
LOGGING = {}
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = False
@@ -79,6 +88,11 @@ LOGIN_REQUIRED = False
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False
# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g.
# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request
# all objects by specifying "?limit=0".
MAX_PAGE_SIZE = 1000
# Credentials that NetBox will use to access live devices (future use).
NETBOX_USERNAME = ''
NETBOX_PASSWORD = ''

View File

@@ -13,9 +13,9 @@ except ImportError:
)
VERSION = '2.0.4'
VERSION = '2.0.7'
# Import local configuration
# Import required configuration parameters
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
try:
@@ -25,32 +25,35 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
"Mandatory setting {} is missing from configuration.py.".format(setting)
)
# Default configurations
# Import optional configuration parameters
ADMINS = getattr(configuration, 'ADMINS', [])
DEBUG = getattr(configuration, 'DEBUG', False)
EMAIL = getattr(configuration, 'EMAIL', {})
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False)
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
EMAIL = getattr(configuration, 'EMAIL', {})
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '')
NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '')
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined
@@ -208,7 +211,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.DjangoFilterBackend',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
'utilities.api.TokenPermissions',
),

View File

@@ -1,6 +1,7 @@
$(document).ready(function() {
var search_field = $('#id_livesearch');
var real_field = $('#id_' + search_field.attr('data-field'));
var select_fields = $('#select select');
var search_key = search_field.attr('data-key');
var label = search_field.attr('data-label');
if (!label) {
@@ -40,13 +41,22 @@ $(document).ready(function() {
select: function(event, ui) {
event.preventDefault();
search_field.val(ui.item.label);
select_fields.val('');
select_fields.attr('disabled', 'disabled');
real_field.empty();
real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
real_field.change();
// If the field has a parent helper, reset the parent to no selection
$('select[filter-for="' + real_field.attr('name') + '"]').val('');
// Disable parent selection fields
// $('select[filter-for="' + real_field.attr('name') + '"]').val('');
},
minLength: 4,
delay: 500
});
search_field.change(function() {
if (!search_field.val()) {
select_fields.removeAttr('disabled');
select_fields.val('');
}
});
});

View File

@@ -32,7 +32,7 @@ class SecretFilter(django_filters.FilterSet):
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',

View File

@@ -7,7 +7,7 @@ from django import forms
from django.db.models import Count
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -65,27 +65,40 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
})
class SecretFromCSVForm(forms.ModelForm):
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid secret role.'})
plaintext = forms.CharField()
class SecretCSVForm(forms.ModelForm):
device = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Device name or ID',
error_messages={
'invalid_choice': 'Device not found.',
}
)
role = forms.ModelChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='name',
help_text='Name of assigned role',
error_messages={
'invalid_choice': 'Invalid secret role.',
}
)
plaintext = forms.CharField(
help_text='Plaintext secret data'
)
class Meta:
model = Secret
fields = ['device', 'role', 'name', 'plaintext']
help_texts = {
'name': 'Name or username',
}
def save(self, *args, **kwargs):
s = super(SecretFromCSVForm, self).save(*args, **kwargs)
s = super(SecretCSVForm, self).save(*args, **kwargs)
s.plaintext = str(self.cleaned_data['plaintext'])
return s
class SecretImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-session-key'}))
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)

View File

@@ -291,6 +291,7 @@ class Secret(CreatedUpdatedModel):
hash = models.CharField(max_length=128, editable=False)
plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']
class Meta:
ordering = ['device', 'role', 'name']

View File

@@ -10,13 +10,13 @@ urlpatterns = [
# Secret roles
url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
url(r'^secret-roles/add/$', views.SecretRoleEditView.as_view(), name='secretrole_add'),
url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
# Secrets
url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
url(r'^secrets/import/$', views.secret_import, name='secret_import'),
url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'),
url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),

View File

@@ -12,7 +12,9 @@ from django.utils.decorators import method_decorator
from django.views.generic import View
from dcim.models import Device
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .decorators import userkey_required
from .models import SecretRole, Secret, SessionKey
@@ -38,8 +40,8 @@ class SecretRoleListView(ObjectListView):
template_name = 'secrets/secretrole_list.html'
class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole'
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.add_secretrole'
model = SecretRole
form_class = forms.SecretRoleForm
@@ -47,6 +49,10 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('secrets:secretrole_list')
class SecretRoleEditView(SecretRoleCreateView):
permission_required = 'secrets.change_secretrole'
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
cls = SecretRole
@@ -185,58 +191,50 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
default_return_url = 'secrets:secret_list'
@permission_required('secrets.add_secret')
@userkey_required()
def secret_import(request):
class SecretBulkImportView(BulkImportView):
permission_required = 'ipam.add_vlan'
model_form = forms.SecretCSVForm
table = tables.SecretTable
default_return_url = 'secrets:secret_list'
session_key = request.COOKIES.get('session_key', None)
master_key = None
if request.method == 'POST':
form = forms.SecretImportForm(request.POST)
def _save_obj(self, obj_form):
"""
Encrypt each object before saving it to the database.
"""
obj = obj_form.save(commit=False)
obj.encrypt(self.master_key)
obj.save()
return obj
if session_key is None:
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
def post(self, request):
if form.is_valid():
# Grab the session key from cookies.
session_key = request.COOKIES.get('session_key')
if session_key:
new_secrets = []
session_key = base64.b64decode(session_key)
master_key = None
# Attempt to derive the master key using the provided session key.
try:
sk = SessionKey.objects.get(userkey__user=request.user)
master_key = sk.get_master_key(session_key)
self.master_key = sk.get_master_key(base64.b64decode(session_key))
except SessionKey.DoesNotExist:
form.add_error(None, "No session key found for this user.")
messages.error(request, "No session key found for this user.")
if master_key is None:
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
if self.master_key is not None:
return super(SecretBulkImportView, self).post(request)
else:
try:
with transaction.atomic():
for secret in form.cleaned_data['csv']:
secret.encrypt(master_key)
secret.save()
new_secrets.append(secret)
messages.error(request, "Invalid private key! Unable to encrypt secret data.")
table = tables.SecretTable(new_secrets)
messages.success(request, "Imported {} new secrets.".format(len(new_secrets)))
else:
messages.error(request, "No session key was provided with the request. Unable to encrypt secret data.")
return render(request, 'import_success.html', {
'table': table,
'return_url': 'secrets:secret_list',
})
except IntegrityError as e:
form.add_error('csv', "Record {}: {}".format(len(new_secrets) + 1, e.__cause__))
else:
form = forms.SecretImportForm()
return render(request, 'secrets/secret_import.html', {
'form': form,
'return_url': 'secrets:secret_list',
})
return render(request, self.template_name, {
'form': self._import_form(request.POST),
'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
'return_url': self.default_return_url,
})
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):

View File

@@ -1,55 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Circuit Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Circuit ID</td>
<td>Alphanumeric circuit identifier</td>
<td>IC-603122</td>
</tr>
<tr>
<td>Provider</td>
<td>Name of circuit provider</td>
<td>TeliaSonera</td>
</tr>
<tr>
<td>Type</td>
<td>Circuit type</td>
<td>Transit</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Strickland Propane</td>
</tr>
<tr>
<td>Install Date</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Commit rate</td>
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
{% endblock %}

View File

@@ -181,7 +181,7 @@
</tr>
{% empty %}
<tr>
<td class="text-muted">None</td>
<td colspan="6" class="text-muted">None</td>
</tr>
{% endfor %}
</table>

View File

@@ -1,45 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Provider Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Provider's proper name</td>
<td>Level 3</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>level3</td>
</tr>
<tr>
<td>ASN</td>
<td>Autonomous system number (optional)</td>
<td>3356</td>
</tr>
<tr>
<td>Account</td>
<td>Account number (optional)</td>
<td>08931544</td>
</tr>
<tr>
<td>Portal URL</td>
<td>Customer service portal URL (optional)</td>
<td>https://mylevel3.net</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Level 3,level3,3356,08931544,https://mylevel3.net</pre>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'utilities/confirmation_form.html' %}
{% load helpers %}
{% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}
{% block message %}
<p>Are you sure you want to disconnect all {{ selected_objects|length }} of these {{ obj_type_plural }} on <strong>{{ device }}</strong>?</p>
<ul>
{% for obj in selected_objects %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -1,45 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Console Connections Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Console server</td>
<td>Device name or {ID}</td>
<td>abc1-cs3</td>
</tr>
<tr>
<td>Console server port</td>
<td>Full CS port name</td>
<td>Port 35</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Console Port</td>
<td>Console port name</td>
<td>Console</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-cs3,Port 35,abc1-switch7,Console,planned</pre>
{% endblock %}

View File

@@ -424,12 +424,17 @@
<div class="panel-footer">
{% 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
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interfaceconnection %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</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
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
@@ -479,9 +484,14 @@
</table>
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
<div class="panel-footer">
{% if cs_ports and perms.dcim.change_consoleport %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</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
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
@@ -531,9 +541,14 @@
</table>
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
<div class="panel-footer">
{% if power_outlets and perms.dcim.change_powerport %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</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
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}

View File

@@ -1,103 +1,5 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/device_import_header.html' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Device name (optional)</td>
<td>rack101_sw1</td>
</tr>
<tr>
<td>Device role</td>
<td>Functional role of device</td>
<td>ToR Switch</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Device manufacturer</td>
<td>Hardware manufacturer</td>
<td>Juniper</td>
</tr>
<tr>
<td>Device model</td>
<td>Hardware model</td>
<td>EX4300-48T</td>
</tr>
<tr>
<td>Platform</td>
<td>Software running on device (optional)</td>
<td>Juniper Junos</td>
</tr>
<tr>
<td>Serial number</td>
<td>Physical serial number (optional)</td>
<td>CAB00577291</td>
</tr>
<tr>
<td>Asset tag</td>
<td>Unique alphanumeric tag (optional)</td>
<td>ABC123456</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Site</td>
<td>Site name</td>
<td>Ashburn-VA</td>
</tr>
<tr>
<td>Rack</td>
<td>Rack name (optional)</td>
<td>R101</td>
</tr>
<tr>
<td>Position (U)</td>
<td>Lowest-numbered rack unit occupied by the device (optional)</td>
<td>21</td>
</tr>
<tr>
<td>Face</td>
<td>Rack face; front or rear (required if position is set)</td>
<td>Rear</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Active,Ashburn-VA,R101,21,Rear</pre>
</div>
</div>
{% block tabs %}
{% include 'dcim/inc/device_import_header.html' %}
{% endblock %}

View File

@@ -1,93 +1,5 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Device name (optional)</td>
<td>Blade12</td>
</tr>
<tr>
<td>Device role</td>
<td>Functional role of device</td>
<td>Blade Server</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Device manufacturer</td>
<td>Hardware manufacturer</td>
<td>Dell</td>
</tr>
<tr>
<td>Device model</td>
<td>Hardware model</td>
<td>BS2000T</td>
</tr>
<tr>
<td>Platform</td>
<td>Software running on device (optional)</td>
<td>Linux</td>
</tr>
<tr>
<td>Serial number</td>
<td>Physical serial number (optional)</td>
<td>CAB00577291</td>
</tr>
<tr>
<td>Asset tag</td>
<td>Unique alphanumeric tag (optional)</td>
<td>ABC123456</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Parent device</td>
<td>Parent device</td>
<td>Server101</td>
</tr>
<tr>
<td>Device bay</td>
<td>Device bay name</td>
<td>Slot 4</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Active,Server101,Slot4</pre>
</div>
</div>
{% block tabs %}
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
{% endblock %}

View File

@@ -1,4 +1,3 @@
<h1>Device Import</h1>
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}><a href="{% url 'dcim:device_import' %}">Racked Devices</a></li>
<li role="presentation"{% if active_tab == 'child_import' %} class="active"{% endif %}><a href="{% url 'dcim:device_import_child' %}">Child Devices</a></li>

View File

@@ -5,10 +5,10 @@
<td>{{ item.part_id }}</td>
<td>{{ item.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_inventory_item %}
{% if perms.dcim.change_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_inventory_item %}
{% if perms.dcim.delete_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>

View File

@@ -1,45 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Interface Connections Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Device A</td>
<td>Device name or {ID}</td>
<td>abc1-core1</td>
</tr>
<tr>
<td>Interface A</td>
<td>Interface name</td>
<td>xe-0/0/6</td>
</tr>
<tr>
<td>Device B</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Interface B</td>
<td>Interface name</td>
<td>xe-0/0/0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned</pre>
{% endblock %}

View File

@@ -1,45 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Power Connections Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>PDU</td>
<td>Device name or {ID}</td>
<td>abc1-pdu1</td>
</tr>
<tr>
<td>Power Outlet</td>
<td>Power outlet name</td>
<td>AC4</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Power Port</td>
<td>Power port name</td>
<td>PSU0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>connected</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-pdu1,AC4,abc1-switch7,PSU0,connected</pre>
{% endblock %}

View File

@@ -14,7 +14,8 @@
{% for rack in page %}
<div style="display: inline-block; width: 266px">
<div class="rack_header">
<h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
<p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
</div>
{% if face_id %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %}
@@ -23,7 +24,8 @@
{% endif %}
<div class="clearfix"></div>
<div class="rack_header">
<h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
<p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
</div>
</div>
{% endfor %}

View File

@@ -1,70 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Rack Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of the assigned site</td>
<td>DC-4</td>
</tr>
<tr>
<td>Group</td>
<td>Rack group name (optional)</td>
<td>Cage 1400</td>
</tr>
<tr>
<td>Name</td>
<td>Internal rack name</td>
<td>R101</td>
</tr>
<tr>
<td>Facility ID</td>
<td>Rack ID assigned by the facility (optional)</td>
<td>J12.100</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Compute</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
<td>4-post cabinet</td>
</tr>
<tr>
<td>Width</td>
<td>Rail-to-rail width (19 or 23 inches)</td>
<td>19</td>
</tr>
<tr>
<td>Height</td>
<td>Height in rack units</td>
<td>42</td>
</tr>
<tr>
<td>Descending units</td>
<td>Units are numbered top-to-bottom</td>
<td>False</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
{% endblock %}

View File

@@ -1,81 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}Site Import{% endblock %}
{% block content %}
<h1>Site Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Site's proper name</td>
<td>ASH-4 South</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>ash4-south</td>
</tr>
<tr>
<td>Region</td>
<td>Name of region (optional)</td>
<td>North America</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Facility</td>
<td>Name of the hosting facility (optional)</td>
<td>Equinix DC6</td>
</tr>
<tr>
<td>ASN</td>
<td>Autonomous system number (optional)</td>
<td>65000</td>
</tr>
<tr>
<td>Contact Name</td>
<td>Name of administrative contact (optional)</td>
<td>Hank Hill</td>
</tr>
<tr>
<td>Contact Phone</td>
<td>Phone number (optional)</td>
<td>+1-214-555-1234</td>
</tr>
<tr>
<td>Contact E-mail</td>
<td>E-mail address (optional)</td>
<td>hhill@example.com</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>ASH-4 South,ash4-south,North America,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com</pre>
</div>
</div>
{% endblock %}

View File

@@ -57,6 +57,12 @@
<a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a>
</td>
</tr>
<tr>
<td>Utilization</td>
<td>
{{ aggregate.get_utilization }}%
</td>
</tr>
<tr>
<td>Date Added</td>
<td>

View File

@@ -1,40 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Aggregate Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>172.16.0.0/12</td>
</tr>
<tr>
<td>RIR</td>
<td>Name of RIR</td>
<td>RFC 1918</td>
</tr>
<tr>
<td>Date Added</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Private IPv4 space</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space</pre>
{% endblock %}

View File

@@ -1,60 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}IP Address Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Address</td>
<td>IPv4 or IPv6 address</td>
<td>192.0.2.42/24</td>
</tr>
<tr>
<td>VRF</td>
<td>VRF route distinguisher (optional)</td>
<td>65000:123</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Device</td>
<td>Device name (optional)</td>
<td>switch12</td>
</tr>
<tr>
<td>Interface</td>
<td>Interface name (optional)</td>
<td>ge-0/0/31</td>
</tr>
<tr>
<td>Is Primary</td>
<td>If "true", IP will be primary for device (optional)</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Management IP</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>192.0.2.42/24,65000:123,ABC01,Active,switch12,ge-0/0/31,True,Management IP</pre>
{% endblock %}

View File

@@ -121,8 +121,8 @@
</td>
</tr>
<tr>
<td>IP Addresses</td>
<td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }}</a></td>
<td>Utilization</td>
<td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }} IP addresses</a> ({{ prefix.get_utilization }}%)</td>
</tr>
</table>
</div>

View File

@@ -8,13 +8,19 @@
{% render_field form.prefix %}
{% render_field form.status %}
{% render_field form.vrf %}
{% render_field form.site %}
{% render_field form.vlan %}
{% render_field form.role %}
{% render_field form.description %}
{% render_field form.is_pool %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Site/VLAN Assignment</strong></div>
<div class="panel-body">
{% render_field form.site %}
{% render_field form.vlan_group %}
{% render_field form.vlan %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">

View File

@@ -1,70 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Prefix Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>192.168.42.0/24</td>
</tr>
<tr>
<td>VRF</td>
<td>VRF route distinguisher (optional)</td>
<td>65000:123</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Site</td>
<td>Name of assigned site (optional)</td>
<td>HQ</td>
</tr>
<tr>
<td>VLAN Group</td>
<td>Name of group for VLAN selection (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>Numeric VLAN ID (optional)</td>
<td>801</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Customer</td>
</tr>
<tr>
<td>Is a pool</td>
<td>True if all IPs are considered usable</td>
<td>False</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>7th floor WiFi</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,False,7th floor WiFi</pre>
{% endblock %}

View File

@@ -1,60 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}VLAN Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of assigned site (optional)</td>
<td>LAS2</td>
</tr>
<tr>
<td>Group</td>
<td>Name of VLAN group (optional)</td>
<td>Backend Network</td>
</tr>
<tr>
<td>ID</td>
<td>Configured VLAN ID</td>
<td>1400</td>
</tr>
<tr>
<td>Name</td>
<td>Configured VLAN name</td>
<td>Cameras</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Internal</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Security</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Security team only</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
{% endblock %}

View File

@@ -1,45 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}VRF Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Name of VRF</td>
<td>Customer_ABC</td>
</tr>
<tr>
<td>RD</td>
<td>Route distinguisher</td>
<td>65000:123456</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Enforce uniqueness</td>
<td>Prevent duplicate prefixes/IP addresses</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Native VRF for customer ABC</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC</pre>
{% endblock %}

View File

@@ -1,40 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% block title %}Tenant Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Tenant name</td>
<td>WIDG01</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>widg01</td>
</tr>
<tr>
<td>Group</td>
<td>Tenant group (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>Description</td>
<td>Long-form name or other text (optional)</td>
<td>Widgets Inc.</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>WIDG01,widg01,Customers,Widgets Inc.</pre>
{% endblock %}

View File

@@ -1,10 +1,12 @@
{% extends '_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<h1>{% block title %}{% endblock %}</h1>
<h1>{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}</h1>
{% block tabs %}{% endblock %}
<div class="row">
<div class="col-md-6">
<div class="col-md-7">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
@@ -26,8 +28,33 @@
</div>
</form>
</div>
<div class="col-md-6">
{% block instructions %}{% endblock %}
<div class="col-md-5">
{% if fields %}
<h4 class="text-center">CSV Format</h4>
<table class="table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
{% for name, field in fields.items %}
<tr>
<td><code>{{ name }}</code></td>
<td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
<td>
{{ field.help_text|default:field.label }}
{% if field.choices %}
<br /><small class="text-muted">Choices: {{ field.choices|example_choices }}</small>
{% elif field|widget_type == 'dateinput' %}
<br /><small class="text-muted">Format: YYYY-MM-DD</small>
{% elif field|widget_type == 'checkboxinput' %}
<br /><small class="text-muted">Specify "true" or "false"</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -5,8 +5,7 @@ from django.db.models import Count
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
FilterChoiceField, SlugField,
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField,
)
from .models import Tenant, TenantGroup
@@ -36,17 +35,25 @@ class TenantForm(BootstrapMixin, CustomFieldForm):
fields = ['name', 'slug', 'group', 'description', 'comments']
class TenantFromCSVForm(forms.ModelForm):
group = forms.ModelChoiceField(TenantGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Group not found.'})
class TenantCSVForm(forms.ModelForm):
slug = SlugField()
group = forms.ModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent group',
error_messages={
'invalid_choice': 'Group not found.'
}
)
class Meta:
model = Tenant
fields = ['name', 'slug', 'group', 'description']
class TenantImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=TenantFromCSVForm)
fields = ['name', 'slug', 'group', 'description', 'comments']
help_texts = {
'name': 'Tenant name',
'comments': 'Free-form comments'
}
class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

View File

@@ -41,6 +41,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['name', 'slug', 'group', 'description']
class Meta:
ordering = ['group', 'name']

View File

@@ -10,13 +10,13 @@ urlpatterns = [
# Tenant groups
url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
url(r'^tenant-groups/add/$', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
# Tenants
url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
url(r'^tenants/add/$', views.TenantEditView.as_view(), name='tenant_add'),
url(r'^tenants/add/$', views.TenantCreateView.as_view(), name='tenant_add'),
url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'),
url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),

View File

@@ -26,8 +26,8 @@ class TenantGroupListView(ObjectListView):
template_name = 'tenancy/tenantgroup_list.html'
class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.change_tenantgroup'
class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.add_tenantgroup'
model = TenantGroup
form_class = forms.TenantGroupForm
@@ -35,6 +35,10 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('tenancy:tenantgroup_list')
class TenantGroupEditView(TenantGroupCreateView):
permission_required = 'tenancy.change_tenantgroup'
class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenantgroup'
cls = TenantGroup
@@ -81,14 +85,18 @@ class TenantView(View):
})
class TenantEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.change_tenant'
class TenantCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.add_tenant'
model = Tenant
form_class = forms.TenantForm
template_name = 'tenancy/tenant_edit.html'
default_return_url = 'tenancy:tenant_list'
class TenantEditView(TenantCreateView):
permission_required = 'tenancy.change_tenant'
class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'tenancy.delete_tenant'
model = Tenant
@@ -97,9 +105,8 @@ class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'tenancy.add_tenant'
form = forms.TenantImportForm
model_form = forms.TenantCSVForm
table = tables.TenantTable
template_name = 'tenancy/tenant_import.html'
default_return_url = 'tenancy:tenant_list'

View File

@@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from rest_framework import authentication, exceptions
from rest_framework.exceptions import APIException
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
from rest_framework.serializers import Field, ValidationError
@@ -105,3 +106,51 @@ class WritableSerializerMixin(object):
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
return self.write_serializer_class
return self.serializer_class
class OptionalLimitOffsetPagination(LimitOffsetPagination):
"""
Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects
matching a query, but retains the same format as a paginated request. The limit can only be disabled if
MAX_PAGE_SIZE has been set to 0 or None.
"""
def paginate_queryset(self, queryset, request, view=None):
try:
self.count = queryset.count()
except (AttributeError, TypeError):
self.count = len(queryset)
self.limit = self.get_limit(request)
self.offset = self.get_offset(request)
self.request = request
if self.limit and self.count > self.limit and self.template is not None:
self.display_page_controls = True
if self.count == 0 or self.offset > self.count:
return list()
if self.limit:
return list(queryset[self.offset:self.offset + self.limit])
else:
return list(queryset[self.offset:])
def get_limit(self, request):
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE:
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
return self.default_limit

View File

@@ -217,45 +217,79 @@ class Livesearch(forms.TextInput):
class CSVDataField(forms.CharField):
"""
A field for comma-separated values (CSV). Values containing commas should be encased within double quotes. Example:
'"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
column headers to values. Each dictionary represents an individual record.
"""
csv_form = None
widget = forms.Textarea
def __init__(self, csv_form, *args, **kwargs):
self.csv_form = csv_form
self.columns = self.csv_form().fields.keys()
def __init__(self, fields, required_fields=[], *args, **kwargs):
self.fields = fields
self.required_fields = required_fields
super(CSVDataField, self).__init__(*args, **kwargs)
self.strip = False
if not self.label:
self.label = 'CSV Data'
if not self.initial:
self.initial = ','.join(required_fields) + '\n'
if not self.help_text:
self.help_text = 'Enter one line per record in CSV format.'
self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
'in double quotes.'
def to_python(self, value):
"""
Return a list of dictionaries, each representing an individual record
"""
# Python 2's csv module has problems with Unicode
if not isinstance(value, str):
value = value.encode('utf-8')
records = []
reader = csv.reader(value.splitlines())
# Consume and valdiate the first line of CSV data as column headers
headers = next(reader)
for f in self.required_fields:
if f not in headers:
raise forms.ValidationError('Required column header "{}" not found.'.format(f))
for f in headers:
if f not in self.fields:
raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
# Parse CSV data
for i, row in enumerate(reader, start=1):
if row:
if len(row) < len(self.columns):
raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})"
.format(i, len(row), len(self.columns)))
elif len(row) > len(self.columns):
raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})"
.format(i, len(row), len(self.columns)))
if len(row) != len(headers):
raise forms.ValidationError(
"Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
)
row = [col.strip() for col in row]
record = dict(zip(self.columns, row))
record = dict(zip(headers, row))
records.append(record)
return records
class CSVChoiceField(forms.ChoiceField):
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
def __init__(self, choices, *args, **kwargs):
super(CSVChoiceField, self).__init__(choices, *args, **kwargs)
self.choices = [(label, label) for value, label in choices]
self.choice_values = {label: value for value, label in choices}
def clean(self, value):
value = super(CSVChoiceField, self).clean(value)
if not value:
return None
if value not in self.choice_values:
raise forms.ValidationError("Invalid choice: {}".format(value))
return self.choice_values[value]
class ExpandableNameField(forms.CharField):
"""
A field which allows for numeric range expansion
@@ -438,14 +472,17 @@ class ChainedFieldsMixin(forms.BaseForm):
def __init__(self, *args, **kwargs):
super(ChainedFieldsMixin, self).__init__(*args, **kwargs)
# if self.is_bound:
# assert False, self.data
for field_name, field in self.fields.items():
if isinstance(field, ChainedModelChoiceField):
filters_dict = {}
for (db_field, parent_field) in field.chains:
if self.is_bound and self.data.get(parent_field):
filters_dict[db_field] = self.data[parent_field]
if self.is_bound and parent_field in self.data:
filters_dict[db_field] = self.data[parent_field] or None
elif self.initial.get(parent_field):
filters_dict[db_field] = self.initial[parent_field]
elif self.fields[parent_field].widget.attrs.get('nullable'):
@@ -483,28 +520,3 @@ class BulkEditForm(forms.Form):
self.nullable_fields = [field for field in self.Meta.nullable_fields]
else:
self.nullable_fields = []
class BulkImportForm(forms.Form):
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
obj_list = []
for i, record in enumerate(records, start=1):
obj_form = self.fields['csv'].csv_form(data=record)
if obj_form.is_valid():
obj = obj_form.save(commit=False)
obj_list.append(obj)
else:
for field, errors in obj_form.errors.items():
for e in errors:
if field == '__all__':
self.add_error('csv', "Record {}: {}".format(i, e))
else:
self.add_error('csv', "Record {} ({}): {}".format(i, field, e))
self.cleaned_data['csv'] = obj_list

View File

@@ -40,7 +40,9 @@ def widget_type(field):
"""
Return the widget type
"""
try:
if hasattr(field, 'widget'):
return field.widget.__class__.__name__.lower()
elif hasattr(field, 'field'):
return field.field.widget.__class__.__name__.lower()
except AttributeError:
else:
return None

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from markdown import markdown
from django import template
@@ -60,6 +62,22 @@ def bettertitle(value):
return ' '.join([w[0].upper() + w[1:] for w in value.split()])
@register.filter()
def example_choices(value, arg=3):
"""
Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms).
"""
choices = []
for id, label in value:
if len(choices) == arg:
choices.append('etc.')
break
if not id:
continue
choices.append(label)
return ', '.join(choices) or 'None'
#
# Tags
#

View File

@@ -6,9 +6,10 @@ from django_tables2 import RequestConfig
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.db.models import ProtectedError
from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError
@@ -19,6 +20,7 @@ from django.utils.safestring import mark_safe
from django.views.generic import View
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
from utilities.forms import BootstrapMixin, CSVDataField
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm
from .paginator import EnhancedPaginator
@@ -100,7 +102,9 @@ class ObjectListView(View):
.format(et.name))
# Fall back to built-in CSV export
elif 'export' in request.GET and hasattr(model, 'to_csv'):
output = '\n'.join([obj.to_csv() for obj in self.queryset])
headers = getattr(model, 'csv_headers', None)
output = ','.join(headers) + '\n' if headers else ''
output += '\n'.join([obj.to_csv() for obj in self.queryset])
response = HttpResponse(
output,
content_type='text/csv'
@@ -286,7 +290,7 @@ class ObjectDeleteView(GetReturnURLMixin, View):
})
class BulkAddView(View):
class BulkCreateView(View):
"""
Create new objects in bulk.
@@ -371,56 +375,85 @@ class BulkImportView(View):
"""
Import objects in bulk (CSV format).
form: Form class
model_form: The form used to create each imported object
table: The django-tables2 Table used to render the list of imported objects
template_name: The name of the template
default_return_url: The name of the URL to use for the cancel button
"""
form = None
model_form = None
table = None
template_name = None
default_return_url = None
template_name = 'utilities/obj_import.html'
def _import_form(self, *args, **kwargs):
fields = self.model_form().fields.keys()
required_fields = [name for name, field in self.model_form().fields.items() if field.required]
class ImportForm(BootstrapMixin, Form):
csv = CSVDataField(fields=fields, required_fields=required_fields)
return ImportForm(*args, **kwargs)
def _save_obj(self, obj_form):
"""
Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data).
"""
return obj_form.save()
def get(self, request):
return render(request, self.template_name, {
'form': self.form(),
'form': self._import_form(),
'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
'return_url': self.default_return_url,
})
def post(self, request):
form = self.form(request.POST)
if form.is_valid():
new_objs = []
try:
with transaction.atomic():
for obj in form.cleaned_data['csv']:
self.save_obj(obj)
new_objs.append(obj)
new_objs = []
form = self._import_form(request.POST)
if form.is_valid():
try:
# Iterate through CSV data and bind each row to a new model form instance.
with transaction.atomic():
for row, data in enumerate(form.cleaned_data['csv'], start=1):
obj_form = self.model_form(data)
if obj_form.is_valid():
obj = self._save_obj(obj_form)
new_objs.append(obj)
else:
for field, err in obj_form.errors.items():
form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
raise ValidationError("")
# Compile a table containing the imported objects
obj_table = self.table(new_objs)
if new_objs:
msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
messages.success(request, msg)
UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
return render(request, "import_success.html", {
'table': obj_table,
'return_url': self.default_return_url,
})
return render(request, "import_success.html", {
'table': obj_table,
'return_url': self.default_return_url,
})
except IntegrityError as e:
form.add_error('csv', "Record {}: {}".format(len(new_objs) + 1, e.__cause__))
except ValidationError:
pass
return render(request, self.template_name, {
'form': form,
'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
'return_url': self.default_return_url,
})
def save_obj(self, obj):
obj.save()
class BulkEditView(View):
"""

View File

@@ -5,6 +5,25 @@
# Once the script completes, remember to restart the WSGI service (e.g.
# gunicorn or uWSGI).
# Determine which version of Python/pip to use. Default to v3 (if available)
# but allow the user to force v2.
PYTHON="python3"
PIP="pip3"
type $PYTHON >/dev/null 2>&1 && type $PIP >/dev/null 2>&1 || PYTHON="python" PIP="pip"
while getopts ":2" opt; do
case $opt in
2)
PYTHON="python"
PIP="pip"
echo "Forcing Python/pip v2"
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit
;;
esac
done
# Optionally use sudo if not already root, and always prompt for password
# before running the command
PREFIX="sudo -k "
@@ -20,21 +39,17 @@ COMMAND="${PREFIX}find . -name \"*.pyc\" -delete"
echo "Cleaning up stale Python bytecode ($COMMAND)..."
eval $COMMAND
# Fall back to pip3 if pip is missing
PIP="pip"
type $PIP >/dev/null 2>&1 || PIP="pip3"
# Install any new Python packages
COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade"
echo "Updating required Python packages ($COMMAND)..."
eval $COMMAND
# Apply any database migrations
COMMAND="./netbox/manage.py migrate"
COMMAND="${PYTHON} netbox/manage.py migrate"
echo "Applying database migrations ($COMMAND)..."
eval $COMMAND
# Collect static files
COMMAND="./netbox/manage.py collectstatic --no-input"
COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input"
echo "Collecting static files ($COMMAND)..."
eval $COMMAND