Merge pull request #5472 from netbox-community/develop

Release v2.10.1
This commit is contained in:
Jeremy Stretch 2020-12-15 22:14:19 -05:00 committed by GitHub
commit 2c1a60b965
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 155 additions and 60 deletions

View File

@ -1,5 +1,21 @@
# NetBox v2.10 # NetBox v2.10
## v2.10.1 (2020-12-15)
### Bug Fixes
* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces
* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value
* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable
* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception
* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception
* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception
* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device
* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields
* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint
---
## v2.10.0 (2020-12-14) ## v2.10.0 (2020-12-14)
**NOTE:** This release completely removes support for embedded graphs. **NOTE:** This release completely removes support for embedded graphs.

View File

@ -1,4 +1,5 @@
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models.functions import Coalesce
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits import filters from circuits import filters
@ -24,7 +25,7 @@ class CircuitsRootView(APIRootView):
class ProviderViewSet(CustomFieldModelViewSet): class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate( queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=get_subquery(Circuit, 'provider') circuit_count=Coalesce(get_subquery(Circuit, 'provider'), 0)
) )
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet filterset_class = filters.ProviderFilterSet
@ -36,7 +37,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet): class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type') circuit_count=Coalesce(get_subquery(Circuit, 'type'), 0)
) )
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet filterset_class = filters.CircuitTypeFilterSet

View File

@ -139,7 +139,7 @@ class CircuitView(generic.ObjectView):
).filter( ).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).first()
if termination_a and termination_a.connected_endpoint: if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination # Z-side termination
@ -148,7 +148,7 @@ class CircuitView(generic.ObjectView):
).filter( ).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first() ).first()
if termination_z and termination_z.connected_endpoint: if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return { return {

View File

@ -3,6 +3,7 @@ from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.db.models.functions import Coalesce
from django.http import HttpResponseForbidden, HttpResponse from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
@ -119,12 +120,12 @@ class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.prefetch_related( queryset = Site.objects.prefetch_related(
'region', 'tenant', 'tags' 'region', 'tenant', 'tags'
).annotate( ).annotate(
device_count=get_subquery(Device, 'site'), device_count=Coalesce(get_subquery(Device, 'site'), 0),
rack_count=get_subquery(Rack, 'site'), rack_count=Coalesce(get_subquery(Rack, 'site'), 0),
prefix_count=get_subquery(Prefix, 'site'), prefix_count=Coalesce(get_subquery(Prefix, 'site'), 0),
vlan_count=get_subquery(VLAN, 'site'), vlan_count=Coalesce(get_subquery(VLAN, 'site'), 0),
circuit_count=get_subquery(Circuit, 'terminations__site'), circuit_count=Coalesce(get_subquery(Circuit, 'terminations__site'), 0),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'), virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster__site'), 0),
) )
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet filterset_class = filters.SiteFilterSet
@ -152,7 +153,7 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet): class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role') rack_count=Coalesce(get_subquery(Rack, 'role'), 0)
) )
serializer_class = serializers.RackRoleSerializer serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet filterset_class = filters.RackRoleFilterSet
@ -166,8 +167,8 @@ class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.prefetch_related( queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant', 'tags' 'site', 'group__site', 'role', 'tenant', 'tags'
).annotate( ).annotate(
device_count=get_subquery(Device, 'rack'), device_count=Coalesce(get_subquery(Device, 'rack'), 0),
powerfeed_count=get_subquery(PowerFeed, 'rack') powerfeed_count=Coalesce(get_subquery(PowerFeed, 'rack'), 0)
) )
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet filterset_class = filters.RackFilterSet
@ -240,9 +241,9 @@ class RackReservationViewSet(ModelViewSet):
class ManufacturerViewSet(ModelViewSet): class ManufacturerViewSet(ModelViewSet):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'), devicetype_count=Coalesce(get_subquery(DeviceType, 'manufacturer'), 0),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), inventoryitem_count=Coalesce(get_subquery(InventoryItem, 'manufacturer'), 0),
platform_count=get_subquery(Platform, 'manufacturer') platform_count=Coalesce(get_subquery(Platform, 'manufacturer'), 0)
) )
serializer_class = serializers.ManufacturerSerializer serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet filterset_class = filters.ManufacturerFilterSet
@ -254,7 +255,7 @@ class ManufacturerViewSet(ModelViewSet):
class DeviceTypeViewSet(CustomFieldModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=get_subquery(Device, 'device_type') device_count=Coalesce(get_subquery(Device, 'device_type'), 0)
) )
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet filterset_class = filters.DeviceTypeFilterSet
@ -318,8 +319,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
class DeviceRoleViewSet(ModelViewSet): class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'), device_count=Coalesce(get_subquery(Device, 'device_role'), 0),
virtualmachine_count=get_subquery(VirtualMachine, 'role') virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'role'), 0)
) )
serializer_class = serializers.DeviceRoleSerializer serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet filterset_class = filters.DeviceRoleFilterSet
@ -331,8 +332,8 @@ class DeviceRoleViewSet(ModelViewSet):
class PlatformViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate( queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'), device_count=Coalesce(get_subquery(Device, 'platform'), 0),
virtualmachine_count=get_subquery(VirtualMachine, 'platform') virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'platform'), 0)
) )
serializer_class = serializers.PlatformSerializer serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet filterset_class = filters.PlatformFilterSet
@ -596,7 +597,7 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate( queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=get_subquery(Device, 'virtual_chassis') member_count=Coalesce(get_subquery(Device, 'virtual_chassis'), 0)
) )
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet filterset_class = filters.VirtualChassisFilterSet
@ -610,7 +611,7 @@ class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
powerfeed_count=get_subquery(PowerFeed, 'power_panel') powerfeed_count=Coalesce(get_subquery(PowerFeed, 'power_panel'), 0)
) )
serializer_class = serializers.PowerPanelSerializer serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet filterset_class = filters.PowerPanelFilterSet

