Compare commits

..

1 Commits

Author SHA1 Message Date
Jeremy Stretch
1033c8677a Release v2.3-beta2 2018-02-06 15:12:31 -05:00
126 changed files with 763 additions and 1838 deletions

View File

@@ -21,7 +21,6 @@
[ ] Feature request <!-- An enhancement of existing functionality --> [ ] Feature request <!-- An enhancement of existing functionality -->
[ ] Bug report <!-- Unexpected or erroneous behavior --> [ ] Bug report <!-- Unexpected or erroneous behavior -->
[ ] Documentation <!-- A modification to the documentation --> [ ] Documentation <!-- A modification to the documentation -->
[ ] Housekeeping <!-- Changes pertaining to the codebase itself -->
<!-- <!--
Please describe the environment in which you are running NetBox. (Be sure Please describe the environment in which you are running NetBox. (Be sure
@@ -32,7 +31,7 @@
--> -->
### Environment ### Environment
* Python version: <!-- Example: 3.5.4 --> * Python version: <!-- Example: 3.5.4 -->
* NetBox version: <!-- Example: 2.3.5 --> * NetBox version: <!-- Example: 2.1.3 -->
<!-- <!--
BUG REPORTS must include: BUG REPORTS must include:

View File

@@ -9,7 +9,7 @@ python:
- "3.5" - "3.5"
install: install:
- pip install -r requirements.txt - pip install -r requirements.txt
- pip install pycodestyle - pip install pep8
before_script: before_script:
- psql --version - psql --version
- psql -U postgres -c 'SELECT version();' - psql -U postgres -c 'SELECT version();'

View File

@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)! or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)!
### Build Status ### Build Status
@@ -41,4 +41,3 @@ and run `upgrade.sh`.
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine)) * [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))

View File

@@ -5,7 +5,7 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects * `GET`: Retrieve an object or list of objects
* `POST`: Create a new object * `POST`: Create a new object
* `PUT`: Update an existing object, all mandatory fields must be specified * `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifying the field to be changed * `PATCH`: Updates an existing object, only specifiying the field to be changed
* `DELETE`: Delete an existing object * `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header: To authenticate a request, attach your token in an `Authorization` header:
@@ -144,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
* Closing connection 0 * Closing connection 0
``` ```
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty. The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.

View File

@@ -206,28 +206,3 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_
!!! warning !!! 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. 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.
# Filtering
A list of objects retrieved via the API can be filtered by passing one or more query parameters. The same parameters used by the web UI work for the API as well. For example, to return only prefixes with a status of "Active" (`1`):
```
GET /api/ipam/prefixes/?status=1
```
The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
```
GET /api/ipam/prefixes/?status=1&status=2
```
## Custom Fields
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
```
GET /api/dcim/sites/?cf_foo=123
```
!!! note
Full versus partial matching when filtering is configurable per custom field. Filtering can be toggled (or disabled) for a custom field in the admin UI.

View File

@@ -42,8 +42,6 @@ A device type represents a particular hardware model that exists in the real wor
Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type. Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type.
A device type can be a parent, child, or neither. Parent devices house child devices in device bays. This relationship is used to model things like blade servers, where child devices function independently but share physical resources like rack space and power. Note that this is **not** intended to model chassis-based devices, wherein child members share a common control plane.
### Manufacturers ### Manufacturers
Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer. Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer.

View File

@@ -81,13 +81,13 @@ AUTH_LDAP_USER_ATTR_MAP = {
# User Groups for Permissions # User Groups for Permissions
!!! info !!! info
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` . When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
```python ```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
# hierarchy. # heirarchy.
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE, AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
"(objectClass=group)") "(objectClass=group)")
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

View File

@@ -91,7 +91,9 @@ Checking connectivity... done.
!!! warning !!! warning
Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.) Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.)
`# chown -R netbox:netbox /opt/netbox/netbox/media/` ```
# chown -R netbox:netbox /opt/netbox/netbox/media/
```
## Install Python Packages ## Install Python Packages

View File

@@ -12,37 +12,25 @@ Download and extract the latest version:
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz # wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt # tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/ # cd /opt/
# ln -sfn netbox-X.Y.Z/ netbox # ln -sf netbox-X.Y.Z/ netbox
``` ```
Copy the 'configuration.py' you created when first installing to the new version: Copy the 'configuration.py' you created when first installing to the new version:
```no-highlight ```no-highlight
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py # cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
```
Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location.
```no-highlight
# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/
``` ```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight ```no-highlight
# cp netbox-X.Y.Z/gunicorn_config.py netbox/gunicorn_config.py # cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
``` ```
Copy the LDAP configuration if using LDAP: Copy the LDAP configuration if using LDAP:
```no-highlight ```no-highlight
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py # cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
``` ```
## Option B: Clone the Git Repository (latest master release) ## Option B: Clone the Git Repository (latest master release)

View File

@@ -82,7 +82,6 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
ProxyPass ! ProxyPass !
</Location> </Location>
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
ProxyPass / http://127.0.0.1:8001/ ProxyPass / http://127.0.0.1:8001/
ProxyPassReverse / http://127.0.0.1:8001/ ProxyPassReverse / http://127.0.0.1:8001/
</VirtualHost> </VirtualHost>
@@ -93,7 +92,6 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
```no-highlight ```no-highlight
# a2enmod proxy # a2enmod proxy
# a2enmod proxy_http # a2enmod proxy_http
# a2enmod headers
# a2ensite netbox # a2ensite netbox
# service apache2 restart # service apache2 restart
``` ```

View File

@@ -32,7 +32,7 @@ class DeviceIPsReport(Report):
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections. Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
``` ```
from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE
from dcim.models import ConsolePort, Device, PowerPort from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report from extras.reports import Report
@@ -43,7 +43,7 @@ class DeviceConnectionsReport(Report):
def test_console_connection(self): def test_console_connection(self):
# Check that every console port for every active device has a connection defined. # Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE):
if console_port.cs_port is None: if console_port.cs_port is None:
self.log_failure( self.log_failure(
console_port.device, console_port.device,
@@ -60,7 +60,7 @@ class DeviceConnectionsReport(Report):
def test_power_connections(self): def test_power_connections(self):
# Check that every active device has at least two connected power supplies. # Check that every active device has at least two connected power supplies.
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE): for device in Device.objects.filter(status=STATUS_ACTIVE):
connected_ports = 0 connected_ports = 0
for power_port in PowerPort.objects.filter(device=device): for power_port in PowerPort.objects.filter(device=device):
if power_port.power_outlet is not None: if power_port.power_outlet is not None:

View File

@@ -1,4 +1,4 @@
NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command: NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
``` ```
./manage.py nbshell ./manage.py nbshell
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
982 982
``` ```
Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper." Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
``` ```
>>> Device.objects.filter(tenant__name='Pied Piper') >>> Device.objects.filter(tenant__name='Pied Piper')

View File

@@ -19,7 +19,6 @@ from . import serializers
class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
fields = ( fields = (
(Circuit, ['status']),
(CircuitTermination, ['term_side']), (CircuitTermination, ['term_side']),
) )

View File

@@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
) )
from .constants import CIRCUIT_STATUS_CHOICES from .constants import CIRCUIT_STATUS_CHOICES
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -169,6 +169,13 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments'] nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
def circuit_status_choices():
status_counts = {}
for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES]
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit model = Circuit
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
@@ -180,12 +187,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=Provider.objects.annotate(filter_count=Count('circuits')), queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug' to_field_name='slug'
) )
status = AnnotatedMultipleChoiceField( status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
choices=CIRCUIT_STATUS_CHOICES,
annotate=Circuit.objects.all(),
annotate_field='status',
required=False
)
tenant = FilterChoiceField( tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('circuits')), queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug', to_field_name='slug',

View File

@@ -80,7 +80,7 @@ class NestedSiteSerializer(serializers.ModelSerializer):
class WritableSiteSerializer(CustomFieldModelSerializer): class WritableSiteSerializer(CustomFieldModelSerializer):
time_zone = TimeZoneField(required=False, allow_null=True) time_zone = TimeZoneField(required=False)
class Meta: class Meta:
model = Site model = Site
@@ -233,7 +233,7 @@ class WritableRackReservationSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ['id', 'rack', 'units', 'user', 'tenant', 'description'] fields = ['id', 'rack', 'units', 'user', 'description']
# #
@@ -731,20 +731,15 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
def validate(self, data): def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site. # Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
device = self.instance.device if self.instance else data.get('device') # VirtualMachine, or are global.
untagged_vlan = data.get('untagged_vlan') parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []): for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]: if vlan.site not in [parent, None]:
raise serializers.ValidationError({ raise serializers.ValidationError(
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must " "Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
"be global.".format(vlan) "global".format(vlan)
}) )
return super(WritableInterfaceSerializer, self).validate(data) return super(WritableInterfaceSerializer, self).validate(data)

View File

