Przeglądaj źródła

Merge branch 'develop' into feature

Jeremy Stretch 2 lat temu
rodzic
commit
1f2f0860fe
38 zmienionych plików z 520 dodań i 65 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 3 0
      docs/features/search.md
  4. 1 1
      docs/models/dcim/inventoryitem.md
  5. 23 1
      docs/release-notes/version-3.6.md
  6. 8 3
      netbox/core/management/commands/syncdatasource.py
  7. 9 6
      netbox/core/views.py
  8. 1 0
      netbox/dcim/filtersets.py
  9. 22 0
      netbox/dcim/migrations/0182_zero_length_cable_fix.py
  10. 1 1
      netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py
  11. 1 1
      netbox/dcim/migrations/0184_protect_child_interfaces.py
  12. 1 1
      netbox/dcim/migrations/0185_gfk_indexes.py
  13. 1 1
      netbox/dcim/models/cables.py
  14. 2 2
      netbox/dcim/svg/cables.py
  15. 1 1
      netbox/dcim/tables/devices.py
  16. 1 2
      netbox/dcim/views.py
  17. 1 1
      netbox/extras/management/commands/runscript.py
  18. 1 2
      netbox/extras/models/customfields.py
  19. 1 1
      netbox/extras/models/models.py
  20. 14 15
      netbox/extras/signals.py
  21. 265 0
      netbox/extras/tests/test_custom_validation.py
  22. 7 6
      netbox/extras/tests/test_customfields.py
  23. 21 2
      netbox/extras/validators.py
  24. 8 0
      netbox/ipam/filtersets.py
  25. 3 3
      netbox/ipam/models/vlans.py
  26. 29 0
      netbox/ipam/querysets.py
  27. 6 0
      netbox/ipam/signals.py
  28. 11 2
      netbox/ipam/tests/test_filtersets.py
  29. 20 0
      netbox/ipam/views.py
  30. 7 6
      netbox/netbox/api/serializers/base.py
  31. 11 0
      netbox/netbox/forms/base.py
  32. 8 0
      netbox/netbox/views/generic/bulk_views.py
  33. 1 1
      netbox/templates/dcim/cable.html
  34. 5 0
      netbox/templates/ipam/iprange.html
  35. 16 0
      netbox/utilities/filters.py
  36. 1 1
      netbox/utilities/forms/fields/dynamic.py
  37. 3 3
      requirements.txt
  38. 4 0
      upgrade.sh

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -23,7 +23,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.7
+      placeholder: v3.6.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -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.7
+      placeholder: v3.6.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 3 - 0
docs/features/search.md

@@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact
 
 
 Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
 Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
 
 
+!!! note
+    NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
+
 ## Saved Filters
 ## Saved Filters
 
 
 Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.
 Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.

+ 1 - 1
docs/models/dcim/inventoryitem.md

@@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional).
 
 
 ### Name
 ### Name
 
 
-The inventory item's name. Must be unique to the parent device.
+The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item).
 
 
 ### Label
 ### Label
 
 

+ 23 - 1
docs/release-notes/version-3.6.md

@@ -1,6 +1,28 @@
 # NetBox v3.6
 # NetBox v3.6
 
 
-## v3.6.8 (FUTURE)
+## v3.6.8 (2023-12-27)
+
+### Enhancements
+
+* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view
+* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script
+* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs
+* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices
+
+### Bug Fixes
+
+* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment
+* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields
+* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null
+* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables
+* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command
+* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field
+* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view
+* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted
+* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command
+* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs
+* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table
+* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI
 
 
 ---
 ---
 
 

+ 8 - 3
netbox/core/management/commands/syncdatasource.py

@@ -1,5 +1,6 @@
 from django.core.management.base import BaseCommand, CommandError
 from django.core.management.base import BaseCommand, CommandError
 
 
+from core.choices import DataSourceStatusChoices
 from core.models import DataSource
 from core.models import DataSource
 
 
 
 
@@ -33,9 +34,13 @@ class Command(BaseCommand):
         for i, datasource in enumerate(datasources, start=1):
         for i, datasource in enumerate(datasources, start=1):
             self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
             self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
             self.stdout.flush()
             self.stdout.flush()
-            datasource.sync()
-            self.stdout.write(datasource.get_status_display())
-            self.stdout.flush()
+            try:
+                datasource.sync()
+                self.stdout.write(datasource.get_status_display())
+                self.stdout.flush()
+            except Exception as e:
+                DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
+                raise e
 
 
         if len(options['name']) > 1:
         if len(options['name']) > 1:
             self.stdout.write(f"Finished.")
             self.stdout.write(f"Finished.")

