Compare commits

..

57 Commits

Author SHA1 Message Date
Jeremy Stretch
aa8f734bd1 Merge pull request #1537 from digitalocean/develop
Release v2.1.5
2017-09-25 14:52:43 -04:00
Jeremy Stretch
0015484679 Release v2.1.5 2017-09-25 14:50:45 -04:00
Jeremy Stretch
aa5aa798e6 Fixes #1486: Ignore subinterface IDs when validating LLDP neighbor connections 2017-09-25 14:33:32 -04:00
Jeremy Stretch
63a4a70420 #1499: Added utilization graph to child prefixes for aggregates 2017-09-25 13:39:03 -04:00
Jeremy Stretch
2badb04a03 Fixes #1489: Corrected server error on validation of empty required custom field 2017-09-25 13:37:11 -04:00
Jeremy Stretch
a328e12642 Closes #1536: Improved formatting of aggregate prefix statistics 2017-09-25 12:14:55 -04:00
Jeremy Stretch
ce9f1eb201 Closes #1499: Added utilization graph to child prefixes table 2017-09-22 21:17:46 -04:00
Jeremy Stretch
b454ac9ce5 Merge pull request #1524 from larsuhartmann/develop
mentioned support of nested groups in LDAP installation documentation.
2017-09-22 16:19:32 -04:00
Jeremy Stretch
a010f7439d More work on #1523 (natural ordering for interfaces) 2017-09-22 16:18:43 -04:00
Jeremy Stretch
d7b0ba57e0 Merge pull request #1526 from tarkatronic/order_naturally_enhancements
Fixes #1523 - Interface.objects.order_naturally() enhancements
2017-09-22 14:12:43 -04:00
Joey Wilhelm
89194c067b Another test case to ensure subinterface ordering 2017-09-21 17:21:21 -07:00
Joey Wilhelm
126a5e5e4e Fix order_naturally with unbalanced names and use RawSQL instead of extra 2017-09-21 11:00:55 -07:00
Lars Hartmann
466ab0b404 mentioned support of nested groups in LDAP installation documentation. 2017-09-21 16:36:25 +02:00
Jeremy Stretch
a52a871a94 Fixes #1522: Removed object create/edit forms from the browsable API 2017-09-20 16:54:27 -04:00
Jeremy Stretch
4f9379a5e2 Cleaned up device interfaces/IPs table 2017-09-20 14:44:51 -04:00
Jeremy Stretch
e31d2c9857 Fixes #1520: Redirect on GET request to bulk edit/delete views 2017-09-20 09:56:20 -04:00
Ryan Breaker
b252f52f8e Added missing package for CentOS and Fedora (#1470)
Without the added package, installation fails during `pip install -r requirements.txt` on the pycrypto package for me in CentOS 6 and 7 and Fedora.
2017-09-18 16:37:51 -04:00
Chris Howells
eba30c4d79 Document the PATCH method. Closes #1481. (#1482) 2017-09-18 16:37:09 -04:00
Jeremy Stretch
ae1662e433 Fixes #1507: Fixed error when creating the next available IP from a prefix within a VRF 2017-09-18 16:34:28 -04:00
Jeremy Stretch
81df837a33 Closes #1485: Added LOGIN_BANNER configuration setting to display a banner on the login page 2017-09-15 15:28:09 -04:00
Jeremy Stretch
b84b526a43 Closes #1484: Added individual "add VLAN" buttons on the VLAN groups list 2017-09-15 15:14:35 -04:00
Jeremy Stretch
f6d1163ddd Merge pull request #1461 from digitalocean/develop
Release v2.1.4
2017-08-30 14:43:01 -04:00
Jeremy Stretch
5be30bd278 Merge pull request #1428 from digitalocean/develop
Release v2.1.3
2017-08-15 15:52:34 -04:00
Jeremy Stretch
fa7b7288c9 Merge pull request #1398 from digitalocean/develop
Release v2.1.2
2017-08-04 10:54:29 -04:00
Jeremy Stretch
9cc03aaa9a Merge pull request #1387 from digitalocean/develop
Release v2.1.1
2017-08-02 14:22:30 -04:00
Jeremy Stretch
1bda56ea23 Merge pull request #1372 from digitalocean/develop
Release v2.1.0
2017-07-25 11:21:44 -04:00
Jeremy Stretch
64a34ced72 Merge pull request #1346 from digitalocean/develop
Release v2.0.10
2017-07-14 10:09:16 -04:00
Jeremy Stretch
e05d379101 Merge pull request #1327 from digitalocean/develop
Release v2.0.9
2017-07-10 09:43:59 -04:00
Jeremy Stretch
a355783377 Merge pull request #1316 from digitalocean/develop
Release v2.0.8
2017-07-05 14:36:08 -04:00
Jeremy Stretch
88239e0b0d Merge pull request #1278 from digitalocean/develop
Release v2.0.7
2017-06-15 14:26:38 -04:00
Jeremy Stretch
5c63a499d5 Merge pull request #1259 from digitalocean/develop
Release v2.0.6
2017-06-12 09:51:15 -04:00
Jeremy Stretch
50496b1a59 Merge pull request #1251 from digitalocean/develop
Release v2.0.5
2017-06-08 10:10:41 -04:00
Jeremy Stretch
f7b0d22f86 Merge pull request #1230 from digitalocean/develop
Release v2.0.4
2017-05-25 14:45:13 -04:00
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -04:00
Jeremy Stretch
43e1e0dbc8 Merge pull request #1181 from digitalocean/develop
Release v2.0.2
2017-05-15 13:23:33 -04:00
Jeremy Stretch
f731900e2f Merge pull request #1154 from digitalocean/develop
Release v2.0.1
2017-05-09 22:47:52 -04:00
Jeremy Stretch
b1bcaa33e7 Merge pull request #1148 from digitalocean/develop
Release v2.0.0
2017-05-09 15:09:28 -04:00
Jeremy Stretch
17873706b7 Merge pull request #1094 from digitalocean/develop
Release v1.9.6
2017-04-21 14:52:53 -04:00
Jeremy Stretch
e0ad2b4555 Merge pull request #1054 from digitalocean/develop
Release v1.9.5
2017-04-06 16:35:15 -04:00
Jeremy Stretch
f89d91783b Merge pull request #1035 from digitalocean/develop
Release v1.9.4-r1
2017-04-04 15:50:28 -04:00
Jeremy Stretch
3ffe36e5ed Merge pull request #1032 from digitalocean/develop
Release v1.9.4
2017-04-04 12:01:58 -04:00
Jeremy Stretch
be393a9d10 Merge pull request #989 from digitalocean/develop
Release v1.9.3
2017-03-23 16:27:06 -04:00
Jeremy Stretch
27eefd8705 Merge pull request #966 from digitalocean/develop
Release v1.9.2
2017-03-14 17:14:19 -04:00
Jeremy Stretch
097e0f38ff Merge pull request #949 from digitalocean/develop
Release v1.9.1
2017-03-08 14:40:16 -05:00
Jeremy Stretch
ce26b566a4 Merge pull request #939 from digitalocean/develop
Release v1.9.0-r1
2017-03-03 11:28:02 -05:00
Jeremy Stretch
0e14bc1e02 Merge pull request #933 from digitalocean/develop
Release v1.9.0
2017-03-02 13:27:10 -05:00
Jeremy Stretch
ce6796ed9b Merge pull request #870 from digitalocean/develop
Release v1.8.4
2017-02-03 13:59:02 -05:00
Jeremy Stretch
c90cecc2fb Merge pull request #849 from digitalocean/develop
Release v1.8.3
2017-01-26 13:58:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
88dace75a1 Merge pull request #766 from digitalocean/develop
Release v1.8.0
2017-01-03 15:13:36 -05:00
Jeremy Stretch
8eb140fd65 Merge pull request #736 from digitalocean/develop
Release v1.7.3
2016-12-08 12:34:53 -05:00
Jeremy Stretch
1f09f3d096 Merge pull request #728 from digitalocean/develop
Release v1.7.2-r1
2016-12-06 15:38:52 -05:00
Jeremy Stretch
66be85a41f Merge pull request #726 from digitalocean/develop
Release v1.7.2
2016-12-06 14:55:19 -05:00
Jeremy Stretch
814c11167e Merge pull request #694 from digitalocean/develop
Release v1.7.1
2016-11-15 12:34:09 -05:00
Jeremy Stretch
57ddd5086f Merge pull request #666 from digitalocean/develop
Release v1.7.0
2016-11-03 15:12:33 -04:00
Jeremy Stretch
c171547037 Merge pull request #625 from digitalocean/develop
Release v1.6.3
2016-10-19 16:25:50 -04:00
90 changed files with 767 additions and 3548 deletions

View File

@@ -4,7 +4,8 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects
* `POST`: Create a new object
* `PUT`: Update an existing object
* `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifiying the field to be changed
* `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header:
@@ -104,12 +105,19 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
### Modify an existing site
Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included.
Make an authenticated `PUT` request to the site detail endpoint. As with a create (`POST`) request, all mandatory fields must be included.
```
$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
```
### Modify an object by changing a field
Make an authenticated `PATCH` request to the device endpoint. With `PATCH`, unlike `POST` and `PUT`, we only specify the field that is being changed. In this example, we add a serial number to a device.
```
$ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/2549/ --data '{"serial": "FTX1123A090"}'
```
### Delete an existing site
Send an authenticated `DELETE` request to the site detail endpoint.

View File

@@ -6,7 +6,7 @@ REST stands for [representational state transfer](https://en.wikipedia.org/wiki/
* `GET`: Retrieve an object or list of objects
* `POST`: Create an object
* `PUT` / `PATCH`: Modify an existing object
* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
* `DELETE`: Delete an existing object
The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)

View File

@@ -17,7 +17,7 @@ ADMINS = [
## BANNER_BOTTOM
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. To replicate the content of the top banner in the bottom banner, set:
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
```
BANNER_TOP = 'Your banner text'
@@ -26,6 +26,12 @@ BANNER_BOTTOM = BANNER_TOP
---
## BANNER_LOGIN
The value of this variable will be displayed on the login page above the login form. HTML is allowed.
---
## BASE_PATH
Default: None

View File

@@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each
# Services
A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)
A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)

View File

@@ -1,29 +0,0 @@
NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it.
# Clusters
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group.
Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
### Cluster Types
A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider.
### Cluster Groups
Cluster groups may be created for the purpose of organizing clusters.
---
# Virtual Machines
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster.
Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays.
The following resources can be defined for each VM:
* vCPU count
* Memory (MB)
* Disk space (GB)

View File

@@ -1,53 +0,0 @@
# Utility Views
Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once.
## Individual Views
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
### ObjectEditView
Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created.
### ObjectDeleteView
Deletes an object. The user is redirected to a confirmation page before the deletion is executed.
## Bulk Views
### BulkCreateView
Creates multiple objects at once based on a given pattern. Currently used only for IP addresses.
### BulkImportView
Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none.
### BulkEditView
Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects.
### BulkDeleteView
Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion.
## Component Views
### ComponentCreateView
Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device).
### ComponentEditView
A subclass of `ObjectEditView`: Updates an individual component object.
### ComponentDeleteView
A subclass of `ObjectDeleteView`: Deletes an individual component object.
### BulkComponentCreateView
Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once.

View File

@@ -78,6 +78,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
```
# User Groups for Permissions
!!! Info
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE.
```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

View File

@@ -20,7 +20,7 @@ Python 3:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# easy_install-3.4 pip
# ln -s -f python3.4 /usr/bin/python
```
@@ -29,7 +29,7 @@ Python 2:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.

View File

@@ -18,7 +18,6 @@ pages:
- 'IPAM': 'data-model/ipam.md'
- 'Secrets': 'data-model/secrets.md'
- 'Tenancy': 'data-model/tenancy.md'
- 'Virtualization': 'data-model/virtualization.md'
- 'Extras': 'data-model/extras.md'
- 'API':
- 'Overview': 'api/overview.md'
@@ -27,8 +26,6 @@ pages:
- 'Examples': 'api/examples.md'
- 'Shell':
- 'Introduction': 'shell/intro.md'
- 'Development':
- 'Utility Views': 'development/utility-views.md'
markdown_extensions:
- admonition:

View File

@@ -52,7 +52,7 @@ class ProviderView(View):
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_provider'
model = Provider
model_form = forms.ProviderForm
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list'
@@ -104,7 +104,7 @@ class CircuitTypeListView(ObjectListView):
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittype'
model = CircuitType
model_form = forms.CircuitTypeForm
form_class = forms.CircuitTypeForm
def get_return_url(self, request, obj):
return reverse('circuits:circuittype_list')
@@ -160,7 +160,7 @@ class CircuitView(View):
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuit'
model = Circuit
model_form = forms.CircuitForm
form_class = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
@@ -253,7 +253,7 @@ def circuit_terminations_swap(request, pk):
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittermination'
model = CircuitTermination
model_form = forms.CircuitTerminationForm
form_class = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
def alter_obj(self, obj, request, url_args, url_kwargs):

View File

@@ -16,7 +16,6 @@ from dcim.models import (
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.models import Cluster
#
@@ -447,15 +446,6 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'address']
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
class NestedClusterSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
model = Cluster
fields = ['id', 'url', 'name']
class DeviceSerializer(CustomFieldModelSerializer):
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
@@ -469,14 +459,13 @@ class DeviceSerializer(CustomFieldModelSerializer):
primary_ip4 = DeviceIPAddressSerializer()
primary_ip6 = DeviceIPAddressSerializer()
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer()
class Meta:
model = Device
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'comments', 'custom_fields',
'comments', 'custom_fields',
]
def get_parent_device(self, obj):

View File

@@ -93,15 +93,13 @@ IFACE_FF_JUNIPER_VCP = 5200
# Other
IFACE_FF_OTHER = 32767
VIFACE_FF_CHOICES = [
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
]
IFACE_FF_CHOICES = [
[
'Virtual interfaces',
VIFACE_FF_CHOICES,
[
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
]
],
[
'Ethernet (fixed)',

View File

@@ -10,7 +10,6 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
from virtualization.models import Cluster
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
@@ -409,10 +408,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=Rack.objects.all(),
label='Rack (ID)',
)
cluster_id = NullableModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
label='VM cluster (ID)',
)
model = django_filters.ModelMultipleChoiceFilter(
name='device_type__slug',
queryset=DeviceType.objects.all(),

View File

@@ -12,10 +12,10 @@ from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField, FilterTreeNodeMultipleChoiceField,
APISelect, ArrayFieldSelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
)
from .formfields import MACAddressFormField
from .models import (
@@ -43,6 +43,15 @@ def get_device_by_name_or_pk(name):
return device
class DeviceComponentForm(BootstrapMixin, forms.Form):
"""
Allow inclusion of the parent device as context for limiting field choices.
"""
def __init__(self, device, *args, **kwargs):
self.device = device
super(DeviceComponentForm, self).__init__(*args, **kwargs)
#
# Regions
#
@@ -437,7 +446,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortTemplateCreateForm(ComponentForm):
class ConsolePortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -451,7 +460,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortTemplateCreateForm(ComponentForm):
class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -465,7 +474,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortTemplateCreateForm(ComponentForm):
class PowerPortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -479,7 +488,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerOutletTemplateCreateForm(ComponentForm):
class PowerOutletTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -493,7 +502,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
}
class InterfaceTemplateCreateForm(ComponentForm):
class InterfaceTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
@@ -518,7 +527,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayTemplateCreateForm(ComponentForm):
class DeviceBayTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -912,12 +921,11 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False, initial=True)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
description = forms.CharField(max_length=100, required=False)
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
class Meta:
model = Interface
fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description']
#
@@ -934,7 +942,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortCreateForm(ComponentForm):
class ConsolePortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1103,7 +1111,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortCreateForm(ComponentForm):
class ConsoleServerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1195,7 +1203,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortCreateForm(ComponentForm):
class PowerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1364,7 +1372,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
}
class PowerOutletCreateForm(ComponentForm):
class PowerOutletCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1469,7 +1477,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
)
class InterfaceCreateForm(ComponentForm):
class InterfaceCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False)
@@ -1488,9 +1496,9 @@ class InterfaceCreateForm(ComponentForm):
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device
if self.parent is not None:
if self.device is not None:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.parent, form_factor=IFACE_FF_LAG
device=self.device, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.none()
@@ -1716,7 +1724,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayCreateForm(ComponentForm):
class DeviceBayCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')

