Merge branch 'main' into feature

This commit is contained in:
Jeremy Stretch 2025-10-14 13:54:47 -04:00
commit 37a9d03348
81 changed files with 5739 additions and 5163 deletions

View File

@ -15,7 +15,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: v4.4.2 placeholder: v4.4.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -27,7 +27,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: v4.4.2 placeholder: v4.4.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -12,9 +12,7 @@ django-cors-headers
# Runtime UI tool for debugging Django # Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst # 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'" django-debug-toolbar
# see https://github.com/netbox-community/netbox/issues/19974
django-debug-toolbar==5.2.0
# Library for writing reusable URL query filters # Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@ -71,7 +69,8 @@ django-timezone-field
# A REST API framework for Django projects # A REST API framework for Django projects
# https://www.django-rest-framework.org/community/release-notes/ # 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. # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst

View File

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

View File

@ -1,6 +1,6 @@
# Filters & Filter Sets # 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 ## FilterSet Classes

View File

@ -1,5 +1,32 @@
# NetBox v4.4 # 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) ## v4.4.2 (2025-09-30)
### Enhancements ### Enhancements

View File

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

View File

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

View File

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

View File

@ -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'
),
),
]

View File

@ -14,6 +14,9 @@ class ConfigRevision(models.Model):
""" """
An atomic revision of NetBox's configuration. An atomic revision of NetBox's configuration.
""" """
active = models.BooleanField(
default=False
)
created = models.DateTimeField( created = models.DateTimeField(
verbose_name=_('created'), verbose_name=_('created'),
auto_now_add=True auto_now_add=True
@ -35,6 +38,13 @@ class ConfigRevision(models.Model):
ordering = ['-created'] ordering = ['-created']
verbose_name = _('config revision') verbose_name = _('config revision')
verbose_name_plural = _('config revisions') verbose_name_plural = _('config revisions')
constraints = [
models.UniqueConstraint(
fields=('active',),
condition=models.Q(active=True),
name='unique_active_config_revision',
)
]
def __str__(self): def __str__(self):
if not self.pk: if not self.pk:
@ -59,8 +69,13 @@ class ConfigRevision(models.Model):
""" """
cache.set('config', self.data, None) cache.set('config', self.data, None)
cache.set('config_version', self.pk, 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 activate.alters_data = True
@property @property
def is_active(self): def is_active(self):
return cache.get('config_version') == self.pk return self.active

View File

@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.core.exceptions import ObjectDoesNotExist 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.db.models import Q
from django.utils.translation import gettext as _ 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 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): if not inspect.isclass(model):
model = model.__class__ model = model.__class__
opts = self._get_opts(model, for_concrete_model) opts = self._get_opts(model, for_concrete_model)

View File

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

View File

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

View File

@ -7,13 +7,14 @@ from django.test import override_settings, tag
from django.urls import reverse from django.urls import reverse
from netaddr import EUI from netaddr import EUI
from core.models import ObjectType
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
from tenancy.models import Tenant 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 utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
@ -3728,3 +3729,29 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', '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())

View File

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

View File

@ -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 import CharField, JSONField, Lookup
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from .fields import CachedValueField 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): class Empty(Lookup):
""" """
Filter on whether a string is empty. 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. 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): def as_sql(self, compiler, connection):
# self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform) # 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 return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
ArrayField.register_lookup(RangeContains)
CharField.register_lookup(Empty) CharField.register_lookup(Empty)
JSONField.register_lookup(JSONEmpty) JSONField.register_lookup(JSONEmpty)
CachedValueField.register_lookup(NetHost) CachedValueField.register_lookup(NetHost)

View File

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

View File

@ -908,7 +908,8 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
method='filter_scope' method='filter_scope'
) )
contains_vid = django_filters.NumberFilter( contains_vid = django_filters.NumberFilter(
method='filter_contains_vid' field_name='vid_ranges',
lookup_expr='range_contains',
) )
class Meta: class Meta:
@ -931,21 +932,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
scope_id=value 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): class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(

View File

@ -19,7 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface from virtualization.models import VMInterface
if TYPE_CHECKING: 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 circuits.graphql.filters import ProviderFilter
from core.graphql.filters import ContentTypeFilter from core.graphql.filters import ContentTypeFilter
from dcim.graphql.filters import SiteFilter from dcim.graphql.filters import SiteFilter
@ -340,7 +340,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.VLANGroup, lookups=True) @strawberry_django.filter_type(models.VLANGroup, lookups=True)
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin): 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() strawberry_django.filter_field()
) )