+ 9 - 6
netbox/core/views.py

@@ -1,4 +1,5 @@
 from django.contrib import messages
 from django.contrib import messages
+from django.core.cache import cache
 from django.http import HttpResponseForbidden
 from django.http import HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views.generic import View
 from django.views.generic import View
@@ -159,12 +160,14 @@ class ConfigView(generic.ObjectView):
     queryset = ConfigRevision.objects.all()
     queryset = ConfigRevision.objects.all()
 
 
     def get_object(self, **kwargs):
     def get_object(self, **kwargs):
-        if config := self.queryset.first():
-            return config
-        # Instantiate a dummy default config if none has been created yet
-        return ConfigRevision(
-            data=get_config().defaults
-        )
+        revision_id = cache.get('config_version')
+        try:
+            return ConfigRevision.objects.get(pk=revision_id)
+        except ConfigRevision.DoesNotExist:
+            # Fall back to using the active config data if no record is found
+            return ConfigRevision(
+                data=get_config()
+            )
 
 
 
 
 class ConfigRevisionListView(generic.ObjectListView):
 class ConfigRevisionListView(generic.ObjectListView):

+ 1 - 0
netbox/dcim/filtersets.py

@@ -1020,6 +1020,7 @@ class DeviceFilterSet(
             Q(serial__icontains=value.strip()) |
             Q(serial__icontains=value.strip()) |
             Q(inventoryitems__serial__icontains=value.strip()) |
             Q(inventoryitems__serial__icontains=value.strip()) |
             Q(asset_tag__icontains=value.strip()) |
             Q(asset_tag__icontains=value.strip()) |
+            Q(description_icontains=value.strip()) |
             Q(comments__icontains=value) |
             Q(comments__icontains=value) |
             Q(primary_ip4__address__startswith=value) |
             Q(primary_ip4__address__startswith=value) |
             Q(primary_ip6__address__startswith=value)
             Q(primary_ip6__address__startswith=value)

+ 22 - 0
netbox/dcim/migrations/0182_zero_length_cable_fix.py

@@ -0,0 +1,22 @@
+from django.db import migrations
+
+
+def update_cable_lengths(apps, schema_editor):
+    Cable = apps.get_model('dcim', 'Cable')
+
+    # Set the absolute length for any zero-length Cables
+    Cable.objects.filter(length=0).update(_abs_length=0)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0181_rename_device_role_device_role'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=update_cable_lengths,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py → netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py

@@ -5,7 +5,7 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
     dependencies = [
     dependencies = [
-        ('dcim', '0181_rename_device_role_device_role'),
+        ('dcim', '0182_zero_length_cable_fix'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 1 - 1
netbox/dcim/migrations/0183_protect_child_interfaces.py → netbox/dcim/migrations/0184_protect_child_interfaces.py

@@ -7,7 +7,7 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0182_devicetype_exclude_from_utilization'),
+        ('dcim', '0183_devicetype_exclude_from_utilization'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 1 - 1
netbox/dcim/migrations/0184_gfk_indexes.py → netbox/dcim/migrations/0185_gfk_indexes.py

@@ -6,7 +6,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0183_protect_child_interfaces'),
+        ('dcim', '0184_protect_child_interfaces'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 1 - 1
netbox/dcim/models/cables.py

@@ -200,7 +200,7 @@ class Cable(PrimaryModel):
         _created = self.pk is None
         _created = self.pk is None
 
 
         # Store the given length (if any) in meters for use in database ordering
         # Store the given length (if any) in meters for use in database ordering
-        if self.length and self.length_unit:
+        if self.length is not None and self.length_unit:
             self._abs_length = to_meters(self.length, self.length_unit)
             self._abs_length = to_meters(self.length, self.length_unit)
         else:
         else:
             self._abs_length = None
             self._abs_length = None

+ 2 - 2
netbox/dcim/svg/cables.py

@@ -277,7 +277,7 @@ class CableTraceSVG:
             if cable.type:
             if cable.type:
                 # Include the cable type in the tooltip
                 # Include the cable type in the tooltip
                 description.append(cable.get_type_display())
                 description.append(cable.get_type_display())
-            if cable.length and cable.length_unit:
+            if cable.length is not None and cable.length_unit:
                 # Include the cable length in the tooltip
                 # Include the cable length in the tooltip
                 description.append(f'{cable.length} {cable.get_length_unit_display()}')
                 description.append(f'{cable.length} {cable.get_length_unit_display()}')
         else:
         else:
@@ -288,7 +288,7 @@ class CableTraceSVG:
             description = []
             description = []
             if cable.type:
             if cable.type:
                 labels.append(cable.get_type_display())
                 labels.append(cable.get_type_display())
-            if cable.length and cable.length_unit:
+            if cable.length is not None and cable.length_unit:
                 # Include the cable length in the tooltip
                 # Include the cable length in the tooltip
                 labels.append(f'{cable.length} {cable.get_length_unit_display()}')
                 labels.append(f'{cable.length} {cable.get_length_unit_display()}')
 
 

+ 1 - 1
netbox/dcim/tables/devices.py

@@ -1085,7 +1085,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
 
 
     tags = columns.TagColumn(
     tags = columns.TagColumn(
-        url_name='dcim:vdc_list'
+        url_name='dcim:virtualdevicecontext_list'
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):

+ 1 - 2
netbox/dcim/views.py

@@ -692,8 +692,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
         label=_('Reservations'),
         label=_('Reservations'),
         badge=lambda obj: obj.reservations.count(),
         badge=lambda obj: obj.reservations.count(),
         permission='dcim.view_rackreservation',
         permission='dcim.view_rackreservation',
-        weight=510,
-        hide_if_empty=True
+        weight=510
     )
     )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):

+ 1 - 1
netbox/extras/management/commands/runscript.py

@@ -114,7 +114,7 @@ class Command(BaseCommand):
         # Create the job
         # Create the job
         job = Job.objects.create(
         job = Job.objects.create(
             object=module,
             object=module,
-            name=script.name,
+            name=script.class_name,
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             job_id=uuid.uuid4()
             job_id=uuid.uuid4()
         )
         )

+ 1 - 2
netbox/extras/models/customfields.py

@@ -579,8 +579,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
         # Multiselect
         # Multiselect
         elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
         elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-            filter_class = filters.MultiValueCharFilter
-            kwargs['lookup_expr'] = 'has_key'
+            filter_class = filters.MultiValueArrayFilter
 
 
         # Object
         # Object
         elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
         elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

+ 1 - 1
netbox/extras/models/models.py

@@ -398,7 +398,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         text = clean_html(text, allowed_schemes)
         text = clean_html(text, allowed_schemes)
 
 
         # Sanitize link
         # Sanitize link
-        link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
+        link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;!')
 
 
         # Verify link scheme is allowed
         # Verify link scheme is allowed
         result = urllib.parse.urlparse(link)
         result = urllib.parse.urlparse(link)

+ 14 - 15
netbox/extras/signals.py

@@ -69,21 +69,20 @@ def handle_changed_object(sender, instance, **kwargs):
         return
         return
 
 
     # Record an ObjectChange if applicable
     # Record an ObjectChange if applicable
-    if hasattr(instance, 'to_objectchange'):
-        if m2m_changed:
-            ObjectChange.objects.filter(
-                changed_object_type=ContentType.objects.get_for_model(instance),
-                changed_object_id=instance.pk,
-                request_id=request.id
-            ).update(
-                postchange_data=instance.to_objectchange(action).postchange_data
-            )
-        else:
-            objectchange = instance.to_objectchange(action)
-            if objectchange and objectchange.has_changes:
-                objectchange.user = request.user
-                objectchange.request_id = request.id
-                objectchange.save()
+    if m2m_changed:
+        ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(instance),
+            changed_object_id=instance.pk,
+            request_id=request.id
+        ).update(
+            postchange_data=instance.to_objectchange(action).postchange_data
+        )
+    else:
+        objectchange = instance.to_objectchange(action)
+        if objectchange and objectchange.has_changes:
+            objectchange.user = request.user
+            objectchange.request_id = request.id
+            objectchange.save()
 
 
     # If this is an M2M change, update the previously queued webhook (from post_save)
     # If this is an M2M change, update the previously queued webhook (from post_save)
     queue = events_queue.get()
     queue = events_queue.get()

+ 265 - 0
netbox/extras/tests/test_custom_validation.py

@@ -0,0 +1,265 @@
+from django.test import TestCase
+from django.test import override_settings
+
+from circuits.api.serializers import ProviderSerializer
+from circuits.forms import ProviderForm
+from circuits.models import Provider
+from ipam.models import ASN, RIR
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
+from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
+
+
+class ModelFormCustomValidationTest(TestCase):
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'tags': {'required': True}}
+        ]
+    })
+    def test_tags_validation(self):
+        """
+        Check that custom validation rules work for tag assignment.
+        """
+        data = {
+            'name': 'Provider 1',
+            'slug': 'provider-1',
+        }
+        form = ProviderForm(data)
+        self.assertFalse(form.is_valid())
+
+        tags = create_tags('Tag1', 'Tag2', 'Tag3')
+        data['tags'] = [tag.pk for tag in tags]
+        form = ProviderForm(data)
+        self.assertTrue(form.is_valid())
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'asns': {'required': True}}
+        ]
+    })
+    def test_m2m_validation(self):
+        """
+        Check that custom validation rules work for many-to-many fields.
+        """
+        data = {
+            'name': 'Provider 1',
+            'slug': 'provider-1',
+        }
+        form = ProviderForm(data)
+        self.assertFalse(form.is_valid())
+
+        rir = RIR.objects.create(name='RIR 1', slug='rir-1')
+        asns = ASN.objects.bulk_create((
+            ASN(rir=rir, asn=65001),
+            ASN(rir=rir, asn=65002),
+            ASN(rir=rir, asn=65003),
+        ))
+        data['asns'] = [asn.pk for asn in asns]
+        form = ProviderForm(data)
+        self.assertTrue(form.is_valid())
+
+
+class BulkEditCustomValidationTest(ModelViewTestCase):
+    model = Provider
+
+    @classmethod
+    def setUpTestData(cls):
+        rir = RIR.objects.create(name='RIR 1', slug='rir-1')
+        asns = ASN.objects.bulk_create((
+            ASN(rir=rir, asn=65001),
+            ASN(rir=rir, asn=65002),
+            ASN(rir=rir, asn=65003),
+        ))
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+        for provider in providers:
+            provider.asns.set(asns)
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'asns': {'required': True}}
+        ]
+    })
+    def test_bulk_edit_without_m2m(self):
+        """
+        Check that custom validation rules do not interfere with bulk editing.
+        """
+        data = {
+            'pk': list(Provider.objects.values_list('pk', flat=True)),
+            '_apply': '',
+            'description': 'New description',
+        }
+        self.add_permissions(
+            'circuits.view_provider',
+            'circuits.change_provider',
+        )
+
+        # Bulk edit the description without changing ASN assignments
+        request = {
+            'path': self._get_url('bulk_edit'),
+            'data': post_data(data),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(
+            Provider.objects.filter(description=data['description']).count(),
+            len(data['pk'])
+        )
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'asns': {'required': True}}
+        ]
+    })
+    def test_bulk_edit_m2m(self):
+        """
+        Test that custom validation rules are enforced during bulk editing.
+        """
+        data = {
+            'pk': list(Provider.objects.values_list('pk', flat=True)),
+            '_apply': '',
+            'description': 'New description',
+        }
+        self.add_permissions(
+            'circuits.view_provider',
+            'circuits.change_provider',
+            'ipam.view_asn',
+        )
+
+        # Change the ASN assignments
+        asn = ASN.objects.first()
+        data['asns'] = [asn.pk]
+        request = {
+            'path': self._get_url('bulk_edit'),
+            'data': post_data(data),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+        for provider in Provider.objects.all():
+            self.assertEqual(len(provider.asns.all()), 1)
+
+        # Attempt to remove the ASN assignments
+        data.pop('asns')
+        data['_nullify'] = 'asns'
+        request = {
+            'path': self._get_url('bulk_edit'),
+            'data': post_data(data),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 200)
+        for provider in Provider.objects.all():
+            self.assertTrue(provider.asns.exists())
+
+
+class BulkImportCustomValidationTest(ModelViewTestCase):
+    model = Provider
+
+    @classmethod
+    def setUpTestData(cls):
+        create_tags('Tag1', 'Tag2', 'Tag3')
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'tags': {'required': True}}
+        ]
+    })
+    def test_bulk_import_invalid(self):
+        """
+        Test that custom validation rules are enforced during bulk import.
+        """
+        csv_data = (
+            "name,slug",
+            "Provider 1,provider-1",
+            "Provider 2,provider-2",
+            "Provider 3,provider-3",
+        )
+        data = {
+            'data': '\n'.join(csv_data),
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.COMMA,
+        }
+        self.add_permissions(
+            'circuits.view_provider',
+            'circuits.add_provider',
+            'extras.view_tag',
+        )
+
+        # Attempt to import providers without tags
+        request = {
+            'path': self._get_url('import'),
+            'data': post_data(data),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 200)
+        self.assertFalse(Provider.objects.exists())
+
+        # Import providers successfully with tag assignments
+        csv_data = (
+            "name,slug,tags",
+            "Provider 1,provider-1,tag1",
+            "Provider 2,provider-2,tag2",
+            "Provider 3,provider-3,tag3",
+        )
+        data['data'] = '\n'.join(csv_data)
+        request = {
+            'path': self._get_url('import'),
+            'data': post_data(data),
+        }
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(Provider.objects.exists())
+
+
+class APISerializerCustomValidationTest(APITestCase):
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'tags': {'required': True}}
+        ]
+    })
+    def test_tags_validation(self):
+        """
+        Check that custom validation rules work for tag assignment.
+        """
+        data = {
+            'name': 'Provider 1',
+            'slug': 'provider-1',
+        }
+        serializer = ProviderSerializer(data=data)
+        self.assertFalse(serializer.is_valid())
+
+        tags = create_tags('Tag1', 'Tag2', 'Tag3')
+        data['tags'] = [tag.pk for tag in tags]
+        serializer = ProviderSerializer(data=data)
+        self.assertTrue(serializer.is_valid())
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'circuits.provider': [
+            {'asns': {'required': True}}
+        ]
+    })
+    def test_m2m_validation(self):
+        """
+        Check that custom validation rules work for many-to-many fields.
+        """
+        data = {
+            'name': 'Provider 1',
+            'slug': 'provider-1',
+        }
+        serializer = ProviderSerializer(data=data)
+        self.assertFalse(serializer.is_valid())
+
+        rir = RIR.objects.create(name='RIR 1', slug='rir-1')
+        asns = ASN.objects.bulk_create((
+            ASN(rir=rir, asn=65001),
+            ASN(rir=rir, asn=65002),
+            ASN(rir=rir, asn=65003),
+        ))
+        data['asns'] = [asn.pk for asn in asns]
+        serializer = ProviderSerializer(data=data)
+        self.assertTrue(serializer.is_valid())

