Merge pull request #11059 from netbox-community/develop

Release v3.3.9
This commit is contained in:
Jeremy Stretch 2022-11-30 16:14:00 -05:00 committed by GitHub
commit 85c60670dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 224 additions and 65 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.3.8 placeholder: v3.3.9
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.3.8 placeholder: v3.3.9
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
# Check that every console port for every active device has a connection defined. # Check that every console port for every active device has a connection defined.
active = DeviceStatusChoices.STATUS_ACTIVE active = DeviceStatusChoices.STATUS_ACTIVE
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active): for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
if console_port.connected_endpoint is None: if not console_port.connected_endpoints:
self.log_failure( self.log_failure(
console_port.device, console_port.device,
"No console connection defined for {}".format(console_port.name) "No console connection defined for {}".format(console_port.name)
@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE): for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
connected_ports = 0 connected_ports = 0
for power_port in PowerPort.objects.filter(device=device): for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None: if power_port.connected_endpoints:
connected_ports += 1 connected_ports += 1
if not power_port.path.is_active: if not power_port.path.is_active:
self.log_warning( self.log_warning(

View File

@ -1,5 +1,32 @@
# NetBox v3.3 # NetBox v3.3
## v3.3.9 (2022-11-30)
### 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
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
---
## v3.3.8 (2022-11-16) ## v3.3.8 (2022-11-16)
### Enhancements ### Enhancements

View File

@ -1218,7 +1218,7 @@ class FrontPortBulkEditForm(
fieldsets = ( fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
) )
nullable_fields = ('module', 'label', 'description') nullable_fields = ('module', 'label', 'description', 'color')
class RearPortBulkEditForm( class RearPortBulkEditForm(
@ -1229,7 +1229,7 @@ class RearPortBulkEditForm(
fieldsets = ( fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
) )
nullable_fields = ('module', 'label', 'description') nullable_fields = ('module', 'label', 'description', 'color')
class ModuleBayBulkEditForm( class ModuleBayBulkEditForm(

View File

@ -279,6 +279,17 @@ class CableTermination(models.Model):
def clean(self): def clean(self):
super().clean() 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) # Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: 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") 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)],
[object_to_path_node(circuit_termination.provider_network)], [object_to_path_node(circuit_termination.provider_network)],
]) ])
is_complete = True
break break
elif circuit_termination.site and not circuit_termination.cable: elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site # Circuit terminates to a Site

View File

@ -189,7 +189,7 @@ class PathEndpoint(models.Model):
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
CablePath model. `_path` should not be accessed directly; rather, use the `path` property. CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any. `connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
""" """
_path = models.ForeignKey( _path = models.ForeignKey(
to='dcim.CablePath', to='dcim.CablePath',

View File

@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
max_length=200 max_length=200
) )
clone_fields = ('rack', 'user', 'tenant')
class Meta: class Meta:
ordering = ['created', 'pk'] ordering = ['created', 'pk']

View File

@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices 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 .models.cables import trace_paths
from .utils import create_cablepath, rebuild_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): for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace() cablepath.retrace()
@receiver(post_save, sender=FrontPort)
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
"""
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
"""
if created and not raw:
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 is_active=True
) )
self.assertEqual(CablePath.objects.count(), 1) self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)
# Delete cable 1 # Delete cable 1
cable1.delete() cable1.delete()

View File

@ -589,17 +589,18 @@ class RackElevationListView(generic.ObjectListView):
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count() total_count = racks.count()
# Ordering
ORDERING_CHOICES = { ORDERING_CHOICES = {
'name': 'Name (A-Z)', 'name': 'Name (A-Z)',
'-name': 'Name (Z-A)', '-name': 'Name (Z-A)',
'facility_id': 'Facility ID (A-Z)', 'facility_id': 'Facility ID (A-Z)',
'-facility_id': 'Facility ID (Z-A)', '-facility_id': 'Facility ID (Z-A)',
} }
sort = request.GET.get('sort', "name") sort = request.GET.get('sort', 'name')
if sort not in ORDERING_CHOICES: if sort not in ORDERING_CHOICES:
sort = 'name' sort = 'name'
sort_field = sort.replace("name", "_name") # Use natural ordering
racks = racks.order_by(sort) racks = racks.order_by(sort_field)
# Pagination # Pagination
per_page = get_paginate_count(request) per_page = get_paginate_count(request)

View File

@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField from extras.models import CustomField
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
# #
@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
"values." "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 updating an existing instance, start with existing custom_field_data
if self.parent.instance: if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data} 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 .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
# #
# Change logging/webhooks # Change logging/webhooks
# #
@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs):
""" """
Fires when an object is deleted. Fires when an object is deleted.
""" """
if not hasattr(instance, 'to_objectchange'):
return
# Get the current request, or bail if not set # Get the current request, or bail if not set
request = current_request.get() request = current_request.get()
if request is None: if request is None:
@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs):
# Record an ObjectChange if applicable # Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'): 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 = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user objectchange.user = request.user
objectchange.request_id = request.id objectchange.request_id = request.id

View File

@ -803,6 +803,57 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field']) self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) self.assertEqual(site2.custom_field_data['multiobject_field'], 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): def test_minimum_maximum_values_validation(self):
site2 = Site.objects.get(name='Site 2') site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})

View File

@ -429,7 +429,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
initial['nat_rack'] = nat_inside_parent.device.rack.pk initial['nat_rack'] = nat_inside_parent.device.rack.pk
initial['nat_device'] = nat_inside_parent.device.pk initial['nat_device'] = nat_inside_parent.device.pk
elif type(nat_inside_parent) is VMInterface: 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 initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
kwargs['initial'] = initial kwargs['initial'] = initial

View File

@ -8,8 +8,6 @@ from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import Device
from netbox.models import OrganizationalModel, NetBoxModel
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField from ipam.fields import IPNetworkField, IPAddressField
@ -17,8 +15,7 @@ from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator from ipam.validators import DNSValidator
from netbox.config import get_config from netbox.config import get_config
from virtualization.models import VirtualMachine from netbox.models import OrganizationalModel, NetBoxModel
__all__ = ( __all__ = (
'Aggregate', 'Aggregate',
@ -912,18 +909,6 @@ class IPAddress(NetBoxModel):
) )
}) })
# 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 # Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({ raise ValidationError({

View File

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

View File

@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup # Environment setup
# #
VERSION = '3.3.8' VERSION = '3.3.9'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -445,6 +445,10 @@ EXEMPT_PATHS = (
f'/{BASE_PATH}metrics', f'/{BASE_PATH}metrics',
) )
SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}
# #
# Sentry # Sentry

View File

@ -425,6 +425,12 @@ class CustomFieldColumn(tables.Column):
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
if 'verbose_name' not in kwargs: if 'verbose_name' not in kwargs:
kwargs['verbose_name'] = customfield.label or customfield.name 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) super().__init__(*args, **kwargs)

View File

@ -125,9 +125,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions # Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model) 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_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 this is an HTMX request, return only the rendered table HTML
if is_htmx(request): if is_htmx(request):

View File

@ -229,7 +229,7 @@
<th>Utilization</th> <th>Utilization</th>
</tr> </tr>
{% for powerport in object.powerports.all %} {% for powerport in object.powerports.all %}
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %} {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
<tr> <tr>
<td>{{ powerport }}</td> <td>{{ powerport }}</td>
<td>{{ utilization.outlet_count }}</td> <td>{{ utilization.outlet_count }}</td>
@ -247,10 +247,15 @@
<td style="padding-left: 20px">Leg {{ leg.name }}</td> <td style="padding-left: 20px">Leg {{ leg.name }}</td>
<td>{{ leg.outlet_count }}</td> <td>{{ leg.outlet_count }}</td>
<td>{{ leg.allocated }}</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 %} {% with phase_available=powerfeed.available_power|divide:3 %}
<td>{{ phase_available }}VA</td>
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td> <td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
{% endwith %} {% endwith %}
{% else %}
<td class="text-muted">&mdash;</td>
<td class="text-muted">&mdash;</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}

View File

@ -210,7 +210,7 @@
<div class="card"> <div class="card">
<h5 class="card-header">Wireless</h5> <h5 class="card-header">Wireless</h5>
<div class="card-body"> <div class="card-body">
{% with peer=object.connected_endpoint %} {% with peer=object.connected_endpoints.0 %}
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>

View File

@ -173,7 +173,7 @@
<td>{{ powerfeed|linkify }}</td> <td>{{ powerfeed|linkify }}</td>
<td>{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}</td> <td>{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}</td>
<td>{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}</td> <td>{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}</td>
{% with power_port=powerfeed.connected_endpoint %} {% with power_port=powerfeed.connected_endpoints.0 %}
{% if power_port %} {% if power_port %}
<td>{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}</td> <td>{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}</td>
{% else %} {% else %}

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

@ -106,7 +106,7 @@ class LoginView(View):
return self.redirect_to_next(request, logger) return self.redirect_to_next(request, logger)
else: 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, { return render(request, self.template_name, {
'form': form, 'form': form,

View File

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

View File

@ -0,0 +1,21 @@
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import Deserializer, Serializer as Serializer_ # noqa
from django.utils.encoding import is_protected_type
# NOTE: Module must contain both Serializer and Deserializer
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 %}"> <div class="form-check{% if field.errors %} has-error{% endif %}">
{{ field }} {{ field }}
<label for="{{ field.id_for_label }}" class="form-check-label"> <label for="{{ field.id_for_label }}" class="form-check-label">
{{ field.label }} {{ label }}
</label> </label>
</div> </div>
{% if field.help_text %} {% if field.help_text %}
@ -23,7 +23,7 @@
</div> </div>
</div> </div>
{% elif field|widget_type == 'textarea' and not field.label %} {% elif field|widget_type == 'textarea' and not label %}
<div class="row mb-3"> <div class="row mb-3">
{% if label %} {% if label %}
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_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' %} {% elif field|widget_type == 'slugwidget' %}
<div class="row mb-3"> <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 }}"> <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> </label>
<div class="col"> <div class="col">
<div class="input-group"> <div class="input-group">
@ -71,13 +71,13 @@
accept="{{ field.field.widget.attrs.accept }}" accept="{{ field.field.widget.attrs.accept }}"
{% if field.is_required %}required{% endif %} {% 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> </div>
{% elif field|widget_type == 'clearablefileinput' %} {% elif field|widget_type == 'clearablefileinput' %}
<div class="row mb-3"> <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 %}"> <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> </label>
<div class="col col-md-9"> <div class="col col-md-9">
{{ field }} {{ field }}
@ -87,7 +87,7 @@
{% elif field|widget_type == 'selectmultiple' %} {% elif field|widget_type == 'selectmultiple' %}
<div class="row mb-3"> <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 %}"> <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> </label>
<div class="col col-md-9"> <div class="col col-md-9">
{{ field }} {{ field }}
@ -103,7 +103,7 @@
{% else %} {% else %}
<div class="row mb-3"> <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 %}"> <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> </label>
<div class="col"> <div class="col">
{{ field }} {{ field }}
@ -112,7 +112,7 @@
{% endif %} {% endif %}
<div class="invalid-feedback"> <div class="invalid-feedback">
{% if field.field.required %} {% if field.field.required %}
<strong>{{ field.label }}</strong> field is required. <strong>{{ label }}</strong> field is required.
{% endif %} {% endif %}
</div> </div>
{% if bulk_nullable %} {% if bulk_nullable %}

View File

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

View File

@ -215,6 +215,7 @@ def status_from_tag(tag: str = "info") -> str:
'warning': 'warning', 'warning': 'warning',
'success': 'success', 'success': 'success',
'error': 'danger', 'error': 'danger',
'danger': 'danger',
'debug': 'info', 'debug': 'info',
'info': 'info', 'info': 'info',
} }

View File

@ -11,7 +11,7 @@ django-redis==5.2.0
django-rich==1.4.0 django-rich==1.4.0
django-rq==2.6.0 django-rq==2.6.0
django-tables2==2.4.1 django-tables2==2.4.1
django-taggit==3.0.0 django-taggit==3.1.0
django-timezone-field==5.0 django-timezone-field==5.0
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-yasg[validation]==1.21.4 drf-yasg[validation]==1.21.4
@ -19,21 +19,18 @@ graphene-django==2.15.0
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==8.5.10 mkdocs-material==8.5.11
mkdocstrings[python-legacy]==0.19.0 mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.3.0 Pillow==9.3.0
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.11.0 sentry-sdk==1.11.1
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0 social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.2.1 tablib==3.2.1
tzdata==2022.6 tzdata==2022.7
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0
# Temporary fix for #10712
swagger-spec-validator==2.7.6