Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2023-04-07 13:00:00 -04:00
commit 08017c51f6
39 changed files with 166 additions and 205 deletions

View File

@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
          
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
          
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

View File

@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
```postgresql
CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
ALTER DATABASE netbox OWNER TO netbox;
```
!!! danger "Use a strong password"

View File

@ -2,6 +2,23 @@
## v3.4.8 (FUTURE)
### Enhancements
* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
### Bug Fixes
* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes
* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
---
## v3.4.7 (2023-03-28)

View File

@ -8,6 +8,9 @@ theme:
custom_dir: docs/_theme/
icon:
repo: fontawesome/brands/github
features:
- content.code.copy
- navigation.footer
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
@ -20,7 +23,8 @@ theme:
icon: material/lightbulb
name: Switch to Light Mode
plugins:
- search
- search:
lang: en
- mkdocstrings:
handlers:
python:

View File

@ -247,6 +247,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.CircuitTypeFilterSet
table = tables.CircuitTypeTable

View File

@ -25,6 +25,7 @@ __all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
'CommonInterfaceFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet',
@ -1348,11 +1349,45 @@ class PowerOutletFilterSet(
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
class CommonInterfaceFilterSet(django_filters.FilterSet):
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label=_('Assigned VLAN')
)
vlan = django_filters.CharFilter(
method='filter_vlan',
label=_('Assigned VID')
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label=_('VRF (RD)'),
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
PathEndpointFilterSet,
CommonInterfaceFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
# members
@ -1397,14 +1432,6 @@ class InterfaceFilterSet(
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label=_('Assigned VLAN')
)
vlan = django_filters.CharFilter(
method='filter_vlan',
label=_('Assigned VID')
)
type = django_filters.MultipleChoiceFilter(
choices=InterfaceTypeChoices,
null_value=None
@ -1415,17 +1442,6 @@ class InterfaceFilterSet(
rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label=_('VRF (RD)'),
)
vdc_id = django_filters.ModelMultipleChoiceFilter(
field_name='vdcs',
queryset=VirtualDeviceContext.objects.all(),
@ -1443,17 +1459,6 @@ class InterfaceFilterSet(
to_field_name='name',
label='Virtual Device Context',
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta:
model = Interface

View File

@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
model = ModuleBay
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
field_order = ('name', 'label', 'position', 'description', 'tags')
replication_fields = ('name', 'label', 'position')
position_pattern = ExpandableNameField(
position = ExpandableNameField(
label=_('Position'),
required=False,
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')

View File

@ -152,8 +152,6 @@ class Cable(PrimaryModel):
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.")
@ -187,6 +185,10 @@ class Cable(PrimaryModel):
else:
self._abs_length = None
# Clear length_unit if no length is defined
if self.length is None:
self.length_unit = ''
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)

View File

@ -797,8 +797,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
})
elif self.rf_channel:
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
# Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width:
@ -806,8 +804,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
elif self.rf_channel:
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
# VLAN validation
@ -818,6 +814,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
f"interface's parent device, or it must be global."
})
def save(self, *args, **kwargs):
# Set absolute channel attributes from selected options
if self.rf_channel and not self.rf_channel_frequency:
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
if self.rf_channel and not self.rf_channel_width:
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
super().save(*args, **kwargs)
@property
def _occupied(self):
return super()._occupied or bool(self.wireless_link_id)

View File

@ -707,8 +707,6 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
})
elif self.rack:
self.location = self.rack.location
if self.rack is None:
if self.face:
@ -824,8 +822,10 @@ class Device(PrimaryModel, ConfigContextModel):
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
components = [obj.instantiate(device=self) for obj in queryset]
if components and bulk_create:
if bulk_create:
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
model = components[0]._meta.model
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
@ -838,8 +838,9 @@ class Device(PrimaryModel, ConfigContextModel):
using='default',
update_fields=None
)
elif components:
for component in components:
else:
for obj in queryset:
component = obj.instantiate(device=self)
component.save()
def save(self, *args, **kwargs):
@ -853,6 +854,10 @@ class Device(PrimaryModel, ConfigContextModel):
if is_new and not self.platform:
self.platform = self.device_type.default_platform
# Inherit location from Rack if not set
if self.rack and self.rack.location:
self.location = self.rack.location
super().save(*args, **kwargs)
# If this is a new Device, instantiate all the related components per the DeviceType definition

View File

@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width/depth")
elif self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
else:
self._abs_max_weight = None
# Clear unit if outer width & depth are not set
if self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
super().save(*args, **kwargs)
@property

View File