+ 7 - 6
netbox/extras/tests/test_customfields.py

@@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase):
 
 
         choice_set = CustomFieldChoiceSet.objects.create(
         choice_set = CustomFieldChoiceSet.objects.create(
             name='Custom Field Choice Set 1',
             name='Custom Field Choice Set 1',
-            extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
+            extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
         )
         )
 
 
         # Integer filtering
         # Integer filtering
@@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
                 'cf7': 'http://a.example.com',
                 'cf7': 'http://a.example.com',
                 'cf8': 'http://a.example.com',
                 'cf8': 'http://a.example.com',
                 'cf9': 'A',
                 'cf9': 'A',
-                'cf10': ['A', 'X'],
+                'cf10': ['A', 'B'],
                 'cf11': manufacturers[0].pk,
                 'cf11': manufacturers[0].pk,
                 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
                 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
             }),
             }),
@@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
                 'cf7': 'http://b.example.com',
                 'cf7': 'http://b.example.com',
                 'cf8': 'http://b.example.com',
                 'cf8': 'http://b.example.com',
                 'cf9': 'B',
                 'cf9': 'B',
-                'cf10': ['B', 'X'],
+                'cf10': ['B', 'C'],
                 'cf11': manufacturers[1].pk,
                 'cf11': manufacturers[1].pk,
                 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
                 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
             }),
             }),
