diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 48c14a2da..5e936c5ec 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.6.5
+ placeholder: v3.6.6
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 0525659ae..34103e616 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.6.5
+ placeholder: v3.6.6
validations:
required: true
- type: dropdown
diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md
index b8d316218..10e93be1e 100644
--- a/docs/release-notes/version-3.6.md
+++ b/docs/release-notes/version-3.6.md
@@ -1,6 +1,29 @@
# 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
---
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index cf40c0bd5..efda879af 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -116,6 +116,7 @@ class DataSource(JobsMixin, PrimaryModel):
)
def clean(self):
+ super().clean()
# Validate data backend type
if self.type and self.type not in registry['data_backends']:
diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py
index 138527581..5a321bdc3 100644
--- a/netbox/core/models/files.py
+++ b/netbox/core/models/files.py
@@ -2,6 +2,7 @@ import logging
import os
from django.conf import settings
+from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
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.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):
# Delete file from disk
try:
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
index 5b9b41e53..ce7ac6ec7 100644
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -244,7 +244,7 @@ class Job(models.Model):
model_name=self.object_type.model,
event=event,
data=self.data,
- timestamp=str(timezone.now()),
+ timestamp=timezone.now().isoformat(),
username=self.user.username,
retry=get_rq_retry()
)
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index f518275e0..830982e74 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -283,7 +283,7 @@ class ReportViewSet(ViewSet):
# Retrieve and run the Report. This will create a new Job.
module, report_cls = self._get_report(pk)
- report = report_cls()
+ report = report_cls
input_serializer = serializers.ReportInputSerializer(
data=request.data,
context={'report': report}
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index d336394f9..e0fc44ab1 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -121,8 +121,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
- Q(description__icontains=value) |
- Q(extra_choices__contains=value)
+ Q(description__icontains=value)
)
def filter_by_choice(self, queryset, name, value):
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index b62165e1a..97aed673a 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -1093,7 +1093,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
- name=report.name
+ name=report.class_name
)
jobs_table = JobTable(
diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py
index 1fc869ee8..a22f73c27 100644
--- a/netbox/extras/webhooks.py
+++ b/netbox/extras/webhooks.py
@@ -115,7 +115,7 @@ def flush_webhooks(queue):
event=data['event'],
data=data['data'],
snapshots=data['snapshots'],
- timestamp=str(timezone.now()),
+ timestamp=timezone.now().isoformat(),
username=data['username'],
request_id=data['request_id'],
retry=get_rq_retry()
diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py
index 6bb15523e..bbe38dc1a 100644
--- a/netbox/ipam/tables/asn.py
+++ b/netbox/ipam/tables/asn.py
@@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
asn_asdot = tables.Column(
accessor=tables.A('asn_asdot'),
linkify=True,
+ order_by=tables.A('asn'),
verbose_name=_('ASDOT')
)
site_count = columns.LinkedCountColumn(
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index ce8ab5876..4a97711ff 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -27,7 +27,7 @@ from netbox.plugins import PluginConfig
# Environment setup
#
-VERSION = '3.6.6-dev'
+VERSION = '3.6.7-dev'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index fbe3aa2ba..72d327453 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -394,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
raise ValidationError('')
+ # Take a snapshot for change logging
+ if instance.pk and hasattr(instance, 'snapshot'):
+ instance.snapshot()
+
# Instantiate the model form for the object
model_form_kwargs = {
'data': record,
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index 5fa6a3314..39e78c81b 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -5,6 +5,7 @@
{% load helpers %}
{% load plugins %}
{% load i18n %}
+{% load mptt %}
{% block content %}
@@ -15,16 +16,7 @@
{% trans "Region" %} |
-
- {% if object.site.region %}
- {% for region in object.site.region.get_ancestors %}
- {{ region|linkify }} /
- {% endfor %}
- {{ object.site.region|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
+ {% nested_tree object.site.region %} |
{% trans "Site" %} |
@@ -32,16 +24,7 @@
{% trans "Location" %} |
-
- {% if object.location %}
- {% for location in object.location.get_ancestors %}
- {{ location|linkify }} /
- {% endfor %}
- {{ object.location|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
+ {% nested_tree object.location %} |
{% trans "Rack" %} |
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 671c7ab2e..857061d00 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -4,6 +4,7 @@
{% load static %}
{% load plugins %}
{% load i18n %}
+{% load mptt %}
{% block content %}
@@ -15,26 +16,18 @@
- {% trans "Site" %} |
+ {% trans "Region" %} |
- {% if object.site.region %}
- {{ object.site.region|linkify }} /
- {% endif %}
- {{ object.site|linkify }}
+ {% nested_tree object.site.region %}
|
+
+ {% trans "Site" %} |
+ {{ object.site|linkify }} |
+
{% trans "Location" %} |
-
- {% if object.location %}
- {% for location in object.location.get_ancestors %}
- {{ location|linkify }} /
- {% endfor %}
- {{ object.location|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
+ {% nested_tree object.location %} |
{% trans "Facility ID" %} |
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html
index 8edb75f32..3d145145f 100644
--- a/netbox/templates/dcim/rackreservation.html
+++ b/netbox/templates/dcim/rackreservation.html
@@ -4,6 +4,7 @@
{% load static %}
{% load plugins %}
{% load i18n %}
+{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
@@ -20,25 +21,24 @@
- {% with rack=object.rack %}
-
- {% trans "Site" %} |
-
- {% if rack.site.region %}
- {{ rack.site.region|linkify }} /
- {% endif %}
- {{ rack.site|linkify }}
- |
-
-
- {% trans "Location" %} |
- {{ rack.location|linkify|placeholder }} |
-
-
- {% trans "Rack" %} |
- {{ rack|linkify }} |
-
- {% endwith %}
+
+ {% trans "Region" %} |
+
+ {% nested_tree object.rack.site.region %}
+ |
+
+
+ {% trans "Site" %} |
+ {{ object.rack.site|linkify }} |
+
+
+ {% trans "Location" %} |
+ {{ object.rack.location|linkify|placeholder }} |
+
+
+ {% trans "Rack" %} |
+ {{ object.rack|linkify }} |
+
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index 7f43a0ab3..16a870182 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -3,6 +3,7 @@
{% load plugins %}
{% load tz %}
{% load i18n %}
+{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
@@ -29,27 +30,13 @@
{% trans "Region" %} |
- {% if object.region %}
- {% for region in object.region.get_ancestors %}
- {{ region|linkify }} /
- {% endfor %}
- {{ object.region|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
+ {% nested_tree object.region %}
|
{% trans "Group" %} |
- {% if object.group %}
- {% for group in object.group.get_ancestors %}
- {{ group|linkify }} /
- {% endfor %}
- {{ object.group|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
+ {% nested_tree object.group %}
|
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index 71b240ced..ec6138d69 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -3,6 +3,7 @@
{% load helpers %}
{% load plugins %}
{% load i18n %}
+{% load mptt %}
{% block content %}
@@ -44,18 +45,17 @@
{% endif %}
+ {% if object.site.region %}
+
+ {% trans "Region" %} |
+
+ {% nested_tree object.site.region %}
+ |
+
+ {% endif %}
{% trans "Site" %} |
-
- {% if object.site %}
- {% if object.site.region %}
- {{ object.site.region|linkify }} /
- {% endif %}
- {{ object.site|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
+ {{ object.site|linkify|placeholder }} |
{% trans "VLAN" %} |
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html
index 4ca045d4b..a5ed9d643 100644
--- a/netbox/templates/ipam/vlan.html
+++ b/netbox/templates/ipam/vlan.html
@@ -3,6 +3,7 @@
{% load render_table from django_tables2 %}
{% load plugins %}
{% load i18n %}
+{% load mptt %}
{% block content %}
@@ -13,18 +14,17 @@
+ {% if object.site.region %}
+
+ {% trans "Region" %} |
+
+ {% nested_tree object.site.region %}
+ |
+
+ {% endif %}
{% trans "Site" %} |
-
- {% if object.site %}
- {% if object.site.region %}
- {{ object.site.region|linkify }} /
- {% endif %}
- {{ object.site|linkify }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
+ {{ object.site|linkify|placeholder }} |
{% trans "Group" %} |
diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py
index e7f319051..28bf92958 100644
--- a/netbox/tenancy/models/contacts.py
+++ b/netbox/tenancy/models/contacts.py
@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
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 *
__all__ = (
@@ -110,7 +110,7 @@ class Contact(PrimaryModel):
return reverse('tenancy:contact', args=[self.pk])
-class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
+class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
content_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.CASCADE
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 75ab877cf..c9775e39a 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -52,6 +52,16 @@ class UserSerializer(ValidatedModelSerializer):
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)
def get_display(self, obj):
if full_name := obj.get_full_name():
diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py
index 001142410..090ccc263 100644
--- a/netbox/users/tests/test_api.py
+++ b/netbox/users/tests/test_api.py
@@ -54,6 +54,38 @@ class UserTest(APIViewTestCases.APIViewTestCase):
)
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):
model = Group
diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py
index 4d737f163..64864a6c1 100644
--- a/netbox/utilities/forms/utils.py
+++ b/netbox/utilities/forms/utils.py
@@ -40,7 +40,7 @@ def parse_numeric_range(string, base=10):
except ValueError:
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
values.extend(range(begin, end))
- return list(set(values))
+ return sorted(set(values))
def parse_alphanumeric_range(string):
diff --git a/netbox/utilities/templatetags/mptt.py b/netbox/utilities/templatetags/mptt.py
new file mode 100644
index 000000000..783c2654f
--- /dev/null
+++ b/netbox/utilities/templatetags/mptt.py
@@ -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('—')
+
+ nodes = obj.get_ancestors(include_self=True)
+ return mark_safe(
+ ' / '.join(
+ f'{node}' for node in nodes
+ )
+ )
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index 72990ec76..b76d8a160 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -296,9 +296,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# 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.
for interface in interfaces:
+ vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site
if site is None:
- site = interface.virtual_machine.cluster.site
- elif interface.virtual_machine.cluster.site is not site:
+ site = vm_site
+ elif vm_site is not site:
site = None
break
diff --git a/requirements.txt b/requirements.txt
index 45fb12f80..537c5b77e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,18 +1,18 @@
bleach==6.1.0
Django==4.2.7
-django-cors-headers==4.3.0
+django-cors-headers==4.3.1
django-debug-toolbar==4.2.0
-django-filter==23.3
+django-filter==23.4
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14.0
django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.4.0
django-rich==1.8.0
-django-rq==2.8.1
+django-rq==2.9.0
django-tables2==2.6.0
django-taggit==4.0.0
-django-timezone-field==6.0.1
+django-timezone-field==6.1.0
djangorestframework==3.14.0
drf-spectacular==0.26.5
drf-spectacular-sidecar==2023.10.1
@@ -21,15 +21,15 @@ graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.4.8
-mkdocstrings[python-legacy]==0.23.0
+mkdocs-material==9.4.14
+mkdocstrings[python-legacy]==0.24.0
netaddr==0.9.0
Pillow==10.1.0
-psycopg[binary,pool]==3.1.12
+psycopg[binary,pool]==3.1.13
PyYAML==6.0.1
requests==2.31.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
tablib==3.5.0
tzdata==2023.3