View File

@ -2839,7 +2839,7 @@ class InterfaceBulkCreateForm(
class InterfaceBulkEditForm( class InterfaceBulkEditForm(
form_from_model(Interface, [ form_from_model(Interface, [
'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode' 'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
]), ]),
BootstrapMixin, BootstrapMixin,
AddRemoveTagsForm, AddRemoveTagsForm,
@ -2855,6 +2855,15 @@ class InterfaceBulkEditForm(
disabled=True, disabled=True,
widget=forms.HiddenInput() widget=forms.HiddenInput()
) )
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label='Management only'
)
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,

View File

@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
return instance return instance
def __str__(self): def __str__(self):
return self.label or '#{}'.format(self._pk) pk = self.pk or self._pk
return self.label or f'#{pk}'
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk]) return reverse('dcim:cable', args=[self.pk])

View File

@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments', 'comments': 'New comments',
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_rack_elevations(self):
"""
Test viewing the list of rack elevations.
"""
response = self.client.get(reverse('dcim:rack_elevation_list'))
self.assertHttpStatus(response, 200)
class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Manufacturer model = Manufacturer

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models.functions import Coalesce
from django.http import Http404 from django.http import Http404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -102,7 +103,7 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet): class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
tagged_items=get_subquery(TaggedItem, 'tag') tagged_items=Coalesce(get_subquery(TaggedItem, 'tag'), 0)
) )
serializer_class = serializers.TagSerializer serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet filterset_class = filters.TagFilterSet

View File

@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm):
# Annotate the field in the list of CustomField form fields # Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name) self.custom_fields.append(field_name)
def save(self, commit=True): def clean(self):
# Save custom field data on instance # Save custom field data on instance
for cf_name in self.custom_fields: for cf_name in self.custom_fields:
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name) self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
return super().save(commit) return super().clean()
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):

View File

@ -1,6 +1,6 @@
import re import re
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime from datetime import datetime, date
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -317,10 +317,11 @@ class CustomField(models.Model):
# Validate date # Validate date
if self.type == CustomFieldTypeChoices.TYPE_DATE: if self.type == CustomFieldTypeChoices.TYPE_DATE:
try: if type(value) is not date:
datetime.strptime(value, '%Y-%m-%d') try:
except ValueError: datetime.strptime(value, '%Y-%m-%d')
raise ValidationError("Date values must be in the format YYYY-MM-DD.") except ValueError:
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
# Validate selected choice # Validate selected choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT: if self.type == CustomFieldTypeChoices.TYPE_SELECT:

View File

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
@ -32,8 +33,8 @@ class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related( queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags' 'import_targets', 'export_targets', 'tags'
).annotate( ).annotate(
ipaddress_count=get_subquery(IPAddress, 'vrf'), ipaddress_count=Coalesce(get_subquery(IPAddress, 'vrf'), 0),
prefix_count=get_subquery(Prefix, 'vrf') prefix_count=Coalesce(get_subquery(Prefix, 'vrf'), 0)
) )
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
filterset_class = filters.VRFFilterSet filterset_class = filters.VRFFilterSet
@ -55,7 +56,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
class RIRViewSet(ModelViewSet): class RIRViewSet(ModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir') aggregate_count=Coalesce(get_subquery(Aggregate, 'rir'), 0)
) )
serializer_class = serializers.RIRSerializer serializer_class = serializers.RIRSerializer
filterset_class = filters.RIRFilterSet filterset_class = filters.RIRFilterSet
@ -77,8 +78,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
class RoleViewSet(ModelViewSet): class RoleViewSet(ModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'), prefix_count=Coalesce(get_subquery(Prefix, 'role'), 0),
vlan_count=get_subquery(VLAN, 'role') vlan_count=Coalesce(get_subquery(VLAN, 'role'), 0)
) )
serializer_class = serializers.RoleSerializer serializer_class = serializers.RoleSerializer
filterset_class = filters.RoleFilterSet filterset_class = filters.RoleFilterSet
@ -272,7 +273,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet): class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.prefetch_related('site').annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=get_subquery(VLAN, 'group') vlan_count=Coalesce(get_subquery(VLAN, 'group'), 0)
) )
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
filterset_class = filters.VLANGroupFilterSet filterset_class = filters.VLANGroupFilterSet
@ -286,7 +287,7 @@ class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags' 'site', 'group', 'tenant', 'role', 'tags'
).annotate( ).annotate(
prefix_count=get_subquery(Prefix, 'vlan') prefix_count=Coalesce(get_subquery(Prefix, 'vlan'), 0)
) )
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet filterset_class = filters.VLANFilterSet

View File

@ -1,10 +1,45 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.utils.encoding import force_str from django.utils.encoding import force_str
from rest_framework import exceptions
from rest_framework.metadata import SimpleMetadata from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from netbox.api import ContentTypeField from netbox.api import ContentTypeField
class ContentTypeMetadata(SimpleMetadata): class BulkOperationMetadata(SimpleMetadata):
def determine_actions(self, request, view):
"""
Replace the stock determine_actions() method to assess object permissions only
when viewing a specific object. This is necessary to support OPTIONS requests
with bulk update in place (see #5470).
"""
actions = {}
for method in {'PUT', 'POST'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions (if viewing a specific object)
if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request
return actions
class ContentTypeMetadata(BulkOperationMetadata):
def get_field_info(self, field): def get_field_info(self, field):
field_info = super().get_field_info(field) field_info = super().get_field_info(field)

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.10.0' VERSION = '2.10.1'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -467,6 +467,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend', 'django_filters.rest_framework.DjangoFilterBackend',
), ),
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'netbox.api.authentication.TokenPermissions', 'netbox.api.authentication.TokenPermissions',

View File

@ -1,6 +1,7 @@
import base64 import base64
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -35,7 +36,7 @@ class SecretsRootView(APIRootView):
class SecretRoleViewSet(ModelViewSet): class SecretRoleViewSet(ModelViewSet):
queryset = SecretRole.objects.annotate( queryset = SecretRole.objects.annotate(
secret_count=get_subquery(Secret, 'role') secret_count=Coalesce(get_subquery(Secret, 'role'), 0)
) )
serializer_class = serializers.SecretRoleSerializer serializer_class = serializers.SecretRoleSerializer
filterset_class = filters.SecretRoleFilterSet filterset_class = filters.SecretRoleFilterSet

View File

@ -10,7 +10,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if perms.dcim.delete_cable %} {% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ device.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ object.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -9,18 +9,18 @@
{% include 'responsive_table.html' %} {% include 'responsive_table.html' %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if table.rows %} {% if table.rows %}
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Rename <span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button> </button>
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger"> <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
<div class="pull-right"> <div class="pull-right">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs"> <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Add {{ title }} Add {{ title }}
</a> </a>

View File

@ -35,7 +35,7 @@
<br /><small class="text-muted">{{ rack.facility_id }}</small> <br /><small class="text-muted">{{ rack.facility_id }}</small>
{% endif %} {% endif %}
</div> </div>
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %} {% include 'dcim/inc/rack_elevation.html' with object=rack face=rack_face %}
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="text-center"> <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>

