Compare commits

...

106 Commits

Author SHA1 Message Date
Jeremy Stretch
6159994552 Merge pull request #2212 from digitalocean/develop
Release v2.3.5
2018-07-02 15:55:25 -04:00
Jeremy Stretch
398041c607 Release v2.3.5 2018-07-02 15:54:09 -04:00
Jeremy Stretch
6ce9f8f291 Merge pull request #2210 from eriktm/develop
Adding Swagger settings to describe API authentication correctly.
2018-07-02 15:50:37 -04:00
Jeremy Stretch
c2c8a139f3 Merge branch 'develop' into develop 2018-07-02 15:45:36 -04:00
Jeremy Stretch
698c0decb4 Fixes #2021: Fix recursion error when viewing API docs under Python 3.4 2018-07-02 15:25:49 -04:00
Jeremy Stretch
ef61c70a9d Fixes 2064: Disable calls to online swagger validator 2018-07-02 14:39:32 -04:00
Jeremy Stretch
97863115ba Merge pull request #2206 from abeutot/switch_to_pycodestyle
Switch to pycodestyle
2018-07-02 13:38:36 -04:00
Anaël Beutot
fa5493a5d8 Update CI to use pycostyle instead of pep8 2018-07-02 19:27:53 +02:00
Jeremy Stretch
3e9cec3e8e Closes #2159: Allow custom choice field to specify a default choice 2018-06-29 16:01:28 -04:00
Erik Hetland
943ec0b64b Adding Swagger settings to describe API authentication correctly. Fixes #1826 2018-06-29 22:01:01 +02:00
Jeremy Stretch
8008015082 Tweaked API error reporting from #2181 2018-06-29 15:18:30 -04:00
Jeremy Stretch
af54d96d30 Fixes #2181: Raise validation error on invalid prefix_length when allocating next-available prefix 2018-06-29 15:10:30 -04:00
Jeremy Stretch
d98aa03e9d Fixes #2173: Fixed IndexError when automaticating allocating IP addresses from large IPv6 prefixes 2018-06-29 14:52:37 -04:00
Jeremy Stretch
8d4c686ae2 Fixes #2192: Prevent a 0U device from being assigned to a rack position 2018-06-29 14:09:20 -04:00
Jeremy Stretch
982b9454f8 Closes #2194: Added 'address' filter to IPAddress model 2018-06-29 13:54:21 -04:00
Jeremy Stretch
28a2a37ed2 Fixes #2191: Added missing static choices to circuits and DCIM API endpoints 2018-06-29 13:17:07 -04:00
Jeremy Stretch
3f019732b3 Merge pull request #2209 from digitalocean/revert-2169-patch-1
Revert "Closes #2168: Add Extreme SummitStack interface form factors"
2018-06-29 12:19:33 -04:00
Jeremy Stretch
007852a48f Revert "Closes #2168: Add Extreme SummitStack interface form factors" 2018-06-29 12:18:49 -04:00
Jeremy Stretch
3474697a66 Merge pull request #2169 from tradiuz/patch-1
Closes #2168: Add Extreme SummitStack interface form factors
2018-06-29 12:18:37 -04:00
Anaël Beutot
4e09b32dd9 Fix pycodestyle errors
Mainly two kind of errors:
* pokemon exceptions
* invalid escape sequences
2018-06-27 17:24:33 +02:00
Jeremy Stretch
6dde0f030a Fixes #2182: ValueError raised when viewing the interface connections table 2018-06-19 13:37:12 -04:00
Jeremy Stretch
d154b4cc9e Merge pull request #2178 from chowell5/add-serial-to-bubble
Add a serial number to the popover in rack elevation number
2018-06-18 13:34:44 -04:00
Chris Howells
7c11fa7b50 Add a serial number to the popover in rack elevation number 2018-06-18 14:35:07 +01:00
tradiuz
264bf6c484 Adding SummitStack-256 2018-06-15 13:43:04 -05:00
tradiuz
3854a9d633 Changes for Issue #2168
Adding support for Extreme Networks SummitStack port types.
2018-06-14 16:59:00 -05:00
Jeremy Stretch
8bad3aee74 Post-release version bump 2018-06-07 16:22:36 -04:00
Jeremy Stretch
a1f624c1cc Merge pull request #2152 from digitalocean/develop
Release v2.3.4
2018-06-07 16:14:18 -04:00
Jeremy Stretch
ff0a0df478 Release v2.3.4 2018-06-07 15:53:05 -04:00
Jeremy Stretch
5dd2f37035 Fixes #2087: Don't overwrite existing vc_position of master device when creating a virtual chassis 2018-06-07 15:32:19 -04:00
Jeremy Stretch
862e44e96f Fixes #2148: Do not force timezone selection when editing sites in bulk 2018-06-07 14:51:27 -04:00
Jeremy Stretch
643b0eaf65 Fixes #2127: Prevent non-conntectable interfaces from being connected 2018-06-07 14:22:56 -04:00
Jeremy Stretch
0af6df3121 Fixes #2150: Fix display of LLDP neighbors when interface name contains a colon 2018-06-07 10:55:30 -04:00
Jeremy Stretch
e0616d933f Merge pull request #2144 from digitalocean/update-site-serializer
Fixes #2143 - PUTs to Site Endpoint Requires Value for time_zone
2018-06-06 11:06:51 -04:00
zmoody
1e7fdbc79a Fixes #2143 - PUTs to Site Endpoint Requires Value for time_zone
Allow null values for `time_zone` field in the writeable serializer for the sites endpoint.
2018-06-05 10:26:33 -05:00
Jeremy Stretch
1473d90243 Merge pull request #2110 from mandarg/fix-error-message
Add "does" to error messages
2018-05-24 15:19:43 -04:00
Mandar Gokhale
32eee0bede Add "does" to error messages
Those error messages looked a bit strange when I got them, hence the
fix.
2018-05-23 17:41:52 -04:00
Reimann, Timo
131436fc20 Changed upgrading documentation for ease of use 2018-05-22 16:20:10 -04:00
Jeremy Stretch
966c188977 Merge pull request #1939 from dougthor42/patch-1
Add note about copying reports to `upgrading.md`
2018-05-22 16:16:43 -04:00
Jeremy Stretch
afba80bff9 Merge pull request #2083 from Grokzen/add_rack_role_export
Add missing export button to rack roles list view.
2018-05-22 15:52:50 -04:00
Jeremy Stretch
0d267d97fe Fixes #2075: Enable tenant assignment when creating a rack reservation via the API 2018-05-22 14:09:06 -04:00
Jeremy Stretch
b0cd372af9 Fixes #2066: Catch AddrFormatError on invalid IP addresses 2018-05-22 13:56:11 -04:00
Jeremy Stretch
e5af4f6f17 Fixes #2093: Fix link to circuit termination in device interfaces table 2018-05-21 17:31:43 -04:00
Jeremy Stretch
399a633d9d Post-release version bump 2018-05-21 16:50:31 -04:00
Jeremy Stretch
2ef223b5ea Merge pull request #2099 from eriktm/2098-permission-typo
Fixing typo in permission check for ClusterView.
2018-05-21 16:20:09 -04:00
Erik Hetland
2cdb527df9 Fixing typo in permission check for ClusterView. 2018-05-19 11:50:03 +02:00
Grokzen
fc0e8e2aae Add export button to rack roles list view. 2018-05-08 16:06:53 +02:00
Jeremy Stretch
e5454d6714 Post-release version bump 2018-04-19 11:17:17 -04:00
Jeremy Stretch
328958876a Merge pull request #2041 from digitalocean/develop
Release v2.3.3
2018-04-19 11:15:48 -04:00
Jeremy Stretch
a7389de109 Release v2.3.3 2018-04-19 11:07:19 -04:00
Jeremy Stretch
b911ab01d2 Merge pull request #2038 from DirtyCajunRice/develop
stop force value split w ArrayFieldSelectMultiple. Fixes #2037
2018-04-19 10:55:25 -04:00
Nicholas St. Germain
9153c71cbf stop force value split w ArrayFieldSelectMultiple 2018-04-18 14:02:40 -05:00
Jeremy Stretch
b44aa9d32e Fixes #2014: Allow assignment of VLANs to VM interfaces via the API 2018-04-18 12:37:20 -04:00
Jeremy Stretch
bcb1d9af0b Fixes #2012: Fixed deselection of an IP address as the primary IP for its parent device/VM 2018-04-12 13:03:20 -04:00
Jeremy Stretch
ef84889a57 Fixes #2022: Show 0 for zero-value fields on CSV export 2018-04-12 12:54:21 -04:00
Jeremy Stretch
81c027e7cf Fixes #2023: Manufacturer should not be a required field when importing platforms 2018-04-12 12:45:25 -04:00
Jeremy Stretch
fd62a248ee Merge pull request #2020 from Wikia/intfix
#2019 : avoid illegal casts on large integers
2018-04-12 12:06:44 -04:00
frankfarmer
2c8bea1b59 avoid illegal casts on large integers
A similar fix was applied in e5e32d82d00e454ba5edf25316828c1cdcd7673e
2018-04-09 17:42:54 -07:00
Jeremy Stretch
07364abf9e Fixes #1988: Order interfaces naturally when bulk renaming 2018-03-29 15:15:13 -04:00
Jeremy Stretch
20cb13e1bb Fixes #1975: Correct filtering logic for custom boolean fields 2018-03-29 14:47:35 -04:00
Jeremy Stretch
3f3b385de7 Fixes #1999: Added missing description field to site edit form 2018-03-29 13:49:50 -04:00
Jeremy Stretch
94b12e506e Fixes #1993: Corrected status choices in site CSV import form 2018-03-29 09:50:29 -04:00
Jeremy Stretch
4ec6e52e73 Closes #1990: Improved search function when assigning an IP address to an interface 2018-03-29 09:45:17 -04:00
Jeremy Stretch
88adc5ca86 Post-release version bump 2018-03-22 15:06:59 -04:00
Jeremy Stretch
68f73c7f94 Merge pull request #1987 from digitalocean/develop
Release v2.3.2
2018-03-22 15:05:59 -04:00
Jeremy Stretch
223c95adbc Release v2.3.2 2018-03-22 14:59:23 -04:00
Jeremy Stretch
3aaca1ca02 Require validation dependencies when installing drf-yasg 2018-03-22 11:51:27 -04:00
Jeremy Stretch
6a4d17b8a5 Merge pull request #1985 from lampwins/docs/apache-header
added X-Forwarded-Proto header to apache config
2018-03-22 11:43:43 -04:00
Jeremy Stretch
720c5fabaf Merge pull request #1643 from RyanBreaker/wildcard
Implements #1586, add additional variants for ExpandableNameFields
2018-03-22 11:40:54 -04:00
John Anderson
1c5239a4d0 added X-Forwarded-Proto header to apache config 2018-03-22 10:51:12 -04:00
Jeremy Stretch
05b5609d86 Merge pull request #1930 from davcamer/drf-yasg
Use drf_yasg to generate swagger
2018-03-21 15:43:05 -04:00
Jeremy Stretch
7e92aeb7ac Merge pull request #1981 from luto/patch-1
compare strings using "==" not "is", fix crash bug
2018-03-21 15:22:00 -04:00
Jeremy Stretch
6e2eb15a80 Fixes #1978: Include all virtual chassis member interfaces in LLDP neighbors view 2018-03-21 15:12:15 -04:00
luto
0b825ac3d0 compare strings using "==" not "is", fixes #1980 2018-03-21 14:28:59 +01:00
Dave Cameron
b5f1d74d6f Definition for /dcim/connected-device/ endpoint 2018-03-16 16:48:08 -04:00
Dave Cameron
e071b7dfd5 The id__in field is a csv-separated string of ids
drf_yasg is interpreting it as a number because NumericInFilter inherits
from django's NumberFilter which explicitly identifies as being a
DecimalField.
2018-03-15 17:07:58 -04:00
Dave Cameron
53e4e74930 Differentiate better between boolean and 0, 1 choices 2018-03-15 17:07:58 -04:00
Dave Cameron
b83de7eb11 Use drf_yasg to generate swagger
drf_yasg provides more complete swagger output, allowing for generation
of usable clients.