@@ -3,11 +3,9 @@ from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response from rest_framework.response import Response
@@ -36,12 +34,11 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = ( fields = (
(Device, ['face', 'status']), (Device, ['face', 'status']),
(ConsolePort, ['connection_status']), (ConsolePort, ['connection_status']),
(Interface, ['form_factor', 'mode']), (Interface, ['form_factor']),
(InterfaceConnection, ['connection_status']), (InterfaceConnection, ['connection_status']),
(InterfaceTemplate, ['form_factor']), (InterfaceTemplate, ['form_factor']),
(PowerPort, ['connection_status']), (PowerPort, ['connection_status']),
(Rack, ['type', 'width']), (Rack, ['type', 'width']),
(Site, ['status']),
) )
@@ -421,20 +418,14 @@ class ConnectedDeviceViewSet(ViewSet):
* `peer-interface`: The name of the peer interface * `peer-interface`: The name of the peer interface
""" """
permission_classes = [IsAuthenticatedOrLoginNotRequired] permission_classes = [IsAuthenticatedOrLoginNotRequired]
_device_param = Parameter('peer-device', 'query',
description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
_interface_param = Parameter('peer-interface', 'query',
description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
def get_view_name(self): def get_view_name(self):
return "Connected Device Locator" return "Connected Device Locator"
@swagger_auto_schema(
manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
def list(self, request): def list(self, request):
peer_device_name = request.query_params.get(self._device_param.name) peer_device_name = request.query_params.get('peer-device')
peer_interface_name = request.query_params.get(self._interface_param.name) peer_interface_name = request.query_params.get('peer-interface')
if not peer_device_name or not peer_interface_name: if not peer_device_name or not peer_interface_name:
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.') raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')

View File

@@ -684,46 +684,11 @@ class InventoryItemFilter(DeviceComponentFilterSet):
class VirtualChassisFilter(django_filters.FilterSet): class VirtualChassisFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='master__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='master__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='master__tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='master__tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = ['domain'] fields = ['domain']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(master__name__icontains=value) |
Q(domain__icontains=value)
)
return queryset.filter(qs_filter)
class ConsoleConnectionFilter(django_filters.FilterSet): class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(

View File

@@ -14,10 +14,11 @@ from ipam.models import IPAddress, VLAN, VLANGroup
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from .constants import ( from .constants import (
@@ -34,13 +35,7 @@ from .models import (
RackRole, Region, Site, VirtualChassis RackRole, Region, Site, VirtualChassis
) )
DEVICE_BY_PK_RE = r'{\d+\}' DEVICE_BY_PK_RE = '{\d+\}'
INTERFACE_MODE_HELP_TEXT = """
Access: One untagged VLAN<br />
Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
"""
def get_device_by_name_or_pk(name): def get_device_by_name_or_pk(name):
@@ -112,8 +107,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
'comments',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -123,8 +119,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'name': "Full name of the site", 'name': "Full name of the site",
'facility': "Data center provider and facility (e.g. Equinix NY7)", 'facility': "Data center provider and facility (e.g. Equinix NY7)",
'asn': "BGP autonomous system number", 'asn': "BGP autonomous system number",
'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)", 'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address" 'shipping_address': "If different from the physical address"
} }
@@ -132,7 +126,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class SiteCSVForm(forms.ModelForm): class SiteCSVForm(forms.ModelForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=SITE_STATUS_CHOICES, choices=DEVICE_STATUS_CHOICES,
required=False, required=False,
help_text='Operational status' help_text='Operational status'
) )
@@ -166,51 +160,29 @@ class SiteCSVForm(forms.ModelForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
queryset=Site.objects.all(), status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
widget=forms.MultipleHiddenInput region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField( asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
choices=add_blank_choice(SITE_STATUS_CHOICES), description = forms.CharField(max_length=100, required=False)
required=False, time_zone = TimeZoneFormField(required=False)
initial=''
)
region = TreeNodeChoiceField(
queryset=Region.objects.all(),
required=False
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
asn = forms.IntegerField(
min_value=1,
max_value=4294967295,
required=False,
label='ASN'
)
description = forms.CharField(
max_length=100,
required=False
)
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
required=False
)
class Meta: class Meta:
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone'] nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
def site_status_choices():
status_counts = {}
for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES]
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site model = Site
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
status = AnnotatedMultipleChoiceField( status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
choices=SITE_STATUS_CHOICES,
annotate=Site.objects.all(),
annotate_field='status',
required=False
)
region = FilterTreeNodeMultipleChoiceField( region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')), queryset=Region.objects.annotate(filter_count=Count('sites')),
to_field_name='slug', to_field_name='slug',
@@ -728,21 +700,13 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class PlatformCSVForm(forms.ModelForm): class PlatformCSVForm(forms.ModelForm):
slug = SlugField() slug = SlugField()
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
to_field_name='name',
help_text='Manufacturer name',
error_messages={
'invalid_choice': 'Manufacturer not found.',
}
)
class Meta: class Meta:
model = Platform model = Platform
fields = Platform.csv_headers fields = Platform.csv_headers
help_texts = { help_texts = {
'name': 'Platform name', 'name': 'Platform name',
'manufacturer': 'Manufacturer name',
} }
@@ -1076,6 +1040,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'platform', 'serial'] nullable_fields = ['tenant', 'platform', 'serial']
def device_status_choices():
status_counts = {}
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES]
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device model = Device
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
@@ -1113,22 +1084,8 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug', to_field_name='slug',
null_label='-- None --', null_label='-- None --',
) )
status = AnnotatedMultipleChoiceField( status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
choices=DEVICE_STATUS_CHOICES,
annotate=Device.objects.all(),
annotate_field='status',
required=False
)
mac_address = forms.CharField(required=False, label='MAC address') mac_address = forms.CharField(required=False, label='MAC address')
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=forms.Select(choices=[
('', '---------'),
('True', 'Yes'),
('False', 'No'),
])
)
# #
@@ -1682,23 +1639,61 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
# Interfaces # Interfaces
# #
class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='VLAN 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(
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
untagged_vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'untagged_vlan', 'tagged_vlans', 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
} }
labels = {
'mode': '802.1Q Mode',
}
help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT,
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(InterfaceForm, self).__init__(*args, **kwargs) super(InterfaceForm, self).__init__(*args, **kwargs)
@@ -1715,122 +1710,111 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
) )
def clean(self): # Limit the queryset for the site to only include the interface's device's site
if device and device.site:
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
self.fields['site'].initial = None
else:
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
super(InterfaceForm, self).clean() # Limit the initial vlan choices
if self.is_bound:
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
}
elif self.initial.get('untagged_vlan'):
filter_dict = {
'group_id': self.instance.untagged_vlan.group,
'site_id': self.instance.untagged_vlan.site,
}
elif self.initial.get('tagged_vlans'):
filter_dict = {
'group_id': self.instance.tagged_vlans.first().group,
'site_id': self.instance.tagged_vlans.first().site,
}
else:
filter_dict = {
'group_id': None,
'site_id': None,
}
# Validate VLAN assignments self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
tagged_vlans = self.cleaned_data['tagged_vlans'] self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
# Untagged interfaces cannot be assigned tagged VLANs def clean_tagged_vlans(self):
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: """
raise forms.ValidationError({ Because tagged_vlans is a many-to-many relationship, validation must be done in the form
'mode': "An access interface cannot have tagged VLANs assigned." """
}) if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError(
# Remove all tagged VLAN assignments from "tagged all" interfaces "An Access interface cannot have tagged VLANs."
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
vlans = forms.MultipleChoiceField(
choices=[],
label='VLANs',
widget=forms.SelectMultiple(attrs={'size': 20})
)
tagged = forms.BooleanField(
required=False,
initial=True
)
class Meta:
model = Interface
fields = []
def __init__(self, *args, **kwargs):
super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs)
if self.instance.mode == IFACE_MODE_ACCESS:
self.initial['tagged'] = False
# Find all VLANs already assigned to the interface for exclusion from the list
assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
if self.instance.untagged_vlan is not None:
assigned_vlans.append(self.instance.untagged_vlan.pk)
# Compile VLAN choices
vlan_choices = []
# Add global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((
'Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
# Add grouped global VLANs
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
) )
parent = self.instance.parent if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
if parent is not None: raise forms.ValidationError(
"Interface mode Tagged All implies all VLANs are tagged. "
"Do not select any tagged VLANs."
)
# Add site VLANs return self.cleaned_data['tagged_vlans']
if parent.site:
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=parent.site):
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['vlans'].choices = vlan_choices
def clean(self):
super(InterfaceAssignVLANsForm, self).clean()
# Only untagged VLANs permitted on an access interface
if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
# 'tagged' is required if more than one VLAN is selected
if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
raise forms.ValidationError("Only one untagged VLAN may be selected.")
def save(self, *args, **kwargs):
if self.cleaned_data['tagged']:
for vlan in self.cleaned_data['vlans']:
self.instance.tagged_vlans.add(vlan)
else:
self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs)
class InterfaceCreateForm(ComponentForm, forms.Form): class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False) enabled = forms.BooleanField(required=False)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address') mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField( mgmt_only = forms.BooleanField(required=False, label='OOB Management')
required=False,
label='OOB Management',
help_text='This interface is used only for out-of-band management'
)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='VLAN 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(
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
untagged_vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1848,9 +1832,43 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
else: else:
self.fields['lag'].queryset = Interface.objects.none() self.fields['lag'].queryset = Interface.objects.none()
# Limit the queryset for the site to only include the interface's device's site
if self.parent is not None and self.parent.site:
self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
self.fields['site'].initial = None
else:
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): # Limit the initial vlan choices
if self.is_bound:
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
}
elif self.initial.get('untagged_vlan'):
filter_dict = {
'group_id': self.untagged_vlan.group,
'site_id': self.untagged_vlan.site,
}
elif self.initial.get('tagged_vlans'):
filter_dict = {
'group_id': self.tagged_vlans.first().group,
'site_id': self.tagged_vlans.first().site,
}
else:
filter_dict = {
'group_id': None,
'site_id': None,
}
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
@@ -1858,15 +1876,64 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='VLAN 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(
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
untagged_vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
class Meta: class Meta:
nullable_fields = ['lag', 'mtu', 'description', 'mode'] nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device (or VC master) # Limit LAG choices to interfaces which belong to the parent device (or VC master)
device = self.parent_obj device = None
if self.initial.get('device'):
try:
device = Device.objects.get(pk=self.initial.get('device'))
except Device.DoesNotExist:
pass
if device is not None: if device is not None:
interface_ordering = device.device_type.interface_ordering interface_ordering = device.device_type.interface_ordering
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter( self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
@@ -1875,6 +1942,22 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
else: else:
self.fields['lag'].choices = [] self.fields['lag'].choices = []
# Limit the queryset for the site to only include the interface's device's site
if device and device.site:
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
self.fields['site'].initial = None
else:
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
filter_dict = {
'group_id': None,
'site_id': None,
}
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
class InterfaceBulkRenameForm(BulkRenameForm): class InterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
@@ -1960,7 +2043,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
super(InterfaceConnectionForm, self).__init__(*args, **kwargs) super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
# Initialize interface A choices # Initialize interface A choices
device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related( device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b' 'circuit_termination', 'connected_as_a', 'connected_as_b'
) )
self.fields['interface_a'].choices = [ self.fields['interface_a'].choices = [
@@ -1969,11 +2052,9 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
# Mark connected interfaces as disabled # Mark connected interfaces as disabled
if self.data.get('device_b'): if self.data.get('device_b'):
self.fields['interface_b'].choices = [] self.fields['interface_b'].choices = [
for iface in self.fields['interface_b'].queryset: (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
self.fields['interface_b'].choices.append( ]
(iface.id, {'label': iface.name, 'disabled': iface.is_connected})
)
class InterfaceConnectionCSVForm(forms.ModelForm): class InterfaceConnectionCSVForm(forms.ModelForm):
@@ -2180,61 +2261,6 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = ['master', 'domain'] fields = ['master', 'domain']
widgets = {
'master': SelectWithPK,
}
class BaseVCMemberFormSet(forms.BaseModelFormSet):
def clean(self):
super(BaseVCMemberFormSet, self).clean()
# Check for duplicate VC position values
vc_position_list = []
for form in self.forms:
vc_position = form.cleaned_data.get('vc_position')
if vc_position:
if vc_position in vc_position_list:
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
form.add_error('vc_position', error_msg)
vc_position_list.append(vc_position)
class DeviceVCMembershipForm(forms.ModelForm):
class Meta:
model = Device
fields = ['vc_position', 'vc_priority']
labels = {
'vc_position': 'Position',
'vc_priority': 'Priority',
}
def __init__(self, validate_vc_position=False, *args, **kwargs):
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
# Require VC position (only required when the Device is a VirtualChassis member)
self.fields['vc_position'].required = True
# Validation of vc_position is optional. This is only required when adding a new member to an existing
# VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
self.validate_vc_position = validate_vc_position
def clean_vc_position(self):
vc_position = self.cleaned_data['vc_position']
if self.validate_vc_position:
conflicting_members = Device.objects.filter(
virtual_chassis=self.instance.virtual_chassis,
vc_position=vc_position
)
if conflicting_members.exists():
raise forms.ValidationError(
'A virtual chassis member already exists in position {}.'.format(vc_position)
)
return vc_position
class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@@ -2259,7 +2285,7 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
) )
) )
device = ChainedModelChoiceField( device = ChainedModelChoiceField(
queryset=Device.objects.filter(virtual_chassis__isnull=True), queryset=Device.objects.all(),
chains=( chains=(
('site', 'site'), ('site', 'site'),
('rack', 'rack'), ('rack', 'rack'),
@@ -2267,8 +2293,7 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
label='Device', label='Device',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name', display_field='display_name'
disabled_indicator='virtual_chassis'
) )
) )
@@ -2276,18 +2301,27 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
device = self.cleaned_data['device'] device = self.cleaned_data['device']
if device.virtual_chassis is not None: if device.virtual_chassis is not None:
raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device)) raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device))
return device
class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): class DeviceVCMembershipForm(forms.ModelForm):
model = VirtualChassis
q = forms.CharField(required=False, label='Search') class Meta:
site = FilterChoiceField( model = Device
queryset=Site.objects.all(), fields = ['vc_position', 'vc_priority']
to_field_name='slug', labels = {
) 'vc_position': 'Position',
tenant = FilterChoiceField( 'vc_priority': 'Priority',
queryset=Tenant.objects.all(), }
to_field_name='slug',
null_label='-- None --', def __init__(self, *args, **kwargs):
) super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
# Require VC position when assigning a member
self.fields['vc_position'].required = True
def clean_vc_position(self):
vc_position = self.cleaned_data['vc_position']
if Device.objects.filter(virtual_chassis=self.instance.virtual_chassis, vc_position=vc_position).exists():
raise forms.ValidationError("A virtual chassis member already exists in this position.")
return vc_position