View File

@@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-31 14:15
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0001_virtualization'),
('dcim', '0043_device_component_name_lengths'),
]
operations = [
migrations.AddField(
model_name='device',
name='cluster',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'),
),
migrations.AddField(
model_name='interface',
name='virtual_machine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'),
),
migrations.AlterField(
model_name='interface',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
),
]

View File

@@ -13,6 +13,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
@@ -642,15 +643,16 @@ class InterfaceQuerySet(models.QuerySet):
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit:
{type}{slot}/{subslot}/{position}:{channel}.{vc}
{type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
be parsed as follows:
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
would be parsed as follows:
name = 'GigabitEthernet'
slot = None
subslot = 0
position = 1
slot = 1
subslot = 2
position = 3
subposition = 0
channel = None
vc = 0
@@ -659,17 +661,35 @@ class InterfaceQuerySet(models.QuerySet):
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
IFACE_ORDERING_POSITION: (
'_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', '_id', 'name',
),
IFACE_ORDERING_NAME: (
'_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
),
}[method]
return self.extra(select={
'_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
}).order_by(*ordering)
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"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer)"
POSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer)"
SUBPOSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer)"
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)"
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return self.annotate(**fields).order_by(*ordering)
def connectable(self):
"""
@@ -808,13 +828,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv6'
)
cluster = models.ForeignKey(
to='virtualization.Cluster',
on_delete=models.SET_NULL,
related_name='devices',
blank=True,
null=True
)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
@@ -1164,26 +1177,13 @@ class PowerOutlet(models.Model):
@python_2_unicode_compatible
class Interface(models.Model):
"""
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
Interface via the creation of an InterfaceConnection.
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
of an InterfaceConnection.
"""
device = models.ForeignKey(
to='Device',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
lag = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
'self',
models.SET_NULL,
related_name='member_interfaces',
null=True,
blank=True,
@@ -1212,18 +1212,6 @@ class Interface(models.Model):
def clean(self):
# An Interface must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("An interface must belong to either a device or a virtual machine.")
# VM interfaces must be virtual
if self.virtual_machine and self.form_factor not in VIRTUAL_IFACE_TYPES:
raise ValidationError({
'form_factor': "Virtual machines cannot have physical interfaces."
})
# Virtual interfaces cannot be connected
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
raise ValidationError({
@@ -1253,10 +1241,6 @@ class Interface(models.Model):
)
})
@property
def parent(self):
return self.device or self.virtual_machine
@property
def is_virtual(self):
return self.form_factor in VIRTUAL_IFACE_TYPES

View File

@@ -98,3 +98,112 @@ class RackTestCase(TestCase):
face=None,
)
self.assertTrue(pdu)
class InterfaceTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(
name='TestSite1',
slug='my-test-site'
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=self.site,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
)
self.device_type = DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
)
self.role = DeviceRole.objects.create(
name='Switch',
slug='switch',
)
def test_interface_order_natural(self):
device1 = Device.objects.create(
name='TestSwitch1',
device_type=self.device_type,
device_role=self.role,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
interface1 = Interface.objects.create(
device=device1,
name='Ethernet1/3/1'
)
interface2 = Interface.objects.create(
device=device1,
name='Ethernet1/5/1'
)
interface3 = Interface.objects.create(
device=device1,
name='Ethernet1/4'
)
interface4 = Interface.objects.create(
device=device1,
name='Ethernet1/3/2/4'
)
interface5 = Interface.objects.create(
device=device1,
name='Ethernet1/3/2/1'
)
interface6 = Interface.objects.create(
device=device1,
name='Loopback1'
)
self.assertEqual(
list(Interface.objects.all().order_naturally()),
[interface1, interface5, interface4, interface3, interface2, interface6]
)
def test_interface_order_natural_subinterfaces(self):
device1 = Device.objects.create(
name='TestSwitch1',
device_type=self.device_type,
device_role=self.role,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
interface1 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/3'
)
interface2 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/2.2'
)
interface3 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/0.120'
)
interface4 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/0'
)
interface5 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/1.117'
)
interface6 = Interface.objects.create(
device=device1,
name='GigabitEthernet0'
)
self.assertEqual(
list(Interface.objects.all().order_naturally()),
[interface4, interface3, interface5, interface2, interface1, interface6]
)

View File

@@ -126,7 +126,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports

View File

@@ -23,8 +23,7 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import (
@@ -61,6 +60,87 @@ def expand_pattern(string):
yield "{0}{1}".format(lead, i)
class ComponentCreateView(View):
parent_model = None
parent_field = None
model = None
form = None
model_form = None
def get(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, initial=request.GET)
return render(request, 'dcim/device_component_add.html', {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'return_url': parent.get_absolute_url(),
})
def post(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for name in form.cleaned_data['name_pattern']:
component_data = {
self.parent_field: parent.pk,
'name': name,
}
# Replace objects with their primary key to keep component_form.clean() happy
for k, v in data.items():
if hasattr(v, 'pk'):
component_data[k] = v.pk
else:
component_data[k] = v
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
# Assign errors on the child form's name field to name_pattern on the parent form
if field == 'name':
field = 'name_pattern'
for e in errors:
form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, "Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent
))
if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect(parent.get_absolute_url())
return render(request, 'dcim/device_component_add.html', {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'return_url': parent.get_absolute_url(),
})
class ComponentEditView(ObjectEditView):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
class BulkDisconnectView(View):
"""
An extendable view for disconnection console/power/interface components in bulk.
@@ -112,7 +192,7 @@ class RegionListView(ObjectListView):
class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_region'
model = Region
model_form = forms.RegionForm
form_class = forms.RegionForm
def get_return_url(self, request, obj):
return reverse('dcim:region_list')
@@ -170,7 +250,7 @@ class SiteView(View):
class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_site'
model = Site
model_form = forms.SiteForm
form_class = forms.SiteForm
template_name = 'dcim/site_edit.html'
default_return_url = 'dcim:site_list'
@@ -217,7 +297,7 @@ class RackGroupListView(ObjectListView):
class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackgroup'
model = RackGroup
model_form = forms.RackGroupForm
form_class = forms.RackGroupForm
def get_return_url(self, request, obj):
return reverse('dcim:rackgroup_list')
@@ -249,7 +329,7 @@ class RackRoleListView(ObjectListView):
class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackrole'
model = RackRole
model_form = forms.RackRoleForm
form_class = forms.RackRoleForm
def get_return_url(self, request, obj):
return reverse('dcim:rackrole_list')
@@ -352,7 +432,7 @@ class RackView(View):
class RackCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rack'
model = Rack
model_form = forms.RackForm
form_class = forms.RackForm
template_name = 'dcim/rack_edit.html'
default_return_url = 'dcim:rack_list'
@@ -408,7 +488,7 @@ class RackReservationListView(ObjectListView):
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackreservation'
model = RackReservation
model_form = forms.RackReservationForm
form_class = forms.RackReservationForm
def alter_obj(self, obj, request, args, kwargs):
if not obj.pk:
@@ -452,7 +532,7 @@ class ManufacturerListView(ObjectListView):
class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_manufacturer'
model = Manufacturer
model_form = forms.ManufacturerForm
form_class = forms.ManufacturerForm
def get_return_url(self, request, obj):
return reverse('dcim:manufacturer_list')
@@ -537,7 +617,7 @@ class DeviceTypeView(View):
class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicetype'
model = DeviceType
model_form = forms.DeviceTypeForm
form_class = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
default_return_url = 'dcim:devicetype_list'
@@ -582,7 +662,6 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
model = ConsolePortTemplate
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
template_name = 'dcim/device_component_add.html'
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -601,7 +680,6 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
model = ConsoleServerPortTemplate
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
template_name = 'dcim/device_component_add.html'
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -618,7 +696,6 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
model = PowerPortTemplate
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
template_name = 'dcim/device_component_add.html'
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -635,7 +712,6 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
model = PowerOutletTemplate
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
template_name = 'dcim/device_component_add.html'
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -652,7 +728,6 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
model = InterfaceTemplate
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
template_name = 'dcim/device_component_add.html'
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -677,7 +752,6 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
model = DeviceBayTemplate
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
template_name = 'dcim/device_component_add.html'
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -700,7 +774,7 @@ class DeviceRoleListView(ObjectListView):
class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicerole'
model = DeviceRole
model_form = forms.DeviceRoleForm
form_class = forms.DeviceRoleForm
def get_return_url(self, request, obj):
return reverse('dcim:devicerole_list')
@@ -731,7 +805,7 @@ class PlatformListView(ObjectListView):
class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_platform'
model = Platform
model_form = forms.PlatformForm
form_class = forms.PlatformForm
def get_return_url(self, request, obj):
return reverse('dcim:platform_list')
@@ -889,7 +963,7 @@ class DeviceConfigView(PermissionRequiredMixin, View):
class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_device'
model = Device
model_form = forms.DeviceForm
form_class = forms.DeviceForm
template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list'
@@ -961,7 +1035,6 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
model = ConsolePort
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_consoleport')
@@ -1043,14 +1116,12 @@ def consoleport_disconnect(request, pk):
class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleport'
model = ConsolePort
parent_field = 'device'
model_form = forms.ConsolePortForm
form_class = forms.ConsolePortForm
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleport'
model = ConsolePort
parent_field = 'device'
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1078,7 +1149,6 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
model = ConsoleServerPort
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_consoleserverport')
@@ -1163,14 +1233,12 @@ def consoleserverport_disconnect(request, pk):
class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
parent_field = 'device'
model_form = forms.ConsoleServerPortForm
form_class = forms.ConsoleServerPortForm
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleserverport'
model = ConsoleServerPort
parent_field = 'device'
class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1200,7 +1268,6 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
model = PowerPort
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_powerport')
@@ -1282,14 +1349,12 @@ def powerport_disconnect(request, pk):
class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_powerport'
model = PowerPort
parent_field = 'device'
model_form = forms.PowerPortForm
form_class = forms.PowerPortForm
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_powerport'
model = PowerPort
parent_field = 'device'
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1317,7 +1382,6 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
model = PowerOutlet
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_poweroutlet')
@@ -1402,14 +1466,12 @@ def poweroutlet_disconnect(request, pk):
class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
parent_field = 'device'
model_form = forms.PowerOutletForm
form_class = forms.PowerOutletForm
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_poweroutlet'
model = PowerOutlet
parent_field = 'device'
class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1441,20 +1503,17 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
model = Interface
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
template_name = 'dcim/device_component_add.html'
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_interface'
model = Interface
parent_field = 'device'
model_form = forms.InterfaceForm
form_class = forms.InterfaceForm
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
parent_field = 'device'
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1495,20 +1554,17 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
model = DeviceBay
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_add.html'
class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_devicebay'
model = DeviceBay
parent_field = 'device'
model_form = forms.DeviceBayForm
form_class = forms.DeviceBayForm
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_devicebay'
model = DeviceBay
parent_field = 'device'
@permission_required('dcim.change_devicebay')
@@ -1569,67 +1625,109 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
# Bulk Device component creation
# Bulk device component creation
#
class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_consoleport'
parent_model = Device
parent_field = 'device'
class DeviceBulkAddComponentView(View):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = forms.DeviceBulkAddComponentForm
model = None
model_form = None
def get(self):
return redirect('dcim:device_list')
def post(self, request):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_create' in request.POST:
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for device in data['pk']:
names = data['name_pattern']
for name in names:
component_data = {
'device': device.pk,
'name': name,
}
component_data.update(data)
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, '{} {}: {}'.format(device, name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, "Added {} {} to {} devices.".format(
len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk'])
))
return redirect('dcim:device_list')
else:
form = self.form(initial={'pk': pk_list})
selected_devices = Device.objects.filter(pk__in=pk_list)
if not selected_devices:
messages.warning(request, "No devices were selected.")
return redirect('dcim:device_list')
return render(request, 'dcim/device_bulk_add_component.html', {
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'selected_devices': selected_devices,
'return_url': reverse('dcim:device_list'),
})
class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_consoleport'
model = ConsolePort
model_form = forms.ConsolePortForm
table = tables.DeviceTable
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView):
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_consoleserverport'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = ConsoleServerPort
model_form = forms.ConsoleServerPortForm
table = tables.DeviceTable
class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView):
class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_powerport'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = PowerPort
model_form = forms.PowerPortForm
table = tables.DeviceTable
class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView):
class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_poweroutlet'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = PowerOutlet
model_form = forms.PowerOutletForm
table = tables.DeviceTable
class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_interface'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddInterfaceForm
model = Interface
model_form = forms.InterfaceForm
table = tables.DeviceTable
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_devicebay'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = DeviceBay
model_form = forms.DeviceBayForm
table = tables.DeviceTable
#
@@ -1773,8 +1871,7 @@ class InterfaceConnectionsListView(ObjectListView):
class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_inventoryitem'
model = InventoryItem
parent_field = 'device'
model_form = forms.InventoryItemForm
form_class = forms.InventoryItemForm
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
@@ -1785,4 +1882,3 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_inventoryitem'
model = InventoryItem
parent_field = 'device'