@ -578,6 +578,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
filterset = filtersets.RackRoleFilterSet
table = tables.RackRoleTable
@ -867,6 +868,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable
@ -1728,6 +1730,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
filterset = filtersets.DeviceRoleFilterSet
table = tables.DeviceRoleTable
@ -1785,6 +1788,7 @@ class PlatformBulkEditView(generic.BulkEditView):
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
filterset = filtersets.PlatformFilterSet
table = tables.PlatformTable
@ -2853,6 +2857,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItem.objects.all()
filterset = filtersets.InventoryItemFilterSet
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@ -2909,6 +2914,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
filterset = filtersets.InventoryItemRoleFilterSet
table = tables.InventoryItemRoleTable

View File

@ -221,7 +221,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
for ct in content_types:
model = ct.model_class()
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
instances = model.objects.filter(custom_field_data__has_key=self.name)
for instance in instances:
del instance.custom_field_data[self.name]
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)

View File

@ -306,7 +306,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
max_length=50,
blank=True,
verbose_name='MIME type',
help_text=_('Defaults to <code>text/plain</code>')
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
)
file_extension = models.CharField(
max_length=15,
@ -368,7 +368,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
Render the template to an HTTP response, delivered as a named file attachment
"""
output = self.render(queryset)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
# Build the response
response = HttpResponse(output, content_type=mime_type)

View File

@ -1,13 +1,10 @@
import datetime
from unittest import skipIf
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware
from django_rq.queues import get_connection
from rest_framework import status
from rq import Worker
from core.choices import ManagedFileRootPathChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
@ -17,8 +14,6 @@ from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
rq_worker_running = Worker.count(get_connection('default'))
class AppTest(APITestCase):
@ -547,16 +542,6 @@ class ReportTest(APITestCase):
self.assertEqual(response.data['name'], self.TestReport.__name__)
@skipIf(not rq_worker_running, "RQ worker not running")
def test_run_report(self):
self.add_permissions('extras.run_script')
url = reverse('extras-api:report-run', kwargs={'pk': None})
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['result']['status']['value'], 'pending')
class ScriptTest(APITestCase):
@ -603,27 +588,6 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
@skipIf(not rq_worker_running, "RQ worker not running")
def test_run_script(self):
self.add_permissions('extras.run_script')
script_data = {
'var1': 'FooBar',
'var2': 123,
'var3': False,
}
data = {
'data': script_data,
'commit': True,
}
url = reverse('extras-api:script-detail', kwargs={'pk': None})
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['result']['status']['value'], 'pending')
class CreatedUpdatedFilterTest(APITestCase):

View File

@ -422,6 +422,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
filterset = filtersets.ConfigContextFilterSet
table = tables.ConfigContextTable

View File

@ -120,9 +120,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
if self.prefix:
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# /0 masks are not acceptable
if self.prefix.prefixlen == 0:
raise ValidationError({

View File

@ -952,7 +952,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.add_permissions('ipam.delete_vlan')
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
with disable_warnings('django.request'):
with disable_warnings('netbox.api.views.ModelViewSet'):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)

View File

@ -465,6 +465,7 @@ class RoleBulkEditView(generic.BulkEditView):
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
filterset = filtersets.RoleFilterSet
table = tables.RoleTable

View File

@ -503,6 +503,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
]
nullified_fields = request.POST.getlist('_nullify')
updated_objects = []
model_fields = {}
m2m_fields = {}
# Build list of model fields and m2m fields for later iteration
for name in standard_fields:
try:
model_field = self.queryset.model._meta.get_field(name)
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
m2m_fields[name] = model_field
else:
model_fields[name] = model_field
except FieldDoesNotExist:
# This form field is used to modify a field rather than set its value directly
model_fields[name] = None
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
@ -511,25 +526,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.snapshot()
# Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields:
try:
model_field = self.queryset.model._meta.get_field(name)
except FieldDoesNotExist:
# This form field is used to modify a field rather than set its value directly
model_field = None
for name, model_field in model_fields.items():
# Handle nullification
if name in form.nullable_fields and name in nullified_fields:
if isinstance(model_field, ManyToManyField):
getattr(obj, name).set([])
else:
setattr(obj, name, None if model_field.null else '')
# ManyToManyFields
elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
if form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name])
setattr(obj, name, None if model_field.null else '')
# Normal fields
elif name in form.changed_data:
setattr(obj, name, form.cleaned_data[name])
@ -547,6 +547,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.save()
updated_objects.append(obj)
# Handle M2M fields after save
for name, m2m_field in m2m_fields.items():
if name in form.nullable_fields and name in nullified_fields:
getattr(obj, name).clear()
else:
getattr(obj, name).set(form.cleaned_data[name])
# Add/remove tags
if form.cleaned_data.get('add_tags', None):
obj.tags.add(*form.cleaned_data['add_tags'])

View File

@ -108,7 +108,6 @@
</div>
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>

View File

@ -83,7 +83,6 @@
{% endif %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@ -27,7 +27,6 @@
</div>
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field vc_form.comments %}
</div>

View File

@ -36,7 +36,6 @@
{% if form.comments %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endif %}

View File

@ -134,9 +134,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -66,9 +66,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -53,9 +53,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -53,9 +53,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -1,39 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="row">
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side A</h5>
</div>
{% render_field form.device_a %}
{% render_field form.interface_a %}
</div>
</div>
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side B</h5>
</div>
{% render_field form.device_b %}
{% render_field form.interface_b %}
</div>
</div>
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Comments</h5>
</div>
{% render_field form.comments %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@ -83,6 +83,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
'tenant_count',
cumulative=True
)
filterset = filtersets.TenantGroupFilterSet
table = tables.TenantGroupTable
@ -233,6 +234,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
'contact_count',
cumulative=True
)
filterset = filtersets.ContactGroupFilterSet
table = tables.ContactGroupTable
@ -286,6 +288,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
class ContactRoleBulkDeleteView(generic.BulkDeleteView):
queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
table = tables.ContactRoleTable

View File

@ -12,7 +12,7 @@ class AppTest(APITestCase):
def test_root(self):
url = reverse('users-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
response = self.client.get(f'{url}?format=api', **self.header)
self.assertEqual(response.status_code, 200)
@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
'password': 'password6',
},
]
bulk_update_data = {
'email': 'test@example.com',
}
@classmethod
def setUpTestData(cls):
users = (
User(username='User_1'),
User(username='User_2'),
User(username='User_3'),
User(username='User_1', password='password1'),
User(username='User_2', password='password2'),
User(username='User_3', password='password3'),
)
User.objects.bulk_create(users)
@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
)
Group.objects.bulk_create(users)
def test_bulk_update_objects(self):
"""
Disabled test. There's no attribute we can set in bulk for Groups.
"""
return
class TokenTest(
# No GraphQL support for Token

View File

@ -34,8 +34,8 @@ class CommentField(forms.CharField):
Markdown</a> syntax is supported
"""
def __init__(self, *, label='', help_text=help_text, required=False, **kwargs):
super().__init__(label=label, help_text=help_text, required=required, **kwargs)
def __init__(self, *, help_text=help_text, required=False, **kwargs):
super().__init__(help_text=help_text, required=required, **kwargs)
class SlugField(forms.SlugField):

View File

@ -1,3 +1,5 @@
<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
<i class="mdi mdi-content-copy" aria-hidden="true"></i>&nbsp;Clone
</a>
{% if url %}
<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
<i class="mdi mdi-content-copy" aria-hidden="true"></i> Clone
</a>
{% endif %}

View File

@ -3,9 +3,7 @@
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
{# Render the field label, except for: #}
{# 1. Checkboxes (label appears to the right of the field #}
{# 2. Textareas with no label set (will expand across entire row) #}
{# Render the field label (if any), except for checkboxes #}
{% if label and not field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }}

View File

@ -20,6 +20,8 @@ def clone_button(instance):
param_string = prepare_cloned_fields(instance).urlencode()
if param_string:
url = f'{url}?{param_string}'
else:
url = None
return {
'url': url,

View File

@ -2,9 +2,9 @@ import django_filters
from django.db.models import Q
from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import L2VPN, VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@ -250,7 +250,7 @@ class VirtualMachineFilterSet(
return queryset.exclude(params)
class VMInterfaceFilterSet(NetBoxModelFilterSet):
class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__cluster',
queryset=Cluster.objects.all(),
@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
mac_address = MultiValueMACAddressFilter(
label=_('MAC address'),
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label=_('VRF (RD)'),
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta:
model = VMInterface

View File

@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
raise ValidationError({
'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
})
elif self.cluster:
self.site = self.cluster.site
# Validate assigned cluster device
if self.device and not self.cluster:
@ -201,6 +199,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
field: f"The specified IP address ({ip}) is not assigned to this VM.",
})
def save(self, *args, **kwargs):
# Assign site from cluster if not set
if self.cluster and not self.site:
self.site = self.cluster.site
super().save(*args, **kwargs)
def get_status_color(self):
return VirtualMachineStatusChoices.colors.get(self.status)

View File

@ -72,7 +72,7 @@ class VirtualMachineTestCase(TestCase):
# VM with cluster site but no direct site should have its site set automatically
vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
vm.full_clean()
vm.save()
self.assertEqual(vm.site, sites[0])
def test_vm_name_case_sensitivity(self):

View File

@ -74,6 +74,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
)
filterset = filtersets.ClusterTypeFilterSet
table = tables.ClusterTypeTable
@ -135,6 +136,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
)
filterset = filtersets.ClusterGroupFilterSet
table = tables.ClusterGroupTable