Compare commits

..

38 Commits

Author SHA1 Message Date
Jeremy Stretch
a1f624c1cc Merge pull request #2152 from digitalocean/develop
Release v2.3.4
2018-06-07 16:14:18 -04:00
Jeremy Stretch
ff0a0df478 Release v2.3.4 2018-06-07 15:53:05 -04:00
Jeremy Stretch
5dd2f37035 Fixes #2087: Don't overwrite existing vc_position of master device when creating a virtual chassis 2018-06-07 15:32:19 -04:00
Jeremy Stretch
862e44e96f Fixes #2148: Do not force timezone selection when editing sites in bulk 2018-06-07 14:51:27 -04:00
Jeremy Stretch
643b0eaf65 Fixes #2127: Prevent non-conntectable interfaces from being connected 2018-06-07 14:22:56 -04:00
Jeremy Stretch
0af6df3121 Fixes #2150: Fix display of LLDP neighbors when interface name contains a colon 2018-06-07 10:55:30 -04:00
Jeremy Stretch
e0616d933f Merge pull request #2144 from digitalocean/update-site-serializer
Fixes #2143 - PUTs to Site Endpoint Requires Value for time_zone
2018-06-06 11:06:51 -04:00
zmoody
1e7fdbc79a Fixes #2143 - PUTs to Site Endpoint Requires Value for time_zone
Allow null values for `time_zone` field in the writeable serializer for the sites endpoint.
2018-06-05 10:26:33 -05:00
Jeremy Stretch
1473d90243 Merge pull request #2110 from mandarg/fix-error-message
Add "does" to error messages
2018-05-24 15:19:43 -04:00
Mandar Gokhale
32eee0bede Add "does" to error messages
Those error messages looked a bit strange when I got them, hence the
fix.
2018-05-23 17:41:52 -04:00
Reimann, Timo
131436fc20 Changed upgrading documentation for ease of use 2018-05-22 16:20:10 -04:00
Jeremy Stretch
966c188977 Merge pull request #1939 from dougthor42/patch-1
Add note about copying reports to `upgrading.md`
2018-05-22 16:16:43 -04:00
Jeremy Stretch
afba80bff9 Merge pull request #2083 from Grokzen/add_rack_role_export
Add missing export button to rack roles list view.
2018-05-22 15:52:50 -04:00
Jeremy Stretch
0d267d97fe Fixes #2075: Enable tenant assignment when creating a rack reservation via the API 2018-05-22 14:09:06 -04:00
Jeremy Stretch
b0cd372af9 Fixes #2066: Catch AddrFormatError on invalid IP addresses 2018-05-22 13:56:11 -04:00
Jeremy Stretch
e5af4f6f17 Fixes #2093: Fix link to circuit termination in device interfaces table 2018-05-21 17:31:43 -04:00
Jeremy Stretch
399a633d9d Post-release version bump 2018-05-21 16:50:31 -04:00
Jeremy Stretch
2ef223b5ea Merge pull request #2099 from eriktm/2098-permission-typo
Fixing typo in permission check for ClusterView.
2018-05-21 16:20:09 -04:00
Erik Hetland
2cdb527df9 Fixing typo in permission check for ClusterView. 2018-05-19 11:50:03 +02:00
Grokzen
fc0e8e2aae Add export button to rack roles list view. 2018-05-08 16:06:53 +02:00
Jeremy Stretch
e5454d6714 Post-release version bump 2018-04-19 11:17:17 -04:00
Jeremy Stretch
328958876a Merge pull request #2041 from digitalocean/develop
Release v2.3.3
2018-04-19 11:15:48 -04:00
Jeremy Stretch
a7389de109 Release v2.3.3 2018-04-19 11:07:19 -04:00
Jeremy Stretch
b911ab01d2 Merge pull request #2038 from DirtyCajunRice/develop
stop force value split w ArrayFieldSelectMultiple. Fixes #2037
2018-04-19 10:55:25 -04:00
Nicholas St. Germain
9153c71cbf stop force value split w ArrayFieldSelectMultiple 2018-04-18 14:02:40 -05:00
Jeremy Stretch
b44aa9d32e Fixes #2014: Allow assignment of VLANs to VM interfaces via the API 2018-04-18 12:37:20 -04:00
Jeremy Stretch
bcb1d9af0b Fixes #2012: Fixed deselection of an IP address as the primary IP for its parent device/VM 2018-04-12 13:03:20 -04:00
Jeremy Stretch
ef84889a57 Fixes #2022: Show 0 for zero-value fields on CSV export 2018-04-12 12:54:21 -04:00
Jeremy Stretch
81c027e7cf Fixes #2023: Manufacturer should not be a required field when importing platforms 2018-04-12 12:45:25 -04:00
Jeremy Stretch
fd62a248ee Merge pull request #2020 from Wikia/intfix
#2019 : avoid illegal casts on large integers
2018-04-12 12:06:44 -04:00
frankfarmer
2c8bea1b59 avoid illegal casts on large integers
A similar fix was applied in e5e32d82d00e454ba5edf25316828c1cdcd7673e
2018-04-09 17:42:54 -07:00
Jeremy Stretch
07364abf9e Fixes #1988: Order interfaces naturally when bulk renaming 2018-03-29 15:15:13 -04:00
Jeremy Stretch
20cb13e1bb Fixes #1975: Correct filtering logic for custom boolean fields 2018-03-29 14:47:35 -04:00
Jeremy Stretch
3f3b385de7 Fixes #1999: Added missing description field to site edit form 2018-03-29 13:49:50 -04:00
Jeremy Stretch
94b12e506e Fixes #1993: Corrected status choices in site CSV import form 2018-03-29 09:50:29 -04:00
Jeremy Stretch
4ec6e52e73 Closes #1990: Improved search function when assigning an IP address to an interface 2018-03-29 09:45:17 -04:00
Jeremy Stretch
88adc5ca86 Post-release version bump 2018-03-22 15:06:59 -04:00
Douglas Thor
8d9543cb6a Add note about copying reports to upgrading.md
The `upgrading.md` file does not mention reports. If the user created reports in the old version's default directory (`./netbox/reports`), then the reports will not be transferred to the new version.
2018-03-01 15:05:51 -08:00
23 changed files with 149 additions and 75 deletions

