mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
c3dcd8937f
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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})
|
||||
|
@ -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
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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()
|
||||
|
@ -440,6 +440,10 @@ EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}metrics',
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
'json': 'utilities.serializers.json',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Sentry
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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>
|
||||
{% with phase_available=powerfeed.available_power|divide:3 %}
|
||||
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
|
||||
{% endwith %}
|
||||
{% 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">—</td>
|
||||
<td class="text-muted">—</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
8
netbox/users/apps.py
Normal file
8
netbox/users/apps.py
Normal 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
10
netbox/users/signals.py
Normal 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}")
|
@ -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,
|
||||
|
@ -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):
|
||||
|
19
netbox/utilities/serializers/json.py
Normal file
19
netbox/utilities/serializers/json.py
Normal 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)
|
@ -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 %}
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
@ -34,6 +34,3 @@ tzdata==2022.6
|
||||
|
||||
# Workaround for #7401
|
||||
jsonschema==3.2.0
|
||||
|
||||
# Temporary fix for #10712
|
||||
swagger-spec-validator==2.7.6
|
||||
|
Loading…
Reference in New Issue
Block a user