Compare commits

...

50 Commits

Author SHA1 Message Date
Jeremy Stretch
f9452163c5 Merge pull request #1671 from digitalocean/develop
Release v2.2.4
2017-10-31 15:21:23 -04:00
Jeremy Stretch
76ebd2d34f Release v2.2.4 2017-10-31 15:17:35 -04:00
Jeremy Stretch
85c273c8ca Fixes #1670: Corrected filter names (regression from #1649) 2017-10-31 14:47:14 -04:00
Jeremy Stretch
b9cd834e95 Post-release version bump 2017-10-31 14:07:19 -04:00
Jeremy Stretch
3067c3f262 Merge pull request #1668 from digitalocean/develop
Release v2.2.3
2017-10-31 14:02:15 -04:00
Jeremy Stretch
cfa6bee081 Release v2.2.3 2017-10-31 13:58:27 -04:00
Jeremy Stretch
b46cc2c1a9 Closes #1666: Allow modifying the owner of a rack reservation 2017-10-31 13:52:35 -04:00
Jeremy Stretch
5e734fc5a6 Merge pull request #1664 from wanglf/develop
Fixed #1612 missing field 'serial' in function to_csv()
2017-10-31 13:14:52 -04:00
wanglf
d08bc7767e Fixed missing field 'serial' in function to_csv() 2017-10-31 22:05:30 +08:00
Jeremy Stretch
bbd0761887 Fixes #1653: Remove outdated description for DeviceType's is_network_device flag 2017-10-30 17:45:05 -04:00
Jeremy Stretch
4668149943 Fixes #1649: Correct fitlering on null values (e.g. ?tenant_id=0) for django-filters v1.1.0+ 2017-10-30 17:20:22 -04:00
Jeremy Stretch
a5b7c057eb Merge pull request #1604 from RyanBreaker/virt-initial_data
Added virtualization fixture for initial_data
2017-10-30 15:30:28 -04:00
Jeremy Stretch
0a04bb110a Fixes #1603: Hide selection checkboxes for tables with no available actions 2017-10-27 10:33:58 -04:00
Jeremy Stretch
9c0b414676 Fixed vertical heigh of checboxes in tables 2017-10-27 10:31:54 -04:00
Jeremy Stretch
5be4b0c4fd Closes #1631: Added a post_run method to the Report class 2017-10-27 10:02:27 -04:00
Jeremy Stretch
8e0eab20e2 Merge pull request #1652 from Ichabond/develop
Fixes #1651: Remove explicit reference to the `name` field
2017-10-26 21:15:14 -04:00
Tom Strickx
1b5aa67f5d Remove explicit reference to the name field
Remove the explicit usage of the `name` field, and let the __str__ method of the ConsoleServerPort class handle the display
2017-10-26 09:24:39 -07:00
Ryan Breaker
02c278f393 Add Digital Ocean as an initial cluster 2017-10-26 10:05:18 -05:00
Jeremy Stretch
54472b3806 Fixes #1634: Cluster should not be a required field when importing child devices 2017-10-23 13:17:51 -04:00
Jeremy Stretch
14e5f89feb Fixes #1624: Add VM count to device roles table 2017-10-23 13:15:34 -04:00
Jeremy Stretch
a0b93bb4df Fixes #1513: Correct filtering of custom field choices 2017-10-20 16:39:13 -04:00
Jeremy Stretch
85347d9675 Closes #999: Display devices on which circuits are terminated in circuits list 2017-10-20 16:27:19 -04:00
Ryan Breaker
060f7a7191 Add Hyper-V and Azure to Virtualization initial_data 2017-10-19 20:18:09 -05:00
Jeremy Stretch
81ca6f7cba #1621: Allow for loose matching of short-form IOS interface names when validating LLDP neighbors 2017-10-19 17:12:28 -04:00
Jeremy Stretch
515645bb4d Fixes #1620: Loosen IP address search filter to match all IPs that start with the given string 2017-10-18 14:29:56 -04:00
Jeremy Stretch
6ae6209457 Fixes #1619: Correct text-based filtering of IP network and address fields 2017-10-18 13:01:49 -04:00
Jeremy Stretch
272325ff05 Fixes #1618: Allow bulk deletion of all virtual machines 2017-10-18 12:31:58 -04:00
Jeremy Stretch
a84b49b92d Merge pull request #1597 from RyanBreaker/validations
Additional data validation for Rack model
2017-10-18 11:22:26 -04:00
Jeremy Stretch
b63efdd80b Post-release version bump 2017-10-17 11:25:12 -04:00
Jeremy Stretch
7a64404299 Merge pull request #1614 from digitalocean/develop
Release v2.2.2
2017-10-17 11:24:02 -04:00
Jeremy Stretch
2afa6ed2cb Release v2.2.2 2017-10-17 11:21:58 -04:00
Jeremy Stretch
34f1a9ebfb Fixes #1579: Devices already assigned to a cluster cannot be added to a different cluster 2017-10-17 09:59:35 -04:00
Jeremy Stretch
6f2f8697ae Fixes #1609: Added missing virtual_machine field to IP address interface serializer 2017-10-17 09:23:53 -04:00
Jeremy Stretch
6ec9d1d6ce Merge pull request #1598 from candlerb/candlerb/1498
Avoid creating repeated graph nodes where device matches multiple regexps
2017-10-16 17:16:46 -04:00
Jeremy Stretch
047f22e110 Fixes #1605: Added clusters and virtual machines to object list for global search 2017-10-16 16:44:15 -04:00
Ryan Breaker
a91fcbb310 Added virtualization fixture for loaddata initial_data 2017-10-16 14:59:39 -05:00
root
5fc3eac0f6 Avoid creating repeated graph nodes where device matches multiple regexps
Fixes #1498
2017-10-16 10:13:39 +00:00
Ryan Breaker
937faaf149 Add test for validation of device within rack height. 2017-10-15 19:03:28 -05:00
Ryan Breaker
115e7d6e50 Add group and site matching validation to Rack.clean() and testing 2017-10-15 18:35:03 -05:00
Jeremy Stretch
34259d5d9d Removed deprecated xstr and expand_pattern functions 2017-10-13 14:29:55 -04:00
Jeremy Stretch
91b6ebb0c0 Closes #1580: Allow cluster assignment when bulk importing devices 2017-10-13 14:19:41 -04:00
Jeremy Stretch
60b4f1f89f Fixes #1585: Fixed slug-based filtering of virtual machines 2017-10-13 12:14:19 -04:00
Jeremy Stretch
023ff6834a Designated new Docker build repo; removed stale Heroku build repo 2017-10-13 11:50:06 -04:00
Jeremy Stretch
d00cab0b0a Merge branch 'develop' of github.com:digitalocean/netbox into develop 2017-10-13 10:54:24 -04:00
Jeremy Stretch
17493ff655 Closes #1587: Add primary IP column for virtual machines in global search results 2017-10-13 10:53:25 -04:00
Jeremy Stretch
6c27e6c4fe Fixes #1584: Colorized virtual machine role column 2017-10-13 10:45:34 -04:00
Jeremy Stretch
4cb0be4df3 Fixes #1582: Add virtual_machine attribute to IPAddress 2017-10-13 10:42:45 -04:00
Jeremy Stretch
40f555a3b6 Merge pull request #1545 from digitalocean/mdl-ldap-docs-formatting
Minor LDAP documentation formatting cleanup
2017-10-12 23:42:29 -04:00
Jeremy Stretch
881fdbe893 Post-release version bump 2017-10-12 16:39:51 -04:00
Matt Layher
a01c9ff379 Minor LDAP documentation formatting cleanup 2017-09-29 13:22:42 -04:00
41 changed files with 478 additions and 238 deletions