@@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
                 'cf7': 'http://c.example.com',
                 'cf7': 'http://c.example.com',
                 'cf8': 'http://c.example.com',
                 'cf8': 'http://c.example.com',
                 'cf9': 'C',
                 'cf9': 'C',
-                'cf10': ['C', 'X'],
+                'cf10': None,
                 'cf11': manufacturers[2].pk,
                 'cf11': manufacturers[2].pk,
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
             }),
             }),
@@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
 
 
     def test_filter_multiselect(self):
     def test_filter_multiselect(self):
-        self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
+        self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
 
 
     def test_filter_object(self):
     def test_filter_object(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)

+ 21 - 2
netbox/extras/validators.py

@@ -91,8 +91,7 @@ class CustomValidator:
     def __call__(self, instance):
     def __call__(self, instance):
         # Validate instance attributes per validation rules
         # Validate instance attributes per validation rules
         for attr_name, rules in self.validation_rules.items():
         for attr_name, rules in self.validation_rules.items():
-            assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
-            attr = getattr(instance, attr_name)
+            attr = self._getattr(instance, attr_name)
             for descriptor, value in rules.items():
             for descriptor, value in rules.items():
                 validator = self.get_validator(descriptor, value)
                 validator = self.get_validator(descriptor, value)
                 try:
                 try:
@@ -104,6 +103,26 @@ class CustomValidator:
         # Execute custom validation logic (if any)
         # Execute custom validation logic (if any)
         self.validate(instance)
         self.validate(instance)
 
 
+    @staticmethod
+    def _getattr(instance, name):
+        # Attempt to resolve many-to-many fields to their stored values
+        m2m_fields = [f.name for f in instance._meta.local_many_to_many]
+        if name in m2m_fields:
+            if name in getattr(instance, '_m2m_values', []):
+                return instance._m2m_values[name]
+            if instance.pk:
+                return list(getattr(instance, name).all())
+            return []
+
+        # Raise a ValidationError for unknown attributes
+        if not hasattr(instance, name):
+            raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
+                name=name,
+                model=instance.__class__.__name__
+            ))
+
+        return getattr(instance, name)
+
     def get_validator(self, descriptor, value):
     def get_validator(self, descriptor, value):
         """
         """
         Instantiate and return the appropriate validator based on the descriptor given. For
         Instantiate and return the appropriate validator based on the descriptor given. For

+ 8 - 0
netbox/ipam/filtersets.py

@@ -949,6 +949,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         choices=VLANStatusChoices,
         choices=VLANStatusChoices,
         null_value=None
         null_value=None
     )
     )
+    available_at_site = django_filters.ModelChoiceFilter(
+        queryset=Site.objects.all(),
+        method='get_for_site'
+    )
     available_on_device = django_filters.ModelChoiceFilter(
     available_on_device = django_filters.ModelChoiceFilter(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         method='get_for_device'
         method='get_for_device'
@@ -983,6 +987,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
             pass
             pass
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
+    @extend_schema_field(OpenApiTypes.STR)
+    def get_for_site(self, queryset, name, value):
+        return queryset.get_for_site(value)
+
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)
     def get_for_device(self, queryset, name, value):
     def get_for_device(self, queryset, name, value):
         return queryset.get_for_device(value)
         return queryset.get_for_device(value)

+ 3 - 3
netbox/ipam/models/vlans.py

@@ -225,11 +225,11 @@ class VLAN(PrimaryModel):
 
 
         # Validate VLAN group (if assigned)
         # Validate VLAN group (if assigned)
         if self.group and self.site and self.group.scope != self.site:
         if self.group and self.site and self.group.scope != self.site:
-            raise ValidationError({
-                'group': _(
+            raise ValidationError(
+                _(
                     "VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
                     "VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
                 ).format(group=self.group, scope=self.group.scope, site=self.site)
                 ).format(group=self.group, scope=self.group.scope, site=self.site)
-            })
+            )
 
 
         # Validate group min/max VIDs
         # Validate group min/max VIDs
         if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
         if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:

+ 29 - 0
netbox/ipam/querysets.py

@@ -69,6 +69,35 @@ class VLANGroupQuerySet(RestrictedQuerySet):
 
 
 class VLANQuerySet(RestrictedQuerySet):
 class VLANQuerySet(RestrictedQuerySet):
 
 
+    def get_for_site(self, site):
+        """
+        Return all VLANs in the specified site
+        """
+        from .models import VLANGroup
+        q = Q()
+        q |= Q(
+            scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
+            scope_id=site.pk
+        )
+
+        if site.region:
+            q |= Q(
+                scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
+                scope_id__in=site.region.get_ancestors(include_self=True)
+            )
+        if site.group:
+            q |= Q(
+                scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
+                scope_id__in=site.group.get_ancestors(include_self=True)
+            )
+
+        return self.filter(
+            Q(group__in=VLANGroup.objects.filter(q)) |
+            Q(site=site) |
+            Q(group__scope_id__isnull=True, site__isnull=True) |  # Global group VLANs
+            Q(group__isnull=True, site__isnull=True)  # Global VLANs
+        )
+
     def get_for_device(self, device):
     def get_for_device(self, device):
         """
         """
         Return all VLANs available to the specified Device.
         Return all VLANs available to the specified Device.

+ 6 - 0
netbox/ipam/signals.py

@@ -56,8 +56,12 @@ def clear_primary_ip(instance, **kwargs):
     """
     """
     field_name = f'primary_ip{instance.family}'
     field_name = f'primary_ip{instance.family}'
     if device := Device.objects.filter(**{field_name: instance}).first():
     if device := Device.objects.filter(**{field_name: instance}).first():
+        device.snapshot()
+        setattr(device, field_name, None)
         device.save()
         device.save()
     if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
     if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
+        virtualmachine.snapshot()
+        setattr(virtualmachine, field_name, None)
         virtualmachine.save()
         virtualmachine.save()
 
 
 
 
@@ -67,4 +71,6 @@ def clear_oob_ip(instance, **kwargs):
     When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
     When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
     """
     """
     if device := Device.objects.filter(oob_ip=instance).first():
     if device := Device.objects.filter(oob_ip=instance).first():
+        device.snapshot()
+        device.oob_ip = None
         device.save()
         device.save()

+ 11 - 2
netbox/ipam/tests/test_filtersets.py

@@ -1359,6 +1359,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
             VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
             VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
             VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
             VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
+            VLANGroup(name='VLAN Group 4', slug='vlan-group-4'),
         )
         )
         VLANGroup.objects.bulk_create(groups)
         VLANGroup.objects.bulk_create(groups)
 
 