Some custom work was needed to accommodate Netbox's custom field
serializers, and to provide x-nullable attributes where appropriate.
2018-03-15 17:07:58 -04:00
Jeremy Stretch
38a208242b Closes #1945: Implemented a VLAN members view 2018-03-15 15:33:13 -04:00
Jeremy Stretch
4acd8e180d Merge pull request #1902 from lae/feature/ansible-alt-install
Add Ansible alternative installation to README
2018-03-14 15:26:33 -04:00
Jeremy Stretch
debc8521a5 Closes #1968: Link device type instance count to filtered device list 2018-03-14 15:18:24 -04:00
Jeremy Stretch
8bd268d81c Closes #1944: Enable assigning VLANs to virtual machine interfaces 2018-03-14 14:53:28 -04:00
Jeremy Stretch
ae6848b194 Fixed Slack URL 2018-03-14 10:30:55 -04:00
Jeremy Stretch
b22744b031 Removed validation constraint prohibitting a VLAN from being both tagged and untagged 2018-03-09 14:00:48 -05:00
Jeremy Stretch
a75d7079df Fixed tests 2018-03-08 13:36:14 -05:00
Jeremy Stretch
aa8442a345 Removed VLAN assignments from interface bulk editing 2018-03-08 13:29:08 -05:00
Jeremy Stretch
70625a5cb0 Improved validation and workflow 2018-03-08 13:25:51 -05:00
Jeremy Stretch
7c043d9b4f Replaced tagged/untagged VLAN assignment widgets with a VLAN table; separate view for adding VLANs 2018-03-07 17:01:51 -05:00
Jeremy Stretch
546f17ab50 Closes #1866: Introduced AnnotatedMultipleChoiceField for filter forms 2018-03-07 14:16:38 -05:00
Jeremy Stretch
1c9986efc4 Closes #1949: Added a button to view elevations on rack groups list 2018-03-07 11:37:05 -05:00
Jeremy Stretch
8ae13e29f5 Fixes #1955: Require a plaintext value when creating a new secret 2018-03-07 11:20:10 -05:00
Jeremy Stretch
f5bb072f28 Fixes #1953: Ignore duplicate IPs when calculating prefix utilization 2018-03-07 11:08:28 -05:00
Jeremy Stretch
37eef0ba6d Fixes #1951: Fix TypeError exception when importing platforms 2018-03-06 12:10:02 -05:00
Jeremy Stretch
603b80db1b Fixes #1948: Fix TypeError when attempting to add a member to an existing virtual chassis 2018-03-06 11:48:26 -05:00
Douglas Thor
8d9543cb6a Add note about copying reports to upgrading.md
The `upgrading.md` file does not mention reports. If the user created reports in the old version's default directory (`./netbox/reports`), then the reports will not be transferred to the new version.
2018-03-01 15:05:51 -08:00
Jeremy Stretch
c823660a8f Post-release version bump 2018-03-01 15:36:32 -05:00
Musee Ullah
e18b5f5fd4 Add Ansible alternative installation to README 2018-02-22 05:56:33 +09:00
Ryan Breaker
57973f62c5 Fix bug with numbers >10 2017-10-31 22:03:57 -05:00
Ryan Breaker
e57b8aa26f E226 fix 2017-10-24 20:43:02 -05:00
Ryan Breaker
3d023126ba Refactor pattern check 2017-10-24 20:22:15 -05:00
Ryan Breaker
53f58d4496 Update comment 2017-10-24 20:03:10 -05:00
Ryan Breaker
1a6ee237f6 Update help text for ExpandableNameField (again) 2017-10-24 19:59:37 -05:00
Ryan Breaker
33a99441a4 Update help text for ExpandableNameField 2017-10-24 19:55:50 -05:00
Ryan Breaker
3df7e283e3 Prevent mismatch of cases in ranges 2017-10-24 19:46:12 -05:00
Ryan Breaker
b295849f53 Prevent mismatch of types in ranges 2017-10-24 19:30:43 -05:00
Ryan Breaker
c107f35118 Merge letters and numbers into one function 2017-10-24 17:55:00 -05:00
Ryan Breaker
3d91153275 Add alphabetic variants to interface expansions 2017-10-24 00:09:38 -05:00
61 changed files with 1061 additions and 528 deletions