View File

@@ -29,34 +29,47 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
for field_name, value in data.items():
cf = custom_fields[field_name]
try:
cf = custom_fields[field_name]
except KeyError:
raise ValidationError(
"Invalid custom field for {} objects: {}".format(content_type, field_name)
)
# Validate custom field name
if field_name not in custom_fields:
raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
# Data validation
if value not in [None, '']:
# Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value))
# Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError(
"Invalid value for boolean field {}: {}".format(field_name, value)
)
# Validate date
if cf.type == CF_TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(
field_name, value
))
# Validate date
if cf.type == CF_TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError(
"Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
)
# Validate selected choice
if cf.type == CF_TYPE_SELECT:
try:
value = int(value)
except ValueError:
raise ValidationError("{}: Choice selections must be passed as integers.".format(field_name))
valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError("Invalid choice for field {}: {}".format(field_name, value))
# Validate selected choice
if cf.type == CF_TYPE_SELECT:
try:
value = int(value)
except ValueError:
raise ValidationError(
"{}: Choice selections must be passed as integers.".format(field_name)
)
valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError(
"Invalid choice for field {}: {}".format(field_name, value)
)
elif cf.required:
raise ValidationError("Required field {} cannot be empty.".format(field_name))
# Check for missing required fields
missing_fields = []

View File

@@ -11,7 +11,7 @@ from .models import ImageAttachment
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_imageattachment'
model = ImageAttachment
model_form = ImageAttachmentForm
form_class = ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs):
if not imageattachment.pk:

View File

@@ -12,7 +12,6 @@ from ipam.models import (
)
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.api.serializers import NestedVirtualMachineSerializer
#
@@ -296,13 +295,12 @@ class AvailableIPSerializer(serializers.Serializer):
class ServiceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer()
virtual_machine = NestedVirtualMachineSerializer()
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
ipaddresses = NestedIPAddressSerializer(many=True)
class Meta:
model = Service
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
@@ -310,4 +308,4 @@ class WritableServiceSerializer(serializers.ModelSerializer):
class Meta:
model = Service
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']

View File

@@ -98,7 +98,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
# Create the new IP address
data = request.data.copy()
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
data['vrf'] = prefix.vrf
data['vrf'] = prefix.vrf.pk if prefix.vrf else None
serializer = serializers.WritableIPAddressSerializer(data=data)
if serializer.is_valid():
serializer.save()
@@ -134,11 +134,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
#
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'interface__device'
)
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer
filter_class = filters.IPAddressFilter

View File

@@ -70,7 +70,7 @@
"family": 4,
"address": "10.0.255.1/32",
"vrf": null,
"interface_id": 3,
"interface": 3,
"nat_inside": null,
"description": ""
}
@@ -84,7 +84,7 @@
"family": 4,
"address": "169.254.254.1/31",
"vrf": null,
"interface_id": 4,
"interface": 4,
"nat_inside": null,
"description": ""
}
@@ -98,7 +98,7 @@
"family": 4,
"address": "10.0.255.2/32",
"vrf": null,
"interface_id": 185,
"interface": 185,
"nat_inside": null,
"description": ""
}
@@ -112,7 +112,7 @@
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface_id": 213,
"interface": 213,
"nat_inside": null,
"description": ""
}
@@ -126,7 +126,7 @@
"family": 4,
"address": "10.0.254.1/24",
"vrf": null,
"interface_id": 12,
"interface": 12,
"nat_inside": null,
"description": ""
}
@@ -140,7 +140,7 @@
"family": 4,
"address": "10.15.21.1/31",
"vrf": null,
"interface_id": 218,
"interface": 218,
"nat_inside": null,
"description": ""
}
@@ -154,7 +154,7 @@
"family": 4,
"address": "10.15.21.2/31",
"vrf": null,
"interface_id": 9,
"interface": 9,
"nat_inside": null,
"description": ""
}
@@ -168,7 +168,7 @@
"family": 4,
"address": "10.15.22.1/31",
"vrf": null,
"interface_id": 8,
"interface": 8,
"nat_inside": null,
"description": ""
}
@@ -182,7 +182,7 @@
"family": 4,
"address": "10.15.20.1/31",
"vrf": null,
"interface_id": 7,
"interface": 7,
"nat_inside": null,
"description": ""
}
@@ -196,7 +196,7 @@
"family": 4,
"address": "10.16.20.1/31",
"vrf": null,
"interface_id": 216,
"interface": 216,
"nat_inside": null,
"description": ""
}
@@ -210,7 +210,7 @@
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface_id": 206,
"interface": 206,
"nat_inside": null,
"description": ""
}
@@ -224,7 +224,7 @@
"family": 4,
"address": "10.16.22.1/31",
"vrf": null,
"interface_id": 217,
"interface": 217,
"nat_inside": null,
"description": ""
}
@@ -238,7 +238,7 @@
"family": 4,
"address": "10.16.22.2/31",
"vrf": null,
"interface_id": 205,
"interface": 205,
"nat_inside": null,
"description": ""
}
@@ -252,7 +252,7 @@
"family": 4,
"address": "10.16.20.2/31",
"vrf": null,
"interface_id": 211,
"interface": 211,
"nat_inside": null,
"description": ""
}
@@ -266,7 +266,7 @@
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface_id": 212,
"interface": 212,
"nat_inside": null,
"description": ""
}
@@ -280,7 +280,7 @@
"family": 4,
"address": "10.0.254.2/32",
"vrf": null,
"interface_id": 188,
"interface": 188,
"nat_inside": null,
"description": ""
}
@@ -294,7 +294,7 @@
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface_id": 200,
"interface": 200,
"nat_inside": null,
"description": ""
}
@@ -308,7 +308,7 @@
"family": 4,
"address": "169.254.1.2/31",
"vrf": null,
"interface_id": 194,
"interface": 194,
"nat_inside": null,
"description": ""
}

View File

@@ -374,9 +374,50 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
#
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
interface = forms.ModelChoiceField(
interface_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'interface_rack'}
)
)
interface_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'interface_site'),
),
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
)
)
interface_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'interface_site'),
('rack', 'interface_rack'),
),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
interface = ChainedModelChoiceField(
queryset=Interface.objects.all(),
required=False
chains=(
('device', 'interface_device'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
)
)
nat_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
@@ -435,13 +476,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
obj_label='address'
)
)
primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
'nat_inside', 'tenant_group', 'tenant',
]
def __init__(self, *args, **kwargs):
@@ -449,6 +490,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.interface is not None:
initial['interface_site'] = instance.interface.device.site
initial['interface_rack'] = instance.interface.device.rack
initial['interface_device'] = instance.interface.device
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
@@ -459,30 +504,22 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
self.fields['vrf'].empty_label = 'Global'
# Limit interface selections to those belonging to the parent device/VM
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
)
else:
self.fields['interface'].choices = []
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.interface is not None:
parent = self.instance.interface.parent
# Initialize primary_for_device if IP address is already assigned
if self.instance.interface is not None:
device = self.instance.interface.device
if (
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
self.instance.address.version == 4 and device.primary_ip4 == self.instance or
self.instance.address.version == 6 and device.primary_ip6 == self.instance
):
self.initial['primary_for_parent'] = True
self.initial['primary_for_device'] = True
def clean(self):
super(IPAddressForm, self).clean()
# Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
self.add_error(
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
def save(self, *args, **kwargs):
@@ -490,13 +527,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
# Assign this IPAddress as the primary for the associated Device.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if self.cleaned_data['primary_for_device']:
device = self.cleaned_data['interface'].device
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress
device.primary_ip4 = ipaddress
else:
parent.primary_ip6 = ipaddress
parent.save()
device.primary_ip6 = ipaddress
device.save()
# Clear assignment as primary for device if set.
else:
@@ -514,7 +551,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
return ipaddress
class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
class IPAddressPatternForm(BootstrapMixin, forms.Form):
pattern = ExpandableIPAddressField(label='Address pattern')
@@ -858,14 +895,5 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
super(ServiceForm, self).__init__(*args, **kwargs)
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__device=self.instance.device
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__virtual_machine=self.instance.virtual_machine
)
else:
self.fields['ipaddresses'].choices = []
# Limit IP address choices to those assigned to interfaces of the parent device
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)

View File

@@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-31 15:44
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0001_virtualization'),
('ipam', '0018_remove_service_uniqueness_constraint'),
]
operations = [
migrations.AlterModelOptions(
name='service',
options={'ordering': ['protocol', 'port']},
),
migrations.AddField(
model_name='service',
name='virtual_machine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'),
),
migrations.AlterField(
model_name='service',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
),
]

View File

