mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 13:06:30 -06:00
Merge branch 'develop-2.2' into develop-2.1
This commit is contained in:
commit
3d25cecc69
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
*.pyc
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/static
|
||||
.idea
|
||||
/*.sh
|
||||
|
16
.travis.yml
16
.travis.yml
@ -1,11 +1,8 @@
|
||||
sudo: required
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
env:
|
||||
- DOCKER_TAG=$TRAVIS_TAG
|
||||
|
||||
- postgresql
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
@ -13,9 +10,8 @@ python:
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
- pip install pep8
|
||||
before_script:
|
||||
- psql --version
|
||||
- psql -U postgres -c 'SELECT version();'
|
||||
script:
|
||||
- ./scripts/cibuild.sh
|
||||
after_success:
|
||||
- if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
|
||||
./scripts/docker-build.sh;
|
||||
fi
|
||||
|
@ -191,14 +191,6 @@ The amount of time (in seconds) to wait for NAPALM to connect to a device.
|
||||
|
||||
---
|
||||
|
||||
## NETBOX_USERNAME (Deprecated)
|
||||
|
||||
## NETBOX_PASSWORD (Deprecated)
|
||||
|
||||
These settings have been deprecated and will be removed in NetBox v2.2. Please use `NAPALM_USERNAME` and `NAPALM_PASSWORD` instead.
|
||||
|
||||
---
|
||||
|
||||
## PAGINATE_COUNT
|
||||
|
||||
Default: 50
|
||||
|
@ -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. 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.)
|
||||
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.)
|
||||
|
29
docs/data-model/virtualization.md
Normal file
29
docs/data-model/virtualization.md
Normal file
@ -0,0 +1,29 @@
|
||||
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)
|
53
docs/development/utility-views.md
Normal file
53
docs/development/utility-views.md
Normal file
@ -0,0 +1,53 @@
|
||||
# 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.
|
119
docs/miscellaneous/reports.md
Normal file
119
docs/miscellaneous/reports.md
Normal file
@ -0,0 +1,119 @@
|
||||
# NetBox Reports
|
||||
|
||||
A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that:
|
||||
|
||||
* All top-of-rack switches have a console connection
|
||||
* Every router has a loopback interface with an IP address assigned
|
||||
* Each interface description conforms to a standard format
|
||||
* Every site has a minimum set of VLANs defined
|
||||
* All IP addresses have a parent prefix
|
||||
|
||||
...and so on. Reports are completely customizable, so there's practically no limit to what you can test for.
|
||||
|
||||
## Writing Reports
|
||||
|
||||
Reports must be saved as files in the `netbox/reports/` path within the NetBox installation path. Each file created within this path is considered a separate module. Each module holds one or more reports, each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
|
||||
|
||||
!!! warning
|
||||
The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
|
||||
|
||||
For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`.
|
||||
|
||||
```
|
||||
from extras.reports import Report
|
||||
|
||||
class DeviceConnectionsReport(Report):
|
||||
description = "Validate the minimum physical connections for each device"
|
||||
|
||||
class DeviceIPsReport(Report):
|
||||
description = "Check that every device has a primary IP address assigned"
|
||||
```
|
||||
|
||||
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
|
||||
|
||||
```
|
||||
from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE
|
||||
from dcim.models import ConsolePort, Device, PowerPort
|
||||
from extras.reports import Report
|
||||
|
||||
|
||||
class DeviceConnectionsReport(Report):
|
||||
description = "Validate the minimum physical connections for each device"
|
||||
|
||||
def test_console_connection(self):
|
||||
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE):
|
||||
if console_port.cs_port is None:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
"No console connection defined for {}".format(console_port.name)
|
||||
)
|
||||
elif console_port.connection_status == CONNECTION_STATUS_PLANNED:
|
||||
self.log_warning(
|
||||
console_port.device,
|
||||
"Console connection for {} marked as planned".format(console_port.name)
|
||||
)
|
||||
else:
|
||||
self.log_success(console_port.device)
|
||||
|
||||
def test_power_connections(self):
|
||||
|
||||
# Check that every active device has at least two connected power supplies.
|
||||
for device in Device.objects.filter(status=STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.power_outlet is not None:
|
||||
connected_ports += 1
|
||||
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
|
||||
self.log_warning(
|
||||
device,
|
||||
"Power connection for {} marked as planned".format(power_port.name)
|
||||
)
|
||||
if connected_ports < 2:
|
||||
self.log_failure(
|
||||
device,
|
||||
"{} connected power supplies found (2 needed)".format(connected_ports)
|
||||
)
|
||||
else:
|
||||
self.log_success(device)
|
||||
```
|
||||
|
||||
As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed.
|
||||
|
||||
!!! warning
|
||||
Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
|
||||
|
||||
The following methods are available to log results within a report:
|
||||
|
||||
* log(message)
|
||||
* log_success(object, message=None)
|
||||
* log_info(object, message)
|
||||
* log_warning(object, message)
|
||||
* log_failure(object, message)
|
||||
|
||||
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status.
|
||||
|
||||
Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report.
|
||||
|
||||
## Running Reports
|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
|
||||
|
||||
Once a report has been run, its associated results will be included in the report view.
|
||||
|
||||
### Via the API
|
||||
|
||||
To run a report via the API, simply issue a POST request. Reports are identified by their module and class name.
|
||||
|
||||
```
|
||||
POST /api/extras/reports/<module>.<name>/
|
||||
```
|
||||
|
||||
Our example report above would be called as:
|
||||
|
||||
```
|
||||
POST /api/extras/reports/devices.DeviceConnectionsReport/
|
||||
```
|
@ -18,6 +18,7 @@ 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'
|
||||
@ -26,6 +27,10 @@ pages:
|
||||
- 'Examples': 'api/examples.md'
|
||||
- 'Shell':
|
||||
- 'Introduction': 'shell/intro.md'
|
||||
- 'Miscellaneous':
|
||||
- 'Reports': 'miscellaneous/reports.md'
|
||||
- 'Development':
|
||||
- 'Utility Views': 'development/utility-views.md'
|
||||
|
||||
markdown_extensions:
|
||||
- admonition:
|
||||
|
@ -52,7 +52,7 @@ class ProviderView(View):
|
||||
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.add_provider'
|
||||
model = Provider
|
||||
form_class = forms.ProviderForm
|
||||
model_form = 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
|
||||
form_class = forms.CircuitTypeForm
|
||||
model_form = 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
|
||||
form_class = forms.CircuitForm
|
||||
model_form = 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
|
||||
form_class = forms.CircuitTerminationForm
|
||||
model_form = forms.CircuitTerminationForm
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
|
@ -16,6 +16,7 @@ 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
|
||||
|
||||
|
||||
#
|
||||
@ -403,7 +404,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role']
|
||||
|
||||
|
||||
class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
||||
@ -446,6 +447,15 @@ 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()
|
||||
@ -459,13 +469,14 @@ 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',
|
||||
'comments', 'custom_fields',
|
||||
'cluster', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
@ -485,7 +496,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
|
||||
]
|
||||
validators = []
|
||||
|
||||
|
@ -93,13 +93,15 @@ 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',
|
||||
[
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
|
||||
]
|
||||
VIFACE_FF_CHOICES,
|
||||
],
|
||||
[
|
||||
'Ethernet (fixed)',
|
||||
|
@ -10,6 +10,7 @@ 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,
|
||||
@ -408,6 +409,10 @@ 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(),
|
||||
|
@ -12,10 +12,10 @@ from ipam.models import IPAddress
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, ArrayFieldSelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
|
||||
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
FilterTreeNodeMultipleChoiceField,
|
||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
|
||||
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
|
||||
SlugField, FilterTreeNodeMultipleChoiceField,
|
||||
)
|
||||
from .formfields import MACAddressFormField
|
||||
from .models import (
|
||||
@ -43,15 +43,6 @@ 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
|
||||
#
|
||||
@ -532,7 +523,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortTemplateCreateForm(DeviceComponentForm):
|
||||
class ConsolePortTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -546,7 +537,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
|
||||
class ConsoleServerPortTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -560,7 +551,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplateCreateForm(DeviceComponentForm):
|
||||
class PowerPortTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -574,7 +565,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplateCreateForm(DeviceComponentForm):
|
||||
class PowerOutletTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -588,7 +579,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplateCreateForm(DeviceComponentForm):
|
||||
class InterfaceTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
|
||||
@ -613,7 +604,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateCreateForm(DeviceComponentForm):
|
||||
class DeviceBayTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -626,7 +617,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['name', 'slug', 'color']
|
||||
fields = ['name', 'slug', 'color', 'vm_role']
|
||||
|
||||
|
||||
#
|
||||
@ -1007,11 +998,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description']
|
||||
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)
|
||||
|
||||
|
||||
#
|
||||
@ -1028,7 +1020,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortCreateForm(DeviceComponentForm):
|
||||
class ConsolePortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1197,7 +1189,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortCreateForm(DeviceComponentForm):
|
||||
class ConsoleServerPortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1289,7 +1281,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerPortCreateForm(DeviceComponentForm):
|
||||
class PowerPortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1458,7 +1450,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletCreateForm(DeviceComponentForm):
|
||||
class PowerOutletCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@ -1563,7 +1555,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceCreateForm(DeviceComponentForm):
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
@ -1582,9 +1574,9 @@ class InterfaceCreateForm(DeviceComponentForm):
|
||||
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device
|
||||
if self.device is not None:
|
||||
if self.parent is not None:
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device=self.device, form_factor=IFACE_FF_LAG
|
||||
device=self.parent, form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
@ -1810,7 +1802,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(DeviceComponentForm):
|
||||
class DeviceBayCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
|
32
netbox/dcim/migrations/0044_virtualization.py
Normal file
32
netbox/dcim/migrations/0044_virtualization.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- 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'),
|
||||
),
|
||||
]
|
20
netbox/dcim/migrations/0045_devicerole_vm_role.py
Normal file
20
netbox/dcim/migrations/0045_devicerole_vm_role.py
Normal file
@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-29 16:09
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0044_virtualization'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicerole',
|
||||
name='vm_role',
|
||||
field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'),
|
||||
),
|
||||
]
|
@ -795,11 +795,17 @@ class DeviceBayTemplate(models.Model):
|
||||
class DeviceRole(models.Model):
|
||||
"""
|
||||
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
|
||||
color to be used when displaying rack elevations.
|
||||
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
|
||||
virtual machines as well.
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = ColorField()
|
||||
vm_role = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="VM Role",
|
||||
help_text="Virtual machines may be assigned to this role"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@ -880,6 +886,13 @@ 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)
|
||||
@ -986,6 +999,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
|
||||
})
|
||||
|
||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||
raise ValidationError({
|
||||
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
is_new = not bool(self.pk)
|
||||
@ -1229,13 +1248,26 @@ class PowerOutlet(models.Model):
|
||||
@python_2_unicode_compatible
|
||||
class Interface(models.Model):
|
||||
"""
|
||||
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
|
||||
of an InterfaceConnection.
|
||||
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
|
||||
Interface via the creation of an InterfaceConnection.
|
||||
"""
|
||||
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
|
||||
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
|
||||
)
|
||||
lag = models.ForeignKey(
|
||||
'self',
|
||||
models.SET_NULL,
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='member_interfaces',
|
||||
null=True,
|
||||
blank=True,
|
||||
@ -1264,6 +1296,18 @@ 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({
|
||||
@ -1293,6 +1337,10 @@ 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
|
||||
|
@ -362,14 +362,14 @@ class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
|
@ -130,7 +130,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='service_assign'),
|
||||
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
|
||||
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
# Console ports
|
||||
|
@ -23,7 +23,8 @@ 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 (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
|
||||
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .models import (
|
||||
@ -60,87 +61,6 @@ 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.
|
||||
@ -192,7 +112,7 @@ class RegionListView(ObjectListView):
|
||||
class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_region'
|
||||
model = Region
|
||||
form_class = forms.RegionForm
|
||||
model_form = forms.RegionForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('dcim:region_list')
|
||||
@ -257,7 +177,7 @@ class SiteView(View):
|
||||
class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_site'
|
||||
model = Site
|
||||
form_class = forms.SiteForm
|
||||
model_form = forms.SiteForm
|
||||
template_name = 'dcim/site_edit.html'
|
||||
default_return_url = 'dcim:site_list'
|
||||
|
||||
@ -304,7 +224,7 @@ class RackGroupListView(ObjectListView):
|
||||
class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_rackgroup'
|
||||
model = RackGroup
|
||||
form_class = forms.RackGroupForm
|
||||
model_form = forms.RackGroupForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('dcim:rackgroup_list')
|
||||
@ -343,7 +263,7 @@ class RackRoleListView(ObjectListView):
|
||||
class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_rackrole'
|
||||
model = RackRole
|
||||
form_class = forms.RackRoleForm
|
||||
model_form = forms.RackRoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('dcim:rackrole_list')
|
||||
@ -446,7 +366,7 @@ class RackView(View):
|
||||
class RackCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_rack'
|
||||
model = Rack
|
||||
form_class = forms.RackForm
|
||||
model_form = forms.RackForm
|
||||
template_name = 'dcim/rack_edit.html'
|
||||
default_return_url = 'dcim:rack_list'
|
||||
|
||||
@ -502,7 +422,7 @@ class RackReservationListView(ObjectListView):
|
||||
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_rackreservation'
|
||||
model = RackReservation
|
||||
form_class = forms.RackReservationForm
|
||||
model_form = forms.RackReservationForm
|
||||
|
||||
def alter_obj(self, obj, request, args, kwargs):
|
||||
if not obj.pk:
|
||||
@ -546,7 +466,7 @@ class ManufacturerListView(ObjectListView):
|
||||
class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_manufacturer'
|
||||
model = Manufacturer
|
||||
form_class = forms.ManufacturerForm
|
||||
model_form = forms.ManufacturerForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('dcim:manufacturer_list')
|
||||
@ -638,7 +558,7 @@ class DeviceTypeView(View):
|
||||
class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_devicetype'
|
||||
model = DeviceType
|
||||
form_class = forms.DeviceTypeForm
|
||||
model_form = forms.DeviceTypeForm
|
||||
template_name = 'dcim/devicetype_edit.html'
|
||||
default_return_url = 'dcim:devicetype_list'
|
||||
|
||||
@ -690,6 +610,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
|
||||
model = ConsolePortTemplate
|
||||
form = forms.ConsolePortTemplateCreateForm
|
||||
model_form = forms.ConsolePortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -708,6 +629,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
|
||||
model = ConsoleServerPortTemplate
|
||||
form = forms.ConsoleServerPortTemplateCreateForm
|
||||
model_form = forms.ConsoleServerPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -724,6 +646,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = PowerPortTemplate
|
||||
form = forms.PowerPortTemplateCreateForm
|
||||
model_form = forms.PowerPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -740,6 +663,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
|
||||
model = PowerOutletTemplate
|
||||
form = forms.PowerOutletTemplateCreateForm
|
||||
model_form = forms.PowerOutletTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -756,6 +680,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = InterfaceTemplate
|
||||
form = forms.InterfaceTemplateCreateForm
|
||||
model_form = forms.InterfaceTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
@ -780,6 +705,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
model = DeviceBayTemplate
|
||||
form = forms.DeviceBayTemplateCreateForm
|
||||
model_form = forms.DeviceBayTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -802,7 +728,7 @@ class DeviceRoleListView(ObjectListView):
|
||||
class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_devicerole'
|
||||
model = DeviceRole
|
||||
form_class = forms.DeviceRoleForm
|
||||
model_form = forms.DeviceRoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('dcim:devicerole_list')
|
||||
@ -833,7 +759,7 @@ class PlatformListView(ObjectListView):
|
||||
class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_platform'
|
||||
model = Platform
|
||||
form_class = forms.PlatformForm
|
||||
model_form = forms.PlatformForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('dcim:platform_list')
|
||||
@ -991,7 +917,7 @@ class DeviceConfigView(PermissionRequiredMixin, View):
|
||||
class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_device'
|
||||
model = Device
|
||||
form_class = forms.DeviceForm
|
||||
model_form = forms.DeviceForm
|
||||
template_name = 'dcim/device_edit.html'
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@ -1063,6 +989,7 @@ 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')
|
||||
@ -1144,12 +1071,14 @@ def consoleport_disconnect(request, pk):
|
||||
class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_consoleport'
|
||||
model = ConsolePort
|
||||
form_class = forms.ConsolePortForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.ConsolePortForm
|
||||
|
||||
|
||||
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_consoleport'
|
||||
model = ConsolePort
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -1177,6 +1106,7 @@ 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')
|
||||
@ -1261,12 +1191,14 @@ def consoleserverport_disconnect(request, pk):
|
||||
class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_consoleserverport'
|
||||
model = ConsoleServerPort
|
||||
form_class = forms.ConsoleServerPortForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
|
||||
|
||||
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_consoleserverport'
|
||||
model = ConsoleServerPort
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
|
||||
@ -1296,6 +1228,7 @@ 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')
|
||||
@ -1377,12 +1310,14 @@ def powerport_disconnect(request, pk):
|
||||
class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_powerport'
|
||||
model = PowerPort
|
||||
form_class = forms.PowerPortForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.PowerPortForm
|
||||
|
||||
|
||||
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_powerport'
|
||||
model = PowerPort
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@ -1410,6 +1345,7 @@ 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')
|
||||
@ -1494,12 +1430,14 @@ def poweroutlet_disconnect(request, pk):
|
||||
class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_poweroutlet'
|
||||
model = PowerOutlet
|
||||
form_class = forms.PowerOutletForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.PowerOutletForm
|
||||
|
||||
|
||||
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_poweroutlet'
|
||||
model = PowerOutlet
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
|
||||
@ -1531,17 +1469,20 @@ 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
|
||||
form_class = forms.InterfaceForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.InterfaceForm
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
model = Interface
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
|
||||
@ -1582,17 +1523,20 @@ 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
|
||||
form_class = forms.DeviceBayForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.DeviceBayForm
|
||||
|
||||
|
||||
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_devicebay'
|
||||
model = DeviceBay
|
||||
parent_field = 'device'
|
||||
|
||||
|
||||
@permission_required('dcim.change_devicebay')
|
||||
@ -1653,109 +1597,67 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
|
||||
|
||||
#
|
||||
# Bulk device component creation
|
||||
# Bulk Device component creation
|
||||
#
|
||||
|
||||
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):
|
||||
class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
permission_required = 'dcim.add_consoleport'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = ConsolePort
|
||||
model_form = forms.ConsolePortForm
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
|
||||
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
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, DeviceBulkAddComponentView):
|
||||
class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
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, DeviceBulkAddComponentView):
|
||||
class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
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, DeviceBulkAddComponentView):
|
||||
class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
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, DeviceBulkAddComponentView):
|
||||
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
permission_required = 'dcim.add_devicebay'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = DeviceBay
|
||||
model_form = forms.DeviceBayForm
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
#
|
||||
@ -1899,7 +1801,8 @@ class InterfaceConnectionsListView(ObjectListView):
|
||||
class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_inventoryitem'
|
||||
model = InventoryItem
|
||||
form_class = forms.InventoryItemForm
|
||||
parent_field = 'device'
|
||||
model_form = forms.InventoryItemForm
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
if 'device' in url_kwargs:
|
||||
@ -1910,3 +1813,4 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_inventoryitem'
|
||||
model = InventoryItem
|
||||
parent_field = 'device'
|
||||
|
@ -7,6 +7,18 @@ from django.utils.safestring import mark_safe
|
||||
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
|
||||
|
||||
|
||||
def order_content_types(field):
|
||||
"""
|
||||
Order the list of available ContentTypes by application
|
||||
"""
|
||||
queryset = field.queryset.order_by('app_label', 'model')
|
||||
field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
@ -16,9 +28,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CustomFieldForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Organize the available ContentTypes
|
||||
queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model')
|
||||
self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
|
||||
order_content_types(self.fields['obj_type'])
|
||||
|
||||
|
||||
class CustomFieldChoiceAdmin(admin.TabularInline):
|
||||
@ -36,16 +46,43 @@ class CustomFieldAdmin(admin.ModelAdmin):
|
||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||
|
||||
|
||||
#
|
||||
# Graphs
|
||||
#
|
||||
|
||||
@admin.register(Graph)
|
||||
class GraphAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'type', 'weight', 'source']
|
||||
|
||||
|
||||
#
|
||||
# Export templates
|
||||
#
|
||||
|
||||
class ExportTemplateForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
exclude = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ExportTemplateForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Format ContentType choices
|
||||
order_content_types(self.fields['content_type'])
|
||||
self.fields['content_type'].choices.insert(0, ('', '---------'))
|
||||
|
||||
|
||||
@admin.register(ExportTemplate)
|
||||
class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
|
||||
form = ExportTemplateForm
|
||||
|
||||
|
||||
#
|
||||
# Topology maps
|
||||
#
|
||||
|
||||
@admin.register(TopologyMap)
|
||||
class TopologyMapAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'slug', 'site']
|
||||
@ -54,6 +91,10 @@ class TopologyMapAdmin(admin.ModelAdmin):
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# User actions
|
||||
#
|
||||
|
||||
@admin.register(UserAction)
|
||||
class UserActionAdmin(admin.ModelAdmin):
|
||||
actions = None
|
||||
|
@ -7,7 +7,7 @@ from rest_framework import serializers
|
||||
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
|
||||
from dcim.models import Device, Rack, Site
|
||||
from extras.models import (
|
||||
ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
|
||||
ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, ReportResult, TopologyMap, UserAction,
|
||||
)
|
||||
from users.api.serializers import NestedUserSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
|
||||
@ -127,6 +127,41 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportResultSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ReportResult
|
||||
fields = ['created', 'user', 'failed', 'data']
|
||||
|
||||
|
||||
class NestedReportResultSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:report-detail',
|
||||
lookup_field='report',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ReportResult
|
||||
fields = ['url', 'created', 'user', 'failed']
|
||||
|
||||
|
||||
class ReportSerializer(serializers.Serializer):
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=255, required=False)
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
|
||||
result = NestedReportResultSerializer()
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportSerializer):
|
||||
result = ReportResultSerializer()
|
||||
|
||||
|
||||
#
|
||||
# User actions
|
||||
#
|
||||
|
@ -28,6 +28,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet)
|
||||
# Image attachments
|
||||
router.register(r'image-attachments', views.ImageAttachmentViewSet)
|
||||
|
||||
# Reports
|
||||
router.register(r'reports', views.ReportViewSet, base_name='report')
|
||||
|
||||
# Recent activity
|
||||
router.register(r'recent-activity', views.RecentActivityViewSet)
|
||||
|
||||
|
@ -1,14 +1,17 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import HttpResponse
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from extras import filters
|
||||
from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
|
||||
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||
from extras.reports import get_report, get_reports
|
||||
from utilities.api import WritableSerializerMixin
|
||||
from . import serializers
|
||||
|
||||
@ -88,6 +91,77 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
||||
|
||||
|
||||
class ReportViewSet(ViewSet):
|
||||
_ignore_model_permissions = True
|
||||
exclude_from_schema = True
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _retrieve_report(self, pk):
|
||||
|
||||
# Read the PK as "<module>.<report>"
|
||||
if '.' not in pk:
|
||||
raise Http404
|
||||
module_name, report_name = pk.split('.', 1)
|
||||
|
||||
# Raise a 404 on an invalid Report module/name
|
||||
report = get_report(module_name, report_name)
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
return report
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||
"""
|
||||
report_list = []
|
||||
|
||||
# Iterate through all available Reports.
|
||||
for module_name, reports in get_reports():
|
||||
for report in reports:
|
||||
|
||||
# Attach the relevant ReportResult (if any) to each Report.
|
||||
report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
|
||||
report_list.append(report)
|
||||
|
||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||
'request': request,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
"""
|
||||
Retrieve a single Report identified as "<module>.<report>".
|
||||
"""
|
||||
|
||||
# Retrieve the Report and ReportResult, if any.
|
||||
report = self._retrieve_report(pk)
|
||||
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@detail_route(methods=['post'])
|
||||
def run(self, request, pk):
|
||||
"""
|
||||
Run a Report and create a new ReportResult, overwriting any previous result for the Report.
|
||||
"""
|
||||
|
||||
# Check that the user has permission to run reports.
|
||||
if not request.user.has_perm('extras.add_reportresult'):
|
||||
raise PermissionDenied("This user does not have permission to run reports.")
|
||||
|
||||
# Retrieve and run the Report. This will create a new ReportResult.
|
||||
report = self._retrieve_report(pk)
|
||||
report.run()
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RecentActivityViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
List all UserActions to provide a log of recent activity.
|
||||
|
@ -3,10 +3,11 @@ from __future__ import unicode_literals
|
||||
|
||||
# Models which support custom fields
|
||||
CUSTOMFIELD_MODELS = (
|
||||
'provider', 'circuit', # Circuits
|
||||
'site', 'rack', 'devicetype', 'device', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
|
||||
'provider', 'circuit', # Circuits
|
||||
'tenant', # Tenants
|
||||
'tenant', # Tenancy
|
||||
'cluster', 'virtualmachine', # Virtualization
|
||||
)
|
||||
|
||||
# Custom field types
|
||||
@ -37,11 +38,12 @@ GRAPH_TYPE_CHOICES = (
|
||||
|
||||
# Models which support export templates
|
||||
EXPORTTEMPLATE_MODELS = [
|
||||
'provider', 'circuit', # Circuits
|
||||
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
|
||||
'consoleport', 'powerport', 'interfaceconnection', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
|
||||
'provider', 'circuit', # Circuits
|
||||
'tenant', # Tenants
|
||||
'tenant', # Tenancy
|
||||
'cluster', 'virtualmachine', # Virtualization
|
||||
]
|
||||
|
||||
# User action types
|
||||
@ -61,3 +63,17 @@ ACTION_CHOICES = (
|
||||
(ACTION_DELETE, 'deleted'),
|
||||
(ACTION_BULK_DELETE, 'bulk deleted'),
|
||||
)
|
||||
|
||||
# Report logging levels
|
||||
LOG_DEFAULT = 0
|
||||
LOG_SUCCESS = 10
|
||||
LOG_INFO = 20
|
||||
LOG_WARNING = 30
|
||||
LOG_FAILURE = 40
|
||||
LOG_LEVEL_CODES = {
|
||||
LOG_DEFAULT: 'default',
|
||||
LOG_SUCCESS: 'success',
|
||||
LOG_INFO: 'info',
|
||||
LOG_WARNING: 'warning',
|
||||
LOG_FAILURE: 'failure',
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.db.models import Model
|
||||
|
||||
|
||||
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users']
|
||||
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
|
||||
|
||||
BANNER_TEXT = """### NetBox interactive shell ({node})
|
||||
### Python {python} | Django {django} | NetBox {netbox}
|
||||
|
48
netbox/extras/management/commands/runreport.py
Normal file
48
netbox/extras/management/commands/runreport.py
Normal file
@ -0,0 +1,48 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from extras.models import ReportResult
|
||||
from extras.reports import get_reports
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run a report to validate data in NetBox"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('reports', nargs='+', help="Report(s) to run")
|
||||
# parser.add_argument('--verbose', action='store_true', default=False, help="Print all logs")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
# Gather all available reports
|
||||
reports = get_reports()
|
||||
|
||||
# Run reports
|
||||
for module_name, report_list in reports:
|
||||
for report in report_list:
|
||||
if module_name in options['reports'] or report.full_namel in options['reports']:
|
||||
|
||||
# Run the report and create a new ReportResult
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||
)
|
||||
report.run()
|
||||
|
||||
# Report on success/failure
|
||||
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
|
||||
for test_name, attrs in report.result.data.items():
|
||||
self.stdout.write(
|
||||
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||
)
|
||||
)
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||
)
|
||||
|
||||
# Wrap things up
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] Finished".format(timezone.now())
|
||||
)
|
33
netbox/extras/migrations/0008_reports.py
Normal file
33
netbox/extras/migrations/0008_reports.py
Normal file
@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-26 21:25
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('extras', '0007_unicode_literals'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReportResult',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('report', models.CharField(max_length=255, unique=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('failed', models.BooleanField()),
|
||||
('data', django.contrib.postgres.fields.jsonb.JSONField()),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['report'],
|
||||
},
|
||||
),
|
||||
]
|
@ -6,6 +6,7 @@ import graphviz
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
@ -388,6 +389,24 @@ class ImageAttachment(models.Model):
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# Report results
|
||||
#
|
||||
|
||||
class ReportResult(models.Model):
|
||||
"""
|
||||
This model stores the results from running a user-defined report.
|
||||
"""
|
||||
report = models.CharField(max_length=255, unique=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True)
|
||||
failed = models.BooleanField()
|
||||
data = JSONField()
|
||||
|
||||
class Meta:
|
||||
ordering = ['report']
|
||||
|
||||
|
||||
#
|
||||
# User actions
|
||||
#
|
||||
|
178
netbox/extras/reports.py
Normal file
178
netbox/extras/reports.py
Normal file
@ -0,0 +1,178 @@
|
||||
from collections import OrderedDict
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING
|
||||
from .models import ReportResult
|
||||
import reports as custom_reports
|
||||
|
||||
|
||||
def is_report(obj):
|
||||
"""
|
||||
Returns True if the given object is a Report.
|
||||
"""
|
||||
if obj in Report.__subclasses__():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_report(module_name, report_name):
|
||||
"""
|
||||
Return a specific report from within a module.
|
||||
"""
|
||||
module = importlib.import_module('reports.{}'.format(module_name))
|
||||
report = getattr(module, report_name, None)
|
||||
if report is None:
|
||||
return None
|
||||
return report()
|
||||
|
||||
|
||||
def get_reports():
|
||||
"""
|
||||
Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
|
||||
|
||||
[
|
||||
(module_name, (report, report, report, ...)),
|
||||
(module_name, (report, report, report, ...)),
|
||||
...
|
||||
]
|
||||
"""
|
||||
module_list = []
|
||||
|
||||
# Iterate through all modules within the reports path. These are the user-defined files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, is_pkg in pkgutil.walk_packages(custom_reports.__path__):
|
||||
module = importlib.import_module('reports.{}'.format(module_name))
|
||||
report_list = [cls() for _, cls in inspect.getmembers(module, is_report)]
|
||||
module_list.append((module_name, report_list))
|
||||
|
||||
return module_list
|
||||
|
||||
|
||||
class Report(object):
|
||||
"""
|
||||
NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
|
||||
report must have one or more test methods named `test_*`.
|
||||
|
||||
The `_results` attribute of a completed report will take the following form:
|
||||
|
||||
{
|
||||
'test_bar': {
|
||||
'failures': 42,
|
||||
'log': [
|
||||
(<datetime>, <level>, <object>, <message>),
|
||||
...
|
||||
]
|
||||
},
|
||||
'test_foo': {
|
||||
'failures': 0,
|
||||
'log': [
|
||||
(<datetime>, <level>, <object>, <message>),
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
description = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._results = OrderedDict()
|
||||
self.active_test = None
|
||||
self.failed = False
|
||||
|
||||
# Compile test methods and initialize results skeleton
|
||||
test_methods = []
|
||||
for method in dir(self):
|
||||
if method.startswith('test_') and callable(getattr(self, method)):
|
||||
test_methods.append(method)
|
||||
self._results[method] = OrderedDict([
|
||||
('success', 0),
|
||||
('info', 0),
|
||||
('warning', 0),
|
||||
('failure', 0),
|
||||
('log', []),
|
||||
])
|
||||
if not test_methods:
|
||||
raise Exception("A report must contain at least one test method.")
|
||||
self.test_methods = test_methods
|
||||
|
||||
@property
|
||||
def module(self):
|
||||
return self.__module__.rsplit('.', 1)[1]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return '.'.join([self.module, self.name])
|
||||
|
||||
def _log(self, obj, message, level=LOG_DEFAULT):
|
||||
"""
|
||||
Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
|
||||
"""
|
||||
if level not in LOG_LEVEL_CODES:
|
||||
raise Exception("Unknown logging level: {}".format(level))
|
||||
self._results[self.active_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
LOG_LEVEL_CODES.get(level),
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
|
||||
message,
|
||||
))
|
||||
|
||||
def log(self, message):
|
||||
"""
|
||||
Log a message which is not associated with a particular object.
|
||||
"""
|
||||
self._log(None, message, level=LOG_DEFAULT)
|
||||
|
||||
def log_success(self, obj, message=None):
|
||||
"""
|
||||
Record a successful test against an object. Logging a message is optional.
|
||||
"""
|
||||
if message:
|
||||
self._log(obj, message, level=LOG_SUCCESS)
|
||||
self._results[self.active_test]['success'] += 1
|
||||
|
||||
def log_info(self, obj, message):
|
||||
"""
|
||||
Log an informational message.
|
||||
"""
|
||||
self._log(obj, message, level=LOG_INFO)
|
||||
self._results[self.active_test]['info'] += 1
|
||||
|
||||
def log_warning(self, obj, message):
|
||||
"""
|
||||
Log a warning.
|
||||
"""
|
||||
self._log(obj, message, level=LOG_WARNING)
|
||||
self._results[self.active_test]['warning'] += 1
|
||||
|
||||
def log_failure(self, obj, message):
|
||||
"""
|
||||
Log a failure. Calling this method will automatically mark the report as failed.
|
||||
"""
|
||||
self._log(obj, message, level=LOG_FAILURE)
|
||||
self._results[self.active_test]['failure'] += 1
|
||||
self.failed = True
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the report and return its results. Each test method will be executed in order.
|
||||
"""
|
||||
for method_name in self.test_methods:
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
|
||||
# Delete any previous ReportResult and create a new one to record the result.
|
||||
ReportResult.objects.filter(report=self.full_name).delete()
|
||||
result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
|
||||
result.save()
|
||||
self.result = result
|
@ -20,19 +20,6 @@ class RPCClient(object):
|
||||
except AttributeError:
|
||||
raise Exception("Specified device ({}) does not have a primary IP defined.".format(device))
|
||||
|
||||
def get_lldp_neighbors(self):
|
||||
"""
|
||||
Returns a list of dictionaries, each representing an LLDP neighbor adjacency.
|
||||
|
||||
{
|
||||
'local-interface': <str>,
|
||||
'name': <str>,
|
||||
'remote-interface': <str>,
|
||||
'chassis-id': <str>,
|
||||
}
|
||||
"""
|
||||
raise NotImplementedError("Feature not implemented for this platform.")
|
||||
|
||||
def get_inventory(self):
|
||||
"""
|
||||
Returns a dictionary representing the device chassis and installed inventory items.
|
||||
@ -123,30 +110,6 @@ class JunosNC(RPCClient):
|
||||
# Close the connection to the device
|
||||
self.manager.close_session()
|
||||
|
||||
def get_lldp_neighbors(self):
|
||||
|
||||
rpc_reply = self.manager.dispatch('get-lldp-neighbors-information')
|
||||
lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information']
|
||||
|
||||
result = []
|
||||
for neighbor_raw in lldp_neighbors_raw:
|
||||
neighbor = dict()
|
||||
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
|
||||
name = neighbor_raw.get('lldp-remote-system-name')
|
||||
if name:
|
||||
neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present
|
||||
else:
|
||||
neighbor['name'] = ''
|
||||
try:
|
||||
neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description']
|
||||
except KeyError:
|
||||
# Older versions of Junos report on interface ID instead of description
|
||||
neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id')
|
||||
neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id')
|
||||
result.append(neighbor)
|
||||
|
||||
return result
|
||||
|
||||
def get_inventory(self):
|
||||
|
||||
def glean_items(node, depth=0):
|
||||
|
@ -12,4 +12,9 @@ urlpatterns = [
|
||||
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||
|
||||
# Reports
|
||||
url(r'^reports/$', views.ReportListView.as_view(), name='report_list'),
|
||||
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
|
||||
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
|
||||
|
||||
]
|
||||
|
@ -1,17 +1,27 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.views import ObjectDeleteView, ObjectEditView
|
||||
from .forms import ImageAttachmentForm
|
||||
from .models import ImageAttachment
|
||||
from .models import ImageAttachment, ReportResult, UserAction
|
||||
from .reports import get_report, get_reports
|
||||
|
||||
|
||||
#
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'extras.change_imageattachment'
|
||||
model = ImageAttachment
|
||||
form_class = ImageAttachmentForm
|
||||
model_form = ImageAttachmentForm
|
||||
|
||||
def alter_obj(self, imageattachment, request, args, kwargs):
|
||||
if not imageattachment.pk:
|
||||
@ -30,3 +40,79 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportListView(View):
|
||||
"""
|
||||
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
|
||||
reports = get_reports()
|
||||
results = {r.report: r for r in ReportResult.objects.all()}
|
||||
|
||||
ret = []
|
||||
for module, report_list in reports:
|
||||
module_reports = []
|
||||
for report in report_list:
|
||||
report.result = results.get(report.full_name, None)
|
||||
module_reports.append(report)
|
||||
ret.append((module, module_reports))
|
||||
|
||||
return render(request, 'extras/report_list.html', {
|
||||
'reports': ret,
|
||||
})
|
||||
|
||||
|
||||
class ReportView(View):
|
||||
"""
|
||||
Display a single Report and its associated ReportResult (if any).
|
||||
"""
|
||||
|
||||
def get(self, request, name):
|
||||
|
||||
# Retrieve the Report by "<module>.<report>"
|
||||
module_name, report_name = name.split('.')
|
||||
report = get_report(module_name, report_name)
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
# Attach the ReportResult (if any)
|
||||
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
'run_form': ConfirmationForm(),
|
||||
})
|
||||
|
||||
|
||||
class ReportRunView(PermissionRequiredMixin, View):
|
||||
"""
|
||||
Run a Report and record a new ReportResult.
|
||||
"""
|
||||
permission_required = 'extras.add_reportresult'
|
||||
|
||||
def post(self, request, name):
|
||||
|
||||
# Retrieve the Report by "<module>.<report>"
|
||||
module_name, report_name = name.split('.')
|
||||
report = get_report(module_name, report_name)
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
# Run the Report. A new ReportResult is created.
|
||||
report.run()
|
||||
result = 'failed' if report.failed else 'passed'
|
||||
msg = "Ran report {} ({})".format(report.full_name, result)
|
||||
messages.success(request, mark_safe(msg))
|
||||
UserAction.objects.log_create(request.user, report.result, msg)
|
||||
|
||||
return redirect('extras:report', name=report.full_name)
|
||||
|
@ -12,6 +12,7 @@ from ipam.models import (
|
||||
)
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from virtualization.api.serializers import NestedVirtualMachineSerializer
|
||||
|
||||
|
||||
#
|
||||
@ -295,12 +296,13 @@ 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', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
||||
|
||||
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
||||
@ -308,4 +310,4 @@ class WritableServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
@ -134,7 +134,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
).prefetch_related(
|
||||
'interface__device'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||
filter_class = filters.IPAddressFilter
|
||||
|
@ -10,6 +10,7 @@ from dcim.models import Site, Device, Interface
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
from virtualization.models import VirtualMachine
|
||||
from .models import (
|
||||
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
|
||||
Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
|
||||
@ -237,6 +238,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__virtual_machine',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
label='Virtual machine (ID)',
|
||||
)
|
||||
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__virtual_machine__name',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Virtual machine (name)',
|
||||
)
|
||||
interface_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Interface.objects.all(),
|
||||
label='Interface (ID)',
|
||||
@ -372,6 +384,16 @@ class ServiceFilter(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
label='Virtual machine (ID)',
|
||||
)
|
||||
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||
name='virtual_machine__name',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Virtual machine (name)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
|
@ -70,7 +70,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.255.1/32",
|
||||
"vrf": null,
|
||||
"interface": 3,
|
||||
"interface_id": 3,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -84,7 +84,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.254.1/31",
|
||||
"vrf": null,
|
||||
"interface": 4,
|
||||
"interface_id": 4,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -98,7 +98,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.255.2/32",
|
||||
"vrf": null,
|
||||
"interface": 185,
|
||||
"interface_id": 185,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -112,7 +112,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.1.1/31",
|
||||
"vrf": null,
|
||||
"interface": 213,
|
||||
"interface_id": 213,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -126,7 +126,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.254.1/24",
|
||||
"vrf": null,
|
||||
"interface": 12,
|
||||
"interface_id": 12,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -140,7 +140,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.21.1/31",
|
||||
"vrf": null,
|
||||
"interface": 218,
|
||||
"interface_id": 218,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -154,7 +154,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.21.2/31",
|
||||
"vrf": null,
|
||||
"interface": 9,
|
||||
"interface_id": 9,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -168,7 +168,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.22.1/31",
|
||||
"vrf": null,
|
||||
"interface": 8,
|
||||
"interface_id": 8,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -182,7 +182,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.20.1/31",
|
||||
"vrf": null,
|
||||
"interface": 7,
|
||||
"interface_id": 7,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -196,7 +196,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.20.1/31",
|
||||
"vrf": null,
|
||||
"interface": 216,
|
||||
"interface_id": 216,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -210,7 +210,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.22.2/31",
|
||||
"vrf": null,
|
||||
"interface": 206,
|
||||
"interface_id": 206,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -224,7 +224,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.22.1/31",
|
||||
"vrf": null,
|
||||
"interface": 217,
|
||||
"interface_id": 217,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -238,7 +238,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.22.2/31",
|
||||
"vrf": null,
|
||||
"interface": 205,
|
||||
"interface_id": 205,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -252,7 +252,7 @@
|
||||
"family": 4,
|
||||
"address": "10.16.20.2/31",
|
||||
"vrf": null,
|
||||
"interface": 211,
|
||||
"interface_id": 211,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -266,7 +266,7 @@
|
||||
"family": 4,
|
||||
"address": "10.15.22.2/31",
|
||||
"vrf": null,
|
||||
"interface": 212,
|
||||
"interface_id": 212,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -280,7 +280,7 @@
|
||||
"family": 4,
|
||||
"address": "10.0.254.2/32",
|
||||
"vrf": null,
|
||||
"interface": 188,
|
||||
"interface_id": 188,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -294,7 +294,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.1.1/31",
|
||||
"vrf": null,
|
||||
"interface": 200,
|
||||
"interface_id": 200,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
@ -308,7 +308,7 @@
|
||||
"family": 4,
|
||||
"address": "169.254.1.2/31",
|
||||
"vrf": null,
|
||||
"interface": 194,
|
||||
"interface_id": 194,
|
||||
"nat_inside": null,
|
||||
"description": ""
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ from utilities.forms import (
|
||||
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
|
||||
add_blank_choice,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from .models import (
|
||||
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
|
||||
Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
|
||||
@ -374,50 +375,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
|
||||
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(
|
||||
interface = forms.ModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
chains=(
|
||||
('device', 'interface_device'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
|
||||
)
|
||||
required=False
|
||||
)
|
||||
nat_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@ -476,13 +436,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
obj_label='address'
|
||||
)
|
||||
)
|
||||
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
|
||||
primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
|
||||
'nat_inside', 'tenant_group', 'tenant',
|
||||
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
|
||||
'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -490,10 +450,6 @@ 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
|
||||
@ -504,22 +460,30 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Initialize primary_for_device if IP address is already assigned
|
||||
if self.instance.interface is not None:
|
||||
device = self.instance.interface.device
|
||||
# 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
|
||||
if (
|
||||
self.instance.address.version == 4 and device.primary_ip4 == self.instance or
|
||||
self.instance.address.version == 6 and device.primary_ip6 == self.instance
|
||||
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.initial['primary_for_device'] = True
|
||||
self.initial['primary_for_parent'] = 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_device') and not self.cleaned_data.get('interface'):
|
||||
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
|
||||
self.add_error(
|
||||
'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -527,13 +491,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_device']:
|
||||
device = self.cleaned_data['interface'].device
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
parent.primary_ip4 = ipaddress
|
||||
else:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
|
||||
# Clear assignment as primary for device if set.
|
||||
else:
|
||||
@ -551,7 +515,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
return ipaddress
|
||||
|
||||
|
||||
class IPAddressPatternForm(BootstrapMixin, forms.Form):
|
||||
class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
|
||||
pattern = ExpandableIPAddressField(label='Address pattern')
|
||||
|
||||
|
||||
@ -603,6 +567,15 @@ class IPAddressCSVForm(forms.ModelForm):
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
virtual_machine = forms.ModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name of assigned virtual machine',
|
||||
error_messages={
|
||||
'invalid_choice': 'Virtual machine not found.',
|
||||
}
|
||||
)
|
||||
interface_name = forms.CharField(
|
||||
help_text='Name of assigned interface',
|
||||
required=False
|
||||
@ -614,30 +587,47 @@ class IPAddressCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description']
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
|
||||
'description',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(IPAddressCSVForm, self).clean()
|
||||
|
||||
device = self.cleaned_data.get('device')
|
||||
virtual_machine = self.cleaned_data.get('virtual_machine')
|
||||
interface_name = self.cleaned_data.get('interface_name')
|
||||
is_primary = self.cleaned_data.get('is_primary')
|
||||
|
||||
# Validate interface
|
||||
if device and interface_name:
|
||||
if interface_name and device:
|
||||
try:
|
||||
self.instance.interface = Interface.objects.get(device=device, name=interface_name)
|
||||
except Interface.DoesNotExist:
|
||||
raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device))
|
||||
elif device and not interface_name:
|
||||
raise forms.ValidationError("Device set ({}) but interface missing".format(device))
|
||||
elif interface_name and not device:
|
||||
raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name))
|
||||
raise forms.ValidationError("Invalid interface {} for device {}".format(
|
||||
interface_name, device
|
||||
))
|
||||
elif interface_name and virtual_machine:
|
||||
try:
|
||||
self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name)
|
||||
except Interface.DoesNotExist:
|
||||
raise forms.ValidationError("Invalid interface {} for virtual machine {}".format(
|
||||
interface_name, virtual_machine
|
||||
))
|
||||
elif interface_name:
|
||||
raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format(
|
||||
interface_name
|
||||
))
|
||||
elif device:
|
||||
raise forms.ValidationError("Device specified ({}) but interface missing".format(device))
|
||||
elif virtual_machine:
|
||||
raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine))
|
||||
|
||||
# Validate is_primary
|
||||
if is_primary and not device:
|
||||
raise forms.ValidationError("No device specified; cannot set as primary IP")
|
||||
if is_primary and not device and not virtual_machine:
|
||||
raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@ -647,17 +637,22 @@ class IPAddressCSVForm(forms.ModelForm):
|
||||
device=self.cleaned_data['device'],
|
||||
name=self.cleaned_data['interface_name']
|
||||
)
|
||||
elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
|
||||
self.instance.interface = Interface.objects.get(
|
||||
virtual_machine=self.cleaned_data['virtual_machine'],
|
||||
name=self.cleaned_data['interface_name']
|
||||
)
|
||||
|
||||
ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs)
|
||||
|
||||
# Set as primary for device
|
||||
# Set as primary for device/VM
|
||||
if self.cleaned_data['is_primary']:
|
||||
device = self.cleaned_data['device']
|
||||
parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
|
||||
if self.instance.address.version == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
parent.primary_ip4 = ipaddress
|
||||
elif self.instance.address.version == 6:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
@ -895,5 +890,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
super(ServiceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# 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)
|
||||
# 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 = []
|
||||
|
31
netbox/ipam/migrations/0019_virtualization.py
Normal file
31
netbox/ipam/migrations/0019_virtualization.py
Normal file
@ -0,0 +1,31 @@
|
||||
# -*- 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'),
|
||||
),
|
||||
]
|
@ -2,10 +2,12 @@ from __future__ import unicode_literals
|
||||
import netaddr
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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
|
||||
@ -418,7 +420,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
objects = IPAddressManager()
|
||||
|
||||
csv_headers = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description',
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
|
||||
'description',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@ -473,6 +476,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.get_status_display(),
|
||||
self.get_role_display(),
|
||||
self.device.identifier if self.device else None,
|
||||
self.virtual_machine.name if self.device else None,
|
||||
self.interface.name if self.interface else None,
|
||||
is_primary,
|
||||
self.description,
|
||||
@ -596,20 +600,59 @@ 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. A Service may optionally be tied
|
||||
to one or more specific IPAddresses belonging to the Device.
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'protocol', 'port']
|
||||
ordering = ['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.")
|
||||
|
@ -77,9 +77,9 @@ IPADDRESS_LINK = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
IPADDRESS_DEVICE = """
|
||||
IPADDRESS_PARENT = """
|
||||
{% if record.interface %}
|
||||
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
|
||||
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
@ -272,12 +272,12 @@ class IPAddressTable(BaseTable):
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK)
|
||||
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
|
||||
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
|
||||
interface = tables.Column(orderable=False)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description')
|
||||
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
@ -290,7 +290,7 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
|
||||
class Meta(IPAddressTable.Meta):
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description',
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
|
||||
)
|
||||
|
||||
|
||||
|
@ -10,11 +10,12 @@ 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
|
||||
from dcim.models import Device, Interface
|
||||
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 (
|
||||
@ -118,7 +119,7 @@ class VRFView(View):
|
||||
class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_vrf'
|
||||
model = VRF
|
||||
form_class = forms.VRFForm
|
||||
model_form = forms.VRFForm
|
||||
template_name = 'ipam/vrf_edit.html'
|
||||
default_return_url = 'ipam:vrf_list'
|
||||
|
||||
@ -250,7 +251,7 @@ class RIRListView(ObjectListView):
|
||||
class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_rir'
|
||||
model = RIR
|
||||
form_class = forms.RIRForm
|
||||
model_form = forms.RIRForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('ipam:rir_list')
|
||||
@ -342,7 +343,7 @@ class AggregateView(View):
|
||||
class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_aggregate'
|
||||
model = Aggregate
|
||||
form_class = forms.AggregateForm
|
||||
model_form = forms.AggregateForm
|
||||
template_name = 'ipam/aggregate_edit.html'
|
||||
default_return_url = 'ipam:aggregate_list'
|
||||
|
||||
@ -396,7 +397,7 @@ class RoleListView(ObjectListView):
|
||||
class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_role'
|
||||
model = Role
|
||||
form_class = forms.RoleForm
|
||||
model_form = forms.RoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('ipam:role_list')
|
||||
@ -549,7 +550,7 @@ class PrefixIPAddressesView(View):
|
||||
class PrefixCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_prefix'
|
||||
model = Prefix
|
||||
form_class = forms.PrefixForm
|
||||
model_form = forms.PrefixForm
|
||||
template_name = 'ipam/prefix_edit.html'
|
||||
default_return_url = 'ipam:prefix_list'
|
||||
|
||||
@ -596,7 +597,11 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
).prefetch_related(
|
||||
'interface__device', 'interface__virtual_machine'
|
||||
)
|
||||
filter = filters.IPAddressFilter
|
||||
filter_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressDetailTable
|
||||
@ -607,7 +612,7 @@ class IPAddressView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk)
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(
|
||||
@ -624,7 +629,9 @@ class IPAddressView(View):
|
||||
).exclude(
|
||||
pk=ipaddress.pk
|
||||
).select_related(
|
||||
'interface__device', 'nat_inside'
|
||||
'nat_inside'
|
||||
).prefetch_related(
|
||||
'interface__device'
|
||||
)
|
||||
# Exclude anycast IPs if this IP is anycast
|
||||
if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
|
||||
@ -632,7 +639,7 @@ class IPAddressView(View):
|
||||
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.select_related(
|
||||
related_ips = IPAddress.objects.prefetch_related(
|
||||
'interface__device'
|
||||
).exclude(
|
||||
address=str(ipaddress.address)
|
||||
@ -652,10 +659,21 @@ class IPAddressView(View):
|
||||
class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_ipaddress'
|
||||
model = IPAddress
|
||||
form_class = forms.IPAddressForm
|
||||
model_form = 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'
|
||||
@ -669,7 +687,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
|
||||
permission_required = 'ipam.add_ipaddress'
|
||||
pattern_form = forms.IPAddressPatternForm
|
||||
form = forms.IPAddressBulkCreateForm
|
||||
model_form = forms.IPAddressBulkAddForm
|
||||
pattern_target = 'address'
|
||||
template_name = 'ipam/ipaddress_bulk_add.html'
|
||||
@ -686,7 +704,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'ipam.change_ipaddress'
|
||||
cls = IPAddress
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
|
||||
filter = filters.IPAddressFilter
|
||||
table = tables.IPAddressTable
|
||||
form = forms.IPAddressBulkEditForm
|
||||
@ -696,7 +714,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_ipaddress'
|
||||
cls = IPAddress
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
|
||||
filter = filters.IPAddressFilter
|
||||
table = tables.IPAddressTable
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
@ -717,7 +735,7 @@ class VLANGroupListView(ObjectListView):
|
||||
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_vlangroup'
|
||||
model = VLANGroup
|
||||
form_class = forms.VLANGroupForm
|
||||
model_form = forms.VLANGroupForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('ipam:vlangroup_list')
|
||||
@ -768,7 +786,7 @@ class VLANView(View):
|
||||
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_vlan'
|
||||
model = VLAN
|
||||
form_class = forms.VLANForm
|
||||
model_form = forms.VLANForm
|
||||
template_name = 'ipam/vlan_edit.html'
|
||||
default_return_url = 'ipam:vlan_list'
|
||||
|
||||
@ -816,16 +834,18 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_service'
|
||||
model = Service
|
||||
form_class = forms.ServiceForm
|
||||
model_form = 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.device.get_absolute_url()
|
||||
return obj.parent.get_absolute_url()
|
||||
|
||||
|
||||
class ServiceEditView(ServiceCreateView):
|
||||
|
@ -52,8 +52,6 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
||||
NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
|
||||
NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
|
||||
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
|
||||
NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') # Deprecated
|
||||
NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '') # Deprecated
|
||||
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
@ -64,19 +62,6 @@ TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
# Check for deprecated configuration parameters
|
||||
config_logger = logging.getLogger('configuration')
|
||||
config_logger.addHandler(logging.StreamHandler())
|
||||
config_logger.setLevel(logging.WARNING)
|
||||
if NETBOX_USERNAME:
|
||||
config_logger.warning('NETBOX_USERNAME is deprecated and will be removed in v2.2. Please use NAPALM_USERNAME instead.')
|
||||
if not NAPALM_USERNAME:
|
||||
NAPALM_USERNAME = NETBOX_USERNAME
|
||||
if NETBOX_PASSWORD:
|
||||
config_logger.warning('NETBOX_PASSWORD is deprecated and will be removed in v2.2. Please use NAPALM_PASSWORD instead.')
|
||||
if not NAPALM_PASSWORD:
|
||||
NAPALM_PASSWORD = NETBOX_PASSWORD
|
||||
|
||||
# Attempt to import LDAP configuration if it has been defined
|
||||
LDAP_IGNORE_CERT_ERRORS = False
|
||||
try:
|
||||
@ -147,6 +132,7 @@ INSTALLED_APPS = (
|
||||
'tenancy',
|
||||
'users',
|
||||
'utilities',
|
||||
'virtualization',
|
||||
)
|
||||
|
||||
# Middleware
|
||||
|
@ -32,6 +32,7 @@ _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'),
|
||||
@ -41,6 +42,7 @@ _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
|
||||
|
@ -25,6 +25,9 @@ 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
|
||||
|
||||
|
||||
@ -90,7 +93,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
|
||||
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'),
|
||||
'filter': IPAddressFilter,
|
||||
'table': IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
@ -115,6 +118,19 @@ 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',
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
@ -150,6 +166,10 @@ 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, {
|
||||
@ -216,14 +236,15 @@ class APIRootView(APIView):
|
||||
|
||||
def get(self, request, format=None):
|
||||
|
||||
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),
|
||||
})
|
||||
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)),
|
||||
)))
|
||||
|
||||
|
||||
def handle_500(request):
|
||||
|
@ -339,6 +339,18 @@ table.component-list td.subtable td {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* Reports */
|
||||
table.reports td.method {
|
||||
font-family: monospace;
|
||||
padding-left: 30px;
|
||||
}
|
||||
table.reports td.stats label {
|
||||
display: inline-block;
|
||||
line-height: 14px;
|
||||
margin-bottom: 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
/* AJAX loader */
|
||||
.loading {
|
||||
position: fixed;
|
||||
|
@ -77,7 +77,9 @@ $(document).ready(function() {
|
||||
|
||||
// Wipe out any existing options within the child field and create a default option
|
||||
child_field.empty();
|
||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||
if (!child_field.attr('multiple')) {
|
||||
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';
|
||||
|
0
netbox/reports/__init__.py
Normal file
0
netbox/reports/__init__.py
Normal file
@ -42,7 +42,7 @@ class SecretRoleListView(ObjectListView):
|
||||
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'secrets.add_secretrole'
|
||||
model = SecretRole
|
||||
form_class = forms.SecretRoleForm
|
||||
model_form = forms.SecretRoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('secrets:secretrole_list')
|
||||
|
@ -28,7 +28,7 @@
|
||||
<div id="navbar" class="navbar-collapse collapse">
|
||||
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/reports/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:site_list' %}"><strong>Sites</strong></a></li>
|
||||
@ -53,6 +53,8 @@
|
||||
{% if perms.tenancy.add_tenantgroup %}
|
||||
<li class="subnav"><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="fa fa-plus"></i> Add a Tenant Group</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'extras:report_list' %}"><strong>Reports</strong></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
|
||||
@ -206,6 +208,32 @@
|
||||
{% 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: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: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 %}
|
||||
<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">
|
||||
|
@ -155,6 +155,18 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if device.cluster %}
|
||||
<tr>
|
||||
<td>Cluster</td>
|
||||
<td>
|
||||
{% if device.cluster.group %}
|
||||
<a href="{{ device.cluster.group.get_absolute_url }}">{{ device.cluster.group }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ device.cluster.get_absolute_url }}">{{ device.cluster }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% with device.get_custom_fields as custom_fields %}
|
||||
@ -196,7 +208,7 @@
|
||||
{% if services %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for service in services %}
|
||||
{% include 'dcim/inc/service.html' %}
|
||||
{% include 'ipam/inc/service.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
@ -206,7 +218,7 @@
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_service %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
|
||||
</a>
|
||||
</div>
|
||||
|
@ -64,7 +64,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<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">
|
||||
<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">
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
7
netbox/templates/extras/inc/report_label.html
Normal file
7
netbox/templates/extras/inc/report_label.html
Normal file
@ -0,0 +1,7 @@
|
||||
{% if report.result.failed %}
|
||||
<label class="label label-danger">Failed</label>
|
||||
{% elif report.result %}
|
||||
<label class="label label-success">Passed</label>
|
||||
{% else %}
|
||||
<label class="label label-default">N/A</label>
|
||||
{% endif %}
|
92
netbox/templates/extras/report.html
Normal file
92
netbox/templates/extras/report.html
Normal file
@ -0,0 +1,92 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}{{ report.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'extras:report_list' %}">Reports</a></li>
|
||||
<li><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
|
||||
<li>{{ report.name }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.extras.add_reportresult %}
|
||||
<div class="pull-right">
|
||||
<form action="{% url 'extras:report_run' name=report.full_name %}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ run_form }}
|
||||
<button type="submit" name="_run" class="btn btn-primary"><i class="fa fa-play"></i> Run Report</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h1>{{ report.name }}{% include 'extras/inc/report_label.html' %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% if report.description %}
|
||||
<p class="lead">{{ report.description }}</p>
|
||||
{% endif %}
|
||||
{% if report.result %}
|
||||
<p>Last run: {{ report.result.created }}</p>
|
||||
{% else %}
|
||||
<p class="text-muted">Last run: Never</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
{% if report.result %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Level</th>
|
||||
<th>Object</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for method, data in report.result.data.items %}
|
||||
<tr>
|
||||
<th colspan="4"><a name="{{ method }}"></a>{{ method }}</th>
|
||||
</tr>
|
||||
{% for time, level, obj, url, message in data.log %}
|
||||
<tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
|
||||
<td>{{ time }}</td>
|
||||
<td>
|
||||
<label class="label label-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
|
||||
</td>
|
||||
<td>
|
||||
{% if obj and url %}
|
||||
<a href="{{ url }}">{{ obj }}</a>
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="well">No results are available for this report. Please run the report first.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% if report.result %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Methods</strong>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for method, data in report.result.data.items %}
|
||||
<li class="list-group-item">
|
||||
<a href="#{{ method }}">{{ method }}</a>
|
||||
<span class="badge">{{ data.log|length }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
73
netbox/templates/extras/report_list.html
Normal file
73
netbox/templates/extras/report_list.html
Normal file
@ -0,0 +1,73 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Reports{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% for module, module_reports in reports %}
|
||||
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
|
||||
<table class="table table-hover table-headings reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Description</th>
|
||||
<th class="text-right">Last Run</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for report in module_reports %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:report' name=report.full_name %}" name="report.{{ report.name }}"><strong>{{ report.name }}</strong></a>
|
||||
</td>
|
||||
<td>
|
||||
{% include 'extras/inc/report_label.html' %}
|
||||
</td>
|
||||
<td>{{ report.description|default:"" }}</td>
|
||||
{% if report.result %}
|
||||
<td class="text-right">{{ report.result.created }}</td>
|
||||
{% else %}
|
||||
<td class="text-right text-muted">Never</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% for method, stats in report.result.data.items %}
|
||||
<tr>
|
||||
<td colspan="3" class="method">
|
||||
{{ method }}
|
||||
</td>
|
||||
<td class="text-right stats">
|
||||
<label class="label label-success">{{ stats.success }}</label>
|
||||
<label class="label label-info">{{ stats.info }}</label>
|
||||
<label class="label label-warning">{{ stats.warning }}</label>
|
||||
<label class="label label-danger">{{ stats.failure }}</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
{% for module, module_reports in reports %}
|
||||
<div class="panel-heading">
|
||||
<strong>{{ module|bettertitle }}</strong>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for report in module_reports %}
|
||||
<a href="#report.{{ report.name }}" class="list-group-item">
|
||||
<i class="fa fa-list-alt"></i> {{ report.name }}
|
||||
<div class="pull-right">
|
||||
{% include 'extras/inc/report_label.html' %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,14 +1,6 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% if settings.NETBOX_USERNAME or settings.NETBOX_PASSWORD %}
|
||||
<div class="alert alert-warning alert-dismissable" role="alert">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<strong>Warning:</strong> The <code>NETBOX_USERNAME</code> and <code>NETBOX_PASSWORD</code> configuration parameters have been deprecated. Please replace them in configuration.py with <code>NAPALM_USERNAME</code> and <code>NAPALM_PASSWORD</code>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'search_form.html' %}
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-md-4">
|
||||
@ -55,20 +47,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.secrets %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Secrets</strong>
|
||||
<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>
|
||||
</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 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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div class="panel panel-default">
|
||||
@ -120,6 +115,20 @@
|
||||
</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">
|
||||
|
@ -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" aria-hidden="true"></i>
|
||||
<i class="glyphicon glyphicon-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_service %}
|
||||
<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 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>
|
||||
{% endif %}
|
||||
</td>
|
@ -104,7 +104,7 @@
|
||||
<td>Assignment</td>
|
||||
<td>
|
||||
{% if ipaddress.interface %}
|
||||
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
|
||||
<span><a href="{{ ipaddress.interface.parent.get_absolute_url }}">{{ ipaddress.interface.parent }}</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="{% url 'dcim:device' pk=ipaddress.nat_inside.interface.device.pk %}">{{ ipaddress.nat_inside.interface.device }}</a>)
|
||||
(<a href="{{ ipaddress.nat_inside.interface.parent.get_absolute_url }}">{{ ipaddress.nat_inside.interface.parent }}</a>)
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
|
@ -12,7 +12,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>IP Addresses</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field pattern_form.pattern %}
|
||||
{% render_field form.pattern %}
|
||||
{% render_field model_form.status %}
|
||||
{% render_field model_form.role %}
|
||||
{% render_field model_form.vrf %}
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load form_helpers %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block tabs %}
|
||||
{% if not obj.pk %}
|
||||
@ -26,18 +27,25 @@
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Interface Assignment</strong>
|
||||
{% 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>
|
||||
<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>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
|
||||
<div class="panel-body">
|
||||
|
@ -5,12 +5,21 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Service</strong></div>
|
||||
<div class="panel-body">
|
||||
<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>
|
||||
{% 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>
|
||||
</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 %}
|
||||
{% render_field form.name %}
|
||||
<div class="form-group form-inline">
|
||||
<label class="col-md-3 control-label required">Port</label>
|
||||
|
@ -124,6 +124,10 @@
|
||||
<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>
|
||||
|
@ -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 }}
|
||||
|
@ -14,21 +14,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="panel panel-default">
|
||||
<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>
|
||||
{% include 'inc/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
@ -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 }}
|
||||
|
@ -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 }}" />
|
||||
|
38
netbox/templates/utilities/obj_bulk_remove.html
Normal file
38
netbox/templates/utilities/obj_bulk_remove.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% 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 %}
|
@ -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 }}
|
||||
|
@ -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">
|
||||
|
127
netbox/templates/virtualization/cluster.html
Normal file
127
netbox/templates/virtualization/cluster.html
Normal file
@ -0,0 +1,127 @@
|
||||
{% 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>Site</td>
|
||||
<td>
|
||||
{% if cluster.site %}
|
||||
<a href="{{ cluster.site.get_absolute_url }}">{{ cluster.site }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Virtual Machines</td>
|
||||
<td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ 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 %}
|
44
netbox/templates/virtualization/cluster_add_devices.html
Normal file
44
netbox/templates/virtualization/cluster_add_devices.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% 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 %}
|
26
netbox/templates/virtualization/cluster_list.html
Normal file
26
netbox/templates/virtualization/cluster_list.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% 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 %}
|
19
netbox/templates/virtualization/clustergroup_list.html
Normal file
19
netbox/templates/virtualization/clustergroup_list.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% 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 %}
|
19
netbox/templates/virtualization/clustertype_list.html
Normal file
19
netbox/templates/virtualization/clustertype_list.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% 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 %}
|
70
netbox/templates/virtualization/inc/interface.html
Normal file
70
netbox/templates/virtualization/inc/interface.html
Normal file
@ -0,0 +1,70 @@
|
||||
<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 %}
|
@ -0,0 +1,14 @@
|
||||
{% 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 %}
|
300
netbox/templates/virtualization/virtualmachine.html
Normal file
300
netbox/templates/virtualization/virtualmachine.html
Normal file
@ -0,0 +1,300 @@
|
||||
{% 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>Role</td>
|
||||
<td>
|
||||
{% if vm.role %}
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ vm.role.slug }}">{{ vm.role }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Platform</td>
|
||||
<td>
|
||||
{% if vm.platform %}
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ vm.platform.slug }}">{{ vm.platform }}</a>
|
||||
{% 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>Cluster</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<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>
|
||||
</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 %}
|
@ -0,0 +1,44 @@
|
||||
{% 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 %}
|
50
netbox/templates/virtualization/virtualmachine_edit.html
Normal file
50
netbox/templates/virtualization/virtualmachine_edit.html
Normal file
@ -0,0 +1,50 @@
|
||||
{% 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.role %}
|
||||
{% render_field form.platform %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Cluster</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
</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 %}
|
55
netbox/templates/virtualization/virtualmachine_list.html
Normal file
55
netbox/templates/virtualization/virtualmachine_list.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% 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 %}
|
@ -12,6 +12,7 @@ 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
|
||||
|
||||
@ -29,7 +30,7 @@ class TenantGroupListView(ObjectListView):
|
||||
class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'tenancy.add_tenantgroup'
|
||||
model = TenantGroup
|
||||
form_class = forms.TenantGroupForm
|
||||
model_form = forms.TenantGroupForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('tenancy:tenantgroup_list')
|
||||
@ -79,6 +80,7 @@ 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', {
|
||||
@ -90,7 +92,7 @@ class TenantView(View):
|
||||
class TenantCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'tenancy.add_tenant'
|
||||
model = Tenant
|
||||
form_class = forms.TenantForm
|
||||
model_form = forms.TenantForm
|
||||
template_name = 'tenancy/tenant_edit.html'
|
||||
default_return_url = 'tenancy:tenant_list'
|
||||
|
||||
|
@ -188,6 +188,10 @@ 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
|
||||
@ -386,6 +390,15 @@ 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):
|
||||
@ -508,6 +521,15 @@ 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):
|
||||
|
@ -46,6 +46,22 @@ 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):
|
||||
"""
|
||||
|
@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
from copy import deepcopy
|
||||
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
@ -155,12 +156,12 @@ class ObjectEditView(GetReturnURLMixin, View):
|
||||
Create or edit a single object.
|
||||
|
||||
model: The model of the object being edited
|
||||
form_class: The form used to create or edit the object
|
||||
model_form: 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
|
||||
form_class = None
|
||||
model_form = None
|
||||
template_name = 'utilities/obj_edit.html'
|
||||
|
||||
def get_object(self, kwargs):
|
||||
@ -182,7 +183,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.form_class(instance=obj, initial=initial_data)
|
||||
form = self.model_form(instance=obj, initial=initial_data)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'obj': obj,
|
||||
@ -195,7 +196,7 @@ class ObjectEditView(GetReturnURLMixin, View):
|
||||
|
||||
obj = self.get_object(kwargs)
|
||||
obj = self.alter_obj(obj, request, args, kwargs)
|
||||
form = self.form_class(request.POST, request.FILES, instance=obj)
|
||||
form = self.model_form(request.POST, request.FILES, instance=obj)
|
||||
|
||||
if form.is_valid():
|
||||
obj_created = not form.instance.pk
|
||||
@ -214,7 +215,7 @@ class ObjectEditView(GetReturnURLMixin, View):
|
||||
UserAction.objects.log_edit(request.user, obj, msg)
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.path)
|
||||
return redirect(request.get_full_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()):
|
||||
@ -294,12 +295,12 @@ class BulkCreateView(View):
|
||||
"""
|
||||
Create new objects in bulk.
|
||||
|
||||
pattern_form: Form class which provides the `pattern` field
|
||||
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
|
||||
"""
|
||||
pattern_form = None
|
||||
form = None
|
||||
model_form = None
|
||||
pattern_target = ''
|
||||
template_name = None
|
||||
@ -307,12 +308,12 @@ class BulkCreateView(View):
|
||||
|
||||
def get(self, request):
|
||||
|
||||
pattern_form = self.pattern_form()
|
||||
form = self.form()
|
||||
model_form = self.model_form()
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'obj_type': self.model_form._meta.model._meta.verbose_name,
|
||||
'pattern_form': pattern_form,
|
||||
'form': form,
|
||||
'model_form': model_form,
|
||||
'return_url': reverse(self.default_return_url),
|
||||
})
|
||||
@ -320,12 +321,12 @@ class BulkCreateView(View):
|
||||
def post(self, request):
|
||||
|
||||
model = self.model_form._meta.model
|
||||
pattern_form = self.pattern_form(request.POST)
|
||||
form = self.form(request.POST)
|
||||
model_form = self.model_form(request.POST)
|
||||
|
||||
if pattern_form.is_valid():
|
||||
if form.is_valid():
|
||||
|
||||
pattern = pattern_form.cleaned_data['pattern']
|
||||
pattern = form.cleaned_data['pattern']
|
||||
new_objs = []
|
||||
|
||||
try:
|
||||
@ -347,7 +348,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):
|
||||
pattern_form.add_error('pattern', errors[self.pattern_target])
|
||||
form.add_error('pattern', errors[self.pattern_target])
|
||||
# Raise an IntegrityError to break the for loop and abort the transaction.
|
||||
raise IntegrityError()
|
||||
|
||||
@ -364,7 +365,7 @@ class BulkCreateView(View):
|
||||
pass
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'pattern_form': pattern_form,
|
||||
'form': form,
|
||||
'model_form': model_form,
|
||||
'obj_type': model._meta.verbose_name,
|
||||
'return_url': reverse(self.default_return_url),
|
||||
@ -700,3 +701,173 @@ 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'),
|
||||
})
|
||||
|
0
netbox/virtualization/__init__.py
Normal file
0
netbox/virtualization/__init__.py
Normal file
0
netbox/virtualization/api/__init__.py
Normal file
0
netbox/virtualization/api/__init__.py
Normal file
148
netbox/virtualization/api/serializers.py
Normal file
148
netbox/virtualization/api/serializers.py
Normal file
@ -0,0 +1,148 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
|
||||
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()
|
||||
site = NestedSiteSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'name', 'type', 'group', 'site', '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', 'site', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
#
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
||||
cluster = NestedClusterSerializer()
|
||||
role = NestedDeviceRoleSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
platform = NestedPlatformSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
|
||||
'memory', 'disk', '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', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
|
||||
'memory', 'disk', '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',
|
||||
]
|
29
netbox/virtualization/api/urls.py
Normal file
29
netbox/virtualization/api/urls.py
Normal file
@ -0,0 +1,29 @@
|
||||
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
|
48
netbox/virtualization/api/views.py
Normal file
48
netbox/virtualization/api/views.py
Normal file
@ -0,0 +1,48 @@
|
||||
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
|
7
netbox/virtualization/apps.py
Normal file
7
netbox/virtualization/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VirtualizationConfig(AppConfig):
|
||||
name = 'virtualization'
|
18
netbox/virtualization/constants.py
Normal file
18
netbox/virtualization/constants.py
Normal file
@ -0,0 +1,18 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED
|
||||
|
||||
|
||||
# VirtualMachine statuses (replicated from Device statuses)
|
||||
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',
|
||||
}
|
128
netbox/virtualization/filters.py
Normal file
128
netbox/virtualization/filters.py
Normal file
@ -0,0 +1,128 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
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)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (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)',
|
||||
)
|
||||
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Cluster.objects.all(),
|
||||
label='Cluster (ID)',
|
||||
)
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role_id',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role__slug',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Role (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)
|
||||
)
|
397
netbox/virtualization/forms.py
Normal file
397
netbox/virtualization/forms.py
Normal file
@ -0,0 +1,397 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES
|
||||
from dcim.formfields import MACAddressFormField
|
||||
from dcim.models import Device, DeviceRole, 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):
|
||||
comments = CommentField(widget=SmallTextarea)
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['name', 'type', 'group', 'site', 'comments']
|
||||
|
||||
|
||||
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.',
|
||||
}
|
||||
)
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Name of assigned site',
|
||||
error_messages={
|
||||
'invalid_choice': 'Invalid site name.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['name', 'type', 'group', 'site', 'comments']
|
||||
|
||||
|
||||
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)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
comments = CommentField(widget=SmallTextarea)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['group', 'site', 'comments']
|
||||
|
||||
|
||||
class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Cluster
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
type = FilterChoiceField(
|
||||
queryset=ClusterType.objects.annotate(filter_count=Count('clusters')),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
)
|
||||
group = FilterChoiceField(
|
||||
queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
required=False,
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('clusters')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
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, cluster, *args, **kwargs):
|
||||
|
||||
self.cluster = cluster
|
||||
|
||||
super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['devices'].choices = []
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(ClusterAddDevicesForm, self).clean()
|
||||
|
||||
# If the Cluster is assigned to a Site, all Devices must be assigned to that Site.
|
||||
if self.cluster.site is not None:
|
||||
for device in self.cleaned_data.get('devices'):
|
||||
if device.site != self.cluster.site:
|
||||
raise ValidationError({
|
||||
'devices': "{} belongs to a different site ({}) than the cluster ({})".format(
|
||||
device, device.site, self.cluster.site
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
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', 'role', '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.',
|
||||
}
|
||||
)
|
||||
role = forms.ModelChoiceField(
|
||||
queryset=DeviceRole.objects.filter(vm_role=True),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name of functional role',
|
||||
error_messages={
|
||||
'invalid_choice': 'Invalid role 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', 'role', '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)
|
||||
role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), 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 = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
|
||||
|
||||
|
||||
def vm_status_choices():
|
||||
status_counts = {}
|
||||
for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in 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'
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
)
|
||||
platform = FilterChoiceField(
|
||||
queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# 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(),
|
||||
'form_factor': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput())
|
||||
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, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput())
|
||||
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)
|
89
netbox/virtualization/migrations/0001_virtualization.py
Normal file
89
netbox/virtualization/migrations/0001_virtualization.py
Normal file
@ -0,0 +1,89 @@
|
||||
# -*- 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'),
|
||||
),
|
||||
]
|
@ -0,0 +1,20 @@
|
||||
# -*- 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'),
|
||||
),
|
||||
]
|
22
netbox/virtualization/migrations/0003_cluster_add_site.py
Normal file
22
netbox/virtualization/migrations/0003_cluster_add_site.py
Normal file
@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-22 16:30
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0044_virtualization'),
|
||||
('virtualization', '0002_virtualmachine_add_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cluster',
|
||||
name='site',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'),
|
||||
),
|
||||
]
|
@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-29 14:32
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0044_virtualization'),
|
||||
('virtualization', '0003_cluster_add_site'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='virtualmachine',
|
||||
name='role',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'),
|
||||
),
|
||||
]
|
0
netbox/virtualization/migrations/__init__.py
Normal file
0
netbox/virtualization/migrations/__init__.py
Normal file
257
netbox/virtualization/models.py
Normal file
257
netbox/virtualization/models.py
Normal file
@ -0,0 +1,257 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.utils import csv_format
|
||||
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
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
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'
|
||||
)
|
||||
|
||||
csv_headers = [
|
||||
'name', 'type', 'group', 'site', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:cluster', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
|
||||
if self.pk and self.site:
|
||||
nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
|
||||
if nonsite_devices:
|
||||
raise ValidationError({
|
||||
'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format(
|
||||
nonsite_devices, self.site
|
||||
)
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
self.name,
|
||||
self.type.name,
|
||||
self.group.name if self.group else None,
|
||||
self.comments,
|
||||
])
|
||||
|
||||
|
||||
#
|
||||
# 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'
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
to='dcim.DeviceRole',
|
||||
limit_choices_to={'vm_role': True},
|
||||
on_delete=models.PROTECT,
|
||||
related_name='virtual_machines',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
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'
|
||||
)
|
||||
|
||||
csv_headers = [
|
||||
'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:virtualmachine', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
self.name,
|
||||
self.get_status_display(),
|
||||
self.cluster.name,
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.platform.name if self.platform else None,
|
||||
self.vcpus,
|
||||
self.memory,
|
||||
self.disk,
|
||||
self.comments,
|
||||
])
|
||||
|
||||
def get_status_class(self):
|
||||
return VM_STATUS_CLASSES[self.status]
|
119
netbox/virtualization/tables.py
Normal file
119
netbox/virtualization/tables.py
Normal file
@ -0,0 +1,119 @@
|
||||
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>
|
||||
"""
|
||||
|
||||
VIRTUALMACHINE_PRIMARY_IP = """
|
||||
{{ record.primary_ip6.address.ip|default:"" }}
|
||||
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
|
||||
{{ record.primary_ip4.address.ip|default:"" }}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# 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', 'site', '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', 'role', 'tenant', 'vcpus', 'memory', 'disk')
|
||||
|
||||
|
||||
class VirtualMachineDetailTable(VirtualMachineTable):
|
||||
primary_ip = tables.TemplateColumn(
|
||||
orderable=False, verbose_name='IP Address', template_code=VIRTUALMACHINE_PRIMARY_IP
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualMachine
|
||||
fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip')
|
||||
|
||||
|
||||
#
|
||||
# VM components
|
||||
#
|
||||
|
||||
class InterfaceTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('name', 'enabled', 'description')
|
0
netbox/virtualization/tests/__init__.py
Normal file
0
netbox/virtualization/tests/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user