Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2022-11-22 10:08:23 -05:00
commit c3dcd8937f
22 changed files with 192 additions and 45 deletions

View File

@ -2,6 +2,26 @@
## v3.3.9 (FUTURE)
### Enhancements
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
### Bug Fixes
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
---
## v3.3.8 (2022-11-16)

View File

@ -279,6 +279,17 @@ class CableTermination(models.Model):
def clean(self):
super().clean()
# Check for existing termination
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
termination_type=self.termination_type,
termination_id=self.termination_id
).first()
if existing_termination is not None:
raise ValidationError(
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
f"{self.termination_id}: cable {existing_termination.cable.pk}"
)
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
@ -570,6 +581,7 @@ class CablePath(models.Model):
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
is_complete = True
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site

View File

@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models import (
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()
@receiver(post_save, sender=FrontPort)
def extend_rearport_cable_paths(instance, created, **kwargs):
"""
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
"""
if created:
rearport = instance.rear_port
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
cablepath.retrace()

View File

@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)
# Delete cable 1
cable1.delete()

View File

@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
#
@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
"values."
)
# Serialize object and multi-object values
for cf in self._get_custom_fields():
if cf.name in data and cf.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):
serializer_class = get_serializer_for_model(
model=cf.object_type.model_class(),
prefix=NESTED_SERIALIZER_PREFIX
)
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else:
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data}

View File

@ -14,7 +14,6 @@ from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
#
# Change logging/webhooks
#
@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
return
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs):
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
instance.snapshot()
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id

View File

@ -854,6 +854,57 @@ class CustomFieldAPITest(APITestCase):
list(original_cfvs['multiobject_field'])
)
def test_specify_related_object_by_attr(self):
site1 = Site.objects.get(name='Site 1')
vlans = VLAN.objects.all()[:3]
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
self.add_permissions('dcim.change_site')
# Set related objects by PK
data = {
'custom_fields': {
'object_field': vlans[0].pk,
'multiobject_field': [vlans[1].pk, vlans[2].pk],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(
response.data['custom_fields']['object_field']['id'],
vlans[0].pk
)
self.assertListEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
[vlans[1].pk, vlans[2].pk]
)
# Set related objects by name
data = {
'custom_fields': {
'object_field': {
'name': vlans[0].name,
},
'multiobject_field': [
{
'name': vlans[1].name
},
{
'name': vlans[2].name
},
],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(
response.data['custom_fields']['object_field']['id'],
vlans[0].pk
)
self.assertListEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
[vlans[1].pk, vlans[2].pk]
)
def test_minimum_maximum_values_validation(self):
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})

View File

@ -436,7 +436,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
initial['nat_rack'] = nat_inside_parent.device.rack.pk
initial['nat_device'] = nat_inside_parent.device.pk
elif type(nat_inside_parent) is VMInterface:
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
if cluster := nat_inside_parent.virtual_machine.cluster:
initial['nat_cluster'] = cluster.pk
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
kwargs['initial'] = initial

View File