@@ -2,12 +2,10 @@ from __future__ import unicode_literals
import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
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
@@ -514,6 +512,16 @@ class VLANGroup(models.Model):
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
def get_next_available_vid(self):
"""
Return the first available VLAN ID (1-4094) in the group.
"""
vids = [vlan['vid'] for vlan in self.vlans.order_by('vid').values('vid')]
for i in range(1, 4095):
if i not in vids:
return i
return None
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
@@ -588,59 +596,20 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class Service(CreatedUpdatedModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
optionally be tied to one or more specific IPAddresses belonging to its parent.
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
to one or more specific IPAddresses belonging to the Device.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='services',
verbose_name='device',
null=True,
blank=True
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='services',
null=True,
blank=True
)
name = models.CharField(
max_length=30
)
protocol = models.PositiveSmallIntegerField(
choices=IP_PROTOCOL_CHOICES
)
port = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(65535)],
verbose_name='Port number'
)
ipaddresses = models.ManyToManyField(
to='ipam.IPAddress',
related_name='services',
blank=True,
verbose_name='IP addresses'
)
description = models.CharField(
max_length=100,
blank=True
)
device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
name = models.CharField(max_length=30)
protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
verbose_name='Port number')
ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
verbose_name='IP addresses')
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['protocol', 'port']
ordering = ['device', 'protocol', 'port']
def __str__(self):
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
@property
def parent(self):
return self.device or self.virtual_machine
def clean(self):
# A Service must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("A service must be associated with either a device or a virtual machine.")

View File