View File

@@ -9,7 +9,7 @@ python:
- "3.5"
install:
- pip install -r requirements.txt
- pip install pep8
- pip install pycodestyle
before_script:
- psql --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/).
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://slack.networktocode.com/)!
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status
@@ -41,3 +41,4 @@ and run `upgrade.sh`.
* [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))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))

View File

@@ -12,31 +12,37 @@ Download and extract the latest version:
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -sf netbox-X.Y.Z/ netbox
# ln -sfn netbox-X.Y.Z/ netbox
```
Copy the 'configuration.py' you created when first installing to the new version:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
# cp netbox-X.Y.Z/netbox/netbox/configuration.py 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 /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
# 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:
```no-highlight
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
# cp netbox-X.Y.Z/gunicorn_config.py netbox/gunicorn_config.py
```
Copy the LDAP configuration if using LDAP:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
```
## Option B: Clone the Git Repository (latest master release)

View File

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

View File

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

View File

@@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
)
from .constants import CIRCUIT_STATUS_CHOICES
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
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):
model = Circuit
q = forms.CharField(required=False, label='Search')
@@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=CIRCUIT_STATUS_CHOICES,
annotate=Circuit.objects.all(),
annotate_field='status',
required=False
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug',

View File

@@ -80,7 +80,7 @@ class NestedSiteSerializer(serializers.ModelSerializer):
class WritableSiteSerializer(CustomFieldModelSerializer):
time_zone = TimeZoneField(required=False)
time_zone = TimeZoneField(required=False, allow_null=True)
class Meta:
model = Site
@@ -233,7 +233,7 @@ class WritableRackReservationSerializer(ValidatedModelSerializer):
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'user', 'description']
fields = ['id', 'rack', 'units', 'user', 'tenant', 'description']
#