@@ -1415,6 +1416,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
             VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
             VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
             VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
             VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
 
 
+            # Create one globally available VLAN on a VLAN group
+            VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
+
             # Create one globally available VLAN
             # Create one globally available VLAN
             VLAN(vid=1000, name='Global VLAN'),
             VLAN(vid=1000, name='Global VLAN'),
         )
         )
@@ -1488,12 +1492,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_available_on_device(self):
     def test_available_on_device(self):
         device_id = Device.objects.first().pk
         device_id = Device.objects.first().pk
         params = {'available_on_device': device_id}
         params = {'available_on_device': device_id}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)  # 5 scoped + 1 global
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
 
 
     def test_available_on_virtualmachine(self):
     def test_available_on_virtualmachine(self):
         vm_id = VirtualMachine.objects.first().pk
         vm_id = VirtualMachine.objects.first().pk
         params = {'available_on_virtualmachine': vm_id}
         params = {'available_on_virtualmachine': vm_id}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)  # 5 scoped + 1 global
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
+
+    def test_available_at_site(self):
+        site_id = Site.objects.first().pk
+        params = {'available_at_site': site_id}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)  # 4 scoped + 1 global group + 1 global
 
 
 
 
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):

+ 20 - 0
netbox/ipam/views.py

@@ -659,6 +659,26 @@ class IPRangeListView(generic.ObjectListView):
 class IPRangeView(generic.ObjectView):
 class IPRangeView(generic.ObjectView):
     queryset = IPRange.objects.all()
     queryset = IPRange.objects.all()
 
 