View File

@ -1,3 +1,4 @@
from django.db.models.functions import Coalesce
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits.models import Circuit from circuits.models import Circuit
@ -46,13 +47,13 @@ class TenantViewSet(CustomFieldModelViewSet):
).annotate( ).annotate(
circuit_count=get_subquery(Circuit, 'tenant'), circuit_count=get_subquery(Circuit, 'tenant'),
device_count=get_subquery(Device, 'tenant'), device_count=get_subquery(Device, 'tenant'),
ipaddress_count=get_subquery(IPAddress, 'tenant'), ipaddress_count=Coalesce(get_subquery(IPAddress, 'tenant'), 0),
prefix_count=get_subquery(Prefix, 'tenant'), prefix_count=Coalesce(get_subquery(Prefix, 'tenant'), 0),
rack_count=get_subquery(Rack, 'tenant'), rack_count=Coalesce(get_subquery(Rack, 'tenant'), 0),
site_count=get_subquery(Site, 'tenant'), site_count=Coalesce(get_subquery(Site, 'tenant'), 0),
virtualmachine_count=get_subquery(VirtualMachine, 'tenant'), virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'tenant'), 0),
vlan_count=get_subquery(VLAN, 'tenant'), vlan_count=Coalesce(get_subquery(VLAN, 'tenant'), 0),
vrf_count=get_subquery(VRF, 'tenant') vrf_count=Coalesce(get_subquery(VRF, 'tenant'), 0)
) )
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer
filterset_class = filters.TenantFilterSet filterset_class = filters.TenantFilterSet

View File

@ -109,6 +109,15 @@ class APIViewTestCases:
url = self._get_detail_url(instance2) url = self._get_detail_url(instance2)
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND) self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_options_object(self):
"""
Make an OPTIONS request for a single object.
"""
url = self._get_detail_url(self._get_queryset().first())
response = self.client.options(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ListObjectsViewTestCase(APITestCase): class ListObjectsViewTestCase(APITestCase):
brief_fields = [] brief_fields = []
@ -174,6 +183,14 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2) self.assertEqual(len(response.data['results']), 2)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_options_objects(self):
"""
Make an OPTIONS request for a list endpoint.
"""
response = self.client.options(self._get_list_url(), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class CreateObjectViewTestCase(APITestCase): class CreateObjectViewTestCase(APITestCase):
create_data = [] create_data = []
validation_excluded_fields = [] validation_excluded_fields = []

View File

@ -1,3 +1,4 @@
from django.db.models.functions import Coalesce
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
@ -22,7 +23,7 @@ class VirtualizationRootView(APIRootView):
class ClusterTypeViewSet(ModelViewSet): class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.annotate( queryset = ClusterType.objects.annotate(
cluster_count=get_subquery(Cluster, 'type') cluster_count=Coalesce(get_subquery(Cluster, 'type'), 0)
) )
serializer_class = serializers.ClusterTypeSerializer serializer_class = serializers.ClusterTypeSerializer
filterset_class = filters.ClusterTypeFilterSet filterset_class = filters.ClusterTypeFilterSet
@ -30,7 +31,7 @@ class ClusterTypeViewSet(ModelViewSet):
class ClusterGroupViewSet(ModelViewSet): class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.annotate( queryset = ClusterGroup.objects.annotate(
cluster_count=get_subquery(Cluster, 'group') cluster_count=Coalesce(get_subquery(Cluster, 'group'), 0)
) )
serializer_class = serializers.ClusterGroupSerializer serializer_class = serializers.ClusterGroupSerializer
filterset_class = filters.ClusterGroupFilterSet filterset_class = filters.ClusterGroupFilterSet
@ -40,8 +41,8 @@ class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.prefetch_related( queryset = Cluster.objects.prefetch_related(
'type', 'group', 'tenant', 'site', 'tags' 'type', 'group', 'tenant', 'site', 'tags'
).annotate( ).annotate(
device_count=get_subquery(Device, 'cluster'), device_count=Coalesce(get_subquery(Device, 'cluster'), 0),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster') virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster'), 0)
) )
serializer_class = serializers.ClusterSerializer serializer_class = serializers.ClusterSerializer
filterset_class = filters.ClusterFilterSet filterset_class = filters.ClusterFilterSet