1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch
2023-11-29 20:25:44 -05:00
26 changed files with 176 additions and 113 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.6.5 placeholder: v3.6.6
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.6.5 placeholder: v3.6.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,6 +1,29 @@
# NetBox v3.6 # NetBox v3.6
## v3.6.6 (FUTURE) ## v3.6.7 (FUTURE)
---
## v3.6.6 (2023-11-29)
### Enhancements
* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects
### Bug Fixes
* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV
* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report
* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name
* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter
* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments
* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format
* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports
* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API
* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column
* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API
* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources
* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster
--- ---

View File

@ -116,6 +116,7 @@ class DataSource(JobsMixin, PrimaryModel):
) )
def clean(self): def clean(self):
super().clean()
# Validate data backend type # Validate data backend type
if self.type and self.type not in registry['data_backends']: if self.type and self.type not in registry['data_backends']:

View File

@ -2,6 +2,7 @@ import logging
import os import os
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -85,6 +86,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
self.file_path = os.path.basename(self.data_path) self.file_path = os.path.basename(self.data_path)
self.data_file.write_to_disk(self.full_path, overwrite=True) self.data_file.write_to_disk(self.full_path, overwrite=True)
def clean(self):
super().clean()
# Ensure that the file root and path make a unique pair
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
raise ValidationError(
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
# Delete file from disk # Delete file from disk
try: try:

View File

@ -244,7 +244,7 @@ class Job(models.Model):
model_name=self.object_type.model, model_name=self.object_type.model,
event=event, event=event,
data=self.data, data=self.data,
timestamp=str(timezone.now()), timestamp=timezone.now().isoformat(),
username=self.user.username, username=self.user.username,
retry=get_rq_retry() retry=get_rq_retry()
) )

View File

@ -283,7 +283,7 @@ class ReportViewSet(ViewSet):
# Retrieve and run the Report. This will create a new Job. # Retrieve and run the Report. This will create a new Job.
module, report_cls = self._get_report(pk) module, report_cls = self._get_report(pk)
report = report_cls() report = report_cls
input_serializer = serializers.ReportInputSerializer( input_serializer = serializers.ReportInputSerializer(
data=request.data, data=request.data,
context={'report': report} context={'report': report}

View File

@ -121,8 +121,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(description__icontains=value) | Q(description__icontains=value)
Q(extra_choices__contains=value)
) )
def filter_by_choice(self, queryset, name, value): def filter_by_choice(self, queryset, name, value):

View File