View File

@@ -31,6 +31,5 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
## Alternative Installations
* [Docker container](https://github.com/digitalocean/netbox-docker)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))

View File

@@ -55,7 +55,7 @@ LDAP_IGNORE_CERT_ERRORS = True
## User Authentication
!!! info
When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
```python
from django_auth_ldap.config import LDAPSearch
@@ -79,7 +79,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
# User Groups for Permissions
!!! Info
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE.
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

View File

@@ -94,6 +94,8 @@ The following methods are available to log results within a report:
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.
To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`.
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

View File

@@ -7,7 +7,7 @@ from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from utilities.filters import NumericInFilter
from .models import Provider, Circuit, CircuitTermination, CircuitType
@@ -78,12 +78,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Circuit type (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',

View File

@@ -3,6 +3,8 @@ from __future__ import unicode_literals
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.utils.safestring import mark_safe
from utilities.tables import BaseTable, ToggleColumn
from .models import Circuit, CircuitType, Provider
@@ -14,6 +16,21 @@ CIRCUITTYPE_ACTIONS = """
"""
class CircuitTerminationColumn(tables.Column):
def render(self, value):
if value.interface:
return mark_safe('<a href="{}" title="{}">{}</a>'.format(
value.interface.device.get_absolute_url(),
value.site,
value.interface.device
))
return mark_safe('<a href="{}">{}</a>'.format(
value.site.get_absolute_url(),
value.site
))
#
# Providers
#
@@ -61,15 +78,9 @@ class CircuitTable(BaseTable):
cid = tables.LinkColumn(verbose_name='ID')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
a_side = tables.LinkColumn(
'dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
args=[Accessor('termination_a.site.slug')]
)
z_side = tables.LinkColumn(
'dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
args=[Accessor('termination_z.site.slug')]
)
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
class Meta(BaseTable.Meta):
model = Circuit
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')

View File

@@ -134,7 +134,11 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class CircuitListView(ObjectListView):
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
queryset = Circuit.objects.select_related(
'provider', 'type', 'tenant'
).prefetch_related(
'terminations__site', 'terminations__interface__device'
)
filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm
table = tables.CircuitTable

View File

@@ -221,7 +221,7 @@ class WritableRackReservationSerializer(ValidatedModelSerializer):
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'description']
fields = ['id', 'rack', 'units', 'user', 'description']
#

View File

@@ -9,7 +9,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 utilities.filters import NullableCharFieldFilter, NumericInFilter
from virtualization.models import Cluster
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -21,11 +21,12 @@ from .models import (
class RegionFilter(django_filters.FilterSet):
parent_id = NullableModelMultipleChoiceFilter(
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Parent region (ID)',
)
parent = NullableModelMultipleChoiceFilter(
parent = django_filters.ModelMultipleChoiceFilter(
name='parent__slug',
queryset=Region.objects.all(),
to_field_name='slug',
label='Parent region (slug)',
@@ -42,20 +43,22 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
region_id = NullableModelMultipleChoiceFilter(
region_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Region (ID)',
)
region = NullableModelMultipleChoiceFilter(
region = django_filters.ModelMultipleChoiceFilter(
name='region__slug',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
@@ -126,32 +129,32 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = NullableModelMultipleChoiceFilter(
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='group',
group = django_filters.ModelMultipleChoiceFilter(
name='group__slug',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = NullableModelMultipleChoiceFilter(
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackRole.objects.all(),
label='Role (ID)',
)
role = NullableModelMultipleChoiceFilter(
name='role',
role = django_filters.ModelMultipleChoiceFilter(
name='role__slug',
queryset=RackRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
@@ -193,13 +196,13 @@ class RackReservationFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = NullableModelMultipleChoiceFilter(
group_id = django_filters.ModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='rack__group',
group = django_filters.ModelMultipleChoiceFilter(
name='rack__group__slug',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
@@ -368,22 +371,22 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
platform_id = NullableModelMultipleChoiceFilter(
platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = NullableModelMultipleChoiceFilter(
name='platform',
platform = django_filters.ModelMultipleChoiceFilter(
name='platform__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
@@ -405,12 +408,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
rack_id = NullableModelMultipleChoiceFilter(
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
cluster_id = NullableModelMultipleChoiceFilter(
cluster_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
label='VM cluster (ID)',
)
@@ -595,7 +598,7 @@ class DeviceBayFilter(DeviceComponentFilterSet):
class InventoryItemFilter(DeviceComponentFilterSet):
parent_id = NullableModelMultipleChoiceFilter(
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItem.objects.all(),
label='Parent inventory item (ID)',
)

View File

@@ -4,6 +4,7 @@ from mptt.forms import TreeNodeChoiceField
import re
from django import forms
from django.contrib.auth.models import User
from django.contrib.postgres.forms.array import SimpleArrayField
from django.db.models import Count, Q
@@ -17,6 +18,7 @@ from utilities.forms import (
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField, FilterTreeNodeMultipleChoiceField,
)
from virtualization.models import Cluster
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
@@ -375,10 +377,11 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
class RackReservationForm(BootstrapMixin, forms.ModelForm):
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
class Meta:
model = RackReservation
fields = ['units', 'description']
fields = ['units', 'user', 'description']
def __init__(self, *args, **kwargs):
@@ -410,6 +413,15 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
)
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = []
#
# Manufacturers
#
@@ -900,11 +912,20 @@ class DeviceCSVForm(BaseDeviceCSVForm):
required=False,
help_text='Mounted rack face'
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text='Virtualization cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face',
'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster',
]
def clean(self):
@@ -940,11 +961,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
device_bay_name = forms.CharField(
help_text='Name of device bay',
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text='Virtualization cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay_name',
'parent', 'device_bay_name', 'cluster',
]
def clean(self):

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-10-31 17:32
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0048_rack_serial'),
]
operations = [
migrations.AlterField(
model_name='rackreservation',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -256,8 +256,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
def clean(self):
# Validate that Rack is tall enough to house the installed Devices
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
@@ -267,6 +267,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
min_height
)
})
# Validate that Rack was assigned a group of its same site, if applicable
if self.group:
if self.group.site != self.site:
raise ValidationError({
'group': "Rack group must be from the same site, {}.".format(self.site)
})
def save(self, *args, **kwargs):
@@ -290,6 +296,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
self.tenant.name if self.tenant else None,
self.role.name if self.role else None,
self.get_type_display() if self.type else None,
self.serial,
self.width,
self.u_height,
self.desc_units,
@@ -411,7 +418,7 @@ class RackReservation(models.Model):
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
units = ArrayField(models.PositiveSmallIntegerField())
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
user = models.ForeignKey(User, on_delete=models.PROTECT)
description = models.CharField(max_length=100)
class Meta:

View File

@@ -362,6 +362,7 @@ class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices')
vm_count = tables.Column(verbose_name='VMs')
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'}},
@@ -369,7 +370,7 @@ class DeviceRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = DeviceRole
fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions')
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'slug', 'actions')
#

View File

@@ -9,14 +9,29 @@ class RackTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(
self.site1 = Site.objects.create(
name='TestSite1',
slug='my-test-site'
slug='test-site-1'
)
self.site2 = Site.objects.create(
name='TestSite2',
slug='test-site-2'
)
self.group1 = RackGroup.objects.create(
name='TestGroup1',
slug='test-group-1',
site=self.site1
)
self.group2 = RackGroup.objects.create(
name='TestGroup2',
slug='test-group-2',
site=self.site2
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=self.site,
site=self.site1,
group=self.group1,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
@@ -57,13 +72,51 @@ class RackTestCase(TestCase):
}
def test_rack_device_outside_height(self):
rack1 = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42
)
rack1.save()
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
site=self.site1,
rack=rack1,
position=43,
face=RACK_FACE_FRONT,
)
device1.save()
with self.assertRaises(ValidationError):
rack1.clean()
def test_rack_group_site(self):
rack_invalid_group = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42,
group=self.group2
)
rack_invalid_group.save()
with self.assertRaises(ValidationError):
rack_invalid_group.clean()
def test_mount_single_device(self):
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
site=self.site,
site=self.site1,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
@@ -92,7 +145,7 @@ class RackTestCase(TestCase):
name='TestPDU',
device_role=self.role.get('PDU'),
device_type=self.device_type.get('cc5000'),
site=self.site,
site=self.site1,
rack=self.rack,
position=None,
face=None,

View File

@@ -45,6 +45,7 @@ urlpatterns = [
# Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),

View File

@@ -34,32 +34,6 @@ from .models import (
)
EXPANSION_PATTERN = '\[(\d+-\d+)\]'
def xstr(s):
"""
Replace None with an empty string (for CSV export)
"""
return '' if s is None else str(s)
def expand_pattern(string):
"""
Expand a numeric pattern into a list of strings. Examples:
'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7']
"""
lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1)
x, y = pattern.split('-')
for i in range(int(x), int(y) + 1):
if remnant:
for string in expand_pattern(remnant):
yield "{0}{1}{2}".format(lead, i, string)
else:
yield "{0}{1}".format(lead, i)
class BulkDisconnectView(View):
"""
An extendable view for disconnection console/power/interface components in bulk.
@@ -452,6 +426,16 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
return obj.rack.get_absolute_url()
class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rackreservation'
cls = RackReservation
queryset = RackReservation.objects.select_related('rack', 'user')
filter = filters.RackReservationFilter
table = tables.RackReservationTable
form = forms.RackReservationBulkEditForm
default_return_url = 'dcim:rackreservation_list'
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
@@ -543,12 +527,12 @@ class DeviceTypeView(View):
show_header=False
)
if request.user.has_perm('dcim.change_devicetype'):
consoleport_table.base_columns['pk'].visible = True
consoleserverport_table.base_columns['pk'].visible = True
powerport_table.base_columns['pk'].visible = True
poweroutlet_table.base_columns['pk'].visible = True
interface_table.base_columns['pk'].visible = True
devicebay_table.base_columns['pk'].visible = True
consoleport_table.columns.show('pk')
consoleserverport_table.columns.show('pk')
powerport_table.columns.show('pk')
poweroutlet_table.columns.show('pk')
interface_table.columns.show('pk')
devicebay_table.columns.show('pk')
return render(request, 'dcim/devicetype.html', {
'devicetype': devicetype,
@@ -726,7 +710,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class DeviceRoleListView(ObjectListView):
queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
queryset = DeviceRole.objects.annotate(
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.DeviceRoleTable
template_name = 'dcim/devicerole_list.html'

View File

@@ -19,17 +19,28 @@ class CustomFieldFilter(django_filters.Filter):
super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value):
# Skip filter on empty value
if not value.strip():
return queryset
# Treat 0 as None for Select fields
try:
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.name,
)
except ValueError:
pass
# Selection fields get special treatment (values must be integers)
if self.cf_type == CF_TYPE_SELECT:
try:
# Treat 0 as None
if int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.name,
)
# Match on exact CustomFieldChoice PK
else:
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value,
)
except ValueError:
return queryset.none()
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value__icontains=value,

View File

@@ -274,6 +274,7 @@ class TopologyMap(models.Model):
# Construct the graph
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
seen = set()
for i, device_set in enumerate(self.device_sets):
subgraph = graphviz.Graph(name='sg{}'.format(i))
@@ -288,6 +289,9 @@ class TopologyMap(models.Model):
devices = []
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role')
# Remove duplicate devices
devices = [d for d in devices if d.id not in seen]
seen.update([d.id for d in devices])
for d in devices:
bg_color = '#{}'.format(d.device_role.color)
fg_color = '#{}'.format(foreground_color(d.device_role.color))

View File

@@ -177,3 +177,12 @@ class Report(object):
result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
result.save()
self.result = result
# Perform any post-run tasks
self.post_run()
def post_run(self):
"""
Extend this method to include any tasks which should execute after the report has been run.
"""
pass

View File

@@ -240,12 +240,22 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
# IP addresses
#
class IPAddressInterfaceSerializer(InterfaceSerializer):
virtual_machine = NestedVirtualMachineSerializer()
class Meta(InterfaceSerializer.Meta):
fields = [
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
]
class IPAddressSerializer(CustomFieldModelSerializer):
vrf = NestedVRFSerializer()
tenant = NestedTenantSerializer()
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
interface = InterfaceSerializer()
interface = IPAddressInterfaceSerializer()
class Meta:
model = IPAddress
@@ -262,6 +272,7 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
model = IPAddress
fields = ['id', 'url', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()

View File

@@ -151,7 +151,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'interface__device'
'interface__device', 'interface__virtual_machine'
)
serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer

View File

@@ -9,7 +9,7 @@ from django.db.models import Q
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from utilities.filters import NumericInFilter
from virtualization.models import VirtualMachine
from .models import (
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
@@ -23,12 +23,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
@@ -110,37 +110,37 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='filter_mask_length',
label='Mask length',
)
vrf_id = NullableModelMultipleChoiceFilter(
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
label='VRF',
)
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
vrf = django_filters.ModelMultipleChoiceFilter(
name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
site_id = NullableModelMultipleChoiceFilter(
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
)
site = NullableModelMultipleChoiceFilter(
name='site',
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
vlan_id = NullableModelMultipleChoiceFilter(
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label='VLAN (ID)',
)
@@ -148,12 +148,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='vlan__vid',
label='VLAN number (1-4095)',
)
role_id = NullableModelMultipleChoiceFilter(
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
label='Role (ID)',
)
role = NullableModelMultipleChoiceFilter(
name='role',
role = django_filters.ModelMultipleChoiceFilter(
name='role__slug',
queryset=Role.objects.all(),
to_field_name='slug',
label='Role (slug)',
@@ -207,22 +207,22 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='filter_mask_length',
label='Mask length',
)
vrf_id = NullableModelMultipleChoiceFilter(
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
label='VRF',
)
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
vrf = django_filters.ModelMultipleChoiceFilter(
name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
@@ -267,12 +267,10 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
ipaddress = str(IPNetwork(value.strip()))
qs_filter |= Q(address__net_host=ipaddress)
except (AddrFormatError, ValueError):
pass
qs_filter = (
Q(description__icontains=value) |
Q(address__istartswith=value)
)
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, name, value):
@@ -292,12 +290,12 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class VLANGroupFilter(django_filters.FilterSet):
site_id = NullableModelMultipleChoiceFilter(
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
)
site = NullableModelMultipleChoiceFilter(
name='site',
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -314,42 +312,42 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
site_id = NullableModelMultipleChoiceFilter(
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
)
site = NullableModelMultipleChoiceFilter(
name='site',
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = NullableModelMultipleChoiceFilter(
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLANGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='group',
group = django_filters.ModelMultipleChoiceFilter(
name='group__slug',
queryset=VLANGroup.objects.all(),
to_field_name='slug',
label='Group',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = NullableModelMultipleChoiceFilter(
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
label='Role (ID)',
)
role = NullableModelMultipleChoiceFilter(
name='role',
role = django_filters.ModelMultipleChoiceFilter(
name='role__slug',
queryset=Role.objects.all(),
to_field_name='slug',
label='Role (slug)',

View File

@@ -1,7 +1,7 @@
from __future__ import unicode_literals
from django.db.models import Lookup, Transform, IntegerField
from django.db.models.lookups import BuiltinLookup
from django.db.models import lookups
class NetFieldDecoratorMixin(object):
@@ -13,27 +13,27 @@ class NetFieldDecoratorMixin(object):
return lhs_string, lhs_params
class EndsWith(NetFieldDecoratorMixin, BuiltinLookup):
class EndsWith(NetFieldDecoratorMixin, lookups.EndsWith):
lookup_name = 'endswith'
class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup):
class IEndsWith(NetFieldDecoratorMixin, lookups.IEndsWith):
lookup_name = 'iendswith'
class StartsWith(NetFieldDecoratorMixin, BuiltinLookup):
class StartsWith(NetFieldDecoratorMixin, lookups.StartsWith):
lookup_name = 'startswith'
class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup):
class IStartsWith(NetFieldDecoratorMixin, lookups.IStartsWith):
lookup_name = 'istartswith'
class Regex(NetFieldDecoratorMixin, BuiltinLookup):
class Regex(NetFieldDecoratorMixin, lookups.Regex):
lookup_name = 'regex'
class IRegex(NetFieldDecoratorMixin, BuiltinLookup):
class IRegex(NetFieldDecoratorMixin, lookups.IRegex):
lookup_name = 'iregex'

View File

@@ -440,7 +440,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.virtual_machine.name if self.virtual_machine else None,
self.interface.name if self.interface else None,
is_primary,
self.description,
@@ -452,6 +452,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return self.interface.device
return None
@property
def virtual_machine(self):
if self.interface:
return self.interface.virtual_machine
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]

View File

@@ -325,7 +325,7 @@ class AggregateView(View):
prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True
prefix_table.columns.show('pk')
paginate = {
'klass': EnhancedPaginator,
@@ -495,7 +495,7 @@ class PrefixView(View):
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True
child_prefix_table.columns.show('pk')
paginate = {
'klass': EnhancedPaginator,
@@ -538,7 +538,7 @@ class PrefixIPAddressesView(View):
ip_table = tables.IPAddressTable(ipaddresses)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table.base_columns['pk'].visible = True
ip_table.columns.show('pk')
paginate = {
'klass': EnhancedPaginator,

View File

@@ -30,6 +30,10 @@ OBJ_TYPE_CHOICES = (
('Tenancy', (
('tenant', 'Tenants'),
)),
('Virtualization', (
('cluster', 'Clusters'),
('virtualmachine', 'Virtual machines'),
)),
)

View File

@@ -13,7 +13,7 @@ except ImportError:
)
VERSION = '2.2.1'
VERSION = '2.2.4'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -206,6 +206,10 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048
# Django filters
FILTERS_NULL_CHOICE_LABEL = 'None'
FILTERS_NULL_CHOICE_VALUE = '0' # Must be a string
# Django REST framework (API)
REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version
REST_FRAMEWORK = {

View File

@@ -27,7 +27,7 @@ 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 virtualization.tables import ClusterTable, VirtualMachineDetailTable
from .forms import SearchForm
@@ -126,9 +126,11 @@ SEARCH_TYPES = OrderedDict((
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'),
'queryset': VirtualMachine.objects.select_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineTable,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
))

View File

@@ -106,9 +106,14 @@ label {
label.required {
font-weight: bold;
}
input[name="pk"] {
margin-top: 0;
}
/* Tables */
th.pk, td.pk {
.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
padding-bottom: 6px;
padding-top: 10px;
width: 30px;
}
tfoot td {

View File

@@ -54,15 +54,27 @@ $(document).ready(function() {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
var neighbor = neighbors[0];
var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
// Glean configured hostnames/interfaces from the DOM
var configured_device = row.children('td.configured_device').attr('data');
var configured_interface = row.children('td.configured_interface').attr('data');
if (configured_interface) {
// Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1).
configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
}
// Clean up hostnames/interfaces learned via LLDP
var lldp_device = neighbor['hostname'].split(".")[0]; // Strip off any trailing domain name
var lldp_interface = neighbor['port'].split(".")[0]; // Strip off any trailing subinterface ID
// Add LLDP neighbors to table
row.children('td.device').html(neighbor['hostname']);
row.children('td.interface').html(neighbor['port']);
row.children('td.device').html(lldp_device);
row.children('td.interface').html(lldp_interface);
// Apply colors to rows
if (!configured_device && neighbor['hostname']) {
if (!configured_device && lldp_device) {
row.addClass('info');
} else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) {
} else if (configured_device == lldp_device && configured_interface == lldp_interface) {
row.addClass('success');
} else {
row.addClass('danger');

View File

@@ -120,7 +120,7 @@
</td>
<td>
<strong>Network Device</strong><br />
<small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} non-management network interfaces</small>
<small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces</small>
</td>
</tr>
<tr>

View File

@@ -5,7 +5,7 @@
</td>
{% endif %}
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp.name }}
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
</td>
<td></td>
{% if csp.connected_console %}

View File

@@ -5,7 +5,7 @@
<h1>{% block title %}Rack Reservations{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %}
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}

View File

@@ -59,6 +59,7 @@
<script type="text/javascript">
$(document).ready(function() {
var device_list = $('#id_devices');
var disabled_indicator = device_list.attr('disabled-indicator');
$('#id_search').autocomplete({
source: function(request, response) {
$.ajax({
@@ -70,7 +71,11 @@
},
success: function(data) {
response($.map(data.results, function(item) {
device_list.append('<option value="' + item['id'] + '">' + item['display_name'] + '</option>');
var option = $("<option></option>").attr("value", item['id']).text(item['display_name']);
if (disabled_indicator && item[disabled_indicator]) {
option.attr("disabled", "disabled");
}
device_list.append(option);
}));
}
});

View File

@@ -5,7 +5,7 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from utilities.filters import NumericInFilter
from .models import Tenant, TenantGroup
@@ -22,12 +22,12 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
group_id = NullableModelMultipleChoiceFilter(
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='group',
group = django_filters.ModelMultipleChoiceFilter(
name='group__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Group (slug)',

View File

@@ -4,7 +4,6 @@ import django_filters
import itertools
from django import forms
from django.db.models import Q
from django.utils.encoding import force_text
@@ -66,51 +65,3 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
stripped_value = value
super(NullableModelMultipleChoiceField, self).clean(stripped_value)
return value
class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
"""
This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default
queryset filter argument is:
.filter(fieldname=value)
When filtering by the value representing "is null" ('0' by default) the argument is modified to:
.filter(fieldname__isnull=True)
"""
field_class = NullableModelMultipleChoiceField
def __init__(self, *args, **kwargs):
self.null_value = kwargs.get('null_value', 0)
super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs)
def filter(self, qs, value):
value = value or () # Make sure we have an iterable
if self.is_noop(qs, value):
return qs
# Even though not a noop, no point filtering if empty
if not value:
return qs
q = Q()
for v in set(value):
# Filtering by "is null"
if v == force_text(self.null_value):
arg = {'{}__isnull'.format(self.name): True}
# Filtering by a related field (e.g. slug)
elif self.field.to_field_name is not None:
arg = {'{}__{}'.format(self.name, self.field.to_field_name): v}
# Filtering by primary key (default)
else:
arg = {self.name: v}
if self.conjoined:
qs = self.get_method(qs)(**arg)
else:
q |= Q(**arg)
if self.distinct:
return self.get_method(qs)(q).distinct()
return self.get_method(qs)(q)

View File

@@ -9,7 +9,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, Interface, Platform, Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from utilities.filters import NumericInFilter
from .constants import STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -20,11 +20,12 @@ class ClusterFilter(CustomFieldFilterSet):
method='search',
label='Search',
)
group_id = NullableModelMultipleChoiceFilter(
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=ClusterGroup.objects.all(),
label='Parent group (ID)',
)
group = NullableModelMultipleChoiceFilter(
group = django_filters.ModelMultipleChoiceFilter(
name='group__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label='Parent group (slug)',
@@ -72,12 +73,12 @@ class VirtualMachineFilter(CustomFieldFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=STATUS_CHOICES
)
cluster_group_id = NullableModelMultipleChoiceFilter(
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
name='cluster__group',
queryset=ClusterGroup.objects.all(),
label='Cluster group (ID)',
)
cluster_group = NullableModelMultipleChoiceFilter(
cluster_group = django_filters.ModelMultipleChoiceFilter(
name='cluster__group__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
@@ -87,32 +88,32 @@ class VirtualMachineFilter(CustomFieldFilterSet):
queryset=Cluster.objects.all(),
label='Cluster (ID)',
)
role_id = NullableModelMultipleChoiceFilter(
name='role_id',
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceRole.objects.all(),
label='Role (ID)',
)
role = NullableModelMultipleChoiceFilter(
role = django_filters.ModelMultipleChoiceFilter(
name='role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = NullableModelMultipleChoiceFilter(
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
platform_id = NullableModelMultipleChoiceFilter(
platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = NullableModelMultipleChoiceFilter(
name='platform',
platform = django_filters.ModelMultipleChoiceFilter(
name='platform__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',

View File

@@ -0,0 +1,91 @@
[
{
"model": "virtualization.clustertype",
"pk": 1,
"fields": {
"name": "Public Cloud",
"slug": "public-cloud"
}
},
{
"model": "virtualization.clustertype",
"pk": 2,
"fields": {
"name": "vSphere",
"slug": "vsphere"
}
},
{
"model": "virtualization.clustertype",
"pk": 3,
"fields": {
"name": "Hyper-V",
"slug": "hyper-v"
}
},
{
"model": "virtualization.clustertype",
"pk": 4,
"fields": {
"name": "libvirt",
"slug": "libvirt"
}
},
{
"model": "virtualization.clustertype",
"pk": 5,
"fields": {
"name": "LXD",
"slug": "lxd"
}
},
{
"model": "virtualization.clustertype",
"pk": 6,
"fields": {
"name": "Docker",
"slug": "docker"
}
},
{
"model": "virtualization.clustergroup",
"pk": 1,
"fields": {
"name": "VM Host",
"slug": "vm-host"
}
},
{
"model": "virtualization.cluster",
"pk": 1,
"fields": {
"name": "Digital Ocean",
"type": 1,
"group": 1,
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z"
}
},
{
"model": "virtualization.cluster",
"pk": 2,
"fields": {
"name": "Amazon EC2",
"type": 1,
"group": 1,
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z"
}
},
{
"model": "virtualization.cluster",
"pk": 3,
"fields": {
"name": "Microsoft Azure",
"type": 1,
"group": 1,
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z"
}
}
]

View File

@@ -210,7 +210,7 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
# 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'):
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(

View File

@@ -24,6 +24,10 @@ VIRTUALMACHINE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
VIRTUALMACHINE_ROLE = """
<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
"""
VIRTUALMACHINE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -93,6 +97,7 @@ class VirtualMachineTable(BaseTable):
name = tables.LinkColumn()
status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
role = tables.TemplateColumn(VIRTUALMACHINE_ROLE)
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
class Meta(BaseTable.Meta):

View File

@@ -314,6 +314,7 @@ class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
cls = VirtualMachine
queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
filter = filters.VirtualMachineFilter
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'

View File

@@ -1,7 +1,7 @@
Django>=1.11,<2.0
django-cors-headers>=2.1
django-debug-toolbar>=1.8
django-filter>=1.0.4
django-filter>=1.1.0
django-mptt==0.8.7
django-rest-swagger>=2.1.0
django-tables2>=1.10.0