View File

@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-21 14:41
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0054_site_status_timezone_description'),
]
operations = [
migrations.AlterModelOptions(
name='virtualchassis',
options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
),
migrations.AlterField(
model_name='virtualchassis',
name='master',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
),
]

View File

@@ -963,12 +963,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'face': "Must specify rack face when defining rack position.", 'face': "Must specify rack face when defining rack position.",
}) })
# Prevent 0U devices from being assigned to a specific position
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
})
if self.rack: if self.rack:
try: try:
@@ -1211,8 +1205,8 @@ class ConsoleServerPortManager(models.Manager):
def get_queryset(self): def get_queryset(self):
# Pad any trailing digits to effect natural sorting # Pad any trailing digits to effect natural sorting
return super(ConsoleServerPortManager, self).get_queryset().extra(select={ return super(ConsoleServerPortManager, self).get_queryset().extra(select={
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), " 'name_padded': "CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))", "LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
}).order_by('device', 'name_padded') }).order_by('device', 'name_padded')
@@ -1242,7 +1236,7 @@ class ConsoleServerPort(models.Model):
raise ValidationError("Console server ports must be assigned to devices.") raise ValidationError("Console server ports must be assigned to devices.")
device_type = self.device.device_type device_type = self.device.device_type
if not device_type.is_console_server: if not device_type.is_console_server:
raise ValidationError("The {} {} device type does not support assignment of console server ports.".format( raise ValidationError("The {} {} device type not support assignment of console server ports.".format(
device_type.manufacturer, device_type device_type.manufacturer, device_type
)) ))
@@ -1293,8 +1287,8 @@ class PowerOutletManager(models.Manager):
def get_queryset(self): def get_queryset(self):
# Pad any trailing digits to effect natural sorting # Pad any trailing digits to effect natural sorting
return super(PowerOutletManager, self).get_queryset().extra(select={ return super(PowerOutletManager, self).get_queryset().extra(select={
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), " 'name_padded': "CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))", "LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
}).order_by('device', 'name_padded') }).order_by('device', 'name_padded')
@@ -1324,7 +1318,7 @@ class PowerOutlet(models.Model):
raise ValidationError("Power outlets must be assigned to devices.") raise ValidationError("Power outlets must be assigned to devices.")
device_type = self.device.device_type device_type = self.device.device_type
if not device_type.is_pdu: if not device_type.is_pdu:
raise ValidationError("The {} {} device type does not support assignment of power outlets.".format( raise ValidationError("The {} {} device type not support assignment of power outlets.".format(
device_type.manufacturer, device_type device_type.manufacturer, device_type
)) ))
@@ -1409,7 +1403,7 @@ class Interface(models.Model):
if self.device is not None: if self.device is not None:
device_type = self.device.device_type device_type = self.device.device_type
if not device_type.is_network_device: if not device_type.is_network_device:
raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format( raise ValidationError("The {} {} device type not support assignment of network interfaces.".format(
device_type.manufacturer, device_type device_type.manufacturer, device_type
)) ))
@@ -1461,18 +1455,6 @@ class Interface(models.Model):
"device/VM, or it must be global".format(self.untagged_vlan) "device/VM, or it must be global".format(self.untagged_vlan)
}) })
def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces
if self.mode is None:
self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode is not IFACE_MODE_TAGGED:
self.tagged_vlans.clear()
return super(Interface, self).save(*args, **kwargs)
@property @property
def parent(self): def parent(self):
return self.device or self.virtual_machine return self.device or self.virtual_machine
@@ -1542,18 +1524,6 @@ class InterfaceConnection(models.Model):
raise ValidationError({ raise ValidationError({
'interface_b': "Cannot connect an interface to itself." 'interface_b': "Cannot connect an interface to itself."
}) })
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
@@ -1675,10 +1645,6 @@ class VirtualChassis(models.Model):
blank=True blank=True
) )
class Meta:
ordering = ['master']
verbose_name_plural = 'virtual chassis'
def __str__(self): def __str__(self):
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis' return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'

View File

@@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet):
}[method] }[method]
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)" ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)" SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-9]+)\/') AS integer)"
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)" SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)"
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)" POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)"
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)" SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)" VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
fields = { fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []), '_type': RawSQL(TYPE_RE.format(sql_col), []),

View File

@@ -11,13 +11,8 @@ def assign_virtualchassis_master(instance, created, **kwargs):
""" """
When a VirtualChassis is created, automatically assign its master device to the VC. When a VirtualChassis is created, automatically assign its master device to the VC.
""" """
# Default to 1 but don't overwrite an existing position (see #2087)
if instance.master.vc_position is not None:
vc_position = instance.master.vc_position
else:
vc_position = 1
if created: if created:
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=vc_position) Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
@receiver(pre_delete, sender=VirtualChassis) @receiver(pre_delete, sender=VirtualChassis)

View File

