mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-31 09:37:45 -06:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c63a499d5 | ||
|
|
3a2c5b318a | ||
|
|
cfff69a715 | ||
|
|
08883d86ef | ||
|
|
8a849ebeff | ||
|
|
05a796faf1 | ||
|
|
9e1d03b383 | ||
|
|
0a929f2971 | ||
|
|
7878992570 | ||
|
|
4f95926cbd | ||
|
|
f3e997ea39 | ||
|
|
2b921c21ff | ||
|
|
50496b1a59 | ||
|
|
9736d63577 | ||
|
|
13add414c4 | ||
|
|
b032bc13db | ||
|
|
aaad428438 | ||
|
|
203895fc7e | ||
|
|
aab1fab445 | ||
|
|
e06221bc89 | ||
|
|
26a13edcf3 | ||
|
|
65b6fe576f | ||
|
|
4671829ad8 | ||
|
|
293be752ca | ||
|
|
0a6e4f31d5 | ||
|
|
e6c4ce51f7 | ||
|
|
3924063060 | ||
|
|
d122f9f700 | ||
|
|
d0649ba815 | ||
|
|
1ec09270a7 | ||
|
|
1ddd7415cb | ||
|
|
ec9d0d4008 | ||
|
|
08c8bd3049 | ||
|
|
2520d9f400 | ||
|
|
0e863ff9ca | ||
|
|
1b78f54c6b | ||
|
|
b732c24ec4 | ||
|
|
af604aba31 | ||
|
|
c82658440f | ||
|
|
7e660d4d8e | ||
|
|
4a8147f8a5 | ||
|
|
583830c652 | ||
|
|
95fdb549d7 | ||
|
|
a598f0e632 | ||
|
|
293dbd8a8b | ||
|
|
f03a378ce0 | ||
|
|
6aae8aee5b | ||
|
|
6d908d3e79 | ||
|
|
d5016c7133 | ||
|
|
b5a1b692bd | ||
|
|
834c396a22 | ||
|
|
bc18d241e8 | ||
|
|
5ff4e3b194 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -65,9 +65,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'
|
||||
|
||||
|
||||
@@ -163,9 +162,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'
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, 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):
|
||||
@@ -1137,76 +1192,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):
|
||||
@@ -1536,94 +1599,79 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
from copy import deepcopy
|
||||
from difflib import SequenceMatcher
|
||||
import re
|
||||
from natsort import natsorted
|
||||
from operator import attrgetter
|
||||
@@ -29,8 +30,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -219,9 +220,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'
|
||||
|
||||
|
||||
@@ -390,9 +390,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'
|
||||
|
||||
|
||||
@@ -778,20 +777,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()
|
||||
@@ -866,7 +859,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 +867,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'
|
||||
@@ -1016,9 +1008,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'
|
||||
|
||||
|
||||
@@ -1239,9 +1230,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'
|
||||
|
||||
|
||||
@@ -1676,9 +1666,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'
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
62
netbox/extras/management/commands/nbshell.py
Normal file
62
netbox/extras/management/commands/nbshell.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Rack, Device, Interface
|
||||
@@ -9,8 +8,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 +48,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 +122,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 +180,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 +199,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 +213,102 @@ 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))
|
||||
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))
|
||||
|
||||
|
||||
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
@@ -513,16 +562,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=PREFIX_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 +609,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 +618,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 +643,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 +743,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):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -34,7 +34,7 @@ RIR_ACTIONS = """
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
{% if record.pk %}{% utilization_graph value %}{% else %}—{% 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 '',
|
||||
}
|
||||
|
||||
@@ -130,9 +130,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'
|
||||
|
||||
|
||||
@@ -341,9 +340,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'
|
||||
|
||||
|
||||
@@ -538,9 +536,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'
|
||||
|
||||
|
||||
@@ -640,9 +637,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):
|
||||
@@ -748,9 +744,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'
|
||||
|
||||
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -13,9 +13,9 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
VERSION = '2.0.4'
|
||||
VERSION = '2.0.6'
|
||||
|
||||
# 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',
|
||||
),
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -16,7 +16,7 @@ urlpatterns = [
|
||||
|
||||
# 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'),
|
||||
|
||||
@@ -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
|
||||
@@ -185,58 +187,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):
|
||||
|
||||
@@ -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 %}
|
||||
@@ -181,7 +181,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-muted">None</td>
|
||||
<td colspan="6" class="text-muted">None</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -97,9 +97,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'
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = reader.next()
|
||||
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
|
||||
@@ -444,8 +478,8 @@ class ChainedFieldsMixin(forms.BaseForm):
|
||||
|
||||
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 +517,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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'
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
27
upgrade.sh
27
upgrade.sh
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user