Quellcode durchsuchen

Merge branch 'main' into feature

Jeremy Stretch vor 4 Monaten
Ursprung
Commit
37a9d03348
81 geänderte Dateien mit 3280 neuen und 2690 gelöschten Zeilen
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 3 4
      base_requirements.txt
  4. 17 15
      contrib/openapi.json
  5. 1 1
      docs/plugins/development/filtersets.md
  6. 27 0
      docs/release-notes/version-4.4.md
  7. 12 0
      netbox/circuits/apps.py
  8. 6 6
      netbox/core/api/schema.py
  9. 5 5
      netbox/core/graphql/mixins.py
  10. 48 0
      netbox/core/migrations/0019_configrevision_active.py
  11. 16 1
      netbox/core/models/config.py
  12. 9 1
      netbox/core/models/object_types.py
  13. 5 3
      netbox/dcim/graphql/gfk_mixins.py
  14. 1 1
      netbox/dcim/tables/devices.py
  15. 28 1
      netbox/dcim/tests/test_views.py
  16. 5 4
      netbox/extras/graphql/mixins.py
  17. 32 1
      netbox/extras/lookups.py
  18. 1 1
      netbox/extras/querysets.py
  19. 2 16
      netbox/ipam/filtersets.py
  20. 2 2
      netbox/ipam/graphql/filters.py
  21. 1 1
      netbox/ipam/graphql/types.py
  22. 12 2
      netbox/ipam/models/vlans.py
  23. 2 1
      netbox/ipam/tables/vlans.py
  24. 4 0
      netbox/ipam/tests/test_filtersets.py
  25. 66 0
      netbox/ipam/tests/test_lookups.py
  26. 1 1
      netbox/netbox/api/fields.py
  27. 13 7
      netbox/netbox/api/pagination.py
  28. 7 2
      netbox/netbox/config/__init__.py
  29. 33 4
      netbox/netbox/graphql/filter_lookups.py
  30. 2 1
      netbox/netbox/graphql/types.py
  31. 8 3
      netbox/netbox/models/features.py
  32. 39 0
      netbox/netbox/monkey.py
  33. 12 0
      netbox/netbox/settings.py
  34. 2 1
      netbox/netbox/views/generic/object_views.py
  35. 0 0
      netbox/project-static/dist/netbox.js
  36. 0 0
      netbox/project-static/dist/netbox.js.map
  37. 1 1
      netbox/project-static/src/racks.ts
  38. 2 2
      netbox/release.yaml
  39. 1 1
      netbox/templates/account/base.html
  40. 4 4
      netbox/templates/base/base.html
  41. 4 3
      netbox/templates/extras/htmx/script_result.html
  42. 1 1
      netbox/templates/ipam/vlangroup.html
  43. 10 4
      netbox/tenancy/forms/bulk_import.py
  44. 5 0
      netbox/tenancy/forms/model_forms.py
  45. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  46. 183 177
      netbox/translations/cs/LC_MESSAGES/django.po
  47. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  48. 183 177
      netbox/translations/da/LC_MESSAGES/django.po
  49. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  50. 176 170
      netbox/translations/de/LC_MESSAGES/django.po
  51. 75 74
      netbox/translations/en/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  53. 183 177
      netbox/translations/es/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  55. 183 177
      netbox/translations/fr/LC_MESSAGES/django.po
  56. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  57. 183 177
      netbox/translations/it/LC_MESSAGES/django.po
  58. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  59. 183 177
      netbox/translations/ja/LC_MESSAGES/django.po
  60. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  61. 183 177
      netbox/translations/nl/LC_MESSAGES/django.po
  62. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  63. 183 177
      netbox/translations/pl/LC_MESSAGES/django.po
  64. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  65. 184 177
      netbox/translations/pt/LC_MESSAGES/django.po
  66. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  67. 183 177
      netbox/translations/ru/LC_MESSAGES/django.po
  68. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  69. 183 177
      netbox/translations/tr/LC_MESSAGES/django.po
  70. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  71. 183 177
      netbox/translations/uk/LC_MESSAGES/django.po
  72. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  73. 183 177
      netbox/translations/zh/LC_MESSAGES/django.po
  74. 54 15
      netbox/utilities/data.py
  75. 55 0
      netbox/utilities/templatetags/builtins/tags.py
  76. 2 3
      netbox/utilities/tests/test_api.py
  77. 25 9
      netbox/utilities/tests/test_data.py
  78. 48 0
      netbox/utilities/tests/test_templatetags.py
  79. 2 4
      netbox/virtualization/forms/model_forms.py
  80. 1 1
      pyproject.toml
  81. 10 10
      requirements.txt

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

@@ -15,7 +15,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v4.4.2
+      placeholder: v4.4.3
     validations:
       required: true
   - type: dropdown

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

@@ -27,7 +27,7 @@ body:
     attributes:
       label: NetBox Version
       description: What version of NetBox are you currently running?
-      placeholder: v4.4.2
+      placeholder: v4.4.3
     validations:
       required: true
   - type: dropdown

+ 3 - 4
base_requirements.txt

@@ -12,9 +12,7 @@ django-cors-headers
 
 # Runtime UI tool for debugging Django
 # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
-# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'" 
-# see https://github.com/netbox-community/netbox/issues/19974
-django-debug-toolbar==5.2.0
+django-debug-toolbar
 
 # Library for writing reusable URL query filters
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -71,7 +69,8 @@ django-timezone-field
 
 # A REST API framework for Django projects
 # https://www.django-rest-framework.org/community/release-notes/
-djangorestframework
+# TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
+djangorestframework==3.16.1
 
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst

+ 17 - 15
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "info": {
         "title": "NetBox REST API",
-        "version": "4.4.2",
+        "version": "4.4.3",
         "license": {
             "name": "Apache v2 License"
         }
@@ -214738,24 +214738,26 @@
             "IntegerRange": {
                 "type": "array",
                 "items": {
-                    "type": "array",
-                    "items": {
-                        "type": "integer"
-                    },
-                    "minItems": 2,
-                    "maxItems": 2
-                }
+                    "type": "integer"
+                },
+                "minItems": 2,
+                "maxItems": 2,
+                "example": [
+                    10,
+                    20
+                ]
             },
             "IntegerRangeRequest": {
                 "type": "array",
                 "items": {
-                    "type": "array",
-                    "items": {
-                        "type": "integer"
-                    },
-                    "minItems": 2,
-                    "maxItems": 2
-                }
+                    "type": "integer"
+                },
+                "minItems": 2,
+                "maxItems": 2,
+                "example": [
+                    10,
+                    20
+                ]
             },
             "Interface": {
                 "type": "object",

+ 1 - 1
docs/plugins/development/filtersets.md

@@ -1,6 +1,6 @@
 # Filters & Filter Sets
 
-Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
+Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filter](https://django-filter.readthedocs.io/en/stable/) library to define filter sets.
 
 ## FilterSet Classes
 

+ 27 - 0
docs/release-notes/version-4.4.md

@@ -1,5 +1,32 @@
 # NetBox v4.4
 
+## v4.4.3 (2025-10-14)
+
+### Enhancements
+
+* [#20426](https://github.com/netbox-community/netbox/issues/20426) - Add a copy-to-clipboard button for custom script output
+* [#20516](https://github.com/netbox-community/netbox/issues/20516) - Improve rendering of VLAN ID ranges in VLAN group tables
+
+### Bug Fixes
+
+* [#19302](https://github.com/netbox-community/netbox/issues/19302) - Fix uniqueness validation in REST API for nullable fields
+* [#19615](https://github.com/netbox-community/netbox/issues/19615) - Fix support for static file parameters in templates when external storage is in use
+* [#19818](https://github.com/netbox-community/netbox/issues/19818) - Hide primary IP assignment fields when creating a new virtual machine in the UI
+* [#19825](https://github.com/netbox-community/netbox/issues/19825) - Prevent cache for config revisions from being erroneously overwritten when debugging is enabled
+* [#20140](https://github.com/netbox-community/netbox/issues/20140) - Changing a site's region or group should update any associated circuit terminations
+* [#20156](https://github.com/netbox-community/netbox/issues/20156) - Fix display of rack elevation labels
+* [#20290](https://github.com/netbox-community/netbox/issues/20290) - Fix migration error when upgrading to NetBox v4.4 from releases earlier than v4.3
+* [#20471](https://github.com/netbox-community/netbox/issues/20471) - Saving an unmodified VLAN group should not generate a change record
+* [#20475](https://github.com/netbox-community/netbox/issues/20475) - Collapse singleton VLAN IDs in VLAN group display
+* [#20494](https://github.com/netbox-community/netbox/issues/20494) - Correct OpenAPI schema definition for `IntegerRangeSerializer`
+* [#20496](https://github.com/netbox-community/netbox/issues/20496) - REST API should always honor `MAX_PAGE_SIZE` value
+* [#20497](https://github.com/netbox-community/netbox/issues/20497) - Fix filtering of VLAN groups by VLAN ID range in GraphQL API
+* [#20507](https://github.com/netbox-community/netbox/issues/20507) - Fix support for fetching ASN contacts via GraphQL API
+* [#20523](https://github.com/netbox-community/netbox/issues/20523) - Hide password change form for users authenticated via SSO
+* [#20542](https://github.com/netbox-community/netbox/issues/20542) - Fix the creation of MAC addresses using the "quick add" form
+
+---
+
 ## v4.4.2 (2025-09-30)
 
 ### Enhancements

+ 12 - 0
netbox/circuits/apps.py

@@ -1,5 +1,7 @@
 from django.apps import AppConfig
 
+from netbox import denormalized
+
 
 class CircuitsConfig(AppConfig):
     name = "circuits"
@@ -8,6 +10,16 @@ class CircuitsConfig(AppConfig):
     def ready(self):
         from netbox.models.features import register_models
         from . import signals, search  # noqa: F401
+        from .models import CircuitTermination
 
         # Register models
         register_models(*self.get_models())
+
+        denormalized.register(CircuitTermination, '_site', {
+            '_region': 'region',
+            '_site_group': 'group',
+        })
+
+        denormalized.register(CircuitTermination, '_location', {
+            '_site': 'site',
+        })

+ 6 - 6
netbox/core/api/schema.py

@@ -282,18 +282,18 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
 
 class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
     target_class = 'netbox.api.fields.IntegerRangeSerializer'
+    match_subclasses = True
 
     def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
+        # One range = two integers; many=True will wrap this in an outer array
         return {
             'type': 'array',
             'items': {
-                'type': 'array',
-                'items': {
-                    'type': 'integer',
-                },
-                'minItems': 2,
-                'maxItems': 2,
+                'type': 'integer',
             },
+            'minItems': 2,
+            'maxItems': 2,
+            'example': [10, 20],
         }
 
 

+ 5 - 5
netbox/core/graphql/mixins.py

@@ -3,12 +3,12 @@ from typing import Annotated, List, TYPE_CHECKING
 import strawberry
 import strawberry_django
 from django.contrib.contenttypes.models import ContentType
+from strawberry.types import Info
 
 from core.models import ObjectChange
 
 if TYPE_CHECKING:
-    from core.graphql.types import DataFileType, DataSourceType
-    from netbox.core.graphql.types import ObjectChangeType
+    from core.graphql.types import DataFileType, DataSourceType, ObjectChangeType
 
 __all__ = (
     'ChangelogMixin',
@@ -20,7 +20,7 @@ __all__ = (
 class ChangelogMixin:
 
     @strawberry_django.field
-    def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:  # noqa: F821
+    def changelog(self, info: Info) -> List[Annotated['ObjectChangeType', strawberry.lazy('.types')]]:  # noqa: F821
         content_type = ContentType.objects.get_for_model(self)
         object_changes = ObjectChange.objects.filter(
             changed_object_type=content_type,
@@ -31,5 +31,5 @@ class ChangelogMixin:
 
 @strawberry.type
 class SyncedDataMixin:
-    data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
-    data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
+    data_source: Annotated['DataSourceType', strawberry.lazy('core.graphql.types')] | None
+    data_file: Annotated['DataFileType', strawberry.lazy('core.graphql.types')] | None

+ 48 - 0
netbox/core/migrations/0019_configrevision_active.py

@@ -0,0 +1,48 @@
+# Generated by Django 5.2.5 on 2025-09-09 16:48
+
+from django.db import migrations, models
+
+
+def get_active(apps, schema_editor):
+    from django.core.cache import cache
+    ConfigRevision = apps.get_model('core', 'ConfigRevision')
+    version = None
+    revision = None
+
+    # Try and get the latest version from cache
+    try:
+        version = cache.get('config_version')
+    except Exception:
+        pass
+
+    # If there is a version in cache, attempt to set revision to the current version from cache
+    # If the version in cache does not exist or there is no version, try the lastest revision in the database
+    if not version or (version and not (revision := ConfigRevision.objects.filter(pk=version).first())):
+        revision = ConfigRevision.objects.order_by('-created').first()
+
+    # If there is a revision set, set the active revision
+    if revision:
+        revision.active = True
+        revision.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0018_concrete_objecttype'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configrevision',
+            name='active',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.RunPython(code=get_active, reverse_code=migrations.RunPython.noop),
+        migrations.AddConstraint(
+            model_name='configrevision',
+            constraint=models.UniqueConstraint(
+                condition=models.Q(('active', True)), fields=('active',), name='unique_active_config_revision'
+            ),
+        ),
+    ]

+ 16 - 1
netbox/core/models/config.py

@@ -14,6 +14,9 @@ class ConfigRevision(models.Model):
     """
     An atomic revision of NetBox's configuration.
     """
+    active = models.BooleanField(
+        default=False
+    )
     created = models.DateTimeField(
         verbose_name=_('created'),
         auto_now_add=True
@@ -35,6 +38,13 @@ class ConfigRevision(models.Model):
         ordering = ['-created']
         verbose_name = _('config revision')
         verbose_name_plural = _('config revisions')
+        constraints = [
+            models.UniqueConstraint(
+                fields=('active',),
+                condition=models.Q(active=True),
+                name='unique_active_config_revision',
+            )
+        ]
 
     def __str__(self):
         if not self.pk:
@@ -59,8 +69,13 @@ class ConfigRevision(models.Model):
         """
         cache.set('config', self.data, None)
         cache.set('config_version', self.pk, None)
+
+        # Set all instances of ConfigRevision to false and set this instance to true
+        ConfigRevision.objects.all().update(active=False)
+        ConfigRevision.objects.filter(pk=self.pk).update(active=True)
+
     activate.alters_data = True
 
     @property
     def is_active(self):
-        return cache.get('config_version') == self.pk
+        return self.active

+ 9 - 1
netbox/core/models/object_types.py

@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.indexes import GinIndex
 from django.core.exceptions import ObjectDoesNotExist
-from django.db import models
+from django.db import connection, models
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
@@ -66,6 +66,14 @@ class ObjectTypeManager(models.Manager):
         """
         from netbox.models.features import get_model_features, model_is_public
 
+        # TODO: Remove this in NetBox v5.0
+        # If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
+        # fall back to ContentType.
+        if 'core_objecttype' not in connection.introspection.table_names():
+            ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
+            ct.features = get_model_features(ct.model_class())
+            return ct
+
         if not inspect.isclass(model):
             model = model.__class__
         opts = self._get_opts(model, for_concrete_model)

+ 5 - 3
netbox/dcim/graphql/gfk_mixins.py

@@ -1,3 +1,5 @@
+from strawberry.types import Info
+
 from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
 from circuits.models import CircuitTermination, ProviderNetwork
 from dcim.graphql.types import (
@@ -49,7 +51,7 @@ class InventoryItemTemplateComponentType:
         )
 
     @classmethod
-    def resolve_type(cls, instance, info):
+    def resolve_type(cls, instance, info: Info):
         if type(instance) is ConsolePortTemplate:
             return ConsolePortTemplateType
         if type(instance) is ConsoleServerPortTemplate:
@@ -79,7 +81,7 @@ class InventoryItemComponentType:
         )
 
     @classmethod
-    def resolve_type(cls, instance, info):
+    def resolve_type(cls, instance, info: Info):
         if type(instance) is ConsolePort:
             return ConsolePortType
         if type(instance) is ConsoleServerPort:
@@ -112,7 +114,7 @@ class ConnectedEndpointType:
         )
 
     @classmethod
-    def resolve_type(cls, instance, info):
+    def resolve_type(cls, instance, info: Info):
         if type(instance) is CircuitTermination:
             return CircuitTerminationType
         if type(instance) is ConsolePortType:

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

@@ -196,7 +196,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Type')
     )
     u_height = columns.TemplateColumn(
-        accessor=tables.A('device_type.u_height'),
+        accessor=tables.A('device_type__u_height'),
         verbose_name=_('U Height'),
         template_code='{{ value|floatformat }}'
     )

+ 28 - 1
netbox/dcim/tests/test_views.py

@@ -7,13 +7,14 @@ from django.test import override_settings, tag
 from django.urls import reverse
 from netaddr import EUI
 
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
 from tenancy.models import Tenant
-from users.models import User
+from users.models import ObjectPermission, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from wireless.models import WirelessLAN
 
@@ -3728,3 +3729,29 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
             'description': 'New description',
         }
+
+    @tag('regression')  # Issue #20542
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_create_macaddress_via_quickadd(self):
+        """
+        Test creating a MAC address via quick-add modal (e.g., from Interface form).
+        Regression test for issue #20542 where form prefix was missing in POST handler.
+        """
+        obj_perm = ObjectPermission(name='Test permission', actions=['add'])
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        # Simulate quick-add form submission with 'quickadd-' prefix
+        formatted_data = post_data(self.form_data)
+        quickadd_data = {f'quickadd-{k}': v for k, v in formatted_data.items()}
+        quickadd_data['_quickadd'] = 'True'
+
+        initial_count = self._get_queryset().count()
+        url = f"{self._get_url('add')}?_quickadd=True&target=id_primary_mac_address"
+        response = self.client.post(url, data=quickadd_data)
+
+        # Should successfully create the MAC address and return the quick_add_created template
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'quick-add-object', response.content)
+        self.assertEqual(initial_count + 1, self._get_queryset().count())

+ 5 - 4
netbox/extras/graphql/mixins.py

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated, List
 
 import strawberry
 import strawberry_django
+from strawberry.types import Info
 
 __all__ = (
     'ConfigContextMixin',
@@ -37,7 +38,7 @@ class CustomFieldsMixin:
 class ImageAttachmentsMixin:
 
     @strawberry_django.field
-    def image_attachments(self, info) -> List[Annotated["ImageAttachmentType", strawberry.lazy('.types')]]:
+    def image_attachments(self, info: Info) -> List[Annotated['ImageAttachmentType', strawberry.lazy('.types')]]:
         return self.images.restrict(info.context.request.user, 'view')
 
 
@@ -45,17 +46,17 @@ class ImageAttachmentsMixin:
 class JournalEntriesMixin:
 
     @strawberry_django.field
-    def journal_entries(self, info) -> List[Annotated["JournalEntryType", strawberry.lazy('.types')]]:
+    def journal_entries(self, info: Info) -> List[Annotated['JournalEntryType', strawberry.lazy('.types')]]:
         return self.journal_entries.all()
 
 
 @strawberry.type
 class TagsMixin:
 
-    tags: List[Annotated["TagType", strawberry.lazy('.types')]]
+    tags: List[Annotated['TagType', strawberry.lazy('.types')]]
 
 
 @strawberry.type
 class ContactsMixin:
 
-    contacts: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]]
+    contacts: List[Annotated['ContactAssignmentType', strawberry.lazy('tenancy.graphql.types')]]

+ 32 - 1
netbox/extras/lookups.py

@@ -1,9 +1,39 @@
+from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.fields.ranges import RangeField
 from django.db.models import CharField, JSONField, Lookup
 from django.db.models.fields.json import KeyTextTransform
 
 from .fields import CachedValueField
 
 
+class RangeContains(Lookup):
+    """
+    Filter ArrayField(RangeField) columns where ANY element-range contains the scalar RHS.
+
+    Usage (ORM):
+        Model.objects.filter(<range_array_field>__range_contains=<scalar>)
+
+    Works with int4range[], int8range[], daterange[], tstzrange[], etc.
+    """
+
+    lookup_name = 'range_contains'
+
+    def as_sql(self, compiler, connection):
+        # Compile LHS (the array-of-ranges column/expression) and RHS (scalar)
+        lhs, lhs_params = self.process_lhs(compiler, connection)
+        rhs, rhs_params = self.process_rhs(compiler, connection)
+
+        # Guard: only allow ArrayField whose base_field is a PostgreSQL RangeField
+        field = getattr(self.lhs, 'output_field', None)
+        if not (isinstance(field, ArrayField) and isinstance(field.base_field, RangeField)):
+            raise TypeError('range_contains is only valid for ArrayField(RangeField) columns')
+
+        # Range-contains-element using EXISTS + UNNEST keeps the range on the LHS: r @> value
+        sql = f"EXISTS (SELECT 1 FROM unnest({lhs}) AS r WHERE r @> {rhs})"
+        params = lhs_params + rhs_params
+        return sql, params
+
+
 class Empty(Lookup):
     """
     Filter on whether a string is empty.
@@ -25,7 +55,7 @@ class JSONEmpty(Lookup):
 
     A key is considered empty if it is "", null, or does not exist.
     """
-    lookup_name = "empty"
+    lookup_name = 'empty'
 
     def as_sql(self, compiler, connection):
         # self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
@@ -69,6 +99,7 @@ class NetContainsOrEquals(Lookup):
         return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
 
 
+ArrayField.register_lookup(RangeContains)
 CharField.register_lookup(Empty)
 JSONField.register_lookup(JSONEmpty)
 CachedValueField.register_lookup(NetHost)

+ 1 - 1
netbox/extras/querysets.py

@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
                 ConfigContext.objects.filter(
                     self._get_config_context_filters()
                 ).annotate(
-                    _data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
+                    _data=EmptyGroupByJSONBAgg('data', order_by=['weight', 'name'])
                 ).values("_data").order_by()
             )
         )

+ 2 - 16
netbox/ipam/filtersets.py

@@ -908,7 +908,8 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         method='filter_scope'
     )
     contains_vid = django_filters.NumberFilter(
-        method='filter_contains_vid'
+        field_name='vid_ranges',
+        lookup_expr='range_contains',
     )
 
     class Meta:
@@ -931,21 +932,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
             scope_id=value
         )
 
-    def filter_contains_vid(self, queryset, name, value):
-        """
-        Return all VLANGroups which contain the given VLAN ID.
-        """
-        table_name = VLANGroup._meta.db_table
-        # TODO: See if this can be optimized without compromising queryset integrity
-        # Expand VLAN ID ranges to query by integer
-        groups = VLANGroup.objects.raw(
-            f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
-            params=(value,)
-        )
-        return queryset.filter(
-            pk__in=[g.id for g in groups]
-        )
-
 
 class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(

+ 2 - 2
netbox/ipam/graphql/filters.py

@@ -19,7 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
 
 if TYPE_CHECKING:
-    from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
+    from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
     from circuits.graphql.filters import ProviderFilter
     from core.graphql.filters import ContentTypeFilter
     from dcim.graphql.filters import SiteFilter
@@ -340,7 +340,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 @strawberry_django.filter_type(models.VLANGroup, lookups=True)
 class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
-    vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
 

+ 1 - 1
netbox/ipam/graphql/types.py

@@ -74,7 +74,7 @@ class BaseIPAddressFamilyType:
     filters=ASNFilter,
     pagination=True
 )
-class ASNType(NetBoxObjectType):
+class ASNType(NetBoxObjectType, ContactsMixin):
     asn: BigInt
     rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None

+ 12 - 2
netbox/ipam/models/vlans.py

@@ -10,9 +10,9 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import Interface, Site, SiteGroup
 from ipam.choices import *
 from ipam.constants import *
-from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
+from ipam.querysets import VLANGroupQuerySet, VLANQuerySet
 from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
-from utilities.data import check_ranges_overlap, ranges_to_string
+from utilities.data import check_ranges_overlap, ranges_to_string, ranges_to_string_list
 from virtualization.models import VMInterface
 
 __all__ = (
@@ -164,8 +164,18 @@ class VLANGroup(OrganizationalModel):
         """
         return VLAN.objects.filter(group=self).order_by('vid')
 
+    @property
+    def vid_ranges_items(self):
+        """
+        Property that converts VID ranges to a list of string representations.
+        """
+        return ranges_to_string_list(self.vid_ranges)
+
     @property
     def vid_ranges_list(self):
+        """
+        Property that converts VID ranges into a string representation.
+        """
         return ranges_to_string(self.vid_ranges)
 
 

+ 2 - 1
netbox/ipam/tables/vlans.py

@@ -41,7 +41,8 @@ class VLANGroupTable(TenancyColumnsMixin, NetBoxTable):
         linkify=True,
         orderable=False
     )
-    vid_ranges_list = tables.Column(
+    vid_ranges_list = columns.ArrayColumn(
+        accessor='vid_ranges_items',
         verbose_name=_('VID Ranges'),
         orderable=False
     )

+ 4 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1723,6 +1723,10 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'contains_vid': 1}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'contains_vid': 12}  # 11 is NOT in [1,11)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'contains_vid': 4095}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
 
     def test_region(self):
         params = {'region': Region.objects.first().pk}

+ 66 - 0
netbox/ipam/tests/test_lookups.py

@@ -0,0 +1,66 @@
+from django.test import TestCase
+from django.db.backends.postgresql.psycopg_any import NumericRange
+from ipam.models import VLANGroup
+
+
+class VLANGroupRangeContainsLookupTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        # Two ranges: [1,11) and [20,31)
+        cls.g1 = VLANGroup.objects.create(
+            name='VlanGroup-A',
+            slug='VlanGroup-A',
+            vid_ranges=[NumericRange(1, 11), NumericRange(20, 31)],
+        )
+        # One range: [100,201)
+        cls.g2 = VLANGroup.objects.create(
+            name='VlanGroup-B',
+            slug='VlanGroup-B',
+            vid_ranges=[NumericRange(100, 201)],
+        )
+        cls.g_empty = VLANGroup.objects.create(
+            name='VlanGroup-empty',
+            slug='VlanGroup-empty',
+            vid_ranges=[],
+        )
+
+    def test_contains_value_in_first_range(self):
+        """
+        Tests whether a specific value is contained within the first range in a queried
+        set of VLANGroup objects.
+        """
+        names = list(
+            VLANGroup.objects.filter(vid_ranges__range_contains=10).values_list('name', flat=True).order_by('name')
+        )
+        self.assertEqual(names, ['VlanGroup-A'])
+
+    def test_contains_value_in_second_range(self):
+        """
+        Tests if a value exists in the second range of VLANGroup objects and
+        validates the result against the expected list of names.
+        """
+        names = list(
+            VLANGroup.objects.filter(vid_ranges__range_contains=25).values_list('name', flat=True).order_by('name')
+        )
+        self.assertEqual(names, ['VlanGroup-A'])
+
+    def test_upper_bound_is_exclusive(self):
+        """
+        Tests if the upper bound of the range is exclusive in the filter method.
+        """
+        # 11 is NOT in [1,11)
+        self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=11).exists())
+
+    def test_no_match_far_outside(self):
+        """
+        Tests that no VLANGroup contains a VID within a specified range far outside
+        common VID bounds and returns `False`.
+        """
+        self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=4095).exists())
+
+    def test_empty_array_never_matches(self):
+        """
+        Tests the behavior of VLANGroup objects when an empty array is used to match a
+        specific condition.
+        """
+        self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())

+ 1 - 1
netbox/netbox/api/fields.py

@@ -169,7 +169,7 @@ class IntegerRangeSerializer(serializers.Serializer):
         if type(data[0]) is not int or type(data[1]) is not int:
             raise ValidationError(_("Range boundaries must be defined as integers."))
 
-        return NumericRange(data[0], data[1], bounds='[]')
+        return NumericRange(data[0], data[1] + 1, bounds='[)')
 
     def to_representation(self, instance):
         return instance.lower, instance.upper - 1

+ 13 - 7
netbox/netbox/api/pagination.py

@@ -44,22 +44,28 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
             return list(queryset[self.offset:])
 
     def get_limit(self, request):
+        max_limit = self.default_limit
+        MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
+        if MAX_PAGE_SIZE:
+            max_limit = min(max_limit, MAX_PAGE_SIZE)
+
         if self.limit_query_param:
-            MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
-            if MAX_PAGE_SIZE:
-                MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
             try:
                 limit = int(request.query_params[self.limit_query_param])
                 if limit < 0:
                     raise ValueError()
-                # Enforce maximum page size, if defined
+
                 if MAX_PAGE_SIZE:
-                    return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
-                return limit
+                    if limit == 0:
+                        max_limit = MAX_PAGE_SIZE
+                    else:
+                        max_limit = min(MAX_PAGE_SIZE, limit)
+                else:
+                    max_limit = limit
             except (KeyError, ValueError):
                 pass
 
-        return self.default_limit
+        return max_limit
 
     def get_queryset_count(self, queryset):
         return queryset.count()

+ 7 - 2
netbox/netbox/config/__init__.py

@@ -78,11 +78,16 @@ class Config:
         from core.models import ConfigRevision
 
         try:
-            revision = ConfigRevision.objects.last()
+            # Enforce the creation date as the ordering parameter
+            revision = ConfigRevision.objects.get(active=True)
+            logger.debug(f"Loaded active configuration revision #{revision.pk}")
+        except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
+            logger.warning("No active configuration revision found - falling back to most recent")
+            revision = ConfigRevision.objects.order_by('-created').first()
             if revision is None:
                 logger.debug("No previous configuration found in database; proceeding with default values")
                 return
-            logger.debug("Loaded configuration data from database")
+            logger.debug(f"Using fallback configuration revision #{revision.pk}")
         except DatabaseError:
             # The database may not be available yet (e.g. when running a management command)
             logger.warning("Skipping config initialization (database unavailable)")

+ 33 - 4
netbox/netbox/graphql/filter_lookups.py

@@ -7,6 +7,7 @@ from django.core.exceptions import FieldDoesNotExist
 from django.db.models import Q, QuerySet
 from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
 from strawberry import ID
+from strawberry.directive import DirectiveValue
 from strawberry.types import Info
 from strawberry_django import (
     ComparisonFilterLookup,
@@ -24,6 +25,7 @@ __all__ = (
     'FloatLookup',
     'IntegerArrayLookup',
     'IntegerLookup',
+    'IntegerRangeArrayLookup',
     'JSONFilter',
     'StringArrayLookup',
     'TreeNodeFilter',
@@ -67,7 +69,7 @@ class IntegerLookup:
         return None
 
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         filters = self.get_filter()
 
         if not filters:
@@ -90,7 +92,7 @@ class FloatLookup:
         return None
 
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         filters = self.get_filter()
 
         if not filters:
@@ -109,7 +111,7 @@ class JSONFilter:
     lookup: JSONLookup
 
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         filters = self.lookup.get_filter()
 
         if not filters:
@@ -136,7 +138,7 @@ class TreeNodeFilter:
     match_type: TreeNodeMatch
 
     @strawberry_django.filter_field
-    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
         model_field_name = prefix.removesuffix('__').removesuffix('_id')
         model_field = None
         try:
@@ -217,3 +219,30 @@ class FloatArrayLookup(ArrayLookup[float]):
 @strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
 class StringArrayLookup(ArrayLookup[str]):
     pass
+
+
+@strawberry.input(one_of=True, description='Lookups for an ArrayField(RangeField). Only one may be set.')
+class RangeArrayValueLookup(Generic[T]):
+    """
+    class for Array field of Range fields lookups
+    """
+
+    contains: T | None = strawberry.field(
+        default=strawberry.UNSET, description='Return rows where any stored range contains this value.'
+    )
+
+    @strawberry_django.filter_field
+    def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+        """
+        Map GraphQL: { <field>: { contains: <T> } } To Django ORM: <field>__range_contains=<T>
+        """
+        if self.contains is strawberry.UNSET or self.contains is None:
+            return queryset, Q()
+
+        # Build '<prefix>range_contains' so it works for nested paths too
+        return queryset, Q(**{f'{prefix}range_contains': self.contains})
+
+
+@strawberry.input(one_of=True, description='Lookups for an ArrayField(IntegerRangeField). Only one may be set.')
+class IntegerRangeArrayLookup(RangeArrayValueLookup[int]):
+    pass

+ 2 - 1
netbox/netbox/graphql/types.py

@@ -1,5 +1,6 @@
 import strawberry
 import strawberry_django
+from strawberry.types import Info
 from django.contrib.contenttypes.models import ContentType
 
 from core.graphql.mixins import ChangelogMixin
@@ -26,7 +27,7 @@ class BaseObjectType:
     """
 
     @classmethod
-    def get_queryset(cls, queryset, info, **kwargs):
+    def get_queryset(cls, queryset, info: Info, **kwargs):
         # Enforce object permissions on the queryset
         if hasattr(queryset, 'restrict'):
             return queryset.restrict(info.context.request.user, 'view')

+ 8 - 3
netbox/netbox/models/features.py

@@ -673,10 +673,15 @@ def has_feature(model_or_ct, feature):
     # If an ObjectType was passed, we can use it directly
     if type(model_or_ct) is ObjectType:
         ot = model_or_ct
-    # If a ContentType was passed, resolve its model class
+    # If a ContentType was passed, resolve its model class and run the associated feature test
     elif type(model_or_ct) is ContentType:
-        model_class = model_or_ct.model_class()
-        ot = ObjectType.objects.get_for_model(model_class) if model_class else None
+        model = model_or_ct.model_class()
+        try:
+            test_func = registry['model_features'][feature]
+        except KeyError:
+            # Unknown feature
+            return False
+        return test_func(model)
     # For anything else, look up the ObjectType
     else:
         ot = ObjectType.objects.get_for_model(model_or_ct)

+ 39 - 0
netbox/netbox/monkey.py

@@ -0,0 +1,39 @@
+from django.db.models import UniqueConstraint
+from rest_framework.utils.field_mapping import get_unique_error_message
+from rest_framework.validators import UniqueValidator
+
+__all__ = (
+    'get_unique_validators',
+)
+
+
+def get_unique_validators(field_name, model_field):
+    """
+    Extend Django REST Framework's get_unique_validators() function to attach a UniqueValidator to a field *only* if the
+     associated UniqueConstraint does NOT have a condition which references another field. See bug #19302.
+    """
+    field_set = {field_name}
+    conditions = {
+        c.condition
+        for c in model_field.model._meta.constraints
+        if isinstance(c, UniqueConstraint) and set(c.fields) == field_set
+    }
+
+    # START custom logic
+    conditions = {
+        cond for cond in conditions
+        if cond.referenced_base_fields == field_set
+    }
+    # END custom logic
+
+    if getattr(model_field, 'unique', False):
+        conditions.add(None)
+    if not conditions:
+        return
+    unique_error_message = get_unique_error_message(model_field)
+    queryset = model_field.model._default_manager
+    for condition in conditions:
+        yield UniqueValidator(
+            queryset=queryset if condition is None else queryset.filter(condition),
+            message=unique_error_message
+        )

+ 12 - 0
netbox/netbox/settings.py

@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.core.validators import URLValidator
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
+from rest_framework.utils import field_mapping
 
 from core.exceptions import IncompatiblePluginError
 from netbox.config import PARAMS as CONFIG_PARAMS
@@ -21,6 +22,17 @@ import storages.utils  # type: ignore
 from utilities.release import load_release_data
 from utilities.security import validate_peppers
 from utilities.string import trailing_slash
+from .monkey import get_unique_validators
+
+
+#
+# Monkey-patching
+#
+
+# TODO: Remove this once #20547 has been implemented
+# Override DRF's get_unique_validators() function with our own (see bug #19302)
+field_mapping.get_unique_validators = get_unique_validators
+
 
 #
 # Environment setup

+ 2 - 1
netbox/netbox/views/generic/object_views.py

@@ -281,7 +281,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
 
         obj = self.alter_object(obj, request, args, kwargs)
 
-        form = self.form(data=request.POST, files=request.FILES, instance=obj)
+        form_prefix = 'quickadd' if request.GET.get('_quickadd') else None
+        form = self.form(data=request.POST, files=request.FILES, instance=obj, prefix=form_prefix)
         restrict_form_fields(form, request.user)
 
         if form.is_valid():

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
netbox/project-static/dist/netbox.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/src/racks.ts

@@ -83,7 +83,7 @@ export function initRackElevation(): void {
   }
 
   for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
-    element.addEventListener('load', () => {
+    element.addEventListener('htmx:afterSettle', () => {
       setRackView(initialView, element);
     });
   }

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.4.2"
+version: "4.4.3"
 edition: "Community"
-published: "2025-09-30"
+published: "2025-10-14"

+ 1 - 1
netbox/templates/account/base.html

@@ -18,7 +18,7 @@
     <li class="nav-item">
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
     </li>
-    {% if not request.user.ldap_username %}
+    {% if request.user.has_usable_password %}
       <li class="nav-item">
         <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
       </li>

+ 4 - 4
netbox/templates/base/base.html

@@ -26,7 +26,7 @@
     {# Initialize color mode #}
     <script
       type="text/javascript"
-      src="{% static 'setmode.js' %}?v={{ settings.RELEASE.version }}"
+      src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
     </script>
     <script type="text/javascript">
@@ -39,12 +39,12 @@
     {# Static resources #}
     <link
       rel="stylesheet"
-      href="{% static 'netbox-external.css'%}?v={{ settings.RELEASE.version }}"
+      href="{% static_with_params 'netbox-external.css' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
     />
     <link
       rel="stylesheet"
-      href="{% static 'netbox.css'%}?v={{ settings.RELEASE.version }}"
+      href="{% static_with_params 'netbox.css' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
     />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
@@ -53,7 +53,7 @@
     {# Javascript #}
     <script
       type="text/javascript"
-      src="{% static 'netbox.js' %}?v={{ settings.RELEASE.version }}"
+      src="{% static_with_params 'netbox.js' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
     </script>
     {% django_htmx_script %}

+ 4 - 3
netbox/templates/extras/htmx/script_result.html

@@ -44,8 +44,8 @@
         <div class="htmx-container table-responsive"
           hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True&log_threshold={{log_threshold}}"
           hx-target="this"
-          hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
-        ></div>
+          hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML">
+        </div>
       </div>
     </div>
     {% endif %}
@@ -60,11 +60,12 @@
               <a href="?export=output" class="btn btn-sm btn-primary" role="button">
                 <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
               </a>
+              {% copy_content "job_data_output" %}
             </div>
           {% endif %}
         </h2>
         {% if job.data.output %}
-          <pre class="card-body font-monospace">{{ job.data.output }}</pre>
+          <pre class="card-body font-monospace" id="job_data_output">{{ job.data.output }}</pre>
         {% else %}
           <div class="card-body text-muted">{% trans "None" %}</div>
         {% endif %}

+ 1 - 1
netbox/templates/ipam/vlangroup.html

@@ -40,7 +40,7 @@
         </tr>
         <tr>
           <th scope="row">{% trans "VLAN IDs" %}</th>
-          <td>{{ object.vid_ranges_list }}</td>
+          <td>{{ object.vid_ranges_items|join:", " }}</td>
         </tr>
         <tr>
           <th scope="row">Utilization</th>

+ 10 - 4
netbox/tenancy/forms/bulk_import.py

@@ -1,3 +1,4 @@
+from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
@@ -25,7 +26,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
         queryset=TenantGroup.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Parent group')
+        help_text=_('Parent group'),
     )
     slug = SlugField()
 
@@ -41,7 +42,7 @@ class TenantImportForm(NetBoxModelImportForm):
         queryset=TenantGroup.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Assigned group')
+        help_text=_('Assigned group'),
     )
 
     class Meta:
@@ -59,7 +60,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
         queryset=ContactGroup.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Parent group')
+        help_text=_('Parent group'),
     )
     slug = SlugField()
 
@@ -81,7 +82,12 @@ class ContactImportForm(NetBoxModelImportForm):
         queryset=ContactGroup.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
+        help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")'),
+    )
+    link = forms.URLField(
+        label=_('Link'),
+        assume_scheme='https',
+        required=False,
     )
 
     class Meta:

+ 5 - 0
netbox/tenancy/forms/model_forms.py

@@ -100,6 +100,11 @@ class ContactForm(NetBoxModelForm):
         queryset=ContactGroup.objects.all(),
         required=False
     )
+    link = forms.URLField(
+        label=_('Link'),
+        assume_scheme='https',
+        required=False,
+    )
     comments = CommentField()
 
     fieldsets = (

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 176 - 170
netbox/translations/de/LC_MESSAGES/django.po


+ 75 - 74
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-10-02 05:01+0000\n"
+"POT-Creation-Date: 2025-10-10 05:03+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -167,7 +167,7 @@ msgstr ""
 #: netbox/dcim/filtersets.py:467 netbox/dcim/filtersets.py:1108
 #: netbox/dcim/filtersets.py:1430 netbox/dcim/filtersets.py:1528
 #: netbox/dcim/filtersets.py:2221 netbox/dcim/filtersets.py:2464
-#: netbox/dcim/filtersets.py:2522 netbox/ipam/filtersets.py:955
+#: netbox/dcim/filtersets.py:2522 netbox/ipam/filtersets.py:941
 #: netbox/virtualization/filtersets.py:139 netbox/vpn/filtersets.py:361
 msgid "Region (ID)"
 msgstr ""
@@ -180,7 +180,7 @@ msgstr ""
 #: netbox/dcim/filtersets.py:1437 netbox/dcim/filtersets.py:1535
 #: netbox/dcim/filtersets.py:2228 netbox/dcim/filtersets.py:2471
 #: netbox/dcim/filtersets.py:2529 netbox/extras/filtersets.py:646
-#: netbox/ipam/filtersets.py:962 netbox/virtualization/filtersets.py:146
+#: netbox/ipam/filtersets.py:948 netbox/virtualization/filtersets.py:146
 #: netbox/vpn/filtersets.py:356
 msgid "Region (slug)"
 msgstr ""
@@ -192,7 +192,7 @@ msgstr ""
 #: netbox/dcim/filtersets.py:1121 netbox/dcim/filtersets.py:1443
 #: netbox/dcim/filtersets.py:1541 netbox/dcim/filtersets.py:2234
 #: netbox/dcim/filtersets.py:2477 netbox/dcim/filtersets.py:2535
-#: netbox/ipam/filtersets.py:239 netbox/ipam/filtersets.py:968
+#: netbox/ipam/filtersets.py:239 netbox/ipam/filtersets.py:954
 #: netbox/virtualization/filtersets.py:152
 msgid "Site group (ID)"
 msgstr ""
@@ -205,7 +205,7 @@ msgstr ""
 #: netbox/dcim/filtersets.py:1548 netbox/dcim/filtersets.py:2241
 #: netbox/dcim/filtersets.py:2484 netbox/dcim/filtersets.py:2542
 #: netbox/extras/filtersets.py:652 netbox/ipam/filtersets.py:246
-#: netbox/ipam/filtersets.py:975 netbox/virtualization/filtersets.py:159
+#: netbox/ipam/filtersets.py:961 netbox/virtualization/filtersets.py:159
 msgid "Site group (slug)"
 msgstr ""
 
@@ -262,7 +262,7 @@ msgstr ""
 #: netbox/circuits/filtersets.py:315 netbox/dcim/base_filtersets.py:53
 #: netbox/dcim/filtersets.py:245 netbox/dcim/filtersets.py:366
 #: netbox/dcim/filtersets.py:461 netbox/extras/filtersets.py:668
-#: netbox/ipam/filtersets.py:257 netbox/ipam/filtersets.py:985
+#: netbox/ipam/filtersets.py:257 netbox/ipam/filtersets.py:971
 #: netbox/virtualization/filtersets.py:169 netbox/vpn/filtersets.py:366
 msgid "Site (slug)"
 msgstr ""
@@ -321,7 +321,7 @@ msgstr ""
 #: netbox/dcim/filtersets.py:1132 netbox/dcim/filtersets.py:1455
 #: netbox/dcim/filtersets.py:1553 netbox/dcim/filtersets.py:2246
 #: netbox/dcim/filtersets.py:2488 netbox/dcim/filtersets.py:2547
-#: netbox/ipam/filtersets.py:251 netbox/ipam/filtersets.py:979
+#: netbox/ipam/filtersets.py:251 netbox/ipam/filtersets.py:965
 #: netbox/virtualization/filtersets.py:163 netbox/vpn/filtersets.py:371
 msgid "Site (ID)"
 msgstr ""
@@ -1125,7 +1125,7 @@ msgstr ""
 #: netbox/templates/vpn/tunneltermination.html:17
 #: netbox/templates/wireless/inc/wirelesslink_interface.html:20
 #: netbox/tenancy/forms/bulk_edit.py:159 netbox/tenancy/forms/filtersets.py:107
-#: netbox/tenancy/forms/model_forms.py:139
+#: netbox/tenancy/forms/model_forms.py:144
 #: netbox/tenancy/tables/contacts.py:110
 #: netbox/virtualization/forms/bulk_edit.py:127
 #: netbox/virtualization/forms/bulk_import.py:112
@@ -1238,7 +1238,7 @@ msgstr ""
 #: netbox/templates/wireless/inc/wirelesslink_interface.html:10
 #: netbox/templates/wireless/wirelesslink.html:10
 #: netbox/templates/wireless/wirelesslink.html:55
-#: netbox/virtualization/forms/model_forms.py:377
+#: netbox/virtualization/forms/model_forms.py:375
 #: netbox/vpn/forms/bulk_import.py:302 netbox/vpn/forms/model_forms.py:439
 #: netbox/vpn/forms/model_forms.py:448 netbox/wireless/forms/model_forms.py:118
 #: netbox/wireless/forms/model_forms.py:160
@@ -1386,7 +1386,7 @@ msgstr ""
 #: netbox/circuits/tables/circuits.py:191 netbox/dcim/forms/bulk_edit.py:127
 #: netbox/dcim/forms/bulk_import.py:103 netbox/dcim/forms/model_forms.py:126
 #: netbox/dcim/tables/sites.py:103 netbox/extras/forms/filtersets.py:572
-#: netbox/ipam/filtersets.py:995 netbox/ipam/forms/bulk_edit.py:488
+#: netbox/ipam/filtersets.py:981 netbox/ipam/forms/bulk_edit.py:488
 #: netbox/ipam/forms/bulk_import.py:482 netbox/ipam/forms/model_forms.py:571
 #: netbox/ipam/tables/fhrp.py:67 netbox/ipam/tables/vlans.py:93
 #: netbox/ipam/tables/vlans.py:204
@@ -1398,10 +1398,10 @@ msgstr ""
 #: netbox/templates/virtualization/cluster.html:29
 #: netbox/templates/vpn/tunnel.html:29
 #: netbox/templates/wireless/wirelesslan.html:18
-#: netbox/tenancy/forms/bulk_edit.py:44 netbox/tenancy/forms/bulk_import.py:40
+#: netbox/tenancy/forms/bulk_edit.py:44 netbox/tenancy/forms/bulk_import.py:41
 #: netbox/tenancy/forms/filtersets.py:48 netbox/tenancy/forms/filtersets.py:97
 #: netbox/tenancy/forms/model_forms.py:46
-#: netbox/tenancy/forms/model_forms.py:124 netbox/tenancy/tables/tenants.py:50
+#: netbox/tenancy/forms/model_forms.py:129 netbox/tenancy/tables/tenants.py:50
 #: netbox/users/filtersets.py:62 netbox/users/filtersets.py:185
 #: netbox/users/forms/filtersets.py:31 netbox/users/forms/filtersets.py:37
 #: netbox/users/forms/filtersets.py:79
@@ -2547,7 +2547,7 @@ msgstr ""
 msgid "Change logging is not supported for this object type ({type})."
 msgstr ""
 
-#: netbox/core/models/config.py:18 netbox/core/models/data.py:269
+#: netbox/core/models/config.py:21 netbox/core/models/data.py:269
 #: netbox/core/models/files.py:30 netbox/core/models/jobs.py:60
 #: netbox/extras/models/models.py:839 netbox/extras/models/notifications.py:39
 #: netbox/extras/models/notifications.py:195
@@ -2555,31 +2555,31 @@ msgstr ""
 msgid "created"
 msgstr ""
 
-#: netbox/core/models/config.py:22
+#: netbox/core/models/config.py:25
 msgid "comment"
 msgstr ""
 
-#: netbox/core/models/config.py:29
+#: netbox/core/models/config.py:32
 msgid "configuration data"
 msgstr ""
 
-#: netbox/core/models/config.py:36
+#: netbox/core/models/config.py:39
 msgid "config revision"
 msgstr ""
 
-#: netbox/core/models/config.py:37
+#: netbox/core/models/config.py:40
 msgid "config revisions"
 msgstr ""
 
-#: netbox/core/models/config.py:41
+#: netbox/core/models/config.py:51
 msgid "Default configuration"
 msgstr ""
 
-#: netbox/core/models/config.py:43
+#: netbox/core/models/config.py:53
 msgid "Current configuration"
 msgstr ""
 
-#: netbox/core/models/config.py:44
+#: netbox/core/models/config.py:54
 #, python-brace-format
 msgid "Config revision #{id}"
 msgstr ""
@@ -2792,11 +2792,11 @@ msgid ""
 "enqueue() cannot be called with values for both schedule_at and immediate."
 msgstr ""
 
-#: netbox/core/models/object_types.py:180
+#: netbox/core/models/object_types.py:188
 msgid "object type"
 msgstr ""
 
-#: netbox/core/models/object_types.py:181 netbox/extras/models/models.py:56
+#: netbox/core/models/object_types.py:189 netbox/extras/models/models.py:56
 msgid "object types"
 msgstr ""
 
@@ -3196,8 +3196,8 @@ msgstr ""
 #: netbox/templates/virtualization/vminterface.html:39
 #: netbox/templates/wireless/wirelesslangroup.html:37
 #: netbox/tenancy/forms/bulk_edit.py:27 netbox/tenancy/forms/bulk_edit.py:67
-#: netbox/tenancy/forms/bulk_import.py:24
-#: netbox/tenancy/forms/bulk_import.py:58
+#: netbox/tenancy/forms/bulk_import.py:25
+#: netbox/tenancy/forms/bulk_import.py:59
 #: netbox/tenancy/forms/model_forms.py:25
 #: netbox/tenancy/forms/model_forms.py:69 netbox/tenancy/tables/contacts.py:23
 #: netbox/tenancy/tables/tenants.py:20
@@ -3571,7 +3571,7 @@ msgid "Parent site group (slug)"
 msgstr ""
 
 #: netbox/dcim/filtersets.py:167 netbox/extras/filtersets.py:422
-#: netbox/ipam/filtersets.py:837 netbox/ipam/filtersets.py:989
+#: netbox/ipam/filtersets.py:837 netbox/ipam/filtersets.py:975
 msgid "Group (ID)"
 msgstr ""
 
@@ -3618,14 +3618,14 @@ msgstr ""
 #: netbox/dcim/filtersets.py:414 netbox/dcim/filtersets.py:928
 #: netbox/dcim/filtersets.py:1077 netbox/dcim/filtersets.py:2164
 #: netbox/ipam/filtersets.py:376 netbox/ipam/filtersets.py:488
-#: netbox/ipam/filtersets.py:999 netbox/virtualization/filtersets.py:177
+#: netbox/ipam/filtersets.py:985 netbox/virtualization/filtersets.py:177
 msgid "Role (ID)"
 msgstr ""
 
 #: netbox/dcim/filtersets.py:420 netbox/dcim/filtersets.py:934
 #: netbox/dcim/filtersets.py:1084 netbox/dcim/filtersets.py:2170
 #: netbox/extras/filtersets.py:695 netbox/ipam/filtersets.py:382
-#: netbox/ipam/filtersets.py:494 netbox/ipam/filtersets.py:1005
+#: netbox/ipam/filtersets.py:494 netbox/ipam/filtersets.py:991
 #: netbox/virtualization/filtersets.py:184
 msgid "Role (slug)"
 msgstr ""
@@ -3871,14 +3871,14 @@ msgstr ""
 
 #: netbox/dcim/filtersets.py:1487 netbox/dcim/filtersets.py:1585
 #: netbox/dcim/filtersets.py:1775 netbox/ipam/filtersets.py:606
-#: netbox/ipam/filtersets.py:847 netbox/ipam/filtersets.py:1177
+#: netbox/ipam/filtersets.py:847 netbox/ipam/filtersets.py:1163
 #: netbox/virtualization/filtersets.py:127 netbox/vpn/filtersets.py:382
 msgid "Device (ID)"
 msgstr ""
 
 #: netbox/dcim/filtersets.py:1493 netbox/dcim/filtersets.py:1591
 #: netbox/dcim/filtersets.py:1770 netbox/ipam/filtersets.py:601
-#: netbox/ipam/filtersets.py:842 netbox/ipam/filtersets.py:1172
+#: netbox/ipam/filtersets.py:842 netbox/ipam/filtersets.py:1158
 #: netbox/vpn/filtersets.py:377
 msgid "Device (name)"
 msgstr ""
@@ -3918,13 +3918,13 @@ msgid "Cable (ID)"
 msgstr ""
 
 #: netbox/dcim/filtersets.py:1780 netbox/ipam/filtersets.py:611
-#: netbox/ipam/filtersets.py:852 netbox/ipam/filtersets.py:1182
+#: netbox/ipam/filtersets.py:852 netbox/ipam/filtersets.py:1168
 #: netbox/vpn/filtersets.py:388
 msgid "Virtual machine (name)"
 msgstr ""
 
 #: netbox/dcim/filtersets.py:1785 netbox/ipam/filtersets.py:616
-#: netbox/ipam/filtersets.py:857 netbox/ipam/filtersets.py:1187
+#: netbox/ipam/filtersets.py:857 netbox/ipam/filtersets.py:1173
 #: netbox/virtualization/filtersets.py:253
 #: netbox/virtualization/filtersets.py:304 netbox/vpn/filtersets.py:393
 msgid "Virtual machine (ID)"
@@ -3947,7 +3947,7 @@ msgstr ""
 
 #: netbox/dcim/filtersets.py:1849 netbox/templates/dcim/interface.html:81
 #: netbox/templates/virtualization/vminterface.html:55
-#: netbox/virtualization/forms/model_forms.py:395
+#: netbox/virtualization/forms/model_forms.py:393
 msgid "802.1Q Mode"
 msgstr ""
 
@@ -3987,7 +3987,7 @@ msgstr ""
 #: netbox/virtualization/forms/bulk_edit.py:243
 #: netbox/virtualization/forms/bulk_import.py:177
 #: netbox/virtualization/forms/filtersets.py:236
-#: netbox/virtualization/forms/model_forms.py:368
+#: netbox/virtualization/forms/model_forms.py:366
 #: netbox/virtualization/models/virtualmachines.py:336
 #: netbox/virtualization/tables/virtualmachines.py:113
 msgid "VRF"
@@ -3999,13 +3999,13 @@ msgstr ""
 msgid "VRF (RD)"
 msgstr ""
 
-#: netbox/dcim/filtersets.py:1873 netbox/ipam/filtersets.py:1037
+#: netbox/dcim/filtersets.py:1873 netbox/ipam/filtersets.py:1023
 #: netbox/vpn/filtersets.py:345
 msgid "L2VPN (ID)"
 msgstr ""
 
 #: netbox/dcim/filtersets.py:1879 netbox/dcim/forms/filtersets.py:1531
-#: netbox/dcim/tables/devices.py:613 netbox/ipam/filtersets.py:1043
+#: netbox/dcim/tables/devices.py:613 netbox/ipam/filtersets.py:1029
 #: netbox/ipam/forms/filtersets.py:592 netbox/ipam/tables/vlans.py:115
 #: netbox/templates/dcim/interface.html:99 netbox/templates/ipam/vlan.html:82
 #: netbox/templates/vpn/l2vpntermination.html:12
@@ -4016,7 +4016,7 @@ msgstr ""
 msgid "L2VPN"
 msgstr ""
 
-#: netbox/dcim/filtersets.py:1884 netbox/ipam/filtersets.py:1120
+#: netbox/dcim/filtersets.py:1884 netbox/ipam/filtersets.py:1106
 msgid "VLAN Translation Policy (ID)"
 msgstr ""
 
@@ -4027,7 +4027,7 @@ msgstr ""
 #: netbox/templates/ipam/vlantranslationpolicy.html:11
 #: netbox/virtualization/forms/bulk_edit.py:248
 #: netbox/virtualization/forms/filtersets.py:251
-#: netbox/virtualization/forms/model_forms.py:373
+#: netbox/virtualization/forms/model_forms.py:371
 msgid "VLAN Translation Policy"
 msgstr ""
 
@@ -4077,7 +4077,7 @@ msgstr ""
 
 #: netbox/dcim/filtersets.py:1977 netbox/dcim/forms/model_forms.py:1549
 #: netbox/virtualization/filtersets.py:284
-#: netbox/virtualization/forms/model_forms.py:311
+#: netbox/virtualization/forms/model_forms.py:309
 msgid "Primary MAC address"
 msgstr ""
 
@@ -4724,21 +4724,21 @@ msgstr ""
 #: netbox/dcim/forms/bulk_edit.py:1567 netbox/dcim/forms/model_forms.py:1511
 #: netbox/ipam/forms/bulk_import.py:174 netbox/ipam/forms/filtersets.py:561
 #: netbox/ipam/models/vlans.py:93 netbox/virtualization/forms/bulk_edit.py:222
-#: netbox/virtualization/forms/model_forms.py:335
+#: netbox/virtualization/forms/model_forms.py:333
 msgid "VLAN group"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_edit.py:1576 netbox/dcim/forms/model_forms.py:1517
 #: netbox/dcim/tables/devices.py:622
 #: netbox/virtualization/forms/bulk_edit.py:230
-#: netbox/virtualization/forms/model_forms.py:340
+#: netbox/virtualization/forms/model_forms.py:338
 msgid "Untagged VLAN"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_edit.py:1585 netbox/dcim/forms/model_forms.py:1526
 #: netbox/dcim/tables/devices.py:628
 #: netbox/virtualization/forms/bulk_edit.py:238
-#: netbox/virtualization/forms/model_forms.py:349
+#: netbox/virtualization/forms/model_forms.py:347
 msgid "Tagged VLANs"
 msgstr ""
 
@@ -4751,7 +4751,7 @@ msgid "Remove tagged VLANs"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_edit.py:1608 netbox/dcim/forms/model_forms.py:1535
-#: netbox/virtualization/forms/model_forms.py:358
+#: netbox/virtualization/forms/model_forms.py:356
 msgid "Q-in-Q Service VLAN"
 msgstr ""
 
@@ -4774,13 +4774,13 @@ msgstr ""
 #: netbox/templates/ipam/prefix.html:91
 #: netbox/templates/virtualization/vminterface.html:76
 #: netbox/virtualization/forms/filtersets.py:205
-#: netbox/virtualization/forms/model_forms.py:378
+#: netbox/virtualization/forms/model_forms.py:376
 msgid "Addressing"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_edit.py:1638 netbox/dcim/forms/filtersets.py:750
 #: netbox/dcim/forms/model_forms.py:1570
-#: netbox/virtualization/forms/model_forms.py:379
+#: netbox/virtualization/forms/model_forms.py:377
 msgid "Operation"
 msgstr ""
 
@@ -4792,7 +4792,7 @@ msgstr ""
 #: netbox/dcim/forms/bulk_edit.py:1640 netbox/dcim/forms/model_forms.py:1571
 #: netbox/templates/dcim/interface.html:105
 #: netbox/virtualization/forms/bulk_edit.py:254
-#: netbox/virtualization/forms/model_forms.py:380
+#: netbox/virtualization/forms/model_forms.py:378
 msgid "Related Interfaces"
 msgstr ""
 
@@ -4800,7 +4800,7 @@ msgstr ""
 #: netbox/dcim/forms/model_forms.py:1575
 #: netbox/virtualization/forms/bulk_edit.py:257
 #: netbox/virtualization/forms/filtersets.py:206
-#: netbox/virtualization/forms/model_forms.py:383
+#: netbox/virtualization/forms/model_forms.py:381
 msgid "802.1Q Switching"
 msgstr ""
 
@@ -4828,7 +4828,7 @@ msgstr ""
 msgid "Assigned region"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:107 netbox/tenancy/forms/bulk_import.py:44
+#: netbox/dcim/forms/bulk_import.py:107 netbox/tenancy/forms/bulk_import.py:45
 #: netbox/wireless/forms/bulk_import.py:42
 msgid "Assigned group"
 msgstr ""
@@ -4961,7 +4961,7 @@ msgid "Limit platform assignments to this manufacturer"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_import.py:549 netbox/dcim/forms/bulk_import.py:1674
-#: netbox/tenancy/forms/bulk_import.py:105
+#: netbox/tenancy/forms/bulk_import.py:111
 msgid "Assigned role"
 msgstr ""
 
@@ -5072,13 +5072,13 @@ msgstr ""
 
 #: netbox/dcim/forms/bulk_import.py:919 netbox/dcim/forms/model_forms.py:1473
 #: netbox/virtualization/forms/bulk_import.py:161
-#: netbox/virtualization/forms/model_forms.py:319
+#: netbox/virtualization/forms/model_forms.py:317
 msgid "Parent interface"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_import.py:926 netbox/dcim/forms/model_forms.py:1481
 #: netbox/virtualization/forms/bulk_import.py:168
-#: netbox/virtualization/forms/model_forms.py:327
+#: netbox/virtualization/forms/model_forms.py:325
 msgid "Bridged interface"
 msgstr ""
 
@@ -5212,7 +5212,7 @@ msgstr ""
 #: netbox/virtualization/forms/bulk_import.py:213
 #: netbox/virtualization/forms/filtersets.py:220
 #: netbox/virtualization/forms/filtersets.py:266
-#: netbox/virtualization/forms/model_forms.py:295
+#: netbox/virtualization/forms/model_forms.py:293
 #: netbox/vpn/forms/bulk_import.py:93 netbox/vpn/forms/bulk_import.py:295
 msgid "Virtual machine"
 msgstr ""
@@ -5221,7 +5221,7 @@ msgstr ""
 msgid "Parent VM of assigned interface (if any)"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1293 netbox/ipam/filtersets.py:1048
+#: netbox/dcim/forms/bulk_import.py:1293 netbox/ipam/filtersets.py:1034
 #: netbox/ipam/forms/bulk_import.py:328
 msgid "Assigned interface"
 msgstr ""
@@ -5427,8 +5427,8 @@ msgstr ""
 msgid "Parent region"
 msgstr ""
 
-#: netbox/dcim/forms/filtersets.py:165 netbox/tenancy/forms/bulk_import.py:28
-#: netbox/tenancy/forms/bulk_import.py:62 netbox/tenancy/forms/filtersets.py:33
+#: netbox/dcim/forms/filtersets.py:165 netbox/tenancy/forms/bulk_import.py:29
+#: netbox/tenancy/forms/bulk_import.py:63 netbox/tenancy/forms/filtersets.py:33
 #: netbox/tenancy/forms/filtersets.py:62
 #: netbox/wireless/forms/bulk_import.py:27
 #: netbox/wireless/forms/filtersets.py:27
@@ -8045,7 +8045,8 @@ msgid "No"
 msgstr ""
 
 #: netbox/extras/choices.py:108 netbox/templates/tenancy/contact.html:67
-#: netbox/tenancy/forms/bulk_edit.py:130
+#: netbox/tenancy/forms/bulk_edit.py:130 netbox/tenancy/forms/bulk_import.py:88
+#: netbox/tenancy/forms/model_forms.py:104
 #: netbox/wireless/forms/model_forms.py:173
 msgid "Link"
 msgstr ""
@@ -8477,7 +8478,7 @@ msgstr ""
 #: netbox/extras/forms/bulk_import.py:176
 #: netbox/extras/forms/bulk_import.py:200
 #: netbox/extras/forms/bulk_import.py:254
-#: netbox/tenancy/forms/bulk_import.py:95
+#: netbox/tenancy/forms/bulk_import.py:101
 msgid "One or more assigned object types"
 msgstr ""
 
@@ -10166,51 +10167,51 @@ msgstr ""
 msgid "NAT inside IP address (ID)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1028
+#: netbox/ipam/filtersets.py:1014
 msgid "Q-in-Q SVLAN (ID)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1032
+#: netbox/ipam/filtersets.py:1018
 msgid "Q-in-Q SVLAN number (1-4094)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1053
+#: netbox/ipam/filtersets.py:1039
 msgid "Assigned VM interface"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1126
+#: netbox/ipam/filtersets.py:1112
 msgid "VLAN Translation Policy (name)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1192
+#: netbox/ipam/filtersets.py:1178
 msgid "FHRP Group (name)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1197
+#: netbox/ipam/filtersets.py:1183
 msgid "FHRP Group (ID)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1202
+#: netbox/ipam/filtersets.py:1188
 msgid "IP address (ID)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1208 netbox/ipam/models/ip.py:816
+#: netbox/ipam/filtersets.py:1194 netbox/ipam/models/ip.py:816
 msgid "IP address"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1260
+#: netbox/ipam/filtersets.py:1246
 msgid "Primary IPv4 (ID)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1266
+#: netbox/ipam/filtersets.py:1252
 msgid "Primary IPv4 (address)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1271
+#: netbox/ipam/filtersets.py:1257
 msgid "Primary IPv6 (ID)"
 msgstr ""
 
-#: netbox/ipam/filtersets.py:1277
+#: netbox/ipam/filtersets.py:1263
 msgid "Primary IPv6 (address)"
 msgstr ""
 
@@ -12755,7 +12756,7 @@ msgstr ""
 #: netbox/templates/extras/configtemplate.html:77
 #: netbox/templates/extras/eventrule.html:66
 #: netbox/templates/extras/exporttemplate.html:60
-#: netbox/templates/extras/htmx/script_result.html:69
+#: netbox/templates/extras/htmx/script_result.html:70
 #: netbox/templates/extras/webhook.html:65
 #: netbox/templates/extras/webhook.html:75
 #: netbox/templates/inc/panel_table.html:13
@@ -15137,8 +15138,8 @@ msgstr ""
 
 #: netbox/templates/tenancy/contact.html:18 netbox/tenancy/filtersets.py:152
 #: netbox/tenancy/forms/bulk_edit.py:154 netbox/tenancy/forms/filtersets.py:102
-#: netbox/tenancy/forms/forms.py:57 netbox/tenancy/forms/model_forms.py:108
-#: netbox/tenancy/forms/model_forms.py:132
+#: netbox/tenancy/forms/forms.py:57 netbox/tenancy/forms/model_forms.py:113
+#: netbox/tenancy/forms/model_forms.py:137
 #: netbox/tenancy/tables/contacts.py:106
 msgid "Contact"
 msgstr ""
@@ -15522,13 +15523,13 @@ msgstr ""
 msgid "Remove groups"
 msgstr ""
 
-#: netbox/tenancy/forms/bulk_import.py:84
+#: netbox/tenancy/forms/bulk_import.py:85
 msgid ""
 "Group names separated by commas, encased with double quotes (e.g. \"Group 1,"
 "Group 2\")"
 msgstr ""
 
-#: netbox/tenancy/forms/bulk_import.py:100
+#: netbox/tenancy/forms/bulk_import.py:106
 msgid "Assigned contact"
 msgstr ""
 
@@ -16354,7 +16355,7 @@ msgstr ""
 msgid "Disk size is managed via the attachment of virtual disks."
 msgstr ""
 
-#: netbox/virtualization/forms/model_forms.py:405
+#: netbox/virtualization/forms/model_forms.py:403
 #: netbox/virtualization/tables/virtualmachines.py:81
 msgid "Disk"
 msgstr ""

BIN
netbox/translations/es/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/it/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/it/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/nl/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/nl/LC_MESSAGES/django.po


BIN
netbox/translations/pl/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/pl/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 184 - 177
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/tr/LC_MESSAGES/django.po


BIN
netbox/translations/uk/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/uk/LC_MESSAGES/django.po


BIN
netbox/translations/zh/LC_MESSAGES/django.mo


Datei-Diff unterdrückt, da er zu groß ist
+ 183 - 177
netbox/translations/zh/LC_MESSAGES/django.po


+ 54 - 15
netbox/utilities/data.py

@@ -1,7 +1,8 @@
 import decimal
-from django.db.backends.postgresql.psycopg_any import NumericRange
 from itertools import count, groupby
 
+from django.db.backends.postgresql.psycopg_any import NumericRange
+
 __all__ = (
     'array_to_ranges',
     'array_to_string',
@@ -10,6 +11,7 @@ __all__ = (
     'drange',
     'flatten_dict',
     'ranges_to_string',
+    'ranges_to_string_list',
     'shallow_compare_dict',
     'string_to_ranges',
 )
@@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
 def array_to_ranges(array):
     """
     Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
-    single-item tuples. For example:
-        [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
+    single-item tuples.
+
+    Example:
+        [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
     """
     group = (
         list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
@@ -87,7 +91,8 @@ def array_to_ranges(array):
 def array_to_string(array):
     """
     Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
-    For example:
+
+    Example:
         [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
     """
     ret = []
@@ -135,26 +140,60 @@ def check_ranges_overlap(ranges):
     return False
 
 
-def ranges_to_string(ranges):
+def ranges_to_string_list(ranges):
     """
-    Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
-        [[1, 100)], [200, 300)] => "1-99,200-299"
+    Convert numeric ranges to a list of display strings.
+
+    Each range is rendered as "lower-upper" or "lower" (for singletons).
+    Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
+    This underpins ``ranges_to_string()``, which joins the result with commas.
+
+    Example:
+        [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
     """
     if not ranges:
-        return ''
-    output = []
+        return []
+
+    output: list[str] = []
     for r in ranges:
+        # Compute inclusive bounds regardless of how the DB range is stored.
         lower = r.lower if r.lower_inc else r.lower + 1
         upper = r.upper if r.upper_inc else r.upper - 1
-        output.append(f'{lower}-{upper}')
-    return ','.join(output)
+        output.append(f"{lower}-{upper}" if lower != upper else str(lower))
+    return output
+
+
+def ranges_to_string(ranges):
+    """
+    Converts a list of ranges into a string representation.
+
+    This function takes a list of range objects and produces a string
+    representation of those ranges. Each range is represented as a
+    hyphen-separated pair of lower and upper bounds, with inclusive or
+    exclusive bounds adjusted accordingly. If the lower and upper bounds
+    of a range are the same, only the single value is added to the string.
+    Intended for use with ArrayField.
+
+    Example:
+        [NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
+    """
+    if not ranges:
+        return ''
+    return ','.join(ranges_to_string_list(ranges))
 
 
 def string_to_ranges(value):
     """
-    Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
-    For example:
-        "1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
+    Converts a string representation of numeric ranges into a list of NumericRange objects.
+
+    This function parses a string containing numeric values and ranges separated by commas (e.g.,
+    "1-5,8,10-12") and converts it into a list of NumericRange objects.
+    In the case of a single integer, it is treated as a range where the start and end
+    are equal. The returned ranges are represented as half-open intervals [lower, upper).
+    Intended for use with ArrayField.
+
+    Example:
+        "1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
     """
     if not value:
         return None
@@ -172,5 +211,5 @@ def string_to_ranges(value):
             upper = dash_range[1]
         else:
             return None
-        values.append(NumericRange(int(lower), int(upper), bounds='[]'))
+        values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
     return values

+ 55 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -1,4 +1,8 @@
+import logging
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
+
 from django import template
+from django.templatetags.static import static
 
 from extras.choices import CustomFieldTypeChoices
 from utilities.querydict import dict_to_querydict
@@ -10,6 +14,7 @@ __all__ = (
     'customfield_value',
     'htmx_table',
     'formaction',
+    'static_with_params',
     'tag',
 )
 
@@ -124,3 +129,53 @@ def formaction(context):
     with 'hx-push-url="true" hx-post' for HTMX navigation.
     """
     return 'formaction'
+
+
+@register.simple_tag
+def static_with_params(path, **params):
+    """
+    Generate a static URL with properly appended query parameters.
+
+    The original Django static tag doesn't properly handle appending new parameters to URLs
+    that already contain query parameters, which can result in malformed URLs with double
+    question marks. This template tag handles the case where static files are served from
+    AWS S3 or other CDNs that automatically append query parameters to URLs.
+
+    This implementation correctly appends new parameters to existing URLs and checks for
+    parameter conflicts. A warning will be logged if any of the provided parameters
+    conflict with existing parameters in the URL.
+
+    Args:
+        path: The static file path (e.g., 'setmode.js')
+        **params: Query parameters to append (e.g., v='4.3.1')
+
+    Returns:
+        A properly formatted URL with query parameters.
+
+    Note:
+        If any provided parameters conflict with existing URL parameters, a warning
+        will be logged and the new parameter value will override the existing one.
+    """
+    # Get the base static URL
+    static_url = static(path)
+
+    # Parse the URL to extract existing query parameters
+    parsed = urlparse(static_url)
+    existing_params = parse_qs(parsed.query)
+
+    # Check for duplicate parameters and log warnings
+    logger = logging.getLogger('netbox.utilities.templatetags.tags')
+    for key, value in params.items():
+        if key in existing_params:
+            logger.warning(
+                f"Parameter '{key}' already exists in static URL '{static_url}' "
+                f"with value(s) {existing_params[key]}, overwriting with '{value}'"
+            )
+        existing_params[key] = [str(value)]
+
+    # Rebuild the query string
+    new_query = urlencode(existing_params, doseq=True)
+
+    # Reconstruct the URL with the new query string
+    new_parsed = parsed._replace(query=new_query)
+    return urlunparse(new_parsed)

+ 2 - 3
netbox/utilities/tests/test_api.py

@@ -149,14 +149,13 @@ class APIPaginationTestCase(APITestCase):
     def test_default_page_size_with_small_max_page_size(self):
         response = self.client.get(self.url, format='json', **self.header)
         page_size = get_config().MAX_PAGE_SIZE
-        paginate_count = get_config().PAGINATE_COUNT
         self.assertLess(page_size, 100, "Default page size not sufficient for data set")
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['count'], 100)
-        self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
+        self.assertTrue(response.data['next'].endswith(f'?limit={page_size}&offset={page_size}'))
         self.assertIsNone(response.data['previous'])
-        self.assertEqual(len(response.data['results']), paginate_count)
+        self.assertEqual(len(response.data['results']), page_size)
 
     def test_custom_page_size(self):
         response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)

+ 25 - 9
netbox/utilities/tests/test_data.py

@@ -1,7 +1,11 @@
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase
-
-from utilities.data import check_ranges_overlap, ranges_to_string, string_to_ranges
+from utilities.data import (
+    check_ranges_overlap,
+    ranges_to_string,
+    ranges_to_string_list,
+    string_to_ranges,
+)
 
 
 class RangeFunctionsTestCase(TestCase):
@@ -47,32 +51,44 @@ class RangeFunctionsTestCase(TestCase):
             ])
         )
 
+    def test_ranges_to_string_list(self):
+        self.assertEqual(
+            ranges_to_string_list([
+                NumericRange(10, 20),    # 10-19
+                NumericRange(30, 40),    # 30-39
+                NumericRange(50, 51),    # 50-50
+                NumericRange(100, 200),  # 100-199
+            ]),
+            ['10-19', '30-39', '50', '100-199']
+        )
+
     def test_ranges_to_string(self):
         self.assertEqual(
             ranges_to_string([
                 NumericRange(10, 20),    # 10-19
                 NumericRange(30, 40),    # 30-39
+                NumericRange(50, 51),    # 50-50
                 NumericRange(100, 200),  # 100-199
             ]),
-            '10-19,30-39,100-199'
+            '10-19,30-39,50,100-199'
         )
 
     def test_string_to_ranges(self):
         self.assertEqual(
             string_to_ranges('10-19, 30-39, 100-199'),
             [
-                NumericRange(10, 19, bounds='[]'),    # 10-19
-                NumericRange(30, 39, bounds='[]'),    # 30-39
-                NumericRange(100, 199, bounds='[]'),  # 100-199
+                NumericRange(10, 20, bounds='[)'),    # 10-20
+                NumericRange(30, 40, bounds='[)'),    # 30-40
+                NumericRange(100, 200, bounds='[)'),  # 100-200
             ]
         )
 
         self.assertEqual(
             string_to_ranges('1-2, 5, 10-12'),
             [
-                NumericRange(1, 2, bounds='[]'),    # 1-2
-                NumericRange(5, 5, bounds='[]'),    # 5-5
-                NumericRange(10, 12, bounds='[]'),  # 10-12
+                NumericRange(1, 3, bounds='[)'),    # 1-3
+                NumericRange(5, 6, bounds='[)'),    # 5-6
+                NumericRange(10, 13, bounds='[)'),  # 10-13
             ]
         )
 

+ 48 - 0
netbox/utilities/tests/test_templatetags.py

@@ -0,0 +1,48 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+
+from utilities.templatetags.builtins.tags import static_with_params
+
+
+class StaticWithParamsTest(TestCase):
+    """
+    Test the static_with_params template tag functionality.
+    """
+
+    def test_static_with_params_basic(self):
+        """Test basic parameter appending to static URL."""
+        result = static_with_params('test.js', v='1.0.0')
+        self.assertIn('test.js', result)
+        self.assertIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://cdn.example.com/static/')
+    def test_static_with_params_existing_query_params(self):
+        """Test appending parameters to URL that already has query parameters."""
+        # Mock the static() function to return a URL with existing query parameters
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = 'https://cdn.example.com/static/test.js?existing=param'
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            # Should contain both existing and new parameters
+            self.assertIn('existing=param', result)
+            self.assertIn('v=1.0.0', result)
+            # Should not have double question marks
+            self.assertEqual(result.count('?'), 1)
+
+    @override_settings(STATIC_URL='https://cdn.example.com/static/')
+    def test_static_with_params_duplicate_parameter_warning(self):
+        """Test that a warning is logged when parameters conflict."""
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = 'https://cdn.example.com/static/test.js?v=old_version'
+
+            with self.assertLogs('netbox.utilities.templatetags.tags', level='WARNING') as cm:
+                result = static_with_params('test.js', v='new_version')
+
+                # Check that warning was logged
+                self.assertIn("Parameter 'v' already exists", cm.output[0])
+
+                # Check that new parameter value is used
+                self.assertIn('v=new_version', result)
+                self.assertNotIn('v=old_version', result)

+ 2 - 4
netbox/virtualization/forms/model_forms.py

@@ -280,10 +280,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
         else:
 
             # An object that doesn't exist yet can't have any IPs assigned to it
-            self.fields['primary_ip4'].choices = []
-            self.fields['primary_ip4'].widget.attrs['readonly'] = True
-            self.fields['primary_ip6'].choices = []
-            self.fields['primary_ip6'].widget.attrs['readonly'] = True
+            self.fields.pop('primary_ip4')
+            self.fields.pop('primary_ip6')
 
 
 #

+ 1 - 1
pyproject.toml

@@ -3,7 +3,7 @@
 
 [project]
 name = "netbox"
-version = "4.4.2"
+version = "4.4.3"
 requires-python = ">=3.10"
 description = "The premier source of truth powering network automation."
 readme = "README.md"

+ 10 - 10
requirements.txt

@@ -1,8 +1,8 @@
 colorama==0.4.6
-Django==5.2.6
+Django==5.2.7
 django-cors-headers==4.9.0
-django-debug-toolbar==5.2.0
-django-filter==25.1
+django-debug-toolbar==6.0.0
+django-filter==25.2
 django-graphiql-debug-toolbar==0.2.0
 django-htmx==1.26.0
 django-mptt==0.17.0
@@ -17,27 +17,27 @@ django-taggit==6.1.0
 django-timezone-field==7.1
 djangorestframework==3.16.1
 drf-spectacular==0.28.0
-drf-spectacular-sidecar==2025.9.1
+drf-spectacular-sidecar==2025.10.1
 feedparser==6.0.12
 gunicorn==23.0.0
 Jinja2==3.1.6
 jsonschema==4.25.1
 Markdown==3.9
-mkdocs-material==9.6.20
+mkdocs-material==9.6.21
 mkdocstrings==0.30.1
 mkdocstrings-python==1.18.2
 netaddr==1.3.0
-nh3==0.3.0
+nh3==0.3.1
 Pillow==11.3.0
 psycopg[c,pool]==3.2.10
 PyYAML==6.0.3
 requests==2.32.5
 rq==2.6.0
-social-auth-app-django==5.5.1
-social-auth-core==4.7.0
+social-auth-app-django==5.6.0
+social-auth-core==4.8.1
 sorl-thumbnail==12.11.0
-strawberry-graphql==0.282.0
-strawberry-graphql-django==0.65.1
+strawberry-graphql==0.283.3
+strawberry-graphql-django==0.66.1
 svgwrite==1.4.3
 tablib==3.8.0
 tzdata==2025.2

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.