@@ -34,7 +34,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """
{% load helpers %}
{% if record.pk %}{% utilization_graph value %}{% else %}&mdash;{% endif %}
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
"""
ROLE_ACTIONS = """
@@ -77,9 +77,9 @@ IPADDRESS_LINK = """
{% endif %}
"""
IPADDRESS_PARENT = """
IPADDRESS_DEVICE = """
{% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
{% else %}
&mdash;
{% endif %}
@@ -120,6 +120,13 @@ VLAN_ROLE_LINK = """
"""
VLANGROUP_ACTIONS = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -203,10 +210,10 @@ class AggregateTable(BaseTable):
class AggregateDetailTable(AggregateTable):
child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(AggregateTable.Meta):
fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
#
@@ -249,10 +256,10 @@ class PrefixTable(BaseTable):
class PrefixDetailTable(PrefixTable):
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
class Meta(PrefixTable.Meta):
fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
#
@@ -265,12 +272,12 @@ class IPAddressTable(BaseTable):
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK)
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
interface = tables.Column(orderable=False)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
@@ -283,7 +290,7 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta):
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description',
)

View File

@@ -10,12 +10,11 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.generic import View
from dcim.models import Device, Interface
from dcim.models import Device
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .constants import IPADDRESS_ROLE_ANYCAST
from .models import (
@@ -119,7 +118,7 @@ class VRFView(View):
class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vrf'
model = VRF
model_form = forms.VRFForm
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list'
@@ -251,7 +250,7 @@ class RIRListView(ObjectListView):
class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_rir'
model = RIR
model_form = forms.RIRForm
form_class = forms.RIRForm
def get_return_url(self, request, obj):
return reverse('ipam:rir_list')
@@ -287,11 +286,12 @@ class AggregateListView(ObjectListView):
ipv4_total = 0
ipv6_total = 0
for a in self.queryset:
if a.prefix.version == 4:
ipv4_total += a.prefix.size
elif a.prefix.version == 6:
ipv6_total += a.prefix.size / 2 ** 64
for aggregate in self.queryset:
if aggregate.prefix.version == 6:
# Report equivalent /64s for IPv6 to keep things sane
ipv6_total += int(aggregate.prefix.size / 2 ** 64)
else:
ipv4_total += aggregate.prefix.size
return {
'ipv4_total': ipv4_total,
@@ -315,7 +315,7 @@ class AggregateView(View):
)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True
@@ -342,7 +342,7 @@ class AggregateView(View):
class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_aggregate'
model = Aggregate
model_form = forms.AggregateForm
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list'
@@ -396,7 +396,7 @@ class RoleListView(ObjectListView):
class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_role'
model = Role
model_form = forms.RoleForm
form_class = forms.RoleForm
def get_return_url(self, request, obj):
return reverse('ipam:role_list')
@@ -474,11 +474,11 @@ class PrefixView(View):
child_prefixes = Prefix.objects.filter(
vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
).select_related(
'site', 'role'
'site', 'vlan', 'role',
).annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes)
child_prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True
@@ -549,7 +549,7 @@ class PrefixIPAddressesView(View):
class PrefixCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_prefix'
model = Prefix
model_form = forms.PrefixForm
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list'
@@ -596,11 +596,7 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'interface__device', 'interface__virtual_machine'
)
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable
@@ -611,7 +607,7 @@ class IPAddressView(View):
def get(self, request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk)
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(
@@ -628,9 +624,7 @@ class IPAddressView(View):
).exclude(
pk=ipaddress.pk
).select_related(
'nat_inside'
).prefetch_related(
'interface__device'
'interface__device', 'nat_inside'
)
# Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
@@ -638,7 +632,7 @@ class IPAddressView(View):
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table
related_ips = IPAddress.objects.prefetch_related(
related_ips = IPAddress.objects.select_related(
'interface__device'
).exclude(
address=str(ipaddress.address)
@@ -658,21 +652,10 @@ class IPAddressView(View):
class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_ipaddress'
model = IPAddress
model_form = forms.IPAddressForm
form_class = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
default_return_url = 'ipam:ipaddress_list'
def alter_obj(self, obj, request, url_args, url_kwargs):
interface_id = request.GET.get('interface')
if interface_id:
try:
obj.interface = Interface.objects.get(pk=interface_id)
except (ValueError, Interface.DoesNotExist):
pass
return obj
class IPAddressEditView(IPAddressCreateView):
permission_required = 'ipam.change_ipaddress'
@@ -686,7 +669,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkCreateForm
pattern_form = forms.IPAddressPatternForm
model_form = forms.IPAddressBulkAddForm
pattern_target = 'address'
template_name = 'ipam/ipaddress_bulk_add.html'
@@ -703,7 +686,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
filter = filters.IPAddressFilter
table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm
@@ -713,7 +696,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
filter = filters.IPAddressFilter
table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list'
@@ -734,7 +717,7 @@ class VLANGroupListView(ObjectListView):
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlangroup'
model = VLANGroup
model_form = forms.VLANGroupForm
form_class = forms.VLANGroupForm
def get_return_url(self, request, obj):
return reverse('ipam:vlangroup_list')
@@ -785,7 +768,7 @@ class VLANView(View):
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlan'
model = VLAN
model_form = forms.VLANForm
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list'
@@ -833,18 +816,16 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_service'
model = Service
model_form = forms.ServiceForm
form_class = forms.ServiceForm
template_name = 'ipam/service_edit.html'
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
elif 'virtualmachine' in url_kwargs:
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
return obj
def get_return_url(self, request, obj):
return obj.parent.get_absolute_url()
return obj.device.get_absolute_url()
class ServiceEditView(ServiceCreateView):

View File

@@ -38,11 +38,14 @@ ADMINS = [
# ['John Doe', 'jdoe@example.com'],
]
# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
BANNER_TOP = ''
BANNER_BOTTOM = ''
# Text to include on the login page above the login form. HTML is allowed.
BANNER_LOGIN = ''
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = ''

View File

@@ -13,7 +13,7 @@ except ImportError:
)
VERSION = '2.2-beta1'
VERSION = '2.1.5'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -29,8 +29,9 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
# Import optional configuration parameters
ADMINS = getattr(configuration, 'ADMINS', [])
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
@@ -146,7 +147,6 @@ INSTALLED_APPS = (
'tenancy',
'users',
'utilities',
'virtualization',
)
# Middleware
@@ -234,6 +234,10 @@ REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'utilities.api.TokenPermissions',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
'utilities.api.FormlessBrowsableAPIRenderer',
),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT,

View File

@@ -32,7 +32,6 @@ _patterns = [
url(r'^secrets/', include('secrets.urls')),
url(r'^tenancy/', include('tenancy.urls')),
url(r'^user/', include('users.urls')),
url(r'^virtualization/', include('virtualization.urls')),
# API
url(r'^api/$', APIRootView.as_view(), name='api-root'),
@@ -42,7 +41,6 @@ _patterns = [
url(r'^api/ipam/', include('ipam.api.urls')),
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'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware

View File

@@ -25,9 +25,6 @@ from secrets.tables import SecretTable
from tenancy.filters import TenantFilter
from tenancy.models import Tenant
from tenancy.tables import TenantTable
from virtualization.filters import ClusterFilter, VirtualMachineFilter
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineTable
from .forms import SearchForm
@@ -93,7 +90,7 @@ SEARCH_TYPES = OrderedDict((
'url': 'ipam:prefix_list',
}),
('ipaddress', {
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'),
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
'filter': IPAddressFilter,
'table': IPAddressTable,
'url': 'ipam:ipaddress_list',
@@ -118,19 +115,6 @@ SEARCH_TYPES = OrderedDict((
'table': TenantTable,
'url': 'tenancy:tenant_list',
}),
# Virtualization
('cluster', {
'queryset': Cluster.objects.all(),
'filter': ClusterFilter,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'),
'filter': VirtualMachineFilter,
'table': VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
}),
))
@@ -166,10 +150,6 @@ class HomeView(View):
# Secrets
'secret_count': Secret.objects.count(),
# Virtualization
'cluster_count': Cluster.objects.count(),
'virtualmachine_count': VirtualMachine.objects.count(),
}
return render(request, self.template_name, {
@@ -236,15 +216,14 @@ class APIRootView(APIView):
def get(self, request, format=None):
return Response(OrderedDict((
('circuits', reverse('circuits-api:api-root', request=request, format=format)),
('dcim', reverse('dcim-api:api-root', request=request, format=format)),
('extras', reverse('extras-api:api-root', request=request, format=format)),
('ipam', reverse('ipam-api:api-root', request=request, format=format)),
('secrets', reverse('secrets-api:api-root', request=request, format=format)),
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
)))
return Response({
'circuits': reverse('circuits-api:api-root', request=request, format=format),
'dcim': reverse('dcim-api:api-root', request=request, format=format),
'extras': reverse('extras-api:api-root', request=request, format=format),
'ipam': reverse('ipam-api:api-root', request=request, format=format),
'secrets': reverse('secrets-api:api-root', request=request, format=format),
'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
})
def handle_500(request):

View File

@@ -329,13 +329,14 @@ li.occupied + li.available {
}
/* Devices */
table.component-list tr.ipaddress td {
background-color: #eeffff;
padding-bottom: 4px;
padding-top: 4px;
table.component-list td.subtable {
padding: 0;
padding-left: 16px;
}
table.component-list tr.ipaddress:hover td {
background-color: #e6f7f7;
table.component-list td.subtable td {
border: none;
padding-bottom: 6px;
padding-top: 6px;
}
/* AJAX loader */

View File

@@ -77,9 +77,7 @@ $(document).ready(function() {
// Wipe out any existing options within the child field and create a default option
child_field.empty();
if (!child_field.attr('multiple')) {
child_field.append($("<option></option>").attr("value", "").text("---------"));
}
child_field.append($("<option></option>").attr("value", "").text("---------"));
if ($(this).val() || $(this).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url') + '&limit=1000';

View File

@@ -42,7 +42,7 @@ class SecretRoleListView(ObjectListView):
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.add_secretrole'
model = SecretRole
model_form = forms.SecretRoleForm
form_class = forms.SecretRoleForm
def get_return_url(self, request, obj):
return reverse('secrets:secretrole_list')

View File

@@ -202,34 +202,6 @@
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'virtualization:cluster_list' %}"><strong>Clusters</strong></a></li>
{% if perms.virtualization.add_cluster %}
<li class="subnav"><a href="{% url 'virtualization:cluster_add' %}"><i class="fa fa-plus"></i> Add a Cluster</a></li>
<li class="subnav"><a href="{% url 'virtualization:cluster_import' %}"><i class="fa fa-download"></i> Import Clusters</a></li>
{% endif %}
{% if perms.virtualization.add_cluster or perms.virtualization.add_virtualmachine %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'virtualization:virtualmachine_list' %}"><strong>Virtual Machines</strong></a></li>
{% if perms.virtualization.add_virtualmachine %}
<li class="subnav"><a href="{% url 'virtualization:virtualmachine_add' %}"><i class="fa fa-plus"></i> Add a Virtual Machine</a></li>
<li class="subnav"><a href="{% url 'virtualization:virtualmachine_import' %}"><i class="fa fa-download"></i> Import Virtual Machines</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'virtualization:clustertype_list' %}"><strong>Cluster Types</strong></a></li>
{% if perms.virtualization.add_clustertype %}
<li class="subnav"><a href="{% url 'virtualization:clustertype_add' %}"><i class="fa fa-plus"></i> Add a Cluster Type</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'virtualization:clustergroup_list' %}"><strong>Cluster Groups</strong></a></li>
{% if perms.virtualization.add_clustergroup %}
<li class="subnav"><a href="{% url 'virtualization:clustergroup_add' %}"><i class="fa fa-plus"></i> Add a Cluster Group</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/circuits/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
<ul class="dropdown-menu">

View File

@@ -196,7 +196,7 @@
{% if services %}
<table class="table table-hover panel-body">
{% for service in services %}
{% include 'ipam/inc/service.html' %}
{% include 'dcim/inc/service.html' %}
{% endfor %}
</table>
{% else %}
@@ -206,7 +206,7 @@
{% endif %}
{% if perms.ipam.add_service %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
<a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
</a>
</div>

View File

@@ -14,7 +14,21 @@
<div class="row">
<div class="col-md-7">
<div class="panel panel-default">
{% include 'inc/table.html' %}
<div class="panel-heading"><strong>Selected Devices</strong></div>
<table class="panel-body table table-hover">
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
</tr>
{% for device in selected_devices %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type.full_name }}</td>
<td>{{ device.device_role }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="col-md-5">

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.replace(/(\/)/g, "\\$1"));
var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
var configured_device = row.children('td.configured_device').attr('data');
var configured_interface = row.children('td.configured_interface').attr('data');
// Add LLDP neighbors to table
@@ -62,7 +62,7 @@ $(document).ready(function() {
// Apply colors to rows
if (!configured_device && neighbor['hostname']) {
row.addClass('info');
} else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port']) {
} else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) {
row.addClass('success');
} else {
row.addClass('danger');

View File

@@ -1,4 +1,4 @@
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status %} success{% elif iface.connection and not iface.connection.connection_status %} info{% endif %}">
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
@@ -64,7 +64,7 @@
{% endif %}
{% endif %}
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
@@ -113,41 +113,55 @@
{% endif %}
</td>
</tr>
{% for ip in iface.ip_addresses.all %}
<tr class="ipaddress">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td></td>
{% endif %}
<td colspan="3">
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if ip.description %}
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
{% endif %}
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% with iface.ip_addresses.all as ipaddresses %}
{% if ipaddresses %}
<tr class="ipaddress">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td></td>
<td colspan="6" class="subtable">
{% else %}
<span class="text-muted">Global</span>
<td colspan="7" class="subtable">
{% endif %}
</td>
<td>
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td>
<td class="text-right">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
<table class="table table-hover">
{% for ip in ipaddresses %}
<tr>
<td>
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if ip.description %}
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
{% endif %}
</td>
<td>
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td>
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% else %}
<span class="text-muted">Global table</span>
{% endif %}
</td>
<td>
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td>
<td class="text-right">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
{% endif %}
{% endwith %}

View File

@@ -14,12 +14,12 @@
<td class="text-right">
{% if perms.ipam.change_service %}
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
<i class="glyphicon glyphicon-pencil"></i>
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.ipam.delete_service %}
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ service.parent.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" title="Delete service"></i>
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
</a>
{% endif %}
</td>

View File

@@ -55,23 +55,20 @@
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Virtualization</strong>
</div>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.cluster_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'virtualization:cluster_list' %}">Clusters</a></h4>
<p class="list-group-item-text text-muted">Clusters of physical hosts in which VMs reside</p>
{% if perms.secrets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group-item">
<span class="badge pull-right">{{ stats.virtualmachine_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></h4>
<p class="list-group-item-text text-muted">Virtual compute instances running inside clusters</p>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-sm-6 col-md-4">
<div class="panel panel-default">
@@ -123,20 +120,6 @@
</div>
</div>
</div>
{% if perms.secrets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-sm-6 col-md-4">
<div class="panel panel-default">

View File

@@ -20,11 +20,18 @@
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %}
<p class="text-right">IPv4 total: <strong>{{ ipv4_total|intcomma }} /32s</strong></p>
<p class="text-right">IPv6 total: <strong>{{ ipv6_total|intcomma }} /64s</strong></p>
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong><i class="fa fa-bar-chart"></i> Statistics</strong>
</div>
<ul class="list-group">
<li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
<li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
</ul>
</div>
</div>
</div>
{% endblock %}

View File

@@ -104,7 +104,7 @@
<td>Assignment</td>
<td>
{% if ipaddress.interface %}
<span><a href="{{ ipaddress.interface.parent.get_absolute_url }}">{{ ipaddress.interface.parent }}</a> ({{ ipaddress.interface }})</span>
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
{% else %}
<span class="text-muted">None</span>
{% endif %}
@@ -116,7 +116,7 @@
{% if ipaddress.nat_inside %}
<a href="{% url 'ipam:ipaddress' pk=ipaddress.nat_inside.pk %}">{{ ipaddress.nat_inside }}</a>
{% if ipaddress.nat_inside.interface %}
(<a href="{{ ipaddress.nat_inside.interface.parent.get_absolute_url }}">{{ ipaddress.nat_inside.interface.parent }}</a>)
(<a href="{% url 'dcim:device' pk=ipaddress.nat_inside.interface.device.pk %}">{{ ipaddress.nat_inside.interface.device }}</a>)
{% endif %}
{% else %}
<span class="text-muted">None</span>

View File

@@ -12,7 +12,7 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>IP Addresses</strong></div>
<div class="panel-body">
{% render_field form.pattern %}
{% render_field pattern_form.pattern %}
{% render_field model_form.status %}
{% render_field model_form.role %}
{% render_field model_form.vrf %}

View File

@@ -1,7 +1,6 @@
{% extends 'utilities/obj_edit.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% load helpers %}
{% block tabs %}
{% if not obj.pk %}
@@ -27,25 +26,18 @@
{% render_field form.tenant %}
</div>
</div>
{% if obj.interface %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label">{{ obj.interface.parent|model_name|bettertitle }}</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ obj.interface.parent.get_absolute_url }}">{{ obj.interface.parent }}</a>
</p>
</div>
</div>
{% render_field form.interface %}
{% render_field form.primary_for_parent %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
{% endif %}
<div class="panel-body">
{% render_field form.interface_site %}
{% render_field form.interface_rack %}
{% render_field form.interface_device %}
{% render_field form.interface %}
{% render_field form.primary_for_device %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
<div class="panel-body">

View File

@@ -5,21 +5,12 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>Service</strong></div>
<div class="panel-body">
{% if obj.device %}
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{{ obj.device }}</p>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Device</label>
<div class="col-md-9">
<p class="form-control-static">{{ obj.device }}</p>
</div>
{% else %}
<div class="form-group">
<label class="col-md-3 control-label required">Virtual Machine</label>
<div class="col-md-9">
<p class="form-control-static">{{ obj.virtual_machine }}</p>
</div>
</div>
{% endif %}
</div>
{% render_field form.name %}
<div class="form-group form-inline">
<label class="col-md-3 control-label required">Port</label>

View File

@@ -2,8 +2,13 @@
{% load form_helpers %}
{% block content %}
<div class="row" style="margin-top: 150px;">
<div class="row" style="margin-top: {% if settings.BANNER_LOGIN %}100{% else %}150{% endif %}px;">
<div class="col-sm-4 col-sm-offset-4">
{% if settings.BANNER_LOGIN %}
<div style="margin-bottom: 25px">
{{ settings.BANNER_LOGIN|safe }}
</div>
{% endif %}
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>

View File

@@ -124,10 +124,6 @@
<h2><a href="{% url 'circuits:circuit_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
<p>Circuits</p>
</div>
<div class="col-md-4 text-center">
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.virtualmachine_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.virtualmachine_count }}</a></h2>
<p>Virtual machines</p>
</div>
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="" method="post" class="form">
<form action="." method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}

View File

@@ -23,7 +23,7 @@
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="" method="post" class="form">
<form action="." method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}

View File

@@ -4,7 +4,7 @@
{% block content %}
<h1>{% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
<form action="" method="post" class="form form-horizontal">
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% if request.POST.return_url %}
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />

View File

@@ -1,38 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-danger">
<div class="panel-heading"><strong>Confirm Bulk Removal</strong></div>
<div class="panel-body">
<strong>Warning:</strong> The following operation will remove {{ table.rows|length }} {{ obj_type_plural }} from {{ parent_obj }}. Please carefully review the {{ obj_type_plural }} to be removed and confirm below.
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
{% include 'inc/table.html' %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="." method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="text-center">
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -2,7 +2,7 @@
{% load form_helpers %}
{% block content %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
<form action="." method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}

View File

@@ -15,7 +15,7 @@
</div>
</div>
{% endif %}
<form action="" method="post" class="form">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">

View File

@@ -1,117 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row" xmlns="http://www.w3.org/1999/html">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
{% if cluster.group %}
<li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
{% endif %}
<li>{{ cluster }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'virtualization:cluster_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search clusters" />
<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.virtualization.change_cluster %}
<a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this cluster
</a>
{% endif %}
{% if perms.dcim.delete_cluster %}
<a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this cluster
</a>
{% endif %}
</div>
<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=cluster %}
<div class="row">
<div class="col-md-5">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Cluster</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>{{ cluster.name }}</td>
</tr>
<tr>
<td>Type</td>
<td><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></td>
</tr>
<tr>
<td>Group</td>
<td>
{% if cluster.group %}
<a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Virtual Machines</td>
<td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
</tr>
</table>
</div>
{% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
{% if cluster.comments %}
{{ cluster.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Host Devices</strong>
</div>
{% if perms.virtualization.change_cluster %}
<form action="{% url 'virtualization:cluster_remove_devices' pk=cluster.pk %}" method="post">
{% csrf_token %}
{% endif %}
{% include 'responsive_table.html' with table=device_table %}
{% if perms.virtualization.change_cluster %}
<div class="panel-footer">
<div class="pull-right">
<a href="{% url 'virtualization:cluster_add_devices' pk=cluster.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add devices
</a>
</div>
<button type="submit" name="_remove" class="btn btn-danger primary btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Remove devices
</button>
</div>
</form>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h3>{% block title %}Add Devices to Cluster {{ cluster }}{% endblock %}</h3>
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Devices</strong></div>
<div class="panel-body">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.rack %}
{% render_field form.devices %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
<button type="submit" name="_add" class="btn btn-primary">Add Devices</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@@ -1,26 +0,0 @@
{% extends '_base.html' %}
{% block content %}
<div class="pull-right">
{% if perms.virtualization.add_cluster %}
<a href="{% url 'virtualization:cluster_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a cluster
</a>
<a href="{% url 'virtualization:cluster_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import clusters
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='clusters' %}
</div>
<h1>{% block title %}Clusters{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:cluster_bulk_edit' bulk_delete_url='virtualization:cluster_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,19 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="pull-right">
{% if perms.virtualization.add_clustergroup %}
<a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a cluster group
</a>
{% endif %}
</div>
<h1>{% block title %}Cluster Groups{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustergroup_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@@ -1,19 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="pull-right">
{% if perms.virtualization.add_clustertype %}
<a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a cluster type
</a>
{% endif %}
</div>
<h1>{% block title %}Cluster Types{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustertype_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@@ -1,70 +0,0 @@
<tr class="interface{% if not iface.enabled %} danger{% endif %}">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
</td>
{% endif %}
<td>
<i class="fa fa-fw fa-square"></i> <span>{{ iface.name }}</span>
{% if iface.description %}
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
{% endif %}
</td>
<td>{{ iface.mtu|default:"" }}</td>
<td>{{ iface.mac_address|default:"" }}</td>
<td class="text-right">
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ vm.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_interface %}
<a href="{% url 'virtualization:interface_edit' pk=iface.pk %}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.delete_interface %}
<a href="{% url 'virtualization:interface_delete' pk=iface.pk %}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% for ip in iface.ip_addresses.all %}
<tr class="ipaddress">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td></td>
{% endif %}
<td>
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if ip.description %}
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
{% endif %}
{% if vm.primary_ip4 == ip or vm.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% else %}
<span class="text-muted">Global</span>
{% endif %}
</td>
<td>
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td>
<td class="text-right">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ vm.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ vm.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}

View File

@@ -1,14 +0,0 @@
{% extends 'utilities/obj_table.html' %}
{% block extra_actions %}
{% if perms.virtualization.change_virtualmachine %}
<div class="btn-group">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,283 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
{% if vm.cluster %}
<li><a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a></li>
{% endif %}
<li>{{ vm }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.virtualization.change_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_edit' pk=vm.pk %}" class="btn btn-warning">
<span class="fa fa-pencil"></span>
Edit this VM
</a>
{% endif %}
{% if perms.virtualization.delete_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_delete' pk=vm.pk %}" class="btn btn-danger">
<span class="fa fa-trash"></span>
Delete this VM
</a>
{% endif %}
</div>
<h1>{% block title %}{{ vm }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vm %}
<div class="row">
<div class="col-md-5">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Virtual Machine</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>{{ vm.name }}</td>
</tr>
<tr>
<td>Status</td>
<td>
<span class="label label-{{ vm.get_status_class }}">{{ vm.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Cluster</td>
<td>
{% if vm.cluster.group %}
<a href="{{ vm.cluster.group.get_absolute_url }}">{{ vm.cluster.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a>
</td>
</tr>
<tr>
<td>Cluster Type</td>
<td>{{ vm.cluster.type }}</td>
</tr>
<tr>
<td>Platform</td>
<td>
{% if vm.platform %}
<span>{{ vm.platform }}</span>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if vm.tenant %}
{% if vm.tenant.group %}
<a href="{{ vm.tenant.group.get_absolute_url }}">{{ vm.tenant.group.name }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ vm.tenant.get_absolute_url }}">{{ vm.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Primary IPv4</td>
<td>
{% if vm.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip4.pk %}">{{ vm.primary_ip4.address.ip }}</a>
{% if vm.primary_ip4.nat_inside %}
<span>(NAT for {{ vm.primary_ip4.nat_inside.address.ip }})</span>
{% elif vm.primary_ip4.nat_outside %}
<span>(NAT: {{ vm.primary_ip4.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Primary IPv6</td>
<td>
{% if vm.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip6.pk %}">{{ vm.primary_ip6.address.ip }}</a>
{% if vm.primary_ip6.nat_inside %}
<span>(NAT for {{ vm.primary_ip6.nat_inside.address.ip }})</span>
{% elif vm.primary_ip6.nat_outside %}
<span>(NAT: {{ vm.primary_ip6.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Resources</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
<td>
{% if vm.vcpus %}
{{ vm.vcpus }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td><i class="fa fa-microchip"></i> Memory</td>
<td>
{% if vm.memory %}
{{ vm.memory }} MB
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td><i class="fa fa-hdd-o"></i> Disk Space</td>
<td>
{% if vm.disk %}
{{ vm.disk }} GB
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Services</strong>
</div>
{% if services %}
<table class="table table-hover panel-body">
{% for service in services %}
{% include 'ipam/inc/service.html' %}
{% endfor %}
</table>
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
{% if perms.ipam.add_service %}
<div class="panel-footer text-right">
<a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=vm.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
</a>
</div>
{% endif %}
</div>
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
{% if vm.comments %}
{{ vm.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-7">
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="virtual_machine" value="{{ vm.pk }}" />
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
{% if perms.dcim.change_interface and interfaces|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_interface and interfaces|length > 10 %}
<a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
{% endif %}
</div>
</div>
<table id="interfaces_table" class="table table-hover panel-body component-list">
{% for iface in interfaces %}
{% include 'virtualization/inc/interface.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No interfaces defined</td>
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
<div class="panel-footer">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=vm.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
{% if perms.dcim.delete_interface %}
</form>
{% endif %}
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddress').hide();
} else {
$('#interfaces_table tr.ipaddress').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});
</script>
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>{{ component_type|bettertitle }}</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Virtual Machine</label>
<div class="col-md-9">
<p class="form-control-static">{{ parent }}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Virtual Machine</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.status %}
{% render_field form.cluster_group %}
{% render_field form.cluster %}
{% render_field form.platform %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Resources</strong></div>
<div class="panel-body">
{% render_field form.vcpus %}
{% render_field form.memory %}
{% render_field form.disk %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">
{% render_field form.comments %}
</div>
</div>
{% endblock %}

View File

@@ -1,55 +0,0 @@
{% extends '_base.html' %}
{% block content %}
<div class="pull-right">
{% if perms.virtualization.add_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a virtual machine
</a>
<a href="{% url 'virtualization:virtualmachine_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import virtual machines
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='virtual machines' %}
</div>
<h1>{% block title %}Virtual Machines{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'virtualization/inc/virtualmachine_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
var cluster_group_list = $('#id_cluster_group');
var cluster_list = $('#id_cluster_id');
// Update cluster options based on selected group
cluster_group_list.change(function() {
var selected_groups = $(this).val();
if (selected_groups) {
cluster_list.empty();
$.ajax({
url: netbox_api_path + 'virtualization/clusters/?limit=500&group=' + selected_groups.join('&group='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, cluster) {
var option = $("<option></option>").attr("value", cluster.id).text(cluster.name);
cluster_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %}

View File

@@ -12,7 +12,6 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from virtualization.models import VirtualMachine
from .models import Tenant, TenantGroup
from . import filters, forms, tables
@@ -30,7 +29,7 @@ class TenantGroupListView(ObjectListView):
class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.add_tenantgroup'
model = TenantGroup
model_form = forms.TenantGroupForm
form_class = forms.TenantGroupForm
def get_return_url(self, request, obj):
return reverse('tenancy:tenantgroup_list')
@@ -80,7 +79,6 @@ class TenantView(View):
).count(),
'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(),
}
return render(request, 'tenancy/tenant.html', {
@@ -92,7 +90,7 @@ class TenantView(View):
class TenantCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.add_tenant'
model = Tenant
model_form = forms.TenantForm
form_class = forms.TenantForm
template_name = 'tenancy/tenant_edit.html'
default_return_url = 'tenancy:tenant_list'

View File

@@ -8,6 +8,7 @@ from rest_framework.compat import is_authenticated
from rest_framework.exceptions import APIException
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.views import get_view_name as drf_get_view_name
@@ -206,6 +207,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return self.default_limit
#
# Renderers
#
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
"""
Override the built-in BrowsableAPIRenderer to disable HTML forms.
"""
def show_form_for_method(self, *args, **kwargs):
return False
#
# Miscellaneous
#

View File

@@ -188,10 +188,6 @@ class APISelect(SelectWithDisabled):
self.attrs['disabled-indicator'] = disabled_indicator
class APISelectMultiple(APISelect):
allow_multiple_selected = True
class Livesearch(forms.TextInput):
"""
A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
@@ -390,15 +386,6 @@ class ChainedModelChoiceField(forms.ModelChoiceField):
super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
See ChainedModelChoiceField
"""
def __init__(self, chains=None, *args, **kwargs):
self.chains = chains
super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs)
class SlugField(forms.SlugField):
def __init__(self, slug_source='name', *args, **kwargs):
@@ -521,15 +508,6 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
class ComponentForm(BootstrapMixin, forms.Form):
"""
Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
"""
def __init__(self, parent, *args, **kwargs):
self.parent = parent
super(ComponentForm, self).__init__(*args, **kwargs)
class BulkEditForm(forms.Form):
def __init__(self, model, *args, **kwargs):

View File

@@ -46,22 +46,6 @@ def gfm(value):
return mark_safe(html)
@register.filter()
def model_name(obj):
"""
Return the name of the model of the given object
"""
return obj._meta.verbose_name
@register.filter()
def model_name_plural(obj):
"""
Return the plural name of the model of the given object
"""
return obj._meta.verbose_name_plural
@register.filter()
def contains(value, arg):
"""