View File

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

View File

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

View File

@@ -112,9 +112,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description', 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'comments',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -124,6 +123,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'name': "Full name of the site", 'name': "Full name of the site",
'facility': "Data center provider and facility (e.g. Equinix NY7)", 'facility': "Data center provider and facility (e.g. Equinix NY7)",
'asn': "BGP autonomous system number", 'asn': "BGP autonomous system number",
'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)", 'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address" 'shipping_address': "If different from the physical address"
} }
@@ -131,7 +132,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class SiteCSVForm(forms.ModelForm): class SiteCSVForm(forms.ModelForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=DEVICE_STATUS_CHOICES, choices=SITE_STATUS_CHOICES,
required=False, required=False,
help_text='Operational status' help_text='Operational status'
) )
@@ -165,13 +166,37 @@ class SiteCSVForm(forms.ModelForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(
status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='') queryset=Site.objects.all(),
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) widget=forms.MultipleHiddenInput
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) )
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') status = forms.ChoiceField(
description = forms.CharField(max_length=100, required=False) choices=add_blank_choice(SITE_STATUS_CHOICES),
time_zone = TimeZoneFormField(required=False) required=False,
initial=''
)
region = TreeNodeChoiceField(
queryset=Region.objects.all(),
required=False
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
asn = forms.IntegerField(
min_value=1,
max_value=4294967295,
required=False,
label='ASN'
)
description = forms.CharField(
max_length=100,
required=False
)
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
required=False
)
class Meta: class Meta:
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone'] nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
@@ -705,7 +730,7 @@ class PlatformCSVForm(forms.ModelForm):
slug = SlugField() slug = SlugField()
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=True, required=False,
to_field_name='name', to_field_name='name',
help_text='Manufacturer name', help_text='Manufacturer name',
error_messages={ error_messages={

View File

@@ -1236,7 +1236,7 @@ class ConsoleServerPort(models.Model):
raise ValidationError("Console server ports must be assigned to devices.") raise ValidationError("Console server ports must be assigned to devices.")
device_type = self.device.device_type device_type = self.device.device_type
if not device_type.is_console_server: if not device_type.is_console_server:
raise ValidationError("The {} {} device type not support assignment of console server ports.".format( raise ValidationError("The {} {} device type does not support assignment of console server ports.".format(
device_type.manufacturer, device_type device_type.manufacturer, device_type
)) ))
@@ -1318,7 +1318,7 @@ class PowerOutlet(models.Model):
raise ValidationError("Power outlets must be assigned to devices.") raise ValidationError("Power outlets must be assigned to devices.")
device_type = self.device.device_type device_type = self.device.device_type
if not device_type.is_pdu: if not device_type.is_pdu:
raise ValidationError("The {} {} device type not support assignment of power outlets.".format( raise ValidationError("The {} {} device type does not support assignment of power outlets.".format(
device_type.manufacturer, device_type device_type.manufacturer, device_type
)) ))
@@ -1403,7 +1403,7 @@ class Interface(models.Model):
if self.device is not None: if self.device is not None:
device_type = self.device.device_type device_type = self.device.device_type
if not device_type.is_network_device: if not device_type.is_network_device:
raise ValidationError("The {} {} device type not support assignment of network interfaces.".format( raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format(
device_type.manufacturer, device_type device_type.manufacturer, device_type
)) ))
@@ -1536,6 +1536,18 @@ class InterfaceConnection(models.Model):
raise ValidationError({ raise ValidationError({
'interface_b': "Cannot connect an interface to itself." 'interface_b': "Cannot connect an interface to itself."
}) })
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass

View File

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

View File

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

View File

@@ -41,19 +41,21 @@ class BulkRenameView(View):
""" """
An extendable view for renaming device components in bulk. An extendable view for renaming device components in bulk.
""" """
model = None queryset = None
form = None form = None
template_name = 'dcim/bulk_rename.html' template_name = 'dcim/bulk_rename.html'
def post(self, request): def post(self, request):
model = self.queryset.model
return_url = request.GET.get('return_url') return_url = request.GET.get('return_url')
if not return_url or not is_safe_url(url=return_url, host=request.get_host()): if not return_url or not is_safe_url(url=return_url, host=request.get_host()):
return_url = 'home' return_url = 'home'
if '_preview' in request.POST or '_apply' in request.POST: if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
if form.is_valid(): if form.is_valid():
for obj in selected_objects: for obj in selected_objects:
@@ -65,17 +67,17 @@ class BulkRenameView(View):
obj.save() obj.save()
messages.success(request, "Renamed {} {}".format( messages.success(request, "Renamed {} {}".format(
len(selected_objects), len(selected_objects),
self.model._meta.verbose_name_plural model._meta.verbose_name_plural
)) ))
return redirect(return_url) return redirect(return_url)
else: else:
form = self.form(initial={'pk': request.POST.getlist('pk')}) form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
'obj_type_plural': self.model._meta.verbose_name_plural, 'obj_type_plural': model._meta.verbose_name_plural,
'selected_objects': selected_objects, 'selected_objects': selected_objects,
'return_url': return_url, 'return_url': return_url,
}) })
@@ -155,6 +157,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region' permission_required = 'dcim.delete_region'
cls = Region cls = Region
queryset = Region.objects.annotate(site_count=Count('sites')) queryset = Region.objects.annotate(site_count=Count('sites'))
filter = filters.RegionFilter
table = tables.RegionTable table = tables.RegionTable
default_return_url = 'dcim:region_list' default_return_url = 'dcim:region_list'
@@ -489,6 +492,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation' permission_required = 'dcim.delete_rackreservation'
cls = RackReservation cls = RackReservation
filter = filters.RackReservationFilter
table = tables.RackReservationTable table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list' default_return_url = 'dcim:rackreservation_list'
@@ -1316,7 +1320,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_consoleserverport' permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortBulkRenameForm form = forms.ConsoleServerPortBulkRenameForm
@@ -1600,7 +1604,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_poweroutlet' permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet queryset = PowerOutlet.objects.all()
form = forms.PowerOutletBulkRenameForm form = forms.PowerOutletBulkRenameForm
@@ -1676,7 +1680,7 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_interface' permission_required = 'dcim.change_interface'
model = Interface queryset = Interface.objects.order_naturally()
form = forms.InterfaceBulkRenameForm form = forms.InterfaceBulkRenameForm
@@ -1783,7 +1787,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_devicebay' permission_required = 'dcim.change_devicebay'
model = DeviceBay queryset = DeviceBay.objects.all()
form = forms.DeviceBayBulkRenameForm form = forms.DeviceBayBulkRenameForm