+    def get_extra_context(self, request, instance):
+
+        # Parent prefixes table
+        parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
+            Q(prefix__net_contains_or_equals=str(instance.start_address.ip)),
+            Q(prefix__net_contains_or_equals=str(instance.end_address.ip)),
+            vrf=instance.vrf
+        ).prefetch_related(
+            'site', 'role', 'tenant', 'vlan', 'role'
+        )
+        parent_prefixes_table = tables.PrefixTable(
+            list(parent_prefixes),
+            exclude=('vrf', 'utilization'),
+            orderable=False
+        )
+
+        return {
+            'parent_prefixes_table': parent_prefixes_table,
+        }
+
 
 
 @register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
 @register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
 class IPRangeIPAddressesView(generic.ObjectChildrenView):
 class IPRangeIPAddressesView(generic.ObjectChildrenView):

+ 7 - 6
netbox/netbox/api/serializers/base.py

@@ -23,16 +23,16 @@ class ValidatedModelSerializer(BaseModelSerializer):
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
     """
     """
     def validate(self, data):
     def validate(self, data):
-
-        # Remove custom fields data and tags (if any) prior to model validation
         attrs = data.copy()
         attrs = data.copy()
+
+        # Remove custom field data (if any) prior to model validation
         attrs.pop('custom_fields', None)
         attrs.pop('custom_fields', None)
-        attrs.pop('tags', None)
 
 
         # Skip ManyToManyFields
         # Skip ManyToManyFields
-        for field in self.Meta.model._meta.get_fields():
-            if isinstance(field, ManyToManyField):
-                attrs.pop(field.name, None)
+        m2m_values = {}
+        for field in self.Meta.model._meta.local_many_to_many:
+            if field.name in attrs:
+                m2m_values[field.name] = attrs.pop(field.name)
 
 
         # Run clean() on an instance of the model
         # Run clean() on an instance of the model
         if self.instance is None:
         if self.instance is None:
@@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
             instance = self.instance
             instance = self.instance
             for k, v in attrs.items():
             for k, v in attrs.items():
                 setattr(instance, k, v)
                 setattr(instance, k, v)
+        instance._m2m_values = m2m_values
         instance.full_clean()
         instance.full_clean()
 
 
         return data
         return data

+ 11 - 0
netbox/netbox/forms/base.py

@@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
 
 
         return super().clean()
         return super().clean()
 
 
+    def _post_clean(self):
+        """
+        Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
+        """
+        self.instance._m2m_values = {}
+        for field in self.instance._meta.local_many_to_many:
+            if field.name in self.cleaned_data:
+                self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
+
+        return super()._post_clean()
+
 
 
 class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
     """
     """

+ 8 - 0
netbox/netbox/views/generic/bulk_views.py

@@ -556,6 +556,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                 elif name in form.changed_data:
                 elif name in form.changed_data:
                     obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
                     obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
 
 