View File

@@ -1,6 +1,5 @@
from __future__ import unicode_literals
from collections import OrderedDict
from copy import deepcopy
from django_tables2 import RequestConfig
@@ -156,12 +155,12 @@ class ObjectEditView(GetReturnURLMixin, View):
Create or edit a single object.
model: The model of the object being edited
model_form: The form used to create or edit the object
form_class: The form used to create or edit the object
template_name: The name of the template
default_return_url: The name of the URL used to display a list of this object type
"""
model = None
model_form = None
form_class = None
template_name = 'utilities/obj_edit.html'
def get_object(self, kwargs):
@@ -183,7 +182,7 @@ class ObjectEditView(GetReturnURLMixin, View):
obj = self.alter_obj(obj, request, args, kwargs)
# Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET}
form = self.model_form(instance=obj, initial=initial_data)
form = self.form_class(instance=obj, initial=initial_data)
return render(request, self.template_name, {
'obj': obj,
@@ -196,7 +195,7 @@ class ObjectEditView(GetReturnURLMixin, View):
obj = self.get_object(kwargs)
obj = self.alter_obj(obj, request, args, kwargs)
form = self.model_form(request.POST, request.FILES, instance=obj)
form = self.form_class(request.POST, request.FILES, instance=obj)
if form.is_valid():
obj_created = not form.instance.pk
@@ -215,7 +214,7 @@ class ObjectEditView(GetReturnURLMixin, View):
UserAction.objects.log_edit(request.user, obj, msg)
if '_addanother' in request.POST:
return redirect(request.get_full_path())
return redirect(request.path)
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
@@ -295,12 +294,12 @@ class BulkCreateView(View):
"""
Create new objects in bulk.
form: Form class which provides the `pattern` field
pattern_form: Form class which provides the `pattern` field
model_form: The ModelForm used to create individual objects
template_name: The name of the template
default_return_url: Name of the URL to which the user is redirected after creating the objects
"""
form = None
pattern_form = None
model_form = None
pattern_target = ''
template_name = None
@@ -308,12 +307,12 @@ class BulkCreateView(View):
def get(self, request):
form = self.form()
pattern_form = self.pattern_form()
model_form = self.model_form()
return render(request, self.template_name, {
'obj_type': self.model_form._meta.model._meta.verbose_name,
'form': form,
'pattern_form': pattern_form,
'model_form': model_form,
'return_url': reverse(self.default_return_url),
})
@@ -321,12 +320,12 @@ class BulkCreateView(View):
def post(self, request):
model = self.model_form._meta.model
form = self.form(request.POST)
pattern_form = self.pattern_form(request.POST)
model_form = self.model_form(request.POST)
if form.is_valid():
if pattern_form.is_valid():
pattern = form.cleaned_data['pattern']
pattern = pattern_form.cleaned_data['pattern']
new_objs = []
try:
@@ -348,7 +347,7 @@ class BulkCreateView(View):
# Copy any errors on the pattern target field to the pattern form.
errors = model_form.errors.as_data()
if errors.get(self.pattern_target):
form.add_error('pattern', errors[self.pattern_target])
pattern_form.add_error('pattern', errors[self.pattern_target])
# Raise an IntegrityError to break the for loop and abort the transaction.
raise IntegrityError()
@@ -365,7 +364,7 @@ class BulkCreateView(View):
pass
return render(request, self.template_name, {
'form': form,
'pattern_form': pattern_form,
'model_form': model_form,
'obj_type': model._meta.verbose_name,
'return_url': reverse(self.default_return_url),
@@ -479,7 +478,7 @@ class BulkEditView(View):
template_name = 'utilities/obj_bulk_edit.html'
default_return_url = 'home'
def get(self):
def get(self, request):
return redirect(self.default_return_url)
def post(self, request, **kwargs):
@@ -627,6 +626,9 @@ class BulkDeleteView(View):
template_name = 'utilities/obj_bulk_delete.html'
default_return_url = 'home'
def get(self, request):
return redirect(self.default_return_url)
def post(self, request, **kwargs):
# Attempt to derive parent object if a parent class has been given
@@ -698,173 +700,3 @@ class BulkDeleteView(View):
if self.form:
return self.form
return BulkDeleteForm
#
# Device/VirtualMachine components
#
class ComponentCreateView(View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
"""
parent_model = None
parent_field = None
model = None
form = None
model_form = None
template_name = None
def get(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, initial=request.GET)
return render(request, self.template_name, {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'return_url': parent.get_absolute_url(),
})
def post(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for name in form.cleaned_data['name_pattern']:
component_data = {
self.parent_field: parent.pk,
'name': name,
}
# Replace objects with their primary key to keep component_form.clean() happy
for k, v in data.items():
if hasattr(v, 'pk'):
component_data[k] = v.pk
else:
component_data[k] = v
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
# Assign errors on the child form's name field to name_pattern on the parent form
if field == 'name':
field = 'name_pattern'
for e in errors:
form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, "Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent
))
if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect(parent.get_absolute_url())
return render(request, self.template_name, {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'return_url': parent.get_absolute_url(),
})
class ComponentEditView(ObjectEditView):
parent_field = None
def get_return_url(self, request, obj):
return getattr(obj, self.parent_field).get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
parent_field = None
def get_return_url(self, request, obj):
return getattr(obj, self.parent_field).get_absolute_url()
class BulkComponentCreateView(View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
"""
parent_model = None
parent_field = None
form = None
model = None
model_form = None
filter = None
table = None
template_name = 'utilities/obj_bulk_add_component.html'
default_return_url = 'home'
def post(self, request):
# Are we editing *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.model.objects.only('pk')).qs]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
# Determine URL to redirect users upon modification of objects
posted_return_url = request.POST.get('return_url')
if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
return_url = posted_return_url
else:
return_url = reverse(self.default_return_url)
selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
if not selected_objects:
messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
return redirect(return_url)
table = self.table(selected_objects)
if '_create' in request.POST:
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for obj in data['pk']:
names = data['name_pattern']
for name in names:
component_data = {
self.parent_field: obj.pk,
'name': name,
}
component_data.update(data)
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, "Added {} {} to {} {}.".format(
len(new_components),
self.model._meta.verbose_name_plural,
len(form.cleaned_data['pk']),
self.parent_model._meta.verbose_name_plural
))
return redirect(return_url)
else:
form = self.form(initial={'pk': pk_list})
return render(request, self.template_name, {
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'table': table,
'return_url': reverse('dcim:device_list'),
})

View File

@@ -1,146 +0,0 @@
from __future__ import unicode_literals
from rest_framework import serializers
from dcim.api.serializers import NestedPlatformSerializer
from dcim.constants import VIFACE_FF_CHOICES
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.constants import STATUS_CHOICES
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
#
# Cluster types
#
class ClusterTypeSerializer(ValidatedModelSerializer):
class Meta:
model = ClusterType
fields = ['id', 'name', 'slug']
class NestedClusterTypeSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
class Meta:
model = ClusterType
fields = ['id', 'url', 'name', 'slug']
#
# Cluster groups
#
class ClusterGroupSerializer(ValidatedModelSerializer):
class Meta:
model = ClusterGroup
fields = ['id', 'name', 'slug']
class NestedClusterGroupSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
class Meta:
model = ClusterGroup
fields = ['id', 'url', 'name', 'slug']
#
# Clusters
#
class ClusterSerializer(CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer()
class Meta:
model = Cluster
fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
class NestedClusterSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
model = Cluster
fields = ['id', 'url', 'name']
class WritableClusterSerializer(CustomFieldModelSerializer):
class Meta:
model = Cluster
fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
#
# Virtual machines
#
class VirtualMachineSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
cluster = NestedClusterSerializer()
tenant = NestedTenantSerializer()
platform = NestedPlatformSerializer()
class Meta:
model = VirtualMachine
fields = [
'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments',
'custom_fields',
]
class NestedVirtualMachineSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
class Meta:
model = VirtualMachine
fields = ['id', 'url', 'name']
class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
class Meta:
model = VirtualMachine
fields = [
'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments',
'custom_fields',
]
#
# VM interfaces
#
class InterfaceSerializer(serializers.ModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
form_factor = ChoiceFieldSerializer(choices=VIFACE_FF_CHOICES)
class Meta:
model = Interface
fields = [
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
]
class NestedInterfaceSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'name']
class WritableInterfaceSerializer(ValidatedModelSerializer):
class Meta:
model = Interface
fields = [
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
]

View File

@@ -1,29 +0,0 @@
from __future__ import unicode_literals
from rest_framework import routers
from . import views
class VirtualizationRootView(routers.APIRootView):
"""
Virtualization API root view
"""
def get_view_name(self):
return 'Virtualization'
router = routers.DefaultRouter()
router.APIRootView = VirtualizationRootView
# Clusters
router.register(r'cluster-types', views.ClusterTypeViewSet)
router.register(r'cluster-groups', views.ClusterGroupViewSet)
router.register(r'clusters', views.ClusterViewSet)
# VirtualMachines
router.register(r'virtual-machines', views.VirtualMachineViewSet)
router.register(r'interfaces', views.InterfaceViewSet)
app_name = 'virtualization-api'
urlpatterns = router.urls

View File

@@ -1,48 +0,0 @@
from __future__ import unicode_literals
from rest_framework.viewsets import ModelViewSet
from dcim.models import Interface
from extras.api.views import CustomFieldModelViewSet
from utilities.api import WritableSerializerMixin
from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from . import serializers
#
# Clusters
#
class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.all()
serializer_class = serializers.ClusterTypeSerializer
class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.all()
serializer_class = serializers.ClusterGroupSerializer
class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Cluster.objects.select_related('type', 'group')
serializer_class = serializers.ClusterSerializer
write_serializer_class = serializers.WritableClusterSerializer
filter_class = filters.ClusterFilter
#
# Virtual machines
#
class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all()
serializer_class = serializers.VirtualMachineSerializer
write_serializer_class = serializers.WritableVirtualMachineSerializer
filter_class = filters.VirtualMachineFilter
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
serializer_class = serializers.InterfaceSerializer
write_serializer_class = serializers.WritableInterfaceSerializer

View File

@@ -1,7 +0,0 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class VirtualizationConfig(AppConfig):
name = 'virtualization'

View File

@@ -1,19 +0,0 @@
from __future__ import unicode_literals
# VirtualMachine statuses (replicated from Device statuses)
STATUS_OFFLINE = 0
STATUS_ACTIVE = 1
STATUS_STAGED = 3
STATUS_CHOICES = [
[STATUS_ACTIVE, 'Active'],
[STATUS_OFFLINE, 'Offline'],
[STATUS_STAGED, 'Staged'],
]
# Bootstrap CSS classes for VirtualMachine statuses
VM_STATUS_CLASSES = {
0: 'warning',
1: 'success',
3: 'primary',
}

View File

@@ -1,103 +0,0 @@
from __future__ import unicode_literals
import django_filters
from django.db.models import Q
from dcim.models import Platform
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .constants import STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class ClusterFilter(CustomFieldFilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
group_id = NullableModelMultipleChoiceFilter(
queryset=ClusterGroup.objects.all(),
label='Parent group (ID)',
)
group = NullableModelMultipleChoiceFilter(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label='Parent group (slug)',
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ClusterType.objects.all(),
label='Cluster type (ID)',
)
type = django_filters.ModelMultipleChoiceFilter(
name='type__slug',
queryset=ClusterType.objects.all(),
to_field_name='slug',
label='Cluster type (slug)',
)
class Meta:
model = Cluster
fields = ['name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(comments__icontains=value)
)
class VirtualMachineFilter(CustomFieldFilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
status = django_filters.MultipleChoiceFilter(
choices=STATUS_CHOICES
)
cluster_group_id = NullableModelMultipleChoiceFilter(
name='cluster__group',
queryset=ClusterGroup.objects.all(),
label='Cluster group (ID)',
)
cluster_group = NullableModelMultipleChoiceFilter(
name='cluster__group__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label='Cluster group (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
platform_id = NullableModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
)
class Meta:
model = VirtualMachine
fields = ['name', 'cluster']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(comments__icontains=value)
)

View File

@@ -1,335 +0,0 @@
from __future__ import unicode_literals
from mptt.forms import TreeNodeChoiceField
from django import forms
from django.db.models import Count
from dcim.constants import VIFACE_FF_CHOICES
from dcim.formfields import MACAddressFormField
from dcim.models import Device, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
)
from .constants import STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
#
# Cluster types
#
class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = ClusterType
fields = ['name', 'slug']
#
# Cluster groups
#
class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = ClusterGroup
fields = ['name', 'slug']
#
# Clusters
#
class ClusterForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Cluster
fields = ['name', 'type', 'group']
class ClusterCSVForm(forms.ModelForm):
type = forms.ModelChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='name',
help_text='Name of cluster type',
error_messages={
'invalid_choice': 'Invalid cluster type name.',
}
)
group = forms.ModelChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='name',
required=False,
help_text='Name of cluster group',
error_messages={
'invalid_choice': 'Invalid cluster group name.',
}
)
class Meta:
model = Cluster
fields = ['name', 'type', 'group']
class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False)
class Meta:
nullable_fields = ['group']
class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Cluster
q = forms.CharField(required=False, label='Search')
group = FilterChoiceField(
queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
to_field_name='slug',
null_option=(0, 'None'),
required=False,
)
type = FilterChoiceField(
queryset=ClusterType.objects.annotate(filter_count=Count('clusters')),
to_field_name='slug',
required=False,
)
class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
region = TreeNodeChoiceField(
queryset=Region.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'site', 'nullable': 'true'}
)
)
site = ChainedModelChoiceField(
queryset=Site.objects.all(),
chains=(
('region', 'region'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/sites/?region_id={{region}}',
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'devices', 'nullable': 'true'}
)
)
devices = ChainedModelMultipleChoiceField(
queryset=Device.objects.filter(cluster__isnull=True),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
disabled_indicator='cluster'
)
)
class Meta:
fields = ['region', 'site', 'rack', 'devices']
def __init__(self, *args, **kwargs):
super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
self.fields['devices'].choices = []
class ClusterRemoveDevicesForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
#
# Virtual Machines
#
class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
cluster_group = forms.ModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'cluster', 'nullable': 'true'}
)
)
cluster = ChainedModelChoiceField(
queryset=Cluster.objects.all(),
chains=(
('group', 'cluster_group'),
),
widget=APISelect(
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
)
)
class Meta:
model = VirtualMachine
fields = [
'name', 'status', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
]
def __init__(self, *args, **kwargs):
# Initialize helper selector
instance = kwargs.get('instance')
if instance.pk and instance.cluster is not None:
initial = kwargs.get('initial', {}).copy()
initial['cluster_group'] = instance.cluster.group
kwargs['initial'] = initial
super(VirtualMachineForm, self).__init__(*args, **kwargs)
class VirtualMachineCSVForm(forms.ModelForm):
status = CSVChoiceField(
choices=STATUS_CHOICES,
required=False,
help_text='Operational status of device'
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
help_text='Name of parent cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.'
}
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned platform',
error_messages={
'invalid_choice': 'Invalid platform.',
}
)
class Meta:
model = VirtualMachine
fields = ['name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
vcpus = forms.IntegerField(required=False, label='vCPUs')
memory = forms.IntegerField(required=False, label='Memory (MB)')
disk = forms.IntegerField(required=False, label='Disk (GB)')
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['tenant', 'platform', 'vcpus', 'memory', 'disk']
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 STATUS_CHOICES]
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VirtualMachine
q = forms.CharField(required=False, label='Search')
cluster_group = FilterChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
null_option=(0, 'None'),
)
cluster_id = FilterChoiceField(
queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
label='Cluster'
)
status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
#
# VM interfaces
#
class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
widgets = {
'virtual_machine': forms.HiddenInput(),
}
class InterfaceCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address')
description = forms.CharField(max_length=100, required=False)
def __init__(self, *args, **kwargs):
# Set interfaces enabled by default
kwargs['initial'] = kwargs.get('initial', {}).copy()
kwargs['initial'].update({'enabled': True})
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['mtu', 'description']
#
# Bulk VirtualMachine component creation
#
class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
name_pattern = ExpandableNameField(label='Name')
class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False, initial=True)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
description = forms.CharField(max_length=100, required=False)

View File

@@ -1,89 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-31 14:15
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import extras.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('ipam', '0018_remove_service_uniqueness_constraint'),
('dcim', '0043_device_component_name_lengths'),
('tenancy', '0003_unicode_literals'),
]
operations = [
migrations.CreateModel(
name='Cluster',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=100, unique=True)),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
bases=(models.Model, extras.models.CustomFieldModel),
),
migrations.CreateModel(
name='ClusterGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ClusterType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='VirtualMachine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=64, unique=True)),
('vcpus', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='vCPUs')),
('memory', models.PositiveIntegerField(blank=True, null=True, verbose_name='Memory (MB)')),
('disk', models.PositiveIntegerField(blank=True, null=True, verbose_name='Disk (GB)')),
('comments', models.TextField(blank=True)),
('cluster', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.Cluster')),
('platform', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='virtual_machines', to='dcim.Platform')),
('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv4')),
('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv6')),
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='tenancy.Tenant')),
],
options={
'ordering': ['name'],
},
bases=(models.Model, extras.models.CustomFieldModel),
),
migrations.AddField(
model_name='cluster',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterGroup'),
),
migrations.AddField(
model_name='cluster',
name='type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterType'),
),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-09-14 17:49
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0001_virtualization'),
]
operations = [
migrations.AddField(
model_name='virtualmachine',
name='status',
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'),
),
]

View File

@@ -1,198 +0,0 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel
from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
#
# Cluster types
#
@python_2_unicode_compatible
class ClusterType(models.Model):
"""
A type of Cluster.
"""
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
#
# Cluster groups
#
@python_2_unicode_compatible
class ClusterGroup(models.Model):
"""
An organizational group of Clusters.
"""
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
#
# Clusters
#
@python_2_unicode_compatible
class Cluster(CreatedUpdatedModel, CustomFieldModel):
"""
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
"""
name = models.CharField(
max_length=100,
unique=True
)
type = models.ForeignKey(
to=ClusterType,
on_delete=models.PROTECT,
related_name='clusters'
)
group = models.ForeignKey(
to=ClusterGroup,
on_delete=models.PROTECT,
related_name='clusters',
blank=True,
null=True
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to=CustomFieldValue,
content_type_field='obj_type',
object_id_field='obj_id'
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('virtualization:cluster', args=[self.pk])
#
# Virtual machines
#
@python_2_unicode_compatible
class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual machine which runs inside a Cluster.
"""
cluster = models.ForeignKey(
to=Cluster,
on_delete=models.PROTECT,
related_name='virtual_machines'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='virtual_machines',
blank=True,
null=True
)
platform = models.ForeignKey(
to='dcim.Platform',
on_delete=models.SET_NULL,
related_name='virtual_machines',
blank=True,
null=True
)
name = models.CharField(
max_length=64,
unique=True
)
status = models.PositiveSmallIntegerField(
choices=STATUS_CHOICES,
default=STATUS_ACTIVE,
verbose_name='Status'
)
primary_ip4 = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv4'
)
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv6'
)
vcpus = models.PositiveSmallIntegerField(
blank=True,
null=True,
verbose_name='vCPUs'
)
memory = models.PositiveIntegerField(
blank=True,
null=True,
verbose_name='Memory (MB)'
)
disk = models.PositiveIntegerField(
blank=True,
null=True,
verbose_name='Disk (GB)'
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to=CustomFieldValue,
content_type_field='obj_type',
object_id_field='obj_id'
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk])
def get_status_class(self):
return VM_STATUS_CLASSES[self.status]

