diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 48c14a2daf0..5e936c5ec16 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 0525659aee9..34103e61615 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 646c2019ea1..6f81e45261c 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,5 +1,28 @@ # NetBox v3.6 +## 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 + +--- + ## v3.6.5 (2023-11-09) ### Enhancements diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 54a43c7ef9f..9e41e84461d 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -122,6 +122,7 @@ def ready_for_sync(self): ) def clean(self): + super().clean() # Ensure URL scheme matches selected type if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''): diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index 38d82463eca..a9e0e7f0069 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -2,6 +2,7 @@ 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 _ @@ -84,6 +85,14 @@ def sync_data(self): 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 61b0e64fab0..d52cbe165ca 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -229,7 +229,7 @@ def trigger_webhooks(self, event): 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 f518275e0ed..830982e7455 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -283,7 +283,7 @@ def run(self, request, pk): # 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 fec06726357..b33e7048843 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -122,8 +122,7 @@ def search(self, queryset, name, value): 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 55b73d29d3a..2c59c52350b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1073,7 +1073,7 @@ def get(self, request, module, name): 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 1fc869ee8e3..a22f73c27e4 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 6bb15523ec7..bbe38dc1a33 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 eac1c3c3777..9072dd36466 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ # Environment setup # -VERSION = '3.6.5' +VERSION = '3.6.6' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 676e3f5afe3..c5a08c80a2d 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -394,6 +394,10 @@ def create_and_update_objects(self, form, request): 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 5fa6a3314a3..39e78c81b7f 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 "Device" %}
- + @@ -32,16 +24,7 @@
{% trans "Device" %}
- + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 671c7ab2eb2..857061d00b9 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 "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" %}
{% 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/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 8edb75f324f..3d145145fd1 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 @@
{% 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" %}
- {% with rack=object.rack %} - - - - - - - - - - - - - {% endwith %} + + + + + + + + + + + + + + + +
{% trans "Site" %} - {% if rack.site.region %} - {{ rack.site.region|linkify }} / - {% endif %} - {{ rack.site|linkify }} -
{% trans "Location" %}{{ rack.location|linkify|placeholder }}
{% trans "Rack" %}{{ rack|linkify }}
{% 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 7f43a0ab33a..16a87018279 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 "Site" %}
{% 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 71b240cedef..ec6138d69b4 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 @@
{% trans "Prefix" %}
{% 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 4ca045d4b4d..a5ed9d643fb 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 %} + + + + + {% endif %} - + diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index e8327248d14..96ea053f7d5 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.features import TagsMixin +from netbox.models.features import ExportTemplatesMixin, TagsMixin from tenancy.choices import * __all__ = ( @@ -109,7 +109,7 @@ def get_absolute_url(self): return reverse('tenancy:contact', args=[self.pk]) -class ContactAssignment(ChangeLoggedModel, TagsMixin): +class ContactAssignment(ChangeLoggedModel, ExportTemplatesMixin, TagsMixin): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 75ab877cf88..c9775e39a5e 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -52,6 +52,16 @@ def create(self, validated_data): 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 0011424107b..090ccc263d4 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -54,6 +54,38 @@ def setUpTestData(cls): ) 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 4d737f16321..64864a6c130 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 00000000000..783c2654f6b --- /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 a33ffac5376..e5ab24f2ee0 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -294,9 +294,10 @@ def __init__(self, *args, **kwargs): # 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 16bafe62fe3..b99f16e76be 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,16 +21,16 @@ 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 -sentry-sdk==1.34.0 +sentry-sdk==1.38.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
{% trans "Region" %} + {% nested_tree object.site.region %} +
{% 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" %}