View File

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

View File

@@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
SmallTextarea, SlugField,
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
)
from virtualization.models import Cluster
from .constants import (
@@ -35,7 +34,13 @@ from .models import (
RackRole, Region, Site, VirtualChassis
)
DEVICE_BY_PK_RE = '{\d+\}'
DEVICE_BY_PK_RE = r'{\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):
@@ -107,9 +112,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = Site
fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
'comments',
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
]
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -119,6 +123,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'name': "Full name of the site",
'facility': "Data center provider and facility (e.g. Equinix NY7)",
'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)",
'shipping_address': "If different from the physical address"
}
@@ -126,7 +132,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class SiteCSVForm(forms.ModelForm):
status = CSVChoiceField(
choices=DEVICE_STATUS_CHOICES,
choices=SITE_STATUS_CHOICES,
required=False,
help_text='Operational status'
)
@@ -160,29 +166,51 @@ class SiteCSVForm(forms.ModelForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), 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(required=False)
pk = forms.ModelMultipleChoiceField(
queryset=Site.objects.all(),
widget=forms.MultipleHiddenInput
)
status = forms.ChoiceField(
choices=add_blank_choice(SITE_STATUS_CHOICES),
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:
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):
model = Site
q = forms.CharField(required=False, label='Search')
status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=SITE_STATUS_CHOICES,
annotate=Site.objects.all(),
annotate_field='status',
required=False
)
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')),
to_field_name='slug',
@@ -700,13 +728,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class PlatformCSVForm(forms.ModelForm):
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:
model = Platform
fields = Platform.csv_headers
help_texts = {
'name': 'Platform name',
'manufacturer': 'Manufacturer name',
}
@@ -1040,13 +1076,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
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):
model = Device
q = forms.CharField(required=False, label='Search')
@@ -1084,7 +1113,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_label='-- None --',
)
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=DEVICE_STATUS_CHOICES,
annotate=Device.objects.all(),
annotate_field='status',
required=False
)
mac_address = forms.CharField(required=False, label='MAC address')
has_primary_ip = forms.NullBooleanField(
required=False,
@@ -1648,63 +1682,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
# Interfaces
#
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}}',
display_field='display_name'
)
)
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}}',
display_field='display_name'
)
)
class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
fields = [
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'untagged_vlan', 'tagged_vlans',
]
widgets = {
'device': forms.HiddenInput(),
}
labels = {
'mode': '802.1Q Mode',
}
help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT,
}
def __init__(self, *args, **kwargs):
super(InterfaceForm, self).__init__(*args, **kwargs)
@@ -1721,58 +1715,108 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
)
# 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
def clean(self):
# Limit the initial vlan choices
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
}
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,
}
super(InterfaceForm, self).clean()
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
# Validate VLAN assignments
tagged_vlans = self.cleaned_data['tagged_vlans']
def clean_tagged_vlans(self):
"""
Because tagged_vlans is a many-to-many relationship, validation must be done in the form
"""
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError(
"An Access interface cannot have tagged VLANs."
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
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])
)
if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError(
"Interface mode Tagged All implies all VLANs are tagged. "
"Do not select any tagged VLANs."
)
parent = self.instance.parent
if parent is not None:
return self.cleaned_data['tagged_vlans']
# Add site VLANs
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, ChainedFieldsMixin):
class InterfaceCreateForm(ComponentForm, forms.Form):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False)
@@ -1786,50 +1830,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
)
description = forms.CharField(max_length=100, 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):
@@ -1847,41 +1847,8 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
else:
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
# Limit the initial vlan choices
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
}
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):
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
@@ -1890,53 +1857,9 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
description = forms.CharField(max_length=100, 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:
nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
nullable_fields = ['lag', 'mtu', 'description', 'mode']
def __init__(self, *args, **kwargs):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1951,28 +1874,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
else:
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
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('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 InterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)

View File

@@ -963,6 +963,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'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:
try:
@@ -1205,8 +1211,8 @@ class ConsoleServerPortManager(models.Manager):
def get_queryset(self):
# Pad any trailing digits to effect natural sorting
return super(ConsoleServerPortManager, self).get_queryset().extra(select={
'name_padded': "CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
}).order_by('device', 'name_padded')
@@ -1236,7 +1242,7 @@ class ConsoleServerPort(models.Model):
raise ValidationError("Console server ports must be assigned to devices.")
device_type = self.device.device_type
if not device_type.is_console_server:
raise ValidationError("The {} {} device type not support assignment of console server ports.".format(
raise ValidationError("The {} {} device type does not support assignment of console server ports.".format(
device_type.manufacturer, device_type
))
@@ -1287,8 +1293,8 @@ class PowerOutletManager(models.Manager):
def get_queryset(self):
# Pad any trailing digits to effect natural sorting
return super(PowerOutletManager, self).get_queryset().extra(select={
'name_padded': "CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
}).order_by('device', 'name_padded')
@@ -1318,7 +1324,7 @@ class PowerOutlet(models.Model):
raise ValidationError("Power outlets must be assigned to devices.")
device_type = self.device.device_type
if not device_type.is_pdu:
raise ValidationError("The {} {} device type not support assignment of power outlets.".format(
raise ValidationError("The {} {} device type does not support assignment of power outlets.".format(
device_type.manufacturer, device_type
))
@@ -1403,7 +1409,7 @@ class Interface(models.Model):
if self.device is not None:
device_type = self.device.device_type
if not device_type.is_network_device:
raise ValidationError("The {} {} device type not support assignment of network interfaces.".format(
raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format(
device_type.manufacturer, device_type
))
@@ -1455,6 +1461,18 @@ class Interface(models.Model):
"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
def parent(self):
return self.device or self.virtual_machine
@@ -1524,6 +1542,18 @@ class InterfaceConnection(models.Model):
raise ValidationError({
'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:
pass

View File

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

View File

@@ -11,8 +11,13 @@ def assign_virtualchassis_master(instance, created, **kwargs):
"""
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:
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=vc_position)
@receiver(pre_delete, sender=VirtualChassis)

View File

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

View File

@@ -5,7 +5,9 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
from dcim.constants import (
IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
)
from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
@@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
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
}
@@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
{
'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,
},

View File

@@ -185,6 +185,7 @@ urlpatterns = [
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'^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/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),

View File

@@ -41,19 +41,21 @@ class BulkRenameView(View):
"""
An extendable view for renaming device components in bulk.
"""
model = None
queryset = None
form = None
template_name = 'dcim/bulk_rename.html'
def post(self, request):
model = self.queryset.model
return_url = request.GET.get('return_url')
if not return_url or not is_safe_url(url=return_url, host=request.get_host()):
return_url = 'home'
if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
if form.is_valid():
for obj in selected_objects:
@@ -65,17 +67,17 @@ class BulkRenameView(View):
obj.save()
messages.success(request, "Renamed {} {}".format(
len(selected_objects),
self.model._meta.verbose_name_plural
model._meta.verbose_name_plural
))
return redirect(return_url)
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'form': form,
'obj_type_plural': self.model._meta.verbose_name_plural,
'obj_type_plural': model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': return_url,
})
@@ -155,6 +157,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region'
cls = Region
queryset = Region.objects.annotate(site_count=Count('sites'))
filter = filters.RegionFilter
table = tables.RegionTable
default_return_url = 'dcim:region_list'
@@ -489,6 +492,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
filter = filters.RackReservationFilter
table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list'
@@ -962,11 +966,9 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
def get(self, request, pk):
device = get_object_or_404(Device, pk=pk)
interfaces = Interface.objects.order_naturally(
interfaces = device.vc_interfaces.order_naturally(
device.device_type.interface_ordering
).connectable().filter(
device=device
).select_related(
).connectable().select_related(
'connected_as_a', 'connected_as_b'
)
@@ -1318,7 +1320,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortBulkRenameForm
@@ -1602,7 +1604,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
queryset = PowerOutlet.objects.all()
form = forms.PowerOutletBulkRenameForm
@@ -1645,6 +1647,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
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):
permission_required = 'dcim.delete_interface'
model = Interface
@@ -1672,7 +1680,7 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_interface'
model = Interface
queryset = Interface.objects.order_naturally()
form = forms.InterfaceBulkRenameForm
@@ -1779,7 +1787,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_devicebay'
model = DeviceBay
queryset = DeviceBay.objects.all()
form = forms.DeviceBayBulkRenameForm
@@ -2226,7 +2234,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
device = member_select_form.cleaned_data['device']
device.virtual_chassis = virtual_chassis
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device)
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
if membership_form.is_valid():
@@ -2242,7 +2250,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
else:
membership_form = forms.DeviceVCMembershipForm(request.POST)
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
return render(request, 'dcim/virtualchassis_add_member.html', {
'virtual_chassis': virtual_chassis,

View File

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

View File

@@ -43,11 +43,18 @@ class CustomFieldFilter(django_filters.Filter):
return queryset.none()
# Apply the assigned filter logic (exact or loose)
queryset = queryset.filter(custom_field_values__field__name=self.name)
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
return queryset.filter(custom_field_values__serialized_value=value)
queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value
)
else:
return queryset.filter(custom_field_values__serialized_value__icontains=value)
queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value__icontains=value
)
return queryset
class CustomFieldFilterSet(django_filters.FilterSet):

View File

@@ -4,6 +4,7 @@ from collections import OrderedDict
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
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
@@ -53,7 +54,14 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required or bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
# Check for a default choice
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
elif cf.type == CF_TYPE_URL:

View File

@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='customfield',
name='default',
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),
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100),
),
migrations.AlterField(
model_name='customfield',

View File

@@ -19,7 +19,7 @@ def verify_postgresql_version(apps, schema_editor):
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
row = cursor.fetchone()
pg_version = re.match('^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
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))

View File

@@ -91,7 +91,7 @@ class CustomField(models.Model):
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
help_text='Default value for the field. Use "true" or "false" for booleans.'
)
weight = models.PositiveSmallIntegerField(
default=100,
@@ -127,7 +127,7 @@ class CustomField(models.Model):
"""
Convert a string into the object it represents depending on the type of field
"""
if serialized_value is '':
if serialized_value == '':
return None
if self.type == CF_TYPE_INTEGER:
return int(serialized_value)

View File

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

View File

@@ -4,7 +4,7 @@ from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from extras.api.views import CustomFieldModelViewSet
@@ -98,7 +98,31 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
# Allocate prefixes to the requested objects based on availability within the parent
for requested_prefix in requested_prefixes:
for i, requested_prefix in enumerate(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
for available_prefix in available_prefixes.iter_cidrs():
@@ -160,8 +184,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = list(prefix.get_available_ips())
if len(available_ips) < len(requested_ips):
available_ips = prefix.get_available_ips()
if available_ips.size < len(requested_ips):
return Response(
{
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
@@ -171,8 +195,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
)
# 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:
requested_ip['address'] = available_ips.pop(0)
requested_ip['address'] = next(available_ips)
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

View File

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

View File

@@ -1,6 +1,7 @@
from __future__ import unicode_literals
import django_filters
from django.core.exceptions import ValidationError
from django.db.models import Q
import netaddr
from netaddr.core import AddrFormatError
@@ -233,6 +234,10 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search_by_parent',
label='Parent prefix',
)
address = django_filters.CharFilter(
method='filter_address',
label='Address',
)
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
@@ -313,6 +318,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
except (AddrFormatError, ValueError):
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):
if not value:
return queryset

View File

@@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
add_blank_choice,
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
SlugField, add_blank_choice,
)
from virtualization.models import VirtualMachine
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
@@ -350,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
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):
model = Prefix
q = forms.CharField(required=False, label='Search')
@@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_label='-- None --'
)
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=PREFIX_STATUS_CHOICES,
annotate=Prefix.objects.all(),
annotate_field='status',
required=False
)
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
@@ -510,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
# Assign this IPAddress as the primary for the associated Device.
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4:
@@ -518,14 +516,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
else:
parent.primary_ip6 = ipaddress
parent.save()
# Clear assignment as primary for device if set.
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == self:
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None
parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == self:
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None
parent.save()
@@ -688,20 +684,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
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):
model = IPAddress
q = forms.CharField(required=False, label='Search')
@@ -721,8 +703,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_label='-- None --'
)
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=IPADDRESS_STATUS_CHOICES,
annotate=IPAddress.objects.all(),
annotate_field='status',
required=False
)
role = AnnotatedMultipleChoiceField(
choices=IPADDRESS_ROLE_CHOICES,
annotate=IPAddress.objects.all(),
annotate_field='role',
required=False
)
#
@@ -878,13 +870,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
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):
model = VLAN
q = forms.CharField(required=False, label='Search')
@@ -903,7 +888,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_label='-- None --'
)
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=VLAN_STATUS_CHOICES,
annotate=VLAN.objects.all(),
annotate_field='status',
required=False
)
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',

View File

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
@@ -365,7 +366,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
return int(float(child_prefixes.size) / self.prefix.size * 100)
else:
child_count = self.get_child_ips().count()
# Compile an IPSet to avoid counting duplicate IPs
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
prefix_size = self.prefix.size
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
prefix_size -= 2
@@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def get_status_class(self):
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
class Service(CreatedUpdatedModel):

View File

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """
{% 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 = """
{% if record.tenant %}
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
@@ -316,7 +329,7 @@ class IPAddressAssignTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface')
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
orderable = False
@@ -361,3 +374,21 @@ class VLANDetailTable(VLANTable):
class Meta(VLANTable.Meta):
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,6 +80,7 @@ urlpatterns = [
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/(?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+)/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'
).filter(
vrf=form.cleaned_data['vrf'],
address__net_host=form.cleaned_data['address'],
)
address__istartswith=form.cleaned_data['address'],
)[:100] # Limit to 100 results
table = tables.IPAddressAssignTable(queryset)
return render(request, 'ipam/ipaddress_assign.html', {
@@ -851,6 +851,38 @@ 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):
permission_required = 'ipam.add_vlan'
model = VLAN

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.3.1'
VERSION = '2.3.5'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -133,7 +133,6 @@ INSTALLED_APPS = (
'django_tables2',
'mptt',
'rest_framework',
'rest_framework_swagger',
'timezone_field',
'circuits',
'dcim',
@@ -144,6 +143,7 @@ INSTALLED_APPS = (
'users',
'utilities',
'virtualization',
'drf_yasg',
)
# Middleware
@@ -246,6 +246,40 @@ REST_FRAMEWORK = {
'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
INTERNAL_IPS = (
'127.0.0.1',
@@ -255,5 +289,5 @@ INTERNAL_IPS = (
try:
HOSTNAME = socket.gethostname()
except:
except Exception:
HOSTNAME = 'localhost'

View File

@@ -4,12 +4,24 @@ from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.static import serve
from rest_framework_swagger.views import get_swagger_view
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView
swagger_view = get_swagger_view(title='NetBox API')
schema_view = get_schema_view(
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 = [
@@ -40,7 +52,9 @@ _patterns = [
url(r'^api/secrets/', include('secrets.api.urls')),
url(r'^api/tenancy/', include('tenancy.api.urls')),
url(r'^api/virtualization/', include('virtualization.api.urls')),
url(r'^api/docs/', swagger_view, name='api_docs'),
url(r'^api/docs/$', schema_view.with_ui('swagger'), 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
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),

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.")
try:
PKCS1_OAEP.new(key)
except:
except Exception:
raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.")
@@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm):
#
class SecretForm(BootstrapMixin, forms.ModelForm):
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
widget=forms.PasswordInput())
plaintext = forms.CharField(
max_length=65535,
required=False,
label='Plaintext',
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})
)
plaintext2 = forms.CharField(
max_length=65535,
required=False,
label='Plaintext (verify)',
widget=forms.PasswordInput()
)
class Meta:
model = Secret
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):
# Verify that the provided plaintext values match
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
raise forms.ValidationError({
'plaintext2': "The two given plaintext values do not match. Please check your input."

View File

@@ -87,7 +87,7 @@ class UserKey(CreatedUpdatedModel):
raise ValidationError({
'public_key': "Invalid RSA key format."
})
except:
except Exception:
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).")

View File

@@ -53,7 +53,7 @@ $(document).ready(function() {
success: function(json) {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
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
var configured_device = row.children('td.configured_device').attr('data');

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">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</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>
</a>
{% else %}

View File

@@ -0,0 +1,55 @@
<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

@@ -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 %}>
{% 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"
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 %}">
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 %}">
{{ u.device.name|default:u.device.device_role }}
{% if u.device.devicebay_count %}
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})

View File

@@ -13,16 +13,44 @@
{% render_field form.mtu %}
{% render_field form.mgmt_only %}
{% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
<div class="panel-body">
{% render_field form.mode %}
{% render_field form.site %}
{% render_field form.vlan_group %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
</div>
</div>
{% if obj.mode %}
<div class="panel panel-default" id="vlans_panel">
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
{% include 'dcim/inc/interface_vlans_table.html' %}
<div class="panel-footer text-right">
<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 %}">
<i class="glyphicon glyphicon-plus"></i> Add VLANs
</a>
</div>
</div>
{% endif %}
{% 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 %}

View File

@@ -1,22 +1,17 @@
{% extends '_base.html' %}
{% load helpers %}
{% load buttons %}
{% block content %}
<div class="pull-right">
{% if perms.dcim.add_rackrole %}
<a href="{% url 'dcim:rackrole_add' %}" class="btn btn-primary">
<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>
{% add_button 'dcim:rackrole_add' %}
{% import_button 'dcim:rackrole_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Rack Roles{% endblock %}</h1>
<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' %}
</div>
</div>

View File

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

View File

@@ -0,0 +1,46 @@
<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

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

View File

@@ -1,48 +1,7 @@
{% extends '_base.html' %}
{% block content %}
<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 %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">

View File

@@ -0,0 +1,12 @@
{% 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

@@ -31,13 +31,15 @@
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
{% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</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>
{% block buttons %}
{% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</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 %}
</div>
</div>
</form>

View File

@@ -0,0 +1,53 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.enabled %}
{% render_field form.mac_address %}
{% render_field form.mtu %}
{% render_field form.description %}
{% render_field form.mode %}
</div>
</div>
{% if obj.mode %}
<div class="panel panel-default" id="vlans_panel">
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
{% include 'dcim/inc/interface_vlans_table.html' %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
<i class="glyphicon glyphicon-plus"></i> Add VLANs
</a>
</div>
</div>
{% endif %}
{% endblock %}
{% block buttons %}
{% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button>
<button type="submit" formaction="?return_url={% url 'virtualization: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 %}

View File

@@ -0,0 +1,76 @@
from drf_yasg import openapi
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector
from rest_framework.fields import ChoiceField
from extras.api.customfields import CustomFieldsSerializer
from utilities.api import ChoiceFieldSerializer
class CustomChoiceFieldInspector(FieldInspector):
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
# this returns a callable which extracts title, description and other stuff
# https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
if isinstance(field, ChoiceFieldSerializer):
value_schema = openapi.Schema(type=openapi.TYPE_INTEGER)
choices = list(field._choices.keys())
if set([None] + choices) == {None, True, False}:
# DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be
# differentiated since they each have subtly different values in their choice keys.
# - subdevice_role and connection_status are booleans, although subdevice_role includes None
# - face is an integer set {0, 1} which is easily confused with {False, True}
schema_type = openapi.TYPE_INTEGER
if all(type(x) == bool for x in [c for c in choices if c is not None]):
schema_type = openapi.TYPE_BOOLEAN
value_schema = openapi.Schema(type=schema_type)
value_schema['x-nullable'] = True
schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={
"label": openapi.Schema(type=openapi.TYPE_STRING),
"value": value_schema
})
return schema
elif isinstance(field, CustomFieldsSerializer):
schema = SwaggerType(type=openapi.TYPE_OBJECT)
return schema
return NotHandled
class NullableBooleanFieldInspector(FieldInspector):
def process_result(self, result, method_name, obj, **kwargs):
if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean':
keys = obj.choices.keys()
if set(keys) == {None, True, False}:
result['x-nullable'] = True
result.type = 'boolean'
return result
class IdInFilterInspector(FilterInspector):
def process_result(self, result, method_name, obj, **kwargs):
if isinstance(result, list):
params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in']
for p in params:
p.type = 'string'
return result
class NullablePaginatorInspector(PaginatorInspector):
def process_result(self, result, method_name, obj, **kwargs):
if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema):
next = result.properties['next']
if isinstance(next, openapi.Schema):
next['x-nullable'] = True
previous = result.properties['previous']
if isinstance(previous, openapi.Schema):
previous['x-nullable'] = True
return result

View File

@@ -6,6 +6,7 @@ import re
from django import forms
from django.conf import settings
from django.db.models import Count
from django.urls import reverse_lazy
from mptt.forms import TreeNodeMultipleChoiceField
@@ -37,9 +38,10 @@ COLOR_CHOICES = (
('607d8b', 'Dark grey'),
('111111', 'Black'),
)
NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
def parse_numeric_range(string, base=10):
@@ -76,6 +78,45 @@ def expand_numeric_pattern(string):
yield "{}{}{}".format(lead, i, remnant)
def parse_alphanumeric_range(string):
"""
Expand an alphanumeric range (continuous or not) into a list.
'a-d,f' => [a, b, c, d, f]
'0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
"""
values = []
for dash_range in string.split(','):
try:
begin, end = dash_range.split('-')
vals = begin + end
# Break out of loop if there's an invalid pattern to return an error
if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
return []
except ValueError:
begin, end = dash_range, dash_range
if begin.isdigit() and end.isdigit():
for n in list(range(int(begin), int(end) + 1)):
values.append(n)
else:
for n in list(range(ord(begin), ord(end) + 1)):
values.append(chr(n))
return values
def expand_alphanumeric_pattern(string):
"""
Expand an alphabetic pattern into a list of strings.
"""
lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
parsed_range = parse_alphanumeric_range(pattern)
for i in parsed_range:
if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
for string in expand_alphanumeric_pattern(remnant):
yield "{}{}{}".format(lead, i, string)
else:
yield "{}{}{}".format(lead, i, remnant)
def expand_ipaddress_pattern(string, family):
"""
Expand an IP address pattern into a list of strings. Examples:
@@ -164,7 +205,8 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
def optgroups(self, name, value, attrs=None):
# Split the delimited string of values into a list
value = value[0].split(self.delimiter)
if value:
value = value[0].split(self.delimiter)
return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
def value_from_datadict(self, data, files, name):
@@ -305,12 +347,15 @@ class ExpandableNameField(forms.CharField):
def __init__(self, *args, **kwargs):
super(ExpandableNameField, self).__init__(*args, **kwargs)
if not self.help_text:
self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
'Example: <code>ge-0/0/[0-23,25,30]</code>'
self.help_text = 'Alphanumeric ranges are supported for bulk creation.<br />' \
'Mixed cases and types within a single range are not supported.<br />' \
'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
'<li><code>e[0-3][a-d,f]</code></li>' \
'<li><code>e[0-3,a-d,f]</code></li></ul>'
def to_python(self, value):
if re.search(NUMERIC_EXPANSION_PATTERN, value):
return list(expand_numeric_pattern(value))
if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
return list(expand_alphanumeric_pattern(value))
return [value]
@@ -362,7 +407,7 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
try:
if not self.to_field_name:
key = 'pk'
elif re.match('^\{\d+\}$', value):
elif re.match(r'^\{\d+\}$', value):
key = 'pk'
value = value.strip('{}')
else:
@@ -450,6 +495,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple
pass
class AnnotatedMultipleChoiceField(forms.MultipleChoiceField):
"""
Render a set of static choices with each choice annotated to include a count of related objects. For example, this
field can be used to display a list of all available device statuses along with the number of devices currently
assigned to each status.
"""
def annotate_choices(self):
queryset = self.annotate.values(
self.annotate_field
).annotate(
count=Count(self.annotate_field)
).order_by(
self.annotate_field
)
choice_counts = {
c[self.annotate_field]: c['count'] for c in queryset
}
annotated_choices = [
(c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices
]
return annotated_choices
def __init__(self, choices, annotate, annotate_field, *args, **kwargs):
self.annotate = annotate
self.annotate_field = annotate_field
self.static_choices = choices
super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs)
class LaxURLField(forms.URLField):
"""
Modifies Django's built-in URLField in two ways:

View File

@@ -23,9 +23,9 @@ class NaturalOrderByManager(Manager):
id3 = '_{}_{}3'.format(db_table, primary_field)
queryset = super(NaturalOrderByManager, self).get_queryset().extra(select={
id1: "CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, primary_field),
id2: "SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field),
id3: "CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, primary_field),
id1: r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, primary_field),
id2: r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field),
id3: r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, primary_field),
})
ordering = fields[0:-1] + (id1, id2, id3)

View File

@@ -14,7 +14,7 @@ def csv_format(data):
for value in data:
# Represent None or False with empty string
if value in [None, False]:
if value is None or value is False:
csv.append('')
continue

View File

@@ -15,7 +15,7 @@ class EnhancedURLValidator(URLValidator):
A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
"""
def __contains__(self, item):
if not item or not re.match('^[a-z][0-9a-z+\-.]*$', item.lower()):
if not item or not re.match(r'^[a-z][0-9a-z+\-.]*$', item.lower()):
return False
return True

View File

@@ -626,8 +626,11 @@ class BulkDeleteView(View):
return_url = reverse(self.default_return_url)
# Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filter is not None:
pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
if request.POST.get('_all'):
if self.filter is not None:
pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
else:
pk_list = self.cls.objects.values_list('pk', flat=True)
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]

View File

@@ -3,10 +3,10 @@ from __future__ import unicode_literals
from rest_framework import serializers
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.constants import IFACE_FF_VIRTUAL
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress
from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.constants import VM_STATUS_CHOICES
@@ -133,13 +133,26 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
# VM interfaces
#
# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency
class InterfaceVLANSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(serializers.ModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
untagged_vlan = InterfaceVLANSerializer()
tagged_vlans = InterfaceVLANSerializer(many=True)
class Meta:
model = Interface
fields = [
'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description',
'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans',
'description',
]
@@ -157,5 +170,6 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
class Meta:
model = Interface
fields = [
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan',
'tagged_vlans', 'description',
]

View File

@@ -5,7 +5,8 @@ from django.core.exceptions import ValidationError
from django.db.models import Count
from mptt.forms import TreeNodeChoiceField
from dcim.constants import IFACE_FF_VIRTUAL
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -13,9 +14,9 @@ from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice
)
from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
def vm_status_choices():
status_counts = {}
for status in VirtualMachine.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 VM_STATUS_CHOICES]
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VirtualMachine
q = forms.CharField(required=False, label='Search')
@@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_label='-- None --'
)
status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
status = AnnotatedMultipleChoiceField(
choices=VM_STATUS_CHOICES,
annotate=VirtualMachine.objects.all(),
annotate_field='status',
required=False
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
to_field_name='slug',
@@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
fields = [
'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
'untagged_vlan', 'tagged_vlans',
]
widgets = {
'virtual_machine': forms.HiddenInput(),
'form_factor': forms.HiddenInput(),
}
labels = {
'mode': '802.1Q Mode',
}
help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT,
}
def clean(self):
super(InterfaceForm, self).clean()
# Validate VLAN assignments
tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
class InterfaceCreateForm(ComponentForm):

View File

@@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
else:
return None
@property
def site(self):
# used when a child compent (eg Interface) needs to know its parent's site but
# the parent could be either a device or a virtual machine
return self.cluster.site

View File

@@ -115,7 +115,7 @@ class ClusterView(View):
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
device_table = DeviceTable(list(devices), orderable=False)
if request.user.has_perm('virtualization:change_cluster'):
if request.user.has_perm('virtualization.change_cluster'):
device_table.columns.show('pk')
return render(request, 'virtualization/cluster.html', {
@@ -160,6 +160,7 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_cluster'
cls = Cluster
queryset = Cluster.objects.all()
filter = filters.ClusterFilter
table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list'
@@ -329,6 +330,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface'
model = Interface
model_form = forms.InterfaceForm
template_name = 'virtualization/interface_edit.html'
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):

View File

@@ -1,2 +1,3 @@
django-rest-swagger
psycopg2
pycrypto

View File

@@ -3,10 +3,10 @@ django-cors-headers>=2.1.0
django-debug-toolbar>=1.9.0
django-filter>=1.1.0
django-mptt>=0.9.0
django-rest-swagger>=2.1.0
django-tables2>=1.19.0
django-timezone-field>=2.0
djangorestframework>=3.7.7
drf-yasg[validation]>=1.4.4
graphviz>=0.8.2
Markdown>=2.6.11
natsort>=5.2.0

View File

@@ -23,8 +23,11 @@ fi
# Check all python source files for PEP 8 compliance, but explicitly
# ignore:
# - W504: line break after binary operator
# - E501: line greater than 80 characters in length
pep8 --ignore=E501 netbox/
pycodestyle \
--ignore=W504,E501 \
netbox/
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) one or more PEP 8 errors detected, failing build."