@@ -7,9 +7,9 @@ from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ToggleColumn from utilities.tables import BaseTable, ToggleColumn
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
RackReservation, Region, Site, VirtualChassis, VirtualChassis,
) )
REGION_LINK = """ REGION_LINK = """
@@ -47,13 +47,8 @@ REGION_ACTIONS = """
""" """
RACKGROUP_ACTIONS = """ RACKGROUP_ACTIONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="fa fa-eye"></i>
</a>
{% if perms.dcim.change_rackgroup %} {% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit"> <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<i class="glyphicon glyphicon-pencil"></i>
</a>
{% endif %} {% endif %}
""" """
@@ -133,10 +128,6 @@ SUBDEVICE_ROLE_TEMPLATE = """
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}&mdash;{% endif %} {% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}&mdash;{% endif %}
""" """
DEVICETYPE_INSTANCES_TEMPLATE = """
<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
"""
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% load helpers %} {% load helpers %}
{% utilization_graph value %} {% utilization_graph value %}
@@ -191,21 +182,12 @@ class SiteTable(BaseTable):
class RackGroupTable(BaseTable): class RackGroupTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn() name = tables.LinkColumn(verbose_name='Name')
site = tables.LinkColumn( site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
viewname='dcim:site', rack_count = tables.Column(verbose_name='Racks')
args=[Accessor('site.slug')], slug = tables.Column(verbose_name='Slug')
verbose_name='Site' actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
) verbose_name='')
rack_count = tables.Column(
verbose_name='Racks'
)
slug = tables.Column()
actions = tables.TemplateColumn(
template_code=RACKGROUP_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RackGroup model = RackGroup
@@ -317,23 +299,13 @@ class ManufacturerTable(BaseTable):
class DeviceTypeTable(BaseTable): class DeviceTypeTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
model = tables.LinkColumn( model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
viewname='dcim:devicetype',
args=[Accessor('pk')],
verbose_name='Device Type'
)
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
is_console_server = tables.BooleanColumn(verbose_name='CS') is_console_server = tables.BooleanColumn(verbose_name='CS')
is_pdu = tables.BooleanColumn(verbose_name='PDU') is_pdu = tables.BooleanColumn(verbose_name='PDU')
is_network_device = tables.BooleanColumn(verbose_name='Net') is_network_device = tables.BooleanColumn(verbose_name='Net')
subdevice_role = tables.TemplateColumn( subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
template_code=SUBDEVICE_ROLE_TEMPLATE, instance_count = tables.Column(verbose_name='Instances')
verbose_name='Subdevice Role'
)
instance_count = tables.TemplateColumn(
template_code=DEVICETYPE_INSTANCES_TEMPLATE,
verbose_name='Instances'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceType model = DeviceType
@@ -594,7 +566,7 @@ class InterfaceConnectionTable(BaseTable):
interface_b = tables.Column(verbose_name='Interface B') interface_b = tables.Column(verbose_name='Interface B')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = InterfaceConnection model = Interface
fields = ('device_a', 'interface_a', 'device_b', 'interface_b') fields = ('device_a', 'interface_a', 'device_b', 'interface_b')

View File

@@ -5,16 +5,13 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from dcim.constants import ( from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
)
from dcim.models import ( from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis, RackReservation, RackRole, Region, Site, VirtualChassis,
) )
from ipam.models import VLAN
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from users.models import Token from users.models import Token
from utilities.tests import HttpStatusMixin from utilities.tests import HttpStatusMixin
@@ -2261,10 +2258,6 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3')
self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
def test_get_interface(self): def test_get_interface(self):
url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
@@ -2316,27 +2309,6 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.assertEqual(interface4.device_id, data['device']) self.assertEqual(interface4.device_id, data['device'])
self.assertEqual(interface4.name, data['name']) self.assertEqual(interface4.name, data['name'])
def test_create_interface_with_802_1q(self):
data = {
'device': self.device.pk,
'name': 'Test Interface 4',
'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
'untagged_vlan': self.vlan3.id
}
url = reverse('dcim-api:interface-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 4)
interface5 = Interface.objects.get(pk=response.data['id'])
self.assertEqual(interface5.device_id, data['device'])
self.assertEqual(interface5.name, data['name'])
self.assertEqual(interface5.tagged_vlans.count(), 2)
self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan'])
def test_create_interface_bulk(self): def test_create_interface_bulk(self):
data = [ data = [
@@ -2363,47 +2335,6 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name']) self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_create_interface_802_1q_bulk(self):
data = [
{
'device': self.device.pk,
'name': 'Test Interface 4',
'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
{
'device': self.device.pk,
'name': 'Test Interface 5',
'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
{
'device': self.device.pk,
'name': 'Test Interface 6',
'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
]
url = reverse('dcim-api:interface-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
self.assertEqual(len(response.data[0]['tagged_vlans']), 1)
self.assertEqual(len(response.data[1]['tagged_vlans']), 1)
self.assertEqual(len(response.data[2]['tagged_vlans']), 1)
self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id)
self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id)
self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id)
def test_update_interface(self): def test_update_interface(self):
lag_interface = Interface.objects.create( lag_interface = Interface.objects.create(

View File

@@ -185,7 +185,6 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),

View File

@@ -7,7 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction from django.db import transaction
from django.db.models import Count, Q from django.db.models import Count, Q
from django.forms import modelformset_factory from django.forms import ModelChoiceField, ModelForm, modelformset_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@@ -41,21 +41,19 @@ class BulkRenameView(View):
""" """
An extendable view for renaming device components in bulk. An extendable view for renaming device components in bulk.
""" """
queryset = None model = None
form = None form = None
template_name = 'dcim/bulk_rename.html' template_name = 'dcim/bulk_rename.html'
def post(self, request): def post(self, request):
model = self.queryset.model
return_url = request.GET.get('return_url') return_url = request.GET.get('return_url')
if not return_url or not is_safe_url(url=return_url, host=request.get_host()): if not return_url or not is_safe_url(url=return_url, host=request.get_host()):
return_url = 'home' return_url = 'home'
if '_preview' in request.POST or '_apply' in request.POST: if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk']) selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
if form.is_valid(): if form.is_valid():
for obj in selected_objects: for obj in selected_objects:
@@ -67,17 +65,17 @@ class BulkRenameView(View):
obj.save() obj.save()
messages.success(request, "Renamed {} {}".format( messages.success(request, "Renamed {} {}".format(
len(selected_objects), len(selected_objects),
model._meta.verbose_name_plural self.model._meta.verbose_name_plural
)) ))
return redirect(return_url) return redirect(return_url)
else: else:
form = self.form(initial={'pk': request.POST.getlist('pk')}) form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk']) selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
'obj_type_plural': model._meta.verbose_name_plural, 'obj_type_plural': self.model._meta.verbose_name_plural,
'selected_objects': selected_objects, 'selected_objects': selected_objects,
'return_url': return_url, 'return_url': return_url,
}) })
@@ -157,7 +155,6 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region' permission_required = 'dcim.delete_region'
cls = Region cls = Region
queryset = Region.objects.annotate(site_count=Count('sites')) queryset = Region.objects.annotate(site_count=Count('sites'))
filter = filters.RegionFilter
table = tables.RegionTable table = tables.RegionTable
default_return_url = 'dcim:region_list' default_return_url = 'dcim:region_list'
@@ -492,7 +489,6 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation' permission_required = 'dcim.delete_rackreservation'
cls = RackReservation cls = RackReservation
filter = filters.RackReservationFilter
table = tables.RackReservationTable table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list' default_return_url = 'dcim:rackreservation_list'
@@ -861,7 +857,7 @@ class DeviceView(View):
# VirtualChassis members # VirtualChassis members
if device.virtual_chassis is not None: if device.virtual_chassis is not None:
vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis).order_by('vc_position') vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis)
else: else:
vc_members = [] vc_members = []
@@ -966,9 +962,11 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
interfaces = device.vc_interfaces.order_naturally( interfaces = Interface.objects.order_naturally(
device.device_type.interface_ordering device.device_type.interface_ordering
).connectable().select_related( ).connectable().filter(
device=device
).select_related(
'connected_as_a', 'connected_as_b' 'connected_as_a', 'connected_as_b'
) )
@@ -1320,7 +1318,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_consoleserverport' permission_required = 'dcim.change_consoleserverport'
queryset = ConsoleServerPort.objects.all() model = ConsoleServerPort
form = forms.ConsoleServerPortBulkRenameForm form = forms.ConsoleServerPortBulkRenameForm
@@ -1604,7 +1602,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_poweroutlet' permission_required = 'dcim.change_poweroutlet'
queryset = PowerOutlet.objects.all() model = PowerOutlet
form = forms.PowerOutletBulkRenameForm form = forms.PowerOutletBulkRenameForm
@@ -1647,12 +1645,6 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
template_name = 'dcim/interface_edit.html' template_name = 'dcim/interface_edit.html'
class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface'
model = Interface
model_form = forms.InterfaceAssignVLANsForm
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interface' permission_required = 'dcim.delete_interface'
model = Interface model = Interface
@@ -1680,7 +1672,7 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_interface' permission_required = 'dcim.change_interface'
queryset = Interface.objects.order_naturally() model = Interface
form = forms.InterfaceBulkRenameForm form = forms.InterfaceBulkRenameForm
@@ -1787,7 +1779,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_devicebay' permission_required = 'dcim.change_devicebay'
queryset = DeviceBay.objects.all() model = DeviceBay
form = forms.DeviceBayBulkRenameForm form = forms.DeviceBayBulkRenameForm
@@ -2075,10 +2067,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class VirtualChassisListView(ObjectListView): class VirtualChassisListView(ObjectListView):
queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')) queryset = VirtualChassis.objects.annotate(member_count=Count('members'))
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filter = filters.VirtualChassisFilter
filter_form = forms.VirtualChassisFilterForm
template_name = 'dcim/virtualchassis_list.html' template_name = 'dcim/virtualchassis_list.html'
@@ -2090,25 +2080,20 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
# Get the list of devices being added to a VirtualChassis # Get the list of devices being added to a VirtualChassis
pk_form = forms.DeviceSelectionForm(request.POST) pk_form = forms.DeviceSelectionForm(request.POST)
pk_form.full_clean() pk_form.full_clean()
if not pk_form.cleaned_data.get('pk'): device_list = pk_form.cleaned_data.get('pk')
if not device_list:
messages.warning(request, "No devices were selected.") messages.warning(request, "No devices were selected.")
return redirect('dcim:device_list') return redirect('dcim:device_list')
device_queryset = Device.objects.filter(
pk__in=pk_form.cleaned_data.get('pk')
).select_related('rack').order_by('vc_position')
VCMemberFormSet = modelformset_factory( # TODO: Error if any of the devices already belong to a VC
model=Device,
formset=forms.BaseVCMemberFormSet, VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
form=forms.DeviceVCMembershipForm,
extra=0
)
if '_create' in request.POST: if '_create' in request.POST:
vc_form = forms.VirtualChassisForm(request.POST) vc_form = forms.VirtualChassisForm(request.POST)
vc_form.fields['master'].queryset = device_queryset formset = VCMemberFormSet(request.POST)
formset = VCMemberFormSet(request.POST, queryset=device_queryset)
if vc_form.is_valid() and formset.is_valid(): if vc_form.is_valid() and formset.is_valid():
@@ -2126,8 +2111,8 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
else: else:
vc_form = forms.VirtualChassisForm() vc_form = forms.VirtualChassisForm()
vc_form.fields['master'].queryset = device_queryset vc_form.fields['master'].queryset = Device.objects.filter(pk__in=device_list)
formset = VCMemberFormSet(queryset=device_queryset) formset = VCMemberFormSet(queryset=Device.objects.filter(pk__in=device_list))
return render(request, 'dcim/virtualchassis_edit.html', { return render(request, 'dcim/virtualchassis_edit.html', {
'pk_form': pk_form, 'pk_form': pk_form,
@@ -2143,17 +2128,11 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
def get(self, request, pk): def get(self, request, pk):
virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
VCMemberFormSet = modelformset_factory( VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
model=Device,
form=forms.DeviceVCMembershipForm,
formset=forms.BaseVCMemberFormSet,
extra=0
)
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
vc_form = forms.VirtualChassisForm(instance=virtual_chassis) vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset vc_form.fields['master'].queryset = virtual_chassis.members.all()
formset = VCMemberFormSet(queryset=members_queryset) formset = VCMemberFormSet(queryset=virtual_chassis.members.all())
return render(request, 'dcim/virtualchassis_edit.html', { return render(request, 'dcim/virtualchassis_edit.html', {
'vc_form': vc_form, 'vc_form': vc_form,
@@ -2164,17 +2143,11 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
def post(self, request, pk): def post(self, request, pk):
virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
VCMemberFormSet = modelformset_factory( VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
model=Device,
form=forms.DeviceVCMembershipForm,
formset=forms.BaseVCMemberFormSet,
extra=0
)
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis) vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset vc_form.fields['master'].queryset = virtual_chassis.members.all()
formset = VCMemberFormSet(request.POST, queryset=members_queryset) formset = VCMemberFormSet(request.POST, queryset=virtual_chassis.members.all())
if vc_form.is_valid() and formset.is_valid(): if vc_form.is_valid() and formset.is_valid():
@@ -2234,7 +2207,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
device = member_select_form.cleaned_data['device'] device = member_select_form.cleaned_data['device']
device.virtual_chassis = virtual_chassis device.virtual_chassis = virtual_chassis
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']} data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device) membership_form = forms.DeviceVCMembershipForm(data, instance=device)
if membership_form.is_valid(): if membership_form.is_valid():
@@ -2250,7 +2223,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
else: else:
membership_form = forms.DeviceVCMembershipForm(data=request.POST) membership_form = forms.DeviceVCMembershipForm(request.POST)
return render(request, 'dcim/virtualchassis_add_member.html', { return render(request, 'dcim/virtualchassis_add_member.html', {
'virtual_chassis': virtual_chassis, 'virtual_chassis': virtual_chassis,

View File

@@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
@admin.register(CustomField) @admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin): class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin] inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description'] list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
form = CustomFieldForm form = CustomFieldForm
def models(self, obj): def models(self, obj):

View File

@@ -99,7 +99,7 @@ class TopologyMapViewSet(ModelViewSet):
try: try:
data = tmap.render(img_format=img_format) data = tmap.render(img_format=img_format)
except Exception: except:
return HttpResponse( return HttpResponse(
"There was an error generating the requested graph. Ensure that the GraphViz executables have been " "There was an error generating the requested graph. Ensure that the GraphViz executables have been "
"installed correctly." "installed correctly."

View File

@@ -26,16 +26,6 @@ CUSTOMFIELD_TYPE_CHOICES = (
(CF_TYPE_SELECT, 'Selection'), (CF_TYPE_SELECT, 'Selection'),
) )
# Custom field filter logic choices
CF_FILTER_DISABLED = 0
CF_FILTER_LOOSE = 1
CF_FILTER_EXACT = 2
CF_FILTER_CHOICES = (
(CF_FILTER_DISABLED, 'Disabled'),
(CF_FILTER_LOOSE, 'Loose'),
(CF_FILTER_EXACT, 'Exact'),
)
# Graph types # Graph types
GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200 GRAPH_TYPE_PROVIDER = 200
@@ -56,16 +46,6 @@ EXPORTTEMPLATE_MODELS = [
'cluster', 'virtualmachine', # Virtualization 'cluster', 'virtualmachine', # Virtualization
] ]
# Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2
TOPOLOGYMAP_TYPE_POWER = 3
TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
)
# User action types # User action types
ACTION_CREATE = 1 ACTION_CREATE = 1
ACTION_IMPORT = 2 ACTION_IMPORT = 2

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from dcim.models import Site from dcim.models import Site
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT from .constants import CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
@@ -14,9 +14,8 @@ class CustomFieldFilter(django_filters.Filter):
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
""" """
def __init__(self, custom_field, *args, **kwargs): def __init__(self, cf_type, *args, **kwargs):
self.cf_type = custom_field.type self.cf_type = cf_type
self.filter_logic = custom_field.filter_logic
super(CustomFieldFilter, self).__init__(*args, **kwargs) super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value): def filter(self, queryset, value):
@@ -42,19 +41,10 @@ class CustomFieldFilter(django_filters.Filter):
except ValueError: except ValueError:
return queryset.none() return queryset.none()
# Apply the assigned filter logic (exact or loose) return queryset.filter(
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT: custom_field_values__field__name=self.name,
queryset = queryset.filter( custom_field_values__serialized_value__icontains=value,
custom_field_values__field__name=self.name, )
custom_field_values__serialized_value=value
)
else:
queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value__icontains=value
)
return queryset
class CustomFieldFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(django_filters.FilterSet):
@@ -66,9 +56,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
super(CustomFieldFilterSet, self).__init__(*args, **kwargs) super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
obj_type = ContentType.objects.get_for_model(self._meta.model) obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED) custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
for cf in custom_fields: for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf) self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
class GraphFilter(django_filters.FilterSet): class GraphFilter(django_filters.FilterSet):

View File

@@ -4,10 +4,9 @@ from collections import OrderedDict
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .models import CustomField, CustomFieldValue, ImageAttachment from .models import CustomField, CustomFieldValue, ImageAttachment
@@ -16,9 +15,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
Retrieve all CustomFields applicable to the given ContentType Retrieve all CustomFields applicable to the given ContentType
""" """
field_dict = OrderedDict() field_dict = OrderedDict()
custom_fields = CustomField.objects.filter(obj_type=content_type) kwargs = {'obj_type': content_type}
if filterable_only: if filterable_only:
custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED) kwargs['is_filterable'] = True
custom_fields = CustomField.objects.filter(**kwargs)
for cf in custom_fields: for cf in custom_fields:
field_name = 'cf_{}'.format(str(cf.name)) field_name = 'cf_{}'.format(str(cf.name))
@@ -35,9 +35,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(1, 'True'), (1, 'True'),
(0, 'False'), (0, 'False'),
) )
if initial is not None and initial.lower() in ['true', 'yes', '1']: if initial.lower() in ['true', 'yes', '1']:
initial = 1 initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']: elif initial.lower() in ['false', 'no', '0']:
initial = 0 initial = 0
else: else:
initial = None initial = None
@@ -54,14 +54,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required or bulk_edit or filterable_only: if not cf.required or bulk_edit or filterable_only:
choices = [(None, '---------')] + choices choices = [(None, '---------')] + choices
# Check for a default choice field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
default_choice = None
if initial:
try:
default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist:
pass
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
# URL # URL
elif cf.type == CF_TYPE_URL: elif cf.type == CF_TYPE_URL:

View File

@@ -4,6 +4,14 @@ from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
from extras.models import TopologyMap
def commas_to_semicolons(apps, schema_editor):
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
tm.device_patterns = tm.device_patterns.replace(',', ';')
tm.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -17,4 +25,5 @@ class Migration(migrations.Migration):
name='device_patterns', name='device_patterns',
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'), field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
), ),
migrations.RunPython(commas_to_semicolons),
] ]

View File

@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='customfield', model_name='customfield',
name='default', name='default',
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100), field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
), ),
migrations.AlterField( migrations.AlterField(
model_name='customfield', model_name='customfield',

View File

@@ -19,7 +19,7 @@ def verify_postgresql_version(apps, schema_editor):
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()") cursor.execute("SELECT VERSION()")
row = cursor.fetchone() row = cursor.fetchone()
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1) pg_version = re.match('^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
if StrictVersion(pg_version) < StrictVersion('9.4.0'): if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-15 16:28
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0008_reports'),
]
operations = [
migrations.AddField(
model_name='topologymap',
name='type',
field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
),
]

View File

@@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-21 19:48
from __future__ import unicode_literals
from django.db import migrations, models
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
def is_filterable_to_filter_logic(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
# Select fields match on primary key only
CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
def filter_logic_to_is_filterable(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
class Migration(migrations.Migration):
dependencies = [
('extras', '0009_topologymap_type'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='filter_logic',
field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
),
migrations.AlterField(
model_name='customfield',
name='required',
field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
),
migrations.AlterField(
model_name='customfield',
name='weight',
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
),
migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
migrations.RemoveField(
model_name='customfield',
name='is_filterable',
),
]

View File

@@ -16,7 +16,6 @@ from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import foreground_color from utilities.utils import foreground_color
from .constants import * from .constants import *
@@ -55,48 +54,22 @@ class CustomFieldModel(object):
@python_2_unicode_compatible @python_2_unicode_compatible
class CustomField(models.Model): class CustomField(models.Model):
obj_type = models.ManyToManyField( obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
to=ContentType, limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
related_name='custom_fields', help_text="The object(s) to which this field applies.")
verbose_name='Object(s)', type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, name = models.CharField(max_length=50, unique=True)
help_text='The object(s) to which this field applies.' label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
) "provided, the field's name will be used)")
type = models.PositiveSmallIntegerField( description = models.CharField(max_length=100, blank=True)
choices=CUSTOMFIELD_TYPE_CHOICES, required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
default=CF_TYPE_TEXT "new objects or editing an existing object.")
) is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
name = models.CharField( default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
max_length=50, "\"false\" for booleans. N/A for selection "
unique=True "fields.")
) weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
label = models.CharField( "form")
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
)
description = models.CharField(
max_length=100,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects or editing an existing object.'
)
filter_logic = models.PositiveSmallIntegerField(
choices=CF_FILTER_CHOICES,
default=CF_FILTER_LOOSE,
help_text="Loose matches any instance of a given string; exact matches the entire field."
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
class Meta: class Meta:
ordering = ['weight', 'name'] ordering = ['weight', 'name']
@@ -127,7 +100,7 @@ class CustomField(models.Model):
""" """
Convert a string into the object it represents depending on the type of field Convert a string into the object it represents depending on the type of field
""" """
if serialized_value == '': if serialized_value is '':
return None return None
if self.type == CF_TYPE_INTEGER: if self.type == CF_TYPE_INTEGER:
return int(serialized_value) return int(serialized_value)
@@ -280,17 +253,7 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model): class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
type = models.PositiveSmallIntegerField( site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK
)
site = models.ForeignKey(
to='dcim.Site',
related_name='topology_maps',
blank=True,
null=True,
on_delete=models.CASCADE
)
device_patterns = models.TextField( device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. " "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -312,26 +275,22 @@ class TopologyMap(models.Model):
def render(self, img_format='png'): def render(self, img_format='png'):
from dcim.models import Device from circuits.models import CircuitTermination
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
# Construct the graph # Construct the graph
if self.type == TOPOLOGYMAP_TYPE_NETWORK: graph = graphviz.Graph()
G = graphviz.Graph graph.graph_attr['ranksep'] = '1'
else:
G = graphviz.Digraph
self.graph = G()
self.graph.graph_attr['ranksep'] = '1'
seen = set() seen = set()
for i, device_set in enumerate(self.device_sets): for i, device_set in enumerate(self.device_sets):
subgraph = G(name='sg{}'.format(i)) subgraph = graphviz.Graph(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same' subgraph.graph_attr['rank'] = 'same'
subgraph.graph_attr['directed'] = 'true'
# Add a pseudonode for each device_set to enforce hierarchical layout # Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.node('set{}'.format(i), label='', shape='none', width='0') subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i: if i:
self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis') graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph # Add each device to the graph
devices = [] devices = []
@@ -349,64 +308,31 @@ class TopologyMap(models.Model):
for j in range(0, len(devices) - 1): for j in range(0, len(devices) - 1):
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
self.graph.subgraph(subgraph) graph.subgraph(subgraph)
# Compile list of all devices # Compile list of all devices
device_superset = Q() device_superset = Q()
for device_set in self.device_sets: for device_set in self.device_sets:
for query in device_set.split(';'): # Split regexes on semicolons for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query) device_superset = device_superset | Q(name__regex=query)
devices = Device.objects.filter(*(device_superset,))
# Draw edges depending on graph type
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
self.add_network_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
self.add_console_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_POWER:
self.add_power_connections(devices)
return self.graph.pipe(format=img_format)
def add_network_connections(self, devices):
from circuits.models import CircuitTermination
from dcim.models import InterfaceConnection
# Add all interface connections to the graph # Add all interface connections to the graph
devices = Device.objects.filter(*(device_superset,))
connections = InterfaceConnection.objects.filter( connections = InterfaceConnection.objects.filter(
interface_a__device__in=devices, interface_b__device__in=devices interface_a__device__in=devices, interface_b__device__in=devices
) )
for c in connections: for c in connections:
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style) graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph # Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination() peer_termination = termination.get_peer_termination()
if (peer_termination is not None and peer_termination.interface is not None and if (peer_termination is not None and peer_termination.interface is not None and
peer_termination.interface.device in devices): peer_termination.interface.device in devices):
self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue') graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
def add_console_connections(self, devices): return graph.pipe(format=img_format)
from dcim.models import ConsolePort
# Add all console connections to the graph
console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
for cp in console_ports:
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
def add_power_connections(self, devices):
from dcim.models import PowerPort
# Add all power connections to the graph
power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
for pp in power_ports:
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
# #

View File

@@ -163,8 +163,8 @@ class IOSSSH(SSHClient):
sh_ver = self._send('show version').split('\r\n') sh_ver = self._send('show version').split('\r\n')
return { return {
'serial': parse(sh_ver, r'Processor board ID ([^\s]+)'), 'serial': parse(sh_ver, 'Processor board ID ([^\s]+)'),
'description': parse(sh_ver, r'cisco ([^\s]+)') 'description': parse(sh_ver, 'cisco ([^\s]+)')
} }
def items(chassis_serial=None): def items(chassis_serial=None):
@@ -172,9 +172,9 @@ class IOSSSH(SSHClient):
for i in cmd: for i in cmd:
i_fmt = i.replace('\r\n', ' ') i_fmt = i.replace('\r\n', ' ')
try: try:
m_name = re.search(r'NAME: "([^"]+)"', i_fmt).group(1) m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1)
m_pid = re.search(r'PID: ([^\s]+)', i_fmt).group(1) m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1)
m_serial = re.search(r'SN: ([^\s]+)', i_fmt).group(1) m_serial = re.search('SN: ([^\s]+)', i_fmt).group(1)
# Omit built-in items and those with no PID # Omit built-in items and those with no PID
if m_serial != chassis_serial and m_pid.lower() != 'unspecified': if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
yield { yield {
@@ -208,7 +208,7 @@ class OpengearSSH(SSHClient):
try: try:
stdin, stdout, stderr = self.ssh.exec_command("showserial") stdin, stdout, stderr = self.ssh.exec_command("showserial")
serial = stdout.readlines()[0].strip() serial = stdout.readlines()[0].strip()
except Exception: except:
raise RuntimeError("Failed to glean chassis serial from device.") raise RuntimeError("Failed to glean chassis serial from device.")
# Older models don't provide serial info # Older models don't provide serial info
if serial == "No serial number information available": if serial == "No serial number information available":
@@ -217,7 +217,7 @@ class OpengearSSH(SSHClient):
try: try:
stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model") stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
description = stdout.readlines()[0].split(' ', 1)[1].strip() description = stdout.readlines()[0].split(' ', 1)[1].strip()
except Exception: except:
raise RuntimeError("Failed to glean chassis description from device.") raise RuntimeError("Failed to glean chassis description from device.")
return { return {

View File

@@ -4,7 +4,7 @@ from django.conf import settings
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
@@ -98,31 +98,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_prefixes = request.data if isinstance(request.data, list) else [request.data] requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
# Allocate prefixes to the requested objects based on availability within the parent # Allocate prefixes to the requested objects based on availability within the parent
for i, requested_prefix in enumerate(requested_prefixes): for requested_prefix in requested_prefixes:
# Validate requested prefix size
error_msg = None
if 'prefix_length' not in requested_prefix:
error_msg = "Item {}: prefix_length field missing".format(i)
elif not isinstance(requested_prefix['prefix_length'], int):
error_msg = "Item {}: Invalid prefix length ({})".format(
i, requested_prefix['prefix_length']
)
elif prefix.family == 4 and requested_prefix['prefix_length'] > 32:
error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format(
i, requested_prefix['prefix_length']
)
elif prefix.family == 6 and requested_prefix['prefix_length'] > 128:
error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format(
i, requested_prefix['prefix_length']
)
if error_msg:
return Response(
{
"detail": error_msg
},
status=status.HTTP_400_BAD_REQUEST
)
# Find the first available prefix equal to or larger than the requested size # Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs(): for available_prefix in available_prefixes.iter_cidrs():
@@ -184,8 +160,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_ips = request.data if isinstance(request.data, list) else [request.data] requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available # Determine if the requested number of IPs is available
available_ips = prefix.get_available_ips() available_ips = list(prefix.get_available_ips())
if available_ips.size < len(requested_ips): if len(available_ips) < len(requested_ips):
return Response( return Response(
{ {
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} " "detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
@@ -195,9 +171,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
) )
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
available_ips = iter(available_ips)
for requested_ip in requested_ips: for requested_ip in requested_ips:
requested_ip['address'] = next(available_ips) requested_ip['address'] = available_ips.pop(0)
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested # Initialize the serializer with a list or a single object depending on what was requested

View File

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from netaddr import AddrFormatError, IPNetwork from netaddr import IPNetwork
from .formfields import IPFormField from .formfields import IPFormField
from . import lookups from . import lookups
@@ -26,9 +26,7 @@ class BaseIPField(models.Field):
return value return value
try: try:
return IPNetwork(value) return IPNetwork(value)
except AddrFormatError as e: except ValueError as e:
raise ValidationError("Invalid IP address format: {}".format(value))
except (TypeError, ValueError) as e:
raise ValidationError(e) raise ValidationError(e)
def get_prep_value(self, value): def get_prep_value(self, value):

View File

@@ -1,7 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import django_filters import django_filters
from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
import netaddr import netaddr
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@@ -234,10 +233,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search_by_parent', method='search_by_parent',
label='Parent prefix', label='Parent prefix',
) )
address = django_filters.CharFilter(
method='filter_address',
label='Address',
)
mask_length = django_filters.NumberFilter( mask_length = django_filters.NumberFilter(
method='filter_mask_length', method='filter_mask_length',
label='Mask length', label='Mask length',
@@ -318,17 +313,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
return queryset.none() return queryset.none()
def filter_address(self, queryset, name, value):
if not value.strip():
return queryset
try:
# Match address and subnet mask
if '/' in value:
return queryset.filter(address=value)
return queryset.filter(address__net_host=value)
except ValidationError:
return queryset.none()
def filter_mask_length(self, queryset, name, value): def filter_mask_length(self, queryset, name, value):
if not value: if not value:
return queryset return queryset

View File

@@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
SlugField, add_blank_choice, add_blank_choice,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
@@ -350,6 +350,13 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description'] nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
def prefix_status_choices():
status_counts = {}
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix model = Prefix
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
@@ -369,12 +376,7 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug', to_field_name='slug',
null_label='-- None --' null_label='-- None --'
) )
status = AnnotatedMultipleChoiceField( status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
choices=PREFIX_STATUS_CHOICES,
annotate=Prefix.objects.all(),
annotate_field='status',
required=False
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('prefixes')), queryset=Site.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug', to_field_name='slug',
@@ -508,7 +510,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
ipaddress = super(IPAddressForm, self).save(*args, **kwargs) ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. # Assign this IPAddress as the primary for the associated Device.
if self.cleaned_data['primary_for_parent']: if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
@@ -516,14 +518,19 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
else: else:
parent.primary_ip6 = ipaddress parent.primary_ip6 = ipaddress
parent.save() parent.save()
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent # Clear assignment as primary for device if set.
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: else:
parent.primary_ip4 = None try:
parent.save() if ipaddress.address.version == 4:
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: device = ipaddress.primary_ip4_for
parent.primary_ip6 = None device.primary_ip4 = None
parent.save() else:
device = ipaddress.primary_ip6_for
device.primary_ip6 = None
device.save()
except Device.DoesNotExist:
pass
return ipaddress return ipaddress
@@ -684,6 +691,20 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
address = forms.CharField(label='IP Address') address = forms.CharField(label='IP Address')
def ipaddress_status_choices():
status_counts = {}
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
def ipaddress_role_choices():
role_counts = {}
for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
role_counts[role['role']] = role['count']
return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress model = IPAddress
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
@@ -703,18 +724,8 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug', to_field_name='slug',
null_label='-- None --' null_label='-- None --'
) )
status = AnnotatedMultipleChoiceField( status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
choices=IPADDRESS_STATUS_CHOICES, role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
annotate=IPAddress.objects.all(),
annotate_field='status',
required=False
)
role = AnnotatedMultipleChoiceField(
choices=IPADDRESS_ROLE_CHOICES,
annotate=IPAddress.objects.all(),
annotate_field='role',
required=False
)
# #
@@ -870,6 +881,13 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
def vlan_status_choices():
status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN model = VLAN
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
@@ -888,12 +906,7 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug', to_field_name='slug',
null_label='-- None --' null_label='-- None --'
) )
status = AnnotatedMultipleChoiceField( status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
choices=VLAN_STATUS_CHOICES,
annotate=VLAN.objects.all(),
annotate_field='status',
required=False
)
role = FilterChoiceField( role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('vlans')), queryset=Role.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug', to_field_name='slug',

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-07 18:37
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0020_ipaddress_add_role_carp'),
]
operations = [
migrations.AlterModelOptions(
name='vrf',
options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
),
]

View File

@@ -6,7 +6,6 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
@@ -38,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta: class Meta:
ordering = ['name', 'rd'] ordering = ['name']
verbose_name = 'VRF' verbose_name = 'VRF'
verbose_name_plural = 'VRFs' verbose_name_plural = 'VRFs'
@@ -366,8 +365,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
return int(float(child_prefixes.size) / self.prefix.size * 100) return int(float(child_prefixes.size) / self.prefix.size * 100)
else: else:
# Compile an IPSet to avoid counting duplicate IPs child_count = self.get_child_ips().count()
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
prefix_size = self.prefix.size prefix_size = self.prefix.size
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool: if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
prefix_size -= 2 prefix_size -= 2
@@ -617,13 +615,6 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def get_status_class(self): def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status] return STATUS_CHOICE_CLASSES[self.status]
def get_members(self):
# Return all interfaces assigned to this VLAN
return Interface.objects.filter(
Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk)
)
@python_2_unicode_compatible @python_2_unicode_compatible
class Service(CreatedUpdatedModel): class Service(CreatedUpdatedModel):

View File

@@ -3,7 +3,6 @@ from __future__ import unicode_literals
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ToggleColumn from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -139,18 +138,6 @@ VLANGROUP_ACTIONS = """
{% endif %} {% endif %}
""" """
VLAN_MEMBER_UNTAGGED = """
{% if record.untagged_vlan_id == vlan.pk %}
<i class="glyphicon glyphicon-ok">
{% endif %}
"""
VLAN_MEMBER_ACTIONS = """
{% if perms.dcim.change_interface %}
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
{% endif %}
"""
TENANT_LINK = """ TENANT_LINK = """
{% if record.tenant %} {% if record.tenant %}
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a> <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
@@ -329,7 +316,7 @@ class IPAddressAssignTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface')
orderable = False orderable = False
@@ -374,21 +361,3 @@ class VLANDetailTable(VLANTable):
class Meta(VLANTable.Meta): class Meta(VLANTable.Meta):
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANMemberTable(BaseTable):
parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
name = tables.Column(verbose_name='Interface')
untagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_UNTAGGED,
orderable=False
)
actions = tables.TemplateColumn(
template_code=VLAN_MEMBER_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = Interface
fields = ('parent', 'name', 'untagged', 'actions')

View File

@@ -80,7 +80,6 @@ urlpatterns = [
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'), url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),

View File

@@ -729,8 +729,8 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
).filter( ).filter(
vrf=form.cleaned_data['vrf'], vrf=form.cleaned_data['vrf'],
address__istartswith=form.cleaned_data['address'], address__net_host=form.cleaned_data['address'],
)[:100] # Limit to 100 results )
table = tables.IPAddressAssignTable(queryset) table = tables.IPAddressAssignTable(queryset)
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {
@@ -851,38 +851,6 @@ class VLANView(View):
}) })
class VLANMembersView(View):
def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
members = vlan.get_members().select_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members)
# if request.user.has_perm('dcim.change_interface'):
# members_table.columns.show('pk')
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(members_table)
# Compile permissions list for rendering the object table
# permissions = {
# 'add': request.user.has_perm('ipam.add_ipaddress'),
# 'change': request.user.has_perm('ipam.change_ipaddress'),
# 'delete': request.user.has_perm('ipam.delete_ipaddress'),
# }
return render(request, 'ipam/vlan_members.html', {
'vlan': vlan,
'members_table': members_table,
# 'permissions': permissions,
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
})
class VLANCreateView(PermissionRequiredMixin, ObjectEditView): class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlan' permission_required = 'ipam.add_vlan'
model = VLAN model = VLAN