@ -1093,7 +1093,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
jobs = Job.objects.filter( jobs = Job.objects.filter(
object_type=object_type, object_type=object_type,
object_id=module.pk, object_id=module.pk,
name=report.name name=report.class_name
) )
jobs_table = JobTable( jobs_table = JobTable(

View File

@ -115,7 +115,7 @@ def flush_webhooks(queue):
event=data['event'], event=data['event'],
data=data['data'], data=data['data'],
snapshots=data['snapshots'], snapshots=data['snapshots'],
timestamp=str(timezone.now()), timestamp=timezone.now().isoformat(),
username=data['username'], username=data['username'],
request_id=data['request_id'], request_id=data['request_id'],
retry=get_rq_retry() retry=get_rq_retry()

View File

@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
asn_asdot = tables.Column( asn_asdot = tables.Column(
accessor=tables.A('asn_asdot'), accessor=tables.A('asn_asdot'),
linkify=True, linkify=True,
order_by=tables.A('asn'),
verbose_name=_('ASDOT') verbose_name=_('ASDOT')
) )
site_count = columns.LinkedCountColumn( site_count = columns.LinkedCountColumn(

View File

@ -27,7 +27,7 @@ from netbox.plugins import PluginConfig
# Environment setup # Environment setup
# #
VERSION = '3.6.6-dev' VERSION = '3.6.7-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -394,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist") form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
raise ValidationError('') raise ValidationError('')
# Take a snapshot for change logging
if instance.pk and hasattr(instance, 'snapshot'):
instance.snapshot()
# Instantiate the model form for the object # Instantiate the model form for the object
model_form_kwargs = { model_form_kwargs = {
'data': record, 'data': record,

View File

@ -5,6 +5,7 @@
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %} {% load i18n %}
{% load mptt %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -15,16 +16,7 @@
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Region" %}</th> <th scope="row">{% trans "Region" %}</th>
<td> <td>{% nested_tree object.site.region %}</td>
{% if object.site.region %}
{% for region in object.site.region.get_ancestors %}
{{ region|linkify }} /
{% endfor %}
{{ object.site.region|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Site" %}</th>
@ -32,16 +24,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Location" %}</th> <th scope="row">{% trans "Location" %}</th>
<td> <td>{% nested_tree object.location %}</td>
{% if object.location %}
{% for location in object.location.get_ancestors %}
{{ location|linkify }} /
{% endfor %}
{{ object.location|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Rack" %}</th> <th scope="row">{% trans "Rack" %}</th>

View File

@ -4,6 +4,7 @@
{% load static %} {% load static %}
{% load plugins %} {% load plugins %}
{% load i18n %} {% load i18n %}
{% load mptt %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -15,26 +16,18 @@
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Region" %}</th>
<td> <td>
{% if object.site.region %} {% nested_tree object.site.region %}
{{ object.site.region|linkify }} /
{% endif %}
{{ object.site|linkify }}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Location" %}</th> <th scope="row">{% trans "Location" %}</th>
<td> <td>{% nested_tree object.location %}</td>
{% if object.location %}
{% for location in object.location.get_ancestors %}
{{ location|linkify }} /
{% endfor %}
{{ object.location|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Facility ID" %}</th> <th scope="row">{% trans "Facility ID" %}</th>

View File

@ -4,6 +4,7 @@
{% load static %} {% load static %}
{% load plugins %} {% load plugins %}
{% load i18n %} {% load i18n %}
{% load mptt %}
{% block breadcrumbs %} {% block breadcrumbs %}
{{ block.super }} {{ block.super }}
@ -20,25 +21,24 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% with rack=object.rack %} <tr>
<tr> <th scope="row">{% trans "Region" %}</th>
<th scope="row">{% trans "Site" %}</th> <td>
<td> {% nested_tree object.rack.site.region %}
{% if rack.site.region %} </td>
{{ rack.site.region|linkify }} / </tr>
{% endif %} <tr>
{{ rack.site|linkify }} <th scope="row">{% trans "Site" %}</th>
</td> <td>{{ object.rack.site|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Location" %}</th> <th scope="row">{% trans "Location" %}</th>
<td>{{ rack.location|linkify|placeholder }}</td> <td>{{ object.rack.location|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Rack" %}</th> <th scope="row">{% trans "Rack" %}</th>
<td>{{ rack|linkify }}</td> <td>{{ object.rack|linkify }}</td>
</tr> </tr>
{% endwith %}
</table> </table>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@
{% load plugins %} {% load plugins %}
{% load tz %} {% load tz %}
{% load i18n %} {% load i18n %}
{% load mptt %}
{% block breadcrumbs %} {% block breadcrumbs %}
{{ block.super }} {{ block.super }}
@ -29,27 +30,13 @@
<tr> <tr>
<th scope="row">{% trans "Region" %}</th> <th scope="row">{% trans "Region" %}</th>
<td> <td>
{% if object.region %} {% nested_tree object.region %}
{% for region in object.region.get_ancestors %}
{{ region|linkify }} /
{% endfor %}
{{ object.region|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Group" %}</th> <th scope="row">{% trans "Group" %}</th>
<td> <td>
{% if object.group %} {% nested_tree object.group %}
{% for group in object.group.get_ancestors %}
{{ group|linkify }} /
{% endfor %}
{{ object.group|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -3,6 +3,7 @@
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %} {% load i18n %}
{% load mptt %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -44,18 +45,17 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if object.site.region %}
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.site.region %}
</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Site" %}</th>
<td> <td>{{ object.site|linkify|placeholder }}</td>
{% if object.site %}
{% if object.site.region %}
{{ object.site.region|linkify }} /
{% endif %}
{{ object.site|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "VLAN" %}</th> <th scope="row">{% trans "VLAN" %}</th>

View File

@ -3,6 +3,7 @@
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load plugins %} {% load plugins %}
{% load i18n %} {% load i18n %}
{% load mptt %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -13,18 +14,17 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% if object.site.region %}
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.site.region %}
</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Site" %}</th>
<td> <td>{{ object.site|linkify|placeholder }}</td>
{% if object.site %}
{% if object.site.region %}
{{ object.site.region|linkify }} /
{% endif %}
{{ object.site|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Group" %}</th> <th scope="row">{% trans "Group" %}</th>

View File

@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ContentType from core.models import ContentType
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import CustomFieldsMixin, TagsMixin from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin
from tenancy.choices import * from tenancy.choices import *
__all__ = ( __all__ = (
@ -110,7 +110,7 @@ class Contact(PrimaryModel):
return reverse('tenancy:contact', args=[self.pk]) return reverse('tenancy:contact', args=[self.pk])
class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
to='contenttypes.ContentType', to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE

View File

@ -52,6 +52,16 @@ class UserSerializer(ValidatedModelSerializer):
return user return user
def update(self, instance, validated_data):
"""
Ensure proper updated password hash generation.
"""
password = validated_data.pop('password', None)
if password is not None:
instance.set_password(password)
return super().update(instance, validated_data)
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def get_display(self, obj): def get_display(self, obj):
if full_name := obj.get_full_name(): if full_name := obj.get_full_name():

View File

@ -54,6 +54,38 @@ class UserTest(APIViewTestCases.APIViewTestCase):
) )
User.objects.bulk_create(users) User.objects.bulk_create(users)
def test_that_password_is_changed(self):
"""
Test that password is changed
"""
obj_perm = ObjectPermission(
name='Test permission',
actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
user_credentials = {
'username': 'user1',
'password': 'abc123',
}
user = User.objects.create_user(**user_credentials)
data = {
'password': 'newpassword'
}
url = reverse('users-api:user-detail', kwargs={'pk': user.id})
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 200)
updated_user = User.objects.get(id=user.id)
self.assertTrue(updated_user.check_password(data['password']))
class GroupTest(APIViewTestCases.APIViewTestCase): class GroupTest(APIViewTestCases.APIViewTestCase):
model = Group model = Group

View File

@ -40,7 +40,7 @@ def parse_numeric_range(string, base=10):
except ValueError: except ValueError:
raise forms.ValidationError(f'Range "{dash_range}" is invalid.') raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
values.extend(range(begin, end)) values.extend(range(begin, end))
return list(set(values)) return sorted(set(values))
def parse_alphanumeric_range(string): def parse_alphanumeric_range(string):

View File

@ -0,0 +1,20 @@
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.simple_tag()
def nested_tree(obj):
"""
Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup).
"""
if not obj:
return mark_safe('&mdash;')
nodes = obj.get_ancestors(include_self=True)
return mark_safe(
' / '.join(
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
)
)

View File

@ -296,9 +296,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# Check interface sites. First interface should set site, further interfaces will either continue the # Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop. # loop or reset back to no site and break the loop.
for interface in interfaces: for interface in interfaces:
vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site
if site is None: if site is None:
site = interface.virtual_machine.cluster.site site = vm_site
elif interface.virtual_machine.cluster.site is not site: elif vm_site is not site:
site = None site = None
break break

View File

@ -1,18 +1,18 @@
bleach==6.1.0 bleach==6.1.0
Django==4.2.7 Django==4.2.7
django-cors-headers==4.3.0 django-cors-headers==4.3.1
django-debug-toolbar==4.2.0 django-debug-toolbar==4.2.0
django-filter==23.3 django-filter==23.4
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14.0 django-mptt==0.14.0
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.3.1 django-prometheus==2.3.1
django-redis==5.4.0 django-redis==5.4.0
django-rich==1.8.0 django-rich==1.8.0
django-rq==2.8.1 django-rq==2.9.0
django-tables2==2.6.0 django-tables2==2.6.0
django-taggit==4.0.0 django-taggit==4.0.0
django-timezone-field==6.0.1 django-timezone-field==6.1.0
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-spectacular==0.26.5 drf-spectacular==0.26.5
drf-spectacular-sidecar==2023.10.1 drf-spectacular-sidecar==2023.10.1
@ -21,15 +21,15 @@ graphene-django==3.0.0
gunicorn==21.2.0 gunicorn==21.2.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.4.8 mkdocs-material==9.4.14
mkdocstrings[python-legacy]==0.23.0 mkdocstrings[python-legacy]==0.24.0
netaddr==0.9.0 netaddr==0.9.0
Pillow==10.1.0 Pillow==10.1.0
psycopg[binary,pool]==3.1.12 psycopg[binary,pool]==3.1.13
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.31.0
social-auth-app-django==5.4.0 social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.5.0 social-auth-core[openidconnect]==4.5.1
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.5.0 tablib==3.5.0
tzdata==2023.3 tzdata==2023.3