View File

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

View File

@ -10,9 +10,9 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import Interface, Site, SiteGroup from dcim.models import Interface, Site, SiteGroup
from ipam.choices import * from ipam.choices import *
from ipam.constants 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 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 from virtualization.models import VMInterface
__all__ = ( __all__ = (
@ -164,8 +164,18 @@ class VLANGroup(OrganizationalModel):
""" """
return VLAN.objects.filter(group=self).order_by('vid') 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 @property
def vid_ranges_list(self): def vid_ranges_list(self):
"""
Property that converts VID ranges into a string representation.
"""
return ranges_to_string(self.vid_ranges) return ranges_to_string(self.vid_ranges)

View File

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

View File

@ -1723,6 +1723,10 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'contains_vid': 1} params = {'contains_vid': 1}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) 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): def test_region(self):
params = {'region': Region.objects.first().pk} params = {'region': Region.objects.first().pk}

View File

@ -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())

View File

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

View File

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

View File

@ -78,11 +78,16 @@ class Config:
from core.models import ConfigRevision from core.models import ConfigRevision
try: 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: if revision is None:
logger.debug("No previous configuration found in database; proceeding with default values") logger.debug("No previous configuration found in database; proceeding with default values")
return return
logger.debug("Loaded configuration data from database") logger.debug(f"Using fallback configuration revision #{revision.pk}")
except DatabaseError: except DatabaseError:
# The database may not be available yet (e.g. when running a management command) # The database may not be available yet (e.g. when running a management command)
logger.warning("Skipping config initialization (database unavailable)") logger.warning("Skipping config initialization (database unavailable)")

View File