View File

@@ -1,103 +0,0 @@
from __future__ import unicode_literals
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Interface
from utilities.tables import BaseTable, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
CLUSTERTYPE_ACTIONS = """
{% if perms.virtualization.change_clustertype %}
<a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
CLUSTERGROUP_ACTIONS = """
{% if perms.virtualization.change_clustergroup %}
<a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
VIRTUALMACHINE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
#
# Cluster types
#
class ClusterTypeTable(BaseTable):
pk = ToggleColumn()
cluster_count = tables.Column(verbose_name='Clusters')
actions = tables.TemplateColumn(
template_code=CLUSTERTYPE_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = ClusterType
fields = ('pk', 'name', 'cluster_count', 'actions')
#
# Cluster groups
#
class ClusterGroupTable(BaseTable):
pk = ToggleColumn()
cluster_count = tables.Column(verbose_name='Clusters')
actions = tables.TemplateColumn(
template_code=CLUSTERGROUP_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = ClusterGroup
fields = ('pk', 'name', 'cluster_count', 'actions')
#
# Clusters
#
class ClusterTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
device_count = tables.Column(verbose_name='Devices')
vm_count = tables.Column(verbose_name='VMs')
class Meta(BaseTable.Meta):
model = Cluster
fields = ('pk', 'name', 'type', 'group', 'device_count', 'vm_count')
#
# Virtual machines
#
class VirtualMachineTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
class Meta(BaseTable.Meta):
model = VirtualMachine
fields = ('pk', 'name', 'status', 'cluster', 'tenant', 'vcpus', 'memory', 'disk')
#
# VM components
#
class InterfaceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('name', 'enabled', 'description')

View File

@@ -1,297 +0,0 @@
from __future__ import unicode_literals
from rest_framework import status
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from django.urls import reverse
from users.models import Token
from utilities.tests import HttpStatusMixin
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class ClusterTypeTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3')
def test_get_clustertype(self):
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.clustertype1.name)
def test_list_clustertypes(self):
url = reverse('virtualization-api:clustertype-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_clustertype(self):
data = {
'name': 'Test Cluster Type 4',
'slug': 'test-cluster-type-4',
}
url = reverse('virtualization-api:clustertype-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterType.objects.count(), 4)
clustertype4 = ClusterType.objects.get(pk=response.data['id'])
self.assertEqual(clustertype4.name, data['name'])
self.assertEqual(clustertype4.slug, data['slug'])
def test_update_clustertype(self):
data = {
'name': 'Test Cluster Type X',
'slug': 'test-cluster-type-x',
}
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ClusterType.objects.count(), 3)
clustertype1 = ClusterType.objects.get(pk=response.data['id'])
self.assertEqual(clustertype1.name, data['name'])
self.assertEqual(clustertype1.slug, data['slug'])
def test_delete_clustertype(self):
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ClusterType.objects.count(), 2)
class ClusterGroupTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3')
def test_get_clustergroup(self):
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.clustergroup1.name)
def test_list_clustergroups(self):
url = reverse('virtualization-api:clustergroup-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_clustergroup(self):
data = {
'name': 'Test Cluster Group 4',
'slug': 'test-cluster-group-4',
}
url = reverse('virtualization-api:clustergroup-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterGroup.objects.count(), 4)
clustergroup4 = ClusterGroup.objects.get(pk=response.data['id'])
self.assertEqual(clustergroup4.name, data['name'])
self.assertEqual(clustergroup4.slug, data['slug'])
def test_update_clustergroup(self):
data = {
'name': 'Test Cluster Group X',
'slug': 'test-cluster-group-x',
}
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ClusterGroup.objects.count(), 3)
clustergroup1 = ClusterGroup.objects.get(pk=response.data['id'])
self.assertEqual(clustergroup1.name, data['name'])
self.assertEqual(clustergroup1.slug, data['slug'])
def test_delete_clustergroup(self):
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ClusterGroup.objects.count(), 2)
class ClusterTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group)
self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group)
def test_get_cluster(self):
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.cluster1.name)
def test_list_clusters(self):
url = reverse('virtualization-api:cluster-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_cluster(self):
data = {
'name': 'Test Cluster 4',
'type': ClusterType.objects.first().pk,
'group': ClusterGroup.objects.first().pk,
}
url = reverse('virtualization-api:cluster-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cluster.objects.count(), 4)
cluster4 = Cluster.objects.get(pk=response.data['id'])
self.assertEqual(cluster4.name, data['name'])
self.assertEqual(cluster4.type.pk, data['type'])
self.assertEqual(cluster4.group.pk, data['group'])
def test_update_cluster(self):
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
data = {
'name': 'Test Cluster X',
'type': cluster_type2.pk,
'group': cluster_group2.pk,
}
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Cluster.objects.count(), 3)
cluster1 = Cluster.objects.get(pk=response.data['id'])
self.assertEqual(cluster1.name, data['name'])
self.assertEqual(cluster1.type.pk, data['type'])
self.assertEqual(cluster1.group.pk, data['group'])
def test_delete_cluster(self):
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Cluster.objects.count(), 2)
class VirtualMachineTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
def test_get_virtualmachine(self):
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.virtualmachine1.name)
def test_list_virtualmachines(self):
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_virtualmachine(self):
data = {
'name': 'Test Virtual Machine 4',
'cluster': Cluster.objects.first().pk,
}
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualMachine.objects.count(), 4)
virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine4.name, data['name'])
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
def test_update_virtualmachine(self):
cluster2 = Cluster.objects.create(
name='Test Cluster 2',
type=ClusterType.objects.first(),
group=ClusterGroup.objects.first()
)
data = {
'name': 'Test Virtual Machine X',
'cluster': cluster2.pk,
}
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VirtualMachine.objects.count(), 3)
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine1.name, data['name'])
self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
def test_delete_virtualmachine(self):
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(VirtualMachine.objects.count(), 2)