@ -9,7 +9,6 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from dcim.fields import ASNField
from dcim.models import Device
from ipam.choices import *
from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField
@ -18,7 +17,6 @@ from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from virtualization.models import VirtualMachine
__all__ = (
'Aggregate',
@ -864,18 +862,6 @@ class IPAddress(PrimaryModel):
)
})
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk:
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if parent and getattr(self.assigned_object, attr, None) != parent:
# Check for a NAT relationship
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
raise ValidationError({
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
f"not assigned to it!"
})
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({

View File

@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.
"""
# Overrides ListModelMixin to allow processing ExportTemplates.
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()

View File

@ -440,6 +440,10 @@ EXEMPT_PATHS = (
f'/{BASE_PATH}metrics',
)
SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}
#
# Sentry

View File

@ -425,6 +425,12 @@ class CustomFieldColumn(tables.Column):
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
if 'verbose_name' not in kwargs:
kwargs['verbose_name'] = customfield.label or customfield.name
# We can't logically sort on FK values
if customfield.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):
kwargs['orderable'] = False
super().__init__(*args, **kwargs)

View File

@ -128,9 +128,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
table_data = self.prep_table_data(request, child_objects, instance)
table = self.get_table(table_data, request, bool(actions))
table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if is_htmx(request):

View File

@ -283,10 +283,15 @@
<td style="padding-left: 20px">Leg {{ leg.name }}</td>
<td>{{ leg.outlet_count }}</td>
<td>{{ leg.allocated }}</td>
<td>{{ powerfeed.available_power|divide:3 }}VA</td>
{% if powerfeed.available_power %}
{% with phase_available=powerfeed.available_power|divide:3 %}
<td>{{ phase_available }}VA</td>
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
{% endwith %}
{% else %}
<td class="text-muted">&mdash;</td>
<td class="text-muted">&mdash;</td>
{% endif %}
</tr>
{% endfor %}
{% endwith %}

8
netbox/users/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
def ready(self):
import users.signals

10
netbox/users/signals.py Normal file
View File

@ -0,0 +1,10 @@
import logging
from django.dispatch import receiver
from django.contrib.auth.signals import user_login_failed
@receiver(user_login_failed)
def log_user_login_failed(sender, credentials, request, **kwargs):
logger = logging.getLogger('netbox.auth.login')
username = credentials.get("username")
logger.info(f"Failed login attempt for username: {username}")

View File

@ -107,7 +107,7 @@ class LoginView(View):
return self.redirect_to_next(request, logger)
else:
logger.debug("Login form validation failed")
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
return render(request, self.template_name, {
'form': form,

View File

@ -28,13 +28,12 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
serializer = super().get_request_serializer()
if serializer is not None and self.method in self.implicit_body_methods:
writable_class = self.get_writable_class(serializer)
if writable_class is not None:
if writable_class := self.get_writable_class(serializer):
if hasattr(serializer, 'child'):
child_serializer = self.get_writable_class(serializer.child)
serializer = writable_class(child=child_serializer)
serializer = writable_class(context=serializer.context, child=child_serializer)
else:
serializer = writable_class()
serializer = writable_class(context=serializer.context)
return serializer
def get_writable_class(self, serializer):

View File

@ -0,0 +1,19 @@
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import Serializer as Serializer_
from django.utils.encoding import is_protected_type
class Serializer(Serializer_):
"""
Custom extension of Django's JSON serializer to support ArrayFields (see
https://code.djangoproject.com/ticket/33974).
"""
def _value_from_field(self, obj, field):
value = field.value_from_object(obj)
# Handle ArrayFields of protected types
if type(field) is ArrayField:
if not value or is_protected_type(value[0]):
return value
return value if is_protected_type(value) else field.value_to_string(obj)

View File

@ -8,7 +8,7 @@
<div class="form-check{% if field.errors %} has-error{% endif %}">
{{ field }}
<label for="{{ field.id_for_label }}" class="form-check-label">
{{ field.label }}
{{ label }}
</label>
</div>
{% if field.help_text %}
@ -23,7 +23,7 @@
</div>
</div>
{% elif field|widget_type == 'textarea' and not field.label %}
{% elif field|widget_type == 'textarea' and not label %}
<div class="row mb-3">
{% if label %}
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
@ -48,7 +48,7 @@
{% elif field|widget_type == 'slugwidget' %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
{{ field.label }}
{{ label }}
</label>
<div class="col">
<div class="input-group">
@ -71,13 +71,13 @@
accept="{{ field.field.widget.attrs.accept }}"
{% if field.is_required %}required{% endif %}
/>
<label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label>
<label for="{{ field.id_for_label }}" class="input-group-text">{{ label|bettertitle }}</label>
</div>
{% elif field|widget_type == 'clearablefileinput' %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
{{ label }}
</label>
<div class="col col-md-9">
{{ field }}
@ -87,7 +87,7 @@
{% elif field|widget_type == 'selectmultiple' %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
{{ label }}
</label>
<div class="col col-md-9">
{{ field }}
@ -103,7 +103,7 @@
{% else %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
{{ label }}
</label>
<div class="col">
{{ field }}
@ -112,7 +112,7 @@
{% endif %}
<div class="invalid-feedback">
{% if field.field.required %}
<strong>{{ field.label }}</strong> field is required.
<strong>{{ label }}</strong> field is required.
{% endif %}
</div>
{% if bulk_nullable %}

View File

@ -40,7 +40,7 @@ def render_field(field, bulk_nullable=False, label=None):
"""
return {
'field': field,
'label': label,
'label': label or field.label,
'bulk_nullable': bulk_nullable,
}

View File

@ -34,6 +34,3 @@ tzdata==2022.6
# Workaround for #7401
jsonschema==3.2.0
# Temporary fix for #10712
swagger-spec-validator==2.7.6