View File

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

View File

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

View File

@@ -508,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
ipaddress = super(IPAddressForm, self).save(*args, **kwargs) ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
# Assign this IPAddress as the primary for the associated Device. # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']: if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
@@ -516,14 +516,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
else: else:
parent.primary_ip6 = ipaddress parent.primary_ip6 = ipaddress
parent.save() parent.save()
# Clear assignment as primary for device if set.
elif self.cleaned_data['interface']: elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == self: if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None parent.primary_ip4 = None
parent.save() parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == self: elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None parent.primary_ip6 = None
parent.save() parent.save()

View File

@@ -329,7 +329,7 @@ class IPAddressAssignTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface') fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
orderable = False orderable = False

View File

@@ -729,8 +729,8 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
).filter( ).filter(
vrf=form.cleaned_data['vrf'], vrf=form.cleaned_data['vrf'],
address__net_host=form.cleaned_data['address'], address__istartswith=form.cleaned_data['address'],
) )[:100] # Limit to 100 results
table = tables.IPAddressAssignTable(queryset) table = tables.IPAddressAssignTable(queryset)
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.3.2' VERSION = '2.3.4'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -205,6 +205,7 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
def optgroups(self, name, value, attrs=None): def optgroups(self, name, value, attrs=None):
# Split the delimited string of values into a list # Split the delimited string of values into a list
if value:
value = value[0].split(self.delimiter) value = value[0].split(self.delimiter)
return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs) return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)

View File

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

View File

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

View File

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

View File

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