+            # Store M2M values for validation
+            obj._m2m_values = {}
+            for field in obj._meta.local_many_to_many:
+                if value := form.cleaned_data.get(field.name):
+                    obj._m2m_values[field.name] = list(value)
+                elif field.name in nullified_fields:
+                    obj._m2m_values[field.name] = []
+
             obj.full_clean()
             obj.full_clean()
             obj.save()
             obj.save()
             updated_objects.append(obj)
             updated_objects.append(obj)

+ 1 - 1
netbox/templates/dcim/cable.html

@@ -50,7 +50,7 @@
             <tr>
             <tr>
               <th scope="row">{% trans "Length" %}</th>
               <th scope="row">{% trans "Length" %}</th>
               <td>
               <td>
-                {% if object.length %}
+                {% if object.length is not None %}
                   {{ object.length|floatformat }} {{ object.get_length_unit_display }}
                   {{ object.length|floatformat }} {{ object.get_length_unit_display }}
                 {% else %}
                 {% else %}
                   {{ ''|placeholder }}
                   {{ ''|placeholder }}

+ 5 - 0
netbox/templates/ipam/iprange.html

@@ -82,6 +82,11 @@
       {% plugin_right_page object %}
       {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>
+<div class="row">
+  <div class="col col-md-12">
+    {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
+  </div>
+</div>
 <div class="row">
 <div class="row">
     <div class="col col-md-12">
     <div class="col col-md-12">
         {% plugin_full_width_page object %}
         {% plugin_full_width_page object %}

+ 16 - 0
netbox/utilities/filters.py

@@ -9,6 +9,7 @@ from drf_spectacular.types import OpenApiTypes
 __all__ = (
 __all__ = (
     'ContentTypeFilter',
     'ContentTypeFilter',
     'MACAddressFilter',
     'MACAddressFilter',
+    'MultiValueArrayFilter',
     'MultiValueCharFilter',
     'MultiValueCharFilter',
     'MultiValueDateFilter',
     'MultiValueDateFilter',
     'MultiValueDateTimeFilter',
     'MultiValueDateTimeFilter',
@@ -85,6 +86,21 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.TimeField)
     field_class = multivalue_field_factory(forms.TimeField)
 
 
 
 
+@extend_schema_field(OpenApiTypes.STR)
+class MultiValueArrayFilter(django_filters.MultipleChoiceFilter):
+    field_class = multivalue_field_factory(forms.CharField)
+
+    def __init__(self, *args, lookup_expr='contains', **kwargs):
+        # Set default lookup_expr to 'contains'
+        super().__init__(*args, lookup_expr=lookup_expr, **kwargs)
+
+    def get_filter_predicate(self, v):
+        # If filtering for null values, ignore lookup_expr
+        if v is None:
+            return {self.field_name: None}
+        return super().get_filter_predicate(v)
+
+
 class MACAddressFilter(django_filters.CharFilter):
 class MACAddressFilter(django_filters.CharFilter):
     pass
     pass
 
 

+ 1 - 1
netbox/utilities/forms/fields/dynamic.py

@@ -43,7 +43,7 @@ class DynamicMultipleChoiceField(forms.MultipleChoiceField):
 
 
         if data is not None:
         if data is not None:
             self.choices = [
             self.choices = [
-                choice for choice in self.choices if choice[0] in data
+                choice for choice in self.choices if choice[0] and choice[0] in data
             ]
             ]
 
 
         return bound_field
         return bound_field

+ 3 - 3
requirements.txt

@@ -9,7 +9,7 @@ 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.9.0
+django-rq==2.10.1
 django-taggit==5.0.1
 django-taggit==5.0.1
 django-tables2==2.7.0
 django-tables2==2.7.0
 django-timezone-field==6.1.0
 django-timezone-field==6.1.0
@@ -21,11 +21,11 @@ graphene-django==3.0.0
 gunicorn==21.2.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.5.1
 Markdown==3.5.1
-mkdocs-material==9.5.2
+mkdocs-material==9.5.3
 mkdocstrings[python-legacy]==0.24.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.15
+psycopg[binary,pool]==3.1.16
 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

+ 4 - 0
upgrade.sh

@@ -7,6 +7,10 @@
 # Python 3.8 or later.
 # Python 3.8 or later.
 
 
 cd "$(dirname "$0")"
 cd "$(dirname "$0")"
+
+NETBOX_VERSION="$(grep ^VERSION netbox/netbox/settings.py | cut -d\' -f2)"
+echo "You are installing (or upgrading to) NetBox version ${NETBOX_VERSION}"
+
 VIRTUALENV="$(pwd -P)/venv"
 VIRTUALENV="$(pwd -P)/venv"
 PYTHON="${PYTHON:-python3}"
 PYTHON="${PYTHON:-python3}"