View File

@@ -1,55 +0,0 @@
from __future__ import unicode_literals
from django.conf.urls import url
from ipam.views import ServiceCreateView
from . import views
app_name = 'virtualization'
urlpatterns = [
# Cluster types
url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'),
url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
# Cluster groups
url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
# Clusters
url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'),
url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'),
url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
url(r'^clusters/delete/$', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
# Virtual machines
url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
url(r'^virtual-machines/delete/$', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces
url(r'^virtual-machines/interfaces/add/$', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
url(r'^virtual-machines/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
url(r'^virtual-machines/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^virtual-machines/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^vm-interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^vm-interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
]

View File

@@ -1,360 +0,0 @@
from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from dcim.models import Device, Interface
from dcim.tables import DeviceTable
from ipam.models import Service
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from . import filters
from . import forms
from . import tables
#
# Cluster types
#
class ClusterTypeListView(ObjectListView):
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable
template_name = 'virtualization/clustertype_list.html'
class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_clustertype'
model = ClusterType
model_form = forms.ClusterTypeForm
def get_return_url(self, request, obj):
return reverse('virtualization:clustertype_list')
class ClusterTypeEditView(ClusterTypeCreateView):
permission_required = 'virtualization.change_clustertype'
class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_clustertype'
cls = ClusterType
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list'
#
# Cluster groups
#
class ClusterGroupListView(ObjectListView):
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable
template_name = 'virtualization/clustergroup_list.html'
class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_clustergroup'
model = ClusterGroup
model_form = forms.ClusterGroupForm
def get_return_url(self, request, obj):
return reverse('virtualization:clustergroup_list')
class ClusterGroupEditView(ClusterGroupCreateView):
permission_required = 'virtualization.change_clustergroup'
class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_clustergroup'
cls = ClusterGroup
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list'
#
# Clusters
#
class ClusterListView(ObjectListView):
queryset = Cluster.objects.annotate(
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.ClusterTable
filter = filters.ClusterFilter
filter_form = forms.ClusterFilterForm
template_name = 'virtualization/cluster_list.html'
class ClusterView(View):
def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
devices = Device.objects.filter(cluster=cluster).select_related(
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
device_table = DeviceTable(list(devices), orderable=False)
if request.user.has_perm('virtualization:change_cluster'):
device_table.columns.show('pk')
return render(request, 'virtualization/cluster.html', {
'cluster': cluster,
'device_table': device_table,
})
class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_cluster'
model = Cluster
model_form = forms.ClusterForm
class ClusterEditView(ClusterCreateView):
permission_required = 'virtualization.change_cluster'
class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'virtualization.delete_cluster'
model = Cluster
default_return_url = 'virtualization:cluster_list'
class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_cluster'
model_form = forms.ClusterCSVForm
table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list'
class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'virtualization.change_cluster'
cls = Cluster
filter = filters.ClusterFilter
table = tables.ClusterTable
form = forms.ClusterBulkEditForm
default_return_url = 'virtualization:cluster_list'
class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_cluster'
cls = Cluster
queryset = Cluster.objects.annotate(
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list'
class ClusterAddDevicesView(PermissionRequiredMixin, View):
permission_required = 'virtualization.change_cluster'
form = forms.ClusterAddDevicesForm
template_name = 'virtualization/cluster_add_devices.html'
def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
form = self.form()
return render(request, self.template_name, {
'cluster': cluster,
'form': form,
'return_url': reverse('virtualization:cluster', kwargs={'pk': pk}),
})
def post(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
form = self.form(request.POST)
if form.is_valid():
# Assign the selected Devices to the Cluster
devices = form.cleaned_data['devices']
Device.objects.filter(pk__in=devices).update(cluster=cluster)
messages.success(request, "Added {} devices to cluster {}".format(
len(devices), cluster
))
return redirect(cluster.get_absolute_url())
return render(request, self.template_name, {
'cluser': cluster,
'form': form,
'return_url': cluster.get_absolute_url(),
})
class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
permission_required = 'virtualization.change_cluster'
form = forms.ClusterRemoveDevicesForm
template_name = 'utilities/obj_bulk_remove.html'
def post(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
if '_confirm' in request.POST:
form = self.form(request.POST)
if form.is_valid():
# Remove the selected Devices from the Cluster
devices = form.cleaned_data['pk']
Device.objects.filter(pk__in=devices).update(cluster=None)
messages.success(request, "Removed {} devices from cluster {}".format(
len(devices), cluster
))
return redirect(cluster.get_absolute_url())
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = Device.objects.filter(pk__in=form.initial['pk'])
device_table = DeviceTable(list(selected_objects), orderable=False)
return render(request, self.template_name, {
'form': form,
'parent_obj': cluster,
'table': device_table,
'obj_type_plural': 'devices',
'return_url': cluster.get_absolute_url(),
})
#
# Virtual machines
#
class VirtualMachineListView(ObjectListView):
queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
filter = filters.VirtualMachineFilter
filter_form = forms.VirtualMachineFilterForm
table = tables.VirtualMachineTable
template_name = 'virtualization/virtualmachine_list.html'
class VirtualMachineView(View):
def get(self, request, pk):
vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
interfaces = Interface.objects.filter(virtual_machine=vm)
services = Service.objects.filter(virtual_machine=vm)
return render(request, 'virtualization/virtualmachine.html', {
'vm': vm,
'interfaces': interfaces,
'services': services,
})
class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_virtualmachine'
model = VirtualMachine
model_form = forms.VirtualMachineForm
template_name = 'virtualization/virtualmachine_edit.html'
default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineEditView(VirtualMachineCreateView):
permission_required = 'virtualization.change_virtualmachine'
class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
model = VirtualMachine
default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_virtualmachine'
model_form = forms.VirtualMachineCSVForm
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'virtualization.change_virtualmachine'
cls = VirtualMachine
queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
filter = filters.VirtualMachineFilter
table = tables.VirtualMachineTable
form = forms.VirtualMachineBulkEditForm
default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
cls = VirtualMachine
queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'
#
# VM interfaces
#
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = VirtualMachine
parent_field = 'virtual_machine'
model = Interface
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
template_name = 'virtualization/virtualmachine_component_add.html'
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_interface'
model = Interface
parent_field = 'virtual_machine'
model_form = forms.InterfaceForm
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
parent_field = 'virtual_machine'
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
cls = Interface
parent_cls = VirtualMachine
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
cls = Interface
parent_cls = VirtualMachine
table = tables.InterfaceTable
#
# Bulk Device component creation
#
class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = VirtualMachine
parent_field = 'virtual_machine'
form = forms.VirtualMachineBulkAddInterfaceForm
model = Interface
model_form = forms.InterfaceForm
table = tables.VirtualMachineTable