@ -7,6 +7,7 @@ from django.core.exceptions import FieldDoesNotExist
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
from strawberry import ID from strawberry import ID
from strawberry.directive import DirectiveValue
from strawberry.types import Info from strawberry.types import Info
from strawberry_django import ( from strawberry_django import (
ComparisonFilterLookup, ComparisonFilterLookup,
@ -24,6 +25,7 @@ __all__ = (
'FloatLookup', 'FloatLookup',
'IntegerArrayLookup', 'IntegerArrayLookup',
'IntegerLookup', 'IntegerLookup',
'IntegerRangeArrayLookup',
'JSONFilter', 'JSONFilter',
'StringArrayLookup', 'StringArrayLookup',
'TreeNodeFilter', 'TreeNodeFilter',
@ -67,7 +69,7 @@ class IntegerLookup:
return None return None
@strawberry_django.filter_field @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() filters = self.get_filter()
if not filters: if not filters:
@ -90,7 +92,7 @@ class FloatLookup:
return None return None
@strawberry_django.filter_field @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() filters = self.get_filter()
if not filters: if not filters:
@ -109,7 +111,7 @@ class JSONFilter:
lookup: JSONLookup lookup: JSONLookup
@strawberry_django.filter_field @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() filters = self.lookup.get_filter()
if not filters: if not filters:
@ -136,7 +138,7 @@ class TreeNodeFilter:
match_type: TreeNodeMatch match_type: TreeNodeMatch
@strawberry_django.filter_field @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_name = prefix.removesuffix('__').removesuffix('_id')
model_field = None model_field = None
try: 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.') @strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
class StringArrayLookup(ArrayLookup[str]): class StringArrayLookup(ArrayLookup[str]):
pass 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

View File

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

View File

@ -673,10 +673,15 @@ def has_feature(model_or_ct, feature):
# If an ObjectType was passed, we can use it directly # If an ObjectType was passed, we can use it directly
if type(model_or_ct) is ObjectType: if type(model_or_ct) is ObjectType:
ot = model_or_ct 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: elif type(model_or_ct) is ContentType:
model_class = model_or_ct.model_class() model = model_or_ct.model_class()
ot = ObjectType.objects.get_for_model(model_class) if model_class else None try:
test_func = registry['model_features'][feature]
except KeyError:
# Unknown feature
return False
return test_func(model)
# For anything else, look up the ObjectType # For anything else, look up the ObjectType
else: else:
ot = ObjectType.objects.get_for_model(model_or_ct) ot = ObjectType.objects.get_for_model(model_or_ct)

39
netbox/netbox/monkey.py Normal file
View File

@ -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
)

View File

@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.utils import field_mapping
from core.exceptions import IncompatiblePluginError from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS 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.release import load_release_data
from utilities.security import validate_peppers from utilities.security import validate_peppers
from utilities.string import trailing_slash 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 # Environment setup

View File

@ -281,7 +281,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
obj = self.alter_object(obj, request, args, kwargs) 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) restrict_form_fields(form, request.user)
if form.is_valid(): if form.is_valid():

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -25,7 +26,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Parent group') help_text=_('Parent group'),
) )
slug = SlugField() slug = SlugField()
@ -41,7 +42,7 @@ class TenantImportForm(NetBoxModelImportForm):
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned group') help_text=_('Assigned group'),
) )
class Meta: class Meta:
@ -59,7 +60,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Parent group') help_text=_('Parent group'),
) )
slug = SlugField() slug = SlugField()
@ -81,7 +82,12 @@ class ContactImportForm(NetBoxModelImportForm):
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False, required=False,
to_field_name='name', 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: class Meta:

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import decimal import decimal
from django.db.backends.postgresql.psycopg_any import NumericRange
from itertools import count, groupby from itertools import count, groupby
from django.db.backends.postgresql.psycopg_any import NumericRange
__all__ = ( __all__ = (
'array_to_ranges', 'array_to_ranges',
'array_to_string', 'array_to_string',
@ -10,6 +11,7 @@ __all__ = (
'drange', 'drange',
'flatten_dict', 'flatten_dict',
'ranges_to_string', 'ranges_to_string',
'ranges_to_string_list',
'shallow_compare_dict', 'shallow_compare_dict',
'string_to_ranges', 'string_to_ranges',
) )
@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
def array_to_ranges(array): def array_to_ranges(array):
""" """
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
single-item tuples. For example: single-item tuples.
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
Example:
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
""" """
group = ( group = (
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x) 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): def array_to_string(array):
""" """
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. 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" [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
""" """
ret = [] ret = []
@ -135,26 +140,60 @@ def check_ranges_overlap(ranges):
return False return False
def ranges_to_string_list(ranges):
"""
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: 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}" if lower != upper else str(lower))
return output
def ranges_to_string(ranges): def ranges_to_string(ranges):
""" """
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example: Converts a list of ranges into a string representation.
[[1, 100)], [200, 300)] => "1-99,200-299"
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: if not ranges:
return '' return ''
output = [] return ','.join(ranges_to_string_list(ranges))
for r in ranges:
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)
def string_to_ranges(value): 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. Converts a string representation of numeric ranges into a list of NumericRange objects.
For example:
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)] 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: if not value:
return None return None
@ -172,5 +211,5 @@ def string_to_ranges(value):
upper = dash_range[1] upper = dash_range[1]
else: else:
return None return None
values.append(NumericRange(int(lower), int(upper), bounds='[]')) values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
return values return values

View File

@ -1,4 +1,8 @@
import logging
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from django import template from django import template
from django.templatetags.static import static
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from utilities.querydict import dict_to_querydict from utilities.querydict import dict_to_querydict
@ -10,6 +14,7 @@ __all__ = (
'customfield_value', 'customfield_value',
'htmx_table', 'htmx_table',
'formaction', 'formaction',
'static_with_params',
'tag', 'tag',
) )
@ -124,3 +129,53 @@ def formaction(context):
with 'hx-push-url="true" hx-post' for HTMX navigation. with 'hx-push-url="true" hx-post' for HTMX navigation.
""" """
return 'formaction' 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)

View File

@ -149,14 +149,13 @@ class APIPaginationTestCase(APITestCase):
def test_default_page_size_with_small_max_page_size(self): def test_default_page_size_with_small_max_page_size(self):
response = self.client.get(self.url, format='json', **self.header) response = self.client.get(self.url, format='json', **self.header)
page_size = get_config().MAX_PAGE_SIZE 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.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100) 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.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): def test_custom_page_size(self):
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header) response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)

View File

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

View File

@ -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)

View File

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

View File

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

View File

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