View File

@@ -15,7 +15,6 @@ OBJ_TYPE_CHOICES = (
('rack', 'Racks'), ('rack', 'Racks'),
('devicetype', 'Device types'), ('devicetype', 'Device types'),
('device', 'Devices'), ('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'),
)), )),
('IPAM', ( ('IPAM', (
('vrf', 'VRFs'), ('vrf', 'VRFs'),

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.3.6' VERSION = '2.3.0-beta2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -133,6 +133,7 @@ INSTALLED_APPS = (
'django_tables2', 'django_tables2',
'mptt', 'mptt',
'rest_framework', 'rest_framework',
'rest_framework_swagger',
'timezone_field', 'timezone_field',
'circuits', 'circuits',
'dcim', 'dcim',
@@ -143,7 +144,6 @@ INSTALLED_APPS = (
'users', 'users',
'utilities', 'utilities',
'virtualization', 'virtualization',
'drf_yasg',
) )
# Middleware # Middleware
@@ -246,40 +246,6 @@ REST_FRAMEWORK = {
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
} }
# drf_yasg settings for Swagger
SWAGGER_SETTINGS = {
'DEFAULT_FIELD_INSPECTORS': [
'utilities.custom_inspectors.NullableBooleanFieldInspector',
'utilities.custom_inspectors.CustomChoiceFieldInspector',
'drf_yasg.inspectors.CamelCaseJSONFilter',
'drf_yasg.inspectors.ReferencingSerializerInspector',
'drf_yasg.inspectors.RelatedFieldInspector',
'drf_yasg.inspectors.ChoiceFieldInspector',
'drf_yasg.inspectors.FileFieldInspector',
'drf_yasg.inspectors.DictFieldInspector',
'drf_yasg.inspectors.SimpleFieldInspector',
'drf_yasg.inspectors.StringDefaultFieldInspector',
],
'DEFAULT_FILTER_INSPECTORS': [
'utilities.custom_inspectors.IdInFilterInspector',
'drf_yasg.inspectors.CoreAPICompatInspector',
],
'DEFAULT_PAGINATOR_INSPECTORS': [
'utilities.custom_inspectors.NullablePaginatorInspector',
'drf_yasg.inspectors.DjangoRestResponsePagination',
'drf_yasg.inspectors.CoreAPICompatInspector',
],
'SECURITY_DEFINITIONS': {
'Bearer': {
'type': 'apiKey',
'name': 'Authorization',
'in': 'header',
}
},
'VALIDATOR_URL': None,
}
# Django debug toolbar # Django debug toolbar
INTERNAL_IPS = ( INTERNAL_IPS = (
'127.0.0.1', '127.0.0.1',
@@ -289,5 +255,5 @@ INTERNAL_IPS = (
try: try:
HOSTNAME = socket.gethostname() HOSTNAME = socket.gethostname()
except Exception: except:
HOSTNAME = 'localhost' HOSTNAME = 'localhost'

View File

@@ -4,24 +4,12 @@ from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from django.views.static import serve from django.views.static import serve
from drf_yasg.views import get_schema_view from rest_framework_swagger.views import get_swagger_view
from drf_yasg import openapi
from netbox.views import APIRootView, HomeView, SearchView from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView from users.views import LoginView, LogoutView
schema_view = get_schema_view( swagger_view = get_swagger_view(title='NetBox API')
openapi.Info(
title="NetBox API",
default_version='v2',
description="API to access NetBox",
terms_of_service="https://github.com/digitalocean/netbox",
contact=openapi.Contact(email="netbox@digitalocean.com"),
license=openapi.License(name="Apache v2 License"),
),
validators=['flex', 'ssv'],
public=True,
)
_patterns = [ _patterns = [
@@ -52,9 +40,7 @@ _patterns = [
url(r'^api/secrets/', include('secrets.api.urls')), url(r'^api/secrets/', include('secrets.api.urls')),
url(r'^api/tenancy/', include('tenancy.api.urls')), url(r'^api/tenancy/', include('tenancy.api.urls')),
url(r'^api/virtualization/', include('virtualization.api.urls')), url(r'^api/virtualization/', include('virtualization.api.urls')),
url(r'^api/docs/$', schema_view.with_ui('swagger'), name='api_docs'), url(r'^api/docs/', swagger_view, name='api_docs'),
url(r'^api/redoc/$', schema_view.with_ui('redoc'), name='api_redocs'),
url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware # Serving static media in Django to pipe it through LoginRequiredMiddleware
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}), url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),

View File

@@ -12,9 +12,9 @@ from rest_framework.views import APIView
from circuits.filters import CircuitFilter, ProviderFilter from circuits.filters import CircuitFilter, ProviderFilter
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable
from extras.models import ReportResult, TopologyMap, UserAction from extras.models import ReportResult, TopologyMap, UserAction
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -72,12 +72,6 @@ SEARCH_TYPES = OrderedDict((
'table': DeviceDetailTable, 'table': DeviceDetailTable,
'url': 'dcim:device_list', 'url': 'dcim:device_list',
}), }),
('virtualchassis', {
'queryset': VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')),
'filter': VirtualChassisFilter,
'table': VirtualChassisTable,
'url': 'dcim:virtualchassis_list',
}),
# IPAM # IPAM
('vrf', { ('vrf', {
'queryset': VRF.objects.select_related('tenant'), 'queryset': VRF.objects.select_related('tenant'),

View File

@@ -26,7 +26,7 @@ def validate_rsa_key(key, is_secret=True):
raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.") raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.")
try: try:
PKCS1_OAEP.new(key) PKCS1_OAEP.new(key)
except Exception: except:
raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.") raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.")
@@ -58,34 +58,17 @@ class SecretRoleCSVForm(forms.ModelForm):
# #
class SecretForm(BootstrapMixin, forms.ModelForm): class SecretForm(BootstrapMixin, forms.ModelForm):
plaintext = forms.CharField( plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
max_length=65535, widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
required=False, plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
label='Plaintext', widget=forms.PasswordInput())
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})
)
plaintext2 = forms.CharField(
max_length=65535,
required=False,
label='Plaintext (verify)',
widget=forms.PasswordInput()
)
class Meta: class Meta:
model = Secret model = Secret
fields = ['role', 'name', 'plaintext', 'plaintext2'] fields = ['role', 'name', 'plaintext', 'plaintext2']
def __init__(self, *args, **kwargs):
super(SecretForm, self).__init__(*args, **kwargs)
# A plaintext value is required when creating a new Secret
if not self.instance.pk:
self.fields['plaintext'].required = True
def clean(self): def clean(self):
# Verify that the provided plaintext values match
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
raise forms.ValidationError({ raise forms.ValidationError({
'plaintext2': "The two given plaintext values do not match. Please check your input." 'plaintext2': "The two given plaintext values do not match. Please check your input."

View File

@@ -87,7 +87,7 @@ class UserKey(CreatedUpdatedModel):
raise ValidationError({ raise ValidationError({
'public_key': "Invalid RSA key format." 'public_key': "Invalid RSA key format."
}) })
except Exception: except:
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're " raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
"uploading a valid RSA public key in PEM format (no SSH/PGP).") "uploading a valid RSA public key in PEM format (no SSH/PGP).")

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -387,7 +387,6 @@
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th> <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %} {% endif %}
<th>Name</th> <th>Name</th>
<th>Status</th>
<th colspan="2">Installed Device</th> <th colspan="2">Installed Device</th>
<th></th> <th></th>
</tr> </tr>

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -53,7 +53,7 @@ $(document).ready(function() {
success: function(json) { success: function(json) {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) { $.each(json['get_lldp_neighbors'], function(iface, neighbors) {
var neighbor = neighbors[0]; var neighbor = neighbors[0];
var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1")); var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
// Glean configured hostnames/interfaces from the DOM // Glean configured hostnames/interfaces from the DOM
var configured_device = row.children('td.configured_device').attr('data'); var configured_device = row.children('td.configured_device').attr('data');

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -44,7 +44,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -43,23 +43,17 @@
<h1>{{ device }}</h1> <h1>{{ device }}</h1>
{% include 'inc/created_updated.html' with obj=device %} {% include 'inc/created_updated.html' with obj=device %}
<ul class="nav nav-tabs" style="margin-bottom: 20px"> <ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
<a href="{% url 'dcim:device' pk=device.pk %}">Info</a> <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
</li>
{% if perms.dcim.napalm_read %} {% if perms.dcim.napalm_read %}
{% if device.status != 1 %} {% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li>
{% elif not device.platform %} <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %} <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li>
{% elif not device.platform.napalm_driver %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
{% elif not device.primary_ip %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
{% else %} {% else %}
{% include 'dcim/inc/device_napalm_tabs.html' %} <li role="presentation" class="disabled"><a href="#">Status</a></li>
<li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#">Configuration</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
</ul> </ul>

View File

@@ -1,15 +0,0 @@
{% if not disabled_message %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a>
</li>
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a>
</li>
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a>
</li>
{% else %}
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
{% endif %}

View File

@@ -8,9 +8,6 @@
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }} <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
</td> </td>
{% if devicebay.installed_device %} {% if devicebay.installed_device %}
<td>
<span class="label label-{{ devicebay.installed_device.get_status_class }}">{{ devicebay.installed_device.get_status_display }}</span>
</td>
<td> <td>
<a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a> <a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
</td> </td>
@@ -18,7 +15,6 @@
<span>{{ devicebay.installed_device.device_type.full_name }}</span> <span>{{ devicebay.installed_device.device_type.full_name }}</span>
</td> </td>
{% else %} {% else %}
<td></td>
<td colspan="2"> <td colspan="2">
<span class="text-muted">Vacant</span> <span class="text-muted">Vacant</span>
</td> </td>
@@ -44,7 +40,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -1,29 +0,0 @@
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>

View File

@@ -105,7 +105,7 @@
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected"> <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i> <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</button> </button>
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination"> <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i> <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a> </a>
{% else %} {% else %}
@@ -124,7 +124,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface"> <a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -1,55 +0,0 @@
<table class="table panel-body">
<tr>
<th>VID</th>
<th>Name</th>
<th>Untagged</th>
<th>Tagged</th>
</tr>
{% with tagged_vlans=obj.tagged_vlans.all %}
{% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %}
<tr>
<td>
<a href="{{ obj.untagged_vlan.get_absolute_url }}">{{ obj.untagged_vlan.vid }}</a>
</td>
<td>{{ obj.untagged_vlan.name }}</td>
<td>
<input type="radio" name="untagged_vlan" value="{{ obj.untagged_vlan.pk }}" checked="checked" />
</td>
<td>
<input type="checkbox" name="tagged_vlans" value="{{ obj.untagged_vlan.pk }}" />
</td>
</tr>
{% endif %}
{% for vlan in tagged_vlans %}
<tr>
<td>
<a href="{{ vlan.get_absolute_url }}">{{ vlan.vid }}</a>
</td>
<td>{{ vlan.name }}</td>
<td>
<input type="radio" name="untagged_vlan" value="{{ vlan.pk }}"{% if vlan == obj.untagged_vlan %} checked="checked"{% endif %} />
</td>
<td>
<input type="checkbox" name="tagged_vlans" value="{{ vlan.pk }}" checked="checked" />
</td>
</tr>
{% endfor %}
{% if not obj.untagged_vlan and not tagged_vlans %}
<tr>
<td colspan="4" class="text-muted text-center">
No VLANs assigned
</td>
</tr>
{% else %}
<tr>
<td colspan="2"></td>
<td>
<a href="#" id="clear_untagged_vlan" class="btn btn-warning btn-xs">Clear</a>
</td>
<td>
<a href="#" id="clear_tagged_vlans" class="btn btn-warning btn-xs">Clear All</a>
</td>
</tr>
{% endif %}
{% endwith %}
</table>

View File

@@ -11,7 +11,7 @@
<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> <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 %} {% endif %}
{% if perms.dcim.delete_inventoryitem %} {% 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> <a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Delete outlet" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}" title="Delete outlet" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -44,7 +44,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:powerport_delete' pk=pp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -26,7 +26,7 @@
<li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}> <li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
{% ifequal u.device.face face_id %} {% ifequal u.device.face face_id %}
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true" <a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}"> data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}">
{{ u.device.name|default:u.device.device_role }} {{ u.device.name|default:u.device.device_role }}
{% if u.device.devicebay_count %} {% if u.device.devicebay_count %}
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }}) ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})

View File

@@ -13,44 +13,16 @@
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.mgmt_only %} {% render_field form.mgmt_only %}
{% render_field form.description %} {% render_field form.description %}
{% render_field form.mode %}
</div> </div>
</div> </div>
{% if obj.mode %} <div class="panel panel-default">
<div class="panel panel-default" id="vlans_panel"> <div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
<div class="panel-heading"><strong>802.1Q VLANs</strong></div> <div class="panel-body">
{% include 'dcim/inc/interface_vlans_table.html' %} {% render_field form.mode %}
<div class="panel-footer text-right"> {% render_field form.site %}
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}"> {% render_field form.vlan_group %}
<i class="glyphicon glyphicon-plus"></i> Add VLANs {% render_field form.untagged_vlan %}
</a> {% render_field form.tagged_vlans %}
</div>
</div> </div>
{% endif %} </div>
{% endblock %}
{% block buttons %}
{% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button>
<button type="submit" formaction="?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
{% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
$('#clear_untagged_vlan').click(function () {
$('input[name="untagged_vlan"]').prop("checked", false);
return false;
});
$('#clear_tagged_vlans').click(function () {
$('input[name="tagged_vlans"]').prop("checked", false);
return false;
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -45,10 +45,9 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
{% include 'dcim/inc/filter_rack_group.html' %} <script type="text/javascript">
<script type="text/javascript"> $(function() {
$(function() { $('[data-toggle="popover"]').popover()
$('[data-toggle="popover"]').popover() })
}) </script>
</script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
@@ -21,6 +22,34 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
{% include 'dcim/inc/filter_rack_group.html' %} <script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,17 +1,22 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_rackrole %} {% if perms.dcim.add_rackrole %}
{% add_button 'dcim:rackrole_add' %} <a href="{% url 'dcim:rackrole_add' %}" class="btn btn-primary">
{% import_button 'dcim:rackrole_import' %} <span class="fa fa-plus" aria-hidden="true"></span>
Add a rack role
</a>
<a href="{% url 'dcim:rackrole_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import rack roles
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Rack Roles{% endblock %}</h1> <h1>{% block title %}Rack Roles{% endblock %}</h1>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %} {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -12,7 +12,6 @@
{% render_field form.facility %} {% render_field form.facility %}
{% render_field form.asn %} {% render_field form.asn %}
{% render_field form.time_zone %} {% render_field form.time_zone %}
{% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@@ -7,7 +7,7 @@
{{ pk_form.pk }} {{ pk_form.pk }}
{{ formset.management_form }} {{ formset.management_form }}
<div class="row"> <div class="row">
<div class="col-md-8 col-md-offset-2"> <div class="col-md-6 col-md-offset-3">
<h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3> <h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
{% if vc_form.non_field_errors %} {% if vc_form.non_field_errors %}
<div class="panel panel-danger"> <div class="panel panel-danger">
@@ -29,9 +29,6 @@
<thead> <thead>
<tr> <tr>
<th>Device</th> <th>Device</th>
<th>ID</th>
<th>Rack/Unit</th>
<th>Serial</th>
<th>Position</th> <th>Position</th>
<th>Priority</th> <th>Priority</th>
<th></th> <th></th>
@@ -47,33 +44,8 @@
<td> <td>
<a href="{{ device.get_absolute_url }}">{{ device }}</a> <a href="{{ device.get_absolute_url }}">{{ device }}</a>
</td> </td>
<td>{{ device.pk }}</td> <td>{{ form.vc_position }}</td>
<td> <td>{{ form.vc_priority }}</td>
{% if device.rack %}
{{ device.rack }} / {{ device.position }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if device.serial %}
{{ device.serial }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{{ form.vc_position }}
{% if form.vc_position.errors %}
<br /><small class="text-danger">{{ form.vc_position.errors.0 }}</small>
{% endif %}
</td>
<td>
{{ form.vc_priority }}
{% if form.vc_priority.errors %}
<br /><small class="text-danger">{{ form.vc_priority.errors.0 }}</small>
{% endif %}
</td>
<td> <td>
{% if virtual_chassis.pk %} {% if virtual_chassis.pk %}
<a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-xs{% if virtual_chassis.master == device %} disabled{% endif %}"> <a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-xs{% if virtual_chassis.master == device %} disabled{% endif %}">
@@ -90,7 +62,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-8 col-md-offset-2 text-right"> <div class="col-md-6 col-md-offset-3 text-right">
{% if vc_form.instance.pk %} {% if vc_form.instance.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button> <button type="submit" name="_update" class="btn btn-primary">Update</button>
{% else %} {% else %}

View File

@@ -4,11 +4,8 @@
{% block content %} {% block content %}
<h1>{% block title %}Virtual Chassis{% endblock %}</h1> <h1>{% block title %}Virtual Chassis{% endblock %}</h1>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-12">
{% include 'utilities/obj_table.html' %} {% include 'utilities/obj_table.html' %}
</div> </div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -104,7 +104,7 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/virtual-chassis,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}"> <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li class="dropdown-header">Devices</li> <li class="dropdown-header">Devices</li>
@@ -135,9 +135,6 @@
{% endif %} {% endif %}
<a href="{% url 'dcim:platform_list' %}">Platforms</a> <a href="{% url 'dcim:platform_list' %}">Platforms</a>
</li> </li>
<li>
<a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
</li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Device Types</li> <li class="dropdown-header">Device Types</li>
<li> <li>

View File

@@ -1,6 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load humanize %} {% load humanize %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,46 +0,0 @@
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
{% if vlan.site %}
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
{% endif %}
{% if vlan.group %}
<li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
{% endif %}
<li>{{ vlan }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:vlan_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search VLANs" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.ipam.change_vlan %}
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this VLAN
</a>
{% endif %}
{% if perms.ipam.delete_vlan %}
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this VLAN
</a>
{% endif %}
</div>
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vlan %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'vlan' %} class="active"{% endif %}><a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a></li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}><a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a></li>
</ul>

View File

@@ -144,7 +144,7 @@
{% if duplicate_ips_table.rows %} {% if duplicate_ips_table.rows %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %} {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %} {% endif %}
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default' %} {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -39,7 +39,7 @@
</form> </form>
{% if table %} {% if table %}
<div class="row"> <div class="row">
<div class="col-md-12" style="margin-top: 20px"> <div class="col-md-10 col-md-offset-1" style="margin-top: 20px">
<h3>Search Results</h3> <h3>Search Results</h3>
{% include 'utilities/obj_table.html' with table_template='panel_table.html' %} {% include 'utilities/obj_table.html' with table_template='panel_table.html' %}
</div> </div>

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -136,7 +136,7 @@
{% if duplicate_prefix_table.rows %} {% if duplicate_prefix_table.rows %}
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %} {% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %} {% load helpers %}
{% load form_helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,6 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load humanize %} {% load humanize %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,7 +1,48 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% block content %} {% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %} <div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
{% if vlan.site %}
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
{% endif %}
{% if vlan.group %}
<li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
{% endif %}
<li>{{ vlan }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:vlan_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search VLANs" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.ipam.change_vlan %}
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this VLAN
</a>
{% endif %}
{% if perms.ipam.delete_vlan %}
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this VLAN
</a>
{% endif %}
</div>
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vlan %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@@ -1,5 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% load form_helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,12 +0,0 @@
{% extends '_base.html' %}
{% block title %}{{ vlan }} - Members{% endblock %}
{% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load helpers %}
{% load form_helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">

Some files were not shown because too many files have changed in this diff Show More