Compare commits

...

10 Commits

Author SHA1 Message Date
Jason Novinger
d6b9d30086 Fixes #20442: Mark template-accessible methods with alters_data=True (#21431)
Add alters_data=True to methods that modify database or filesystem state
and are accessible from Jinja2 sandbox template contexts:

- UserConfig.set(), clear(): Persist preference changes when commit=True
- ManagedFile.sync_data(): Writes files to scripts/reports storage
- ScriptModule.sync_classes(), sync_data(): Creates/deletes Script objects
- Job.start(), terminate(): Updates job status, creates notifications

Methods intentionally not protected:
- DataFile.refresh_from_disk(): Only modifies instance attributes in memory
- Overridden save()/delete(): Django's AltersData mixin auto-propagates
- Properties like Script.python_class: Not callable in template context

Ref: #20356 for exploit details demonstrating the vulnerability
2026-02-13 10:44:18 -08:00
Martin Hauser
9be5aa188c chore(ruff): Update target Python version to 3.12 (#21405)
Set the `target-version` in `ruff.toml` to Python 3.12. Ensures the
linter aligns with the version used in the project's environment.

Fixes #21404
2026-02-13 10:39:09 -08:00
Jason Novinger
f113557e81 Fixes #21127: Clear _path on interfaces when removed from cable
When editing a cable to remove an interface from the B side, the _path
field on the removed interface was not being cleared. This caused the
interface table to display stale connection info via _path.destinations.

Two changes:
- Signal handler now clears _path when termination removed from origins
- CablePath.delete() clears _path on origins (mirrors save() behavior)
2026-02-13 13:36:09 -05:00
Arthur
de812a5a85 21390 skip m2m processing for internal models to avoid extraneous ObjectChange records 2026-02-13 13:27:25 -05:00
Jason Novinger
0b7375136d Closes #21016: Add missing MPTT tree indexes (#21432)
Upgrade django-mptt to 0.18.0 and add empty indexes tuple to MPTT model
Meta classes. The empty tuple triggers Django's migration detection for
indexes that django-mptt adds dynamically (see
django-mptt/django-mptt#682). We cannot define the indexes explicitly
because the MPTT fields don't exist when the Meta class is evaluated.

Affected models: Region, SiteGroup, Location, DeviceRole, Platform,
ModuleBay, InventoryItem, InventoryItemTemplate, TenantGroup,
ContactGroup, WirelessLANGroup
2026-02-13 17:00:04 +01:00
Jeremy Stretch
1190adde2b Closes #21419: Improve query efficiency for MultipleChoiceFilter (#21421)
* Pass distinct=False to all ModelMultipleChoiceFilters associated with a ForeignKey field

* Pass distinct=False to all MultipleChoiceFilters associated with a concrete model
2026-02-13 12:31:36 +01:00
Arthur Hanson
2330874a8c Fixes #21277: Record pre-change snapshot when adding devices to cluster in UI (#21424) 2026-02-13 04:41:41 -06:00
Jeremy Stretch
dc738c7102 Closes #21257: Introduce & adopt MultiValueContentTypeFilter (#21417) 2026-02-13 04:24:36 -06:00
Jeremy Stretch
76fd3e3c61 Fixes #21196: q filter should match on primary IP only for IP address values (#21401) 2026-02-13 04:08:01 -06:00
github-actions
4ee64a7731 Update source translation strings 2026-02-13 05:27:16 +00:00
46 changed files with 743 additions and 175 deletions

View File

@@ -9,7 +9,7 @@ from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from utilities.filtersets import register_filterset
from .choices import *
@@ -99,11 +99,13 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Provider (slug)'),
)
@@ -127,11 +129,13 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
class ProviderNetworkFilterSet(PrimaryModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Provider (slug)'),
)
@@ -163,22 +167,26 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Provider (slug)'),
)
provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account',
queryset=ProviderAccount.objects.all(),
distinct=False,
label=_('Provider account (ID)'),
)
provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account__account',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='account',
label=_('Provider account (account)'),
)
@@ -189,16 +197,19 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(),
distinct=False,
label=_('Circuit type (ID)'),
)
type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug',
queryset=CircuitType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Circuit type (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices,
distinct=False,
null_value=None
)
region_id = TreeNodeMultipleChoiceFilter(
@@ -245,10 +256,12 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
)
termination_a_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitTermination.objects.all(),
distinct=False,
label=_('Termination A (ID)'),
)
termination_z_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitTermination.objects.all(),
distinct=False,
label=_('Termination A (ID)'),
)
@@ -279,9 +292,10 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
)
circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(),
distinct=False,
label=_('Circuit'),
)
termination_type = ContentTypeFilter()
termination_type = MultiValueContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
@@ -310,12 +324,14 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
distinct=False,
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Site (slug)'),
)
@@ -334,17 +350,20 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
distinct=False,
field_name='_provider_network',
label=_('ProviderNetwork (ID)'),
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider_id',
queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Provider (slug)'),
)
@@ -381,7 +400,7 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
method='search',
label=_('Search'),
)
member_type = ContentTypeFilter()
member_type = MultiValueContentTypeFilter()
circuit = MultiValueCharFilter(
method='filter_circuit',
field_name='cid',
@@ -414,11 +433,13 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(),
distinct=False,
label=_('Circuit group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=CircuitGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Circuit group (slug)'),
)
@@ -488,41 +509,49 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider',
queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider__slug',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Provider (slug)'),
)
provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account',
queryset=ProviderAccount.objects.all(),
distinct=False,
label=_('Provider account (ID)'),
)
provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account__account',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='account',
label=_('Provider account (account)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
distinct=False,
label=_('Provider network (ID)'),
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuitType.objects.all(),
distinct=False,
label=_('Virtual circuit type (ID)'),
)
type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug',
queryset=VirtualCircuitType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Virtual circuit type (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices,
distinct=False,
null_value=None
)
@@ -548,41 +577,49 @@ class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
)
virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuit.objects.all(),
distinct=False,
label=_('Virtual circuit'),
)
role = django_filters.MultipleChoiceFilter(
choices=VirtualCircuitTerminationRoleChoices,
distinct=False,
null_value=None
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_network__provider',
queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_network__provider__slug',
queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Provider (slug)'),
)
provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_account',
queryset=ProviderAccount.objects.all(),
distinct=False,
label=_('Provider account (ID)'),
)
provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_account__account',
queryset=ProviderAccount.objects.all(),
distinct=False,
to_field_name='account',
label=_('Provider account (account)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
distinct=False,
field_name='virtual_circuit__provider_network',
label=_('Provider network (ID)'),
)
interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all(),
distinct=False,
field_name='interface',
label=_('Interface (ID)'),
)

View File

@@ -6,7 +6,7 @@ from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
from netbox.utils import get_data_backend_choices
from users.models import User
from utilities.filters import ContentTypeFilter
from utilities.filters import MultiValueContentTypeFilter
from utilities.filtersets import register_filterset
from .choices import *
from .models import *
@@ -25,14 +25,17 @@ __all__ = (
class DataSourceFilterSet(PrimaryModelFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=get_data_backend_choices,
distinct=False,
null_value=None
)
status = django_filters.MultipleChoiceFilter(
choices=DataSourceStatusChoices,
distinct=False,
null_value=None
)
sync_interval = django_filters.MultipleChoiceFilter(
choices=JobIntervalChoices,
distinct=False,
null_value=None
)
@@ -57,11 +60,13 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
)
source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data source (ID)'),
)
source = django_filters.ModelMultipleChoiceFilter(
field_name='source__name',
queryset=DataSource.objects.all(),
distinct=False,
to_field_name='name',
label=_('Data source (name)'),
)
@@ -86,9 +91,10 @@ class JobFilterSet(BaseFilterSet):
)
object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.with_feature('jobs'),
distinct=False,
field_name='object_type_id',
)
object_type = ContentTypeFilter()
object_type = MultiValueContentTypeFilter()
created = django_filters.DateTimeFilter()
created__before = django_filters.DateTimeFilter(
field_name='created',
@@ -127,6 +133,7 @@ class JobFilterSet(BaseFilterSet):
)
status = django_filters.MultipleChoiceFilter(
choices=JobStatusChoices,
distinct=False,
null_value=None
)
queue_name = django_filters.CharFilter()
@@ -180,18 +187,21 @@ class ObjectChangeFilterSet(BaseFilterSet):
label=_('Search'),
)
time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
changed_object_type = MultiValueContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
queryset=ContentType.objects.all(),
distinct=False,
)
related_object_type = ContentTypeFilter()
related_object_type = MultiValueContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
distinct=False,
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
distinct=False,
to_field_name='username',
label=_('User name'),
)

View File

@@ -89,6 +89,7 @@ class ManagedFile(SyncedDataMixin, models.Model):
with storage.open(self.full_path, 'wb+') as new_file:
new_file.write(self.data_file.data)
sync_data.alters_data = True
@cached_property
def storage(self):

View File

@@ -216,6 +216,7 @@ class Job(models.Model):
# Send signal
job_start.send(self)
start.alters_data = True
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
"""
@@ -245,6 +246,7 @@ class Job(models.Model):
# Send signal
job_end.send(self)
terminate.alters_data = True
def log(self, record: logging.LogRecord):
"""

View File

@@ -209,22 +209,28 @@ def handle_deleted_object(sender, instance, **kwargs):
# for the forward direction of the relationship, ensuring that the change is recorded.
# Similarly, for many-to-one relationships, we set the value on the related object to None
# and save it to trigger a change record on that object.
for relation in instance._meta.related_objects:
if type(relation) not in [ManyToManyRel, ManyToOneRel]:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
setattr(obj, related_field_name, None)
obj.save()
#
# Skip this for private models (e.g. CablePath) whose lifecycle is an internal
# implementation detail. Django's on_delete handlers (e.g. SET_NULL) already take
# care of the database integrity; recording changelog entries for the related
# objects would be spurious. (Ref: #21390)
if not getattr(instance, '_netbox_private', False):
for relation in instance._meta.related_objects:
if type(relation) not in [ManyToManyRel, ManyToOneRel]:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
setattr(obj, related_field_name, None)
obj.save()
# Enqueue the object for event processing
queue = events_queue.get()

View File

@@ -237,9 +237,9 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_changed_object_type(self):
params = {'changed_object_type': 'dcim.site'}
params = {'changed_object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
params = {'changed_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@@ -2,7 +2,7 @@ import django_filters
from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
from .models import *
__all__ = (
@@ -14,7 +14,7 @@ class ScopedFilterSet(BaseFilterSet):
"""
Provides additional filtering functionality for location, site, etc.. for Scoped models.
"""
scope_type = ContentTypeFilter()
scope_type = MultiValueContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
@@ -43,12 +43,14 @@ class ScopedFilterSet(BaseFilterSet):
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
distinct=False,
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Site (slug)'),
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.10 on 2026-02-13 13:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0225_gfk_indexes'),
('extras', '0134_owner'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='devicerole',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_devicerole_tree_id_lfbf11'),
),
migrations.AddIndex(
model_name='inventoryitem',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_inventoryitem_tree_id975c'),
),
migrations.AddIndex(
model_name='inventoryitemtemplate',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_inventoryitemtemplatedee0'),
),
migrations.AddIndex(
model_name='location',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_location_tree_id_lft_idx'),
),
migrations.AddIndex(
model_name='modulebay',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_modulebay_tree_id_lft_idx'),
),
migrations.AddIndex(
model_name='platform',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_platform_tree_id_lft_idx'),
),
migrations.AddIndex(
model_name='region',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_region_tree_id_lft_idx'),
),
migrations.AddIndex(
model_name='sitegroup',
index=models.Index(fields=['tree_id', 'lft'], name='dcim_sitegroup_tree_id_lft_idx'),
),
]

View File

@@ -657,6 +657,16 @@ class CablePath(models.Model):
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
def delete(self, *args, **kwargs):
# Mirror save() - clear _path on origins to prevent stale references
# in table views that render _path.destinations
if self.path:
origin_model = self.origin_type.model_class()
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None)
super().delete(*args, **kwargs)
@property
def origin_type(self):
if self.path:

View File

@@ -1263,6 +1263,9 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
clone_fields = ('device',)
class Meta(ModularComponentModel.Meta):
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('device', 'module', 'name'),

View File

@@ -401,6 +401,9 @@ class DeviceRole(NestedGroupModel):
class Meta:
ordering = ('name',)
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
@@ -452,6 +455,9 @@ class Platform(NestedGroupModel):
class Meta:
ordering = ('name',)
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
verbose_name = _('platform')
verbose_name_plural = _('platforms')
constraints = (

View File

@@ -44,6 +44,9 @@ class Region(ContactsMixin, NestedGroupModel):
)
class Meta:
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
@@ -100,6 +103,9 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
)
class Meta:
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
@@ -318,6 +324,9 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
class Meta:
ordering = ['site', 'name']
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('site', 'parent', 'name'),

View File

@@ -170,6 +170,8 @@ def nullify_connected_endpoints(instance, **kwargs):
# Remove the deleted CableTermination if it's one of the path's originating nodes
if instance.termination in cablepath.origins:
cablepath.origins.remove(instance.termination)
# Clear _path on the removed origin to prevent stale connection display
model.objects.filter(pk=instance.termination_id, _path=cablepath.pk).update(_path=None)
cablepath.retrace()

View File

@@ -2806,7 +2806,6 @@ class LegacyCablePathTests(CablePathTestCase):
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
# Create cables 1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[interface2, interface3]
@@ -2838,6 +2837,10 @@ class LegacyCablePathTests(CablePathTestCase):
is_active=True
)
# Verify _path is cleared on removed interface (#21127)
interface3.refresh_from_db()
self.assertPathIsNotSet(interface3)
def test_401_exclude_midspan_devices(self):
"""
[IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]

View File

@@ -6251,7 +6251,7 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_component_type(self):
params = {'component_type': 'dcim.interface'}
params = {'component_type': ['dcim.interface']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_status(self):
@@ -6723,10 +6723,8 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
# params = {'termination_b_type': 'dcim.consoleserverport'}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'termination_a_type': ['dcim.consoleport', 'dcim.consoleserverport']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_termination_ids(self):
interface_ids = CableTermination.objects.filter(
@@ -6734,7 +6732,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
cable_end='A'
).values_list('termination_id', flat=True)
params = {
'termination_a_type': 'dcim.interface',
'termination_a_type': ['dcim.interface'],
'termination_a_id': list(interface_ids),
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@@ -10,7 +10,7 @@ from tenancy.models import Tenant, TenantGroup
from users.filterset_mixins import OwnerFilterMixin
from users.models import Group, User
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter
)
from utilities.filtersets import register_filterset
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -49,6 +49,7 @@ class ScriptFilterSet(BaseFilterSet):
)
module_id = django_filters.ModelMultipleChoiceFilter(
queryset=ScriptModule.objects.all(),
distinct=False,
label=_('Script module (ID)'),
)
@@ -71,7 +72,8 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
label=_('Search'),
)
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
choices=WebhookHttpMethodChoices,
distinct=False,
)
payload_url = MultiValueCharFilter(
lookup_expr='icontains'
@@ -104,16 +106,17 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
queryset=ObjectType.objects.all(),
field_name='object_types'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_types'
)
event_type = MultiValueCharFilter(
method='filter_event_type'
)
action_type = django_filters.MultipleChoiceFilter(
choices=EventRuleActionChoices
choices=EventRuleActionChoices,
distinct=False,
)
action_object_type = ContentTypeFilter()
action_object_type = MultiValueContentTypeFilter()
action_object_id = MultiValueNumberFilter()
class Meta:
@@ -142,26 +145,30 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
label=_('Search'),
)
type = django_filters.MultipleChoiceFilter(
choices=CustomFieldTypeChoices
choices=CustomFieldTypeChoices,
distinct=False,
)
object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.all(),
field_name='object_types'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_types'
)
related_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.all(),
distinct=False,
field_name='related_object_type'
)
related_object_type = ContentTypeFilter()
related_object_type = MultiValueContentTypeFilter()
choice_set_id = django_filters.ModelMultipleChoiceFilter(
queryset=CustomFieldChoiceSet.objects.all()
queryset=CustomFieldChoiceSet.objects.all(),
distinct=False,
)
choice_set = django_filters.ModelMultipleChoiceFilter(
field_name='choice_set__name',
queryset=CustomFieldChoiceSet.objects.all(),
distinct=False,
to_field_name='name'
)
@@ -224,7 +231,7 @@ class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
queryset=ObjectType.objects.all(),
field_name='object_types'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_types'
)
@@ -255,15 +262,17 @@ class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
queryset=ObjectType.objects.all(),
field_name='object_types'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_types'
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data file (ID)'),
)
@@ -294,16 +303,18 @@ class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
queryset=ObjectType.objects.all(),
field_name='object_types'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_types'
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
distinct=False,
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
distinct=False,
to_field_name='username',
label=_('User (name)'),
)
@@ -345,18 +356,21 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
)
object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.all(),
distinct=False,
field_name='object_type'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_type'
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
distinct=False,
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
distinct=False,
to_field_name='username',
label=_('User (name)'),
)
@@ -395,14 +409,16 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
class BookmarkFilterSet(BaseFilterSet):
created = django_filters.DateTimeFilter()
object_type_id = MultiValueNumberFilter()
object_type = ContentTypeFilter()
object_type = MultiValueContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
distinct=False,
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
distinct=False,
to_field_name='username',
label=_('User (name)'),
)
@@ -462,7 +478,7 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
method='search',
label=_('Search'),
)
object_type = ContentTypeFilter()
object_type = MultiValueContentTypeFilter()
class Meta:
model = ImageAttachment
@@ -481,22 +497,26 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
@register_filterset
class JournalEntryFilterSet(NetBoxModelFilterSet):
created = django_filters.DateTimeFromToRangeFilter()
assigned_object_type = ContentTypeFilter()
assigned_object_type = MultiValueContentTypeFilter()
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
queryset=ContentType.objects.all(),
distinct=False,
)
created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
distinct=False,
label=_('User (ID)'),
)
created_by = django_filters.ModelMultipleChoiceFilter(
field_name='created_by__username',
queryset=User.objects.all(),
distinct=False,
to_field_name='username',
label=_('User (name)'),
)
kind = django_filters.MultipleChoiceFilter(
choices=JournalEntryKindChoices
choices=JournalEntryKindChoices,
distinct=False,
)
class Meta:
@@ -576,19 +596,22 @@ class TaggedItemFilterSet(BaseFilterSet):
method='search',
label=_('Search'),
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='content_type'
)
object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all(),
distinct=False,
field_name='content_type_id'
)
tag_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tag.objects.all()
queryset=Tag.objects.all(),
distinct=False,
)
tag = django_filters.ModelMultipleChoiceFilter(
field_name='tag__slug',
queryset=Tag.objects.all(),
distinct=False,
to_field_name='slug',
)
@@ -614,10 +637,12 @@ class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data file (ID)'),
)
@@ -645,11 +670,13 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
)
profile_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigContextProfile.objects.all(),
distinct=False,
label=_('Profile (ID)'),
)
profile = django_filters.ModelMultipleChoiceFilter(
field_name='profile__name',
queryset=ConfigContextProfile.objects.all(),
distinct=False,
to_field_name='name',
label=_('Profile (name)'),
)
@@ -786,10 +813,12 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data file (ID)'),
)
@@ -815,10 +844,12 @@ class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
distinct=False,
label=_('Data file (ID)'),
)
tag = TagFilter()

View File

@@ -178,9 +178,11 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
name=name,
is_executable=True,
)
sync_classes.alters_data = True
def sync_data(self):
super().sync_data()
sync_data.alters_data = True
def save(self, *args, **kwargs):
self.file_root = ManagedFileRootPathChoices.SCRIPTS

View File

@@ -304,7 +304,7 @@ class ConditionSetTest(TestCase):
Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
"""
ct = ContentType.objects.get(app_label='extras', model='webhook')
ct = ContentType.objects.get_by_natural_key('extras', 'webhook')
site_ct = ContentType.objects.get_for_model(Site)
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
form = EventRuleForm({

View File

@@ -111,13 +111,13 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_related_object_type(self):
params = {'related_object_type': 'dcim.site'}
params = {'related_object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -348,7 +348,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type(self):
params = {'object_type': 'dcim.region'}
params = {'object_type': ['dcim.region']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -417,7 +417,7 @@ class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -508,7 +508,7 @@ class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -600,7 +600,7 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
Bookmark.objects.bulk_create(bookmarks)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -663,7 +663,7 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -697,8 +697,8 @@ class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get(app_label='dcim', model='site')
rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
site_ct = ContentType.objects.get_by_natural_key('dcim', 'site')
rack_ct = ContentType.objects.get_by_natural_key('dcim', 'rack')
sites = (
Site(name='Site 1', slug='site-1'),
@@ -757,12 +757,12 @@ class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_type_id_and_object_id(self):
params = {
'object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk,
'object_type_id': ContentType.objects.get_by_natural_key('dcim', 'site').pk,
'object_id': [Site.objects.first().pk],
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -845,14 +845,14 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_assigned_object_type(self):
params = {'assigned_object_type': 'dcim.site'}
params = {'assigned_object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
params = {'assigned_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_assigned_object(self):
params = {
'assigned_object_type': 'dcim.site',
'assigned_object_type': ['dcim.site'],
'assigned_object_id': [Site.objects.first().pk],
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1426,15 +1426,15 @@ class TaggedItemFilterSetTestCase(TestCase):
def test_object_type(self):
object_type = ObjectType.objects.get_for_model(Site)
params = {'object_type': 'dcim.site'}
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'object_type_id': [object_type.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_object_id(self):
def test_object(self):
site_ids = Site.objects.values_list('pk', flat=True)
params = {
'object_type': 'dcim.site',
'object_type': ['dcim.site'],
'object_id': site_ids[:2],
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -17,7 +17,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
class ImageAttachmentTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
cls.image_content = b''
def _stub_image_attachment(self, object_id, image_filename, name=None):

View File

@@ -27,7 +27,7 @@ class ImageUploadTests(TestCase):
def setUpTestData(cls):
# We only need a ContentType with model="rack" for the prefix;
# this doesn't require creating a Rack object.
cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
def _stub_instance(self, object_id=12, name=None):
"""

View File

@@ -16,7 +16,8 @@ from netbox.filtersets import (
)
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter, NumericArrayFilter,
TreeNodeMultipleChoiceFilter,
)
from utilities.filtersets import register_filterset
from virtualization.models import VirtualMachine, VMInterface
@@ -166,11 +167,13 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
)
rir_id = django_filters.ModelMultipleChoiceFilter(
queryset=RIR.objects.all(),
distinct=False,
label=_('RIR (ID)'),
)
rir = django_filters.ModelMultipleChoiceFilter(
field_name='rir__slug',
queryset=RIR.objects.all(),
distinct=False,
to_field_name='slug',
label=_('RIR (slug)'),
)
@@ -206,11 +209,13 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
rir_id = django_filters.ModelMultipleChoiceFilter(
queryset=RIR.objects.all(),
distinct=False,
label=_('RIR (ID)'),
)
rir = django_filters.ModelMultipleChoiceFilter(
field_name='rir__slug',
queryset=RIR.objects.all(),
distinct=False,
to_field_name='slug',
label=_('RIR (slug)'),
)
@@ -232,11 +237,13 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
rir_id = django_filters.ModelMultipleChoiceFilter(
queryset=RIR.objects.all(),
distinct=False,
label=_('RIR (ID)'),
)
rir = django_filters.ModelMultipleChoiceFilter(
field_name='rir__slug',
queryset=RIR.objects.all(),
distinct=False,
to_field_name='slug',
label=_('RIR (slug)'),
)
@@ -342,11 +349,13 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
distinct=False,
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
distinct=False,
to_field_name='rd',
label=_('VRF (RD)'),
)
@@ -364,17 +373,20 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group',
queryset=VLANGroup.objects.all(),
distinct=False,
to_field_name='id',
label=_('VLAN Group (ID)'),
)
vlan_group = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group__slug',
queryset=VLANGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('VLAN Group (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
distinct=False,
label=_('VLAN (ID)'),
)
vlan_vid = django_filters.NumberFilter(
@@ -383,16 +395,19 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
distinct=False,
label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=Role.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Role (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=PrefixStatusChoices,
distinct=False,
null_value=None
)
@@ -486,26 +501,31 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
distinct=False,
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
distinct=False,
to_field_name='rd',
label=_('VRF (RD)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
distinct=False,
label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=Role.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Role (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=IPRangeStatusChoices,
distinct=False,
null_value=None
)
parent = MultiValueCharFilter(
@@ -588,11 +608,13 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
distinct=False,
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
distinct=False,
to_field_name='rd',
label=_('VRF (RD)'),
)
@@ -607,7 +629,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
to_field_name='rd',
label=_('VRF (RD)'),
)
assigned_object_type = ContentTypeFilter()
assigned_object_type = MultiValueContentTypeFilter()
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
@@ -665,10 +687,12 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
)
status = django_filters.MultipleChoiceFilter(
choices=IPAddressStatusChoices,
distinct=False,
null_value=None
)
role = django_filters.MultipleChoiceFilter(
choices=IPAddressRoleChoices
choices=IPAddressRoleChoices,
distinct=False,
)
service_id = django_filters.ModelMultipleChoiceFilter(
field_name='services',
@@ -678,6 +702,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
nat_inside_id = django_filters.ModelMultipleChoiceFilter(
field_name='nat_inside',
queryset=IPAddress.objects.all(),
distinct=False,
label=_('NAT inside IP address (ID)'),
)
@@ -799,10 +824,12 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
@register_filterset
class FHRPGroupFilterSet(PrimaryModelFilterSet):
protocol = django_filters.MultipleChoiceFilter(
choices=FHRPGroupProtocolChoices
choices=FHRPGroupProtocolChoices,
distinct=False,
)
auth_type = django_filters.MultipleChoiceFilter(
choices=FHRPGroupAuthTypeChoices
choices=FHRPGroupAuthTypeChoices,
distinct=False,
)
related_ip = django_filters.ModelMultipleChoiceFilter(
queryset=IPAddress.objects.all(),
@@ -846,9 +873,10 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
@register_filterset
class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
interface_type = ContentTypeFilter()
interface_type = MultiValueContentTypeFilter()
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=FHRPGroup.objects.all(),
distinct=False,
label=_('Group (ID)'),
)
device = MultiValueCharFilter(
@@ -901,7 +929,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
@register_filterset
class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
scope_type = ContentTypeFilter()
scope_type = MultiValueContentTypeFilter()
region = django_filters.NumberFilter(
method='filter_scope'
)
@@ -979,36 +1007,43 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
distinct=False,
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Site (slug)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLANGroup.objects.all(),
distinct=False,
label=_('Group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=VLANGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Group'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
distinct=False,
label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=Role.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Role (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=VLANStatusChoices,
distinct=False,
null_value=None
)
available_at_site = django_filters.ModelChoiceFilter(
@@ -1024,10 +1059,12 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
method='get_for_virtualmachine'
)
qinq_role = django_filters.MultipleChoiceFilter(
choices=VLANQinQRoleChoices
choices=VLANQinQRoleChoices,
distinct=False,
)
qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
distinct=False,
label=_('Q-in-Q SVLAN (ID)'),
)
qinq_svlan_vid = MultiValueNumberFilter(
@@ -1122,11 +1159,13 @@ class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
policy_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLANTranslationPolicy.objects.all(),
distinct=False,
label=_('VLAN Translation Policy (ID)'),
)
policy = django_filters.ModelMultipleChoiceFilter(
field_name='policy__name',
queryset=VLANTranslationPolicy.objects.all(),
distinct=False,
to_field_name='name',
label=_('VLAN Translation Policy (name)'),
)
@@ -1173,7 +1212,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet):
@register_filterset
class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet):
parent_object_type = ContentTypeFilter()
parent_object_type = MultiValueContentTypeFilter()
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
@@ -1265,22 +1304,26 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4',
queryset=IPAddress.objects.all(),
distinct=False,
label=_('Primary IPv4 (ID)'),
)
primary_ip4 = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4__address',
queryset=IPAddress.objects.all(),
distinct=False,
to_field_name='address',
label=_('Primary IPv4 (address)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
distinct=False,
label=_('Primary IPv6 (ID)'),
)
primary_ip6 = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6__address',
queryset=IPAddress.objects.all(),
distinct=False,
to_field_name='address',
label=_('Primary IPv6 (address)'),
)

View File

@@ -1572,12 +1572,12 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_interface_type(self):
params = {'interface_type': 'dcim.interface'}
params = {'interface_type': ['dcim.interface']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_interface(self):
interfaces = Interface.objects.all()[:2]
params = {'interface_type': 'dcim.interface', 'interface_id': [interfaces[0].pk, interfaces[1].pk]}
params = {'interface_type': ['dcim.interface'], 'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_priority(self):

View File

@@ -143,6 +143,10 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
"""
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
recursively using MPTT. Within each parent, each child instance must have a unique name.
Note: django-mptt injects the (tree_id, lft) index dynamically, but Django's migration autodetector won't
detect it unless concrete subclasses explicitly declare Meta.indexes (even as an empty tuple). See #21016
and django-mptt/django-mptt#682.
"""
parent = TreeForeignKey(
to='self',

View File

@@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
from netbox.filtersets import (
NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
)
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filtersets import register_filterset
from .models import *
@@ -29,11 +29,13 @@ __all__ = (
class ContactGroupFilterSet(NestedGroupModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
distinct=False,
label=_('Parent contact group (ID)'),
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=ContactGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Parent contact group (slug)'),
)
@@ -110,9 +112,10 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
method='search',
label=_('Search'),
)
object_type = ContentTypeFilter()
object_type = MultiValueContentTypeFilter()
contact_id = django_filters.ModelMultipleChoiceFilter(
queryset=Contact.objects.all(),
distinct=False,
label=_('Contact (ID)'),
)
group_id = TreeNodeMultipleChoiceFilter(
@@ -130,11 +133,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContactRole.objects.all(),
distinct=False,
label=_('Contact role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=ContactRole.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Contact role (slug)'),
)
@@ -179,11 +184,13 @@ class ContactModelFilterSet(django_filters.FilterSet):
class TenantGroupFilterSet(NestedGroupModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
distinct=False,
label=_('Parent tenant group (ID)'),
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=TenantGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Parent tenant group (slug)'),
)
@@ -256,10 +263,12 @@ class TenancyFilterSet(django_filters.FilterSet):
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
distinct=False,
label=_('Tenant (ID)'),
)
tenant = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
distinct=False,
field_name='tenant__slug',
to_field_name='slug',
label=_('Tenant (slug)'),

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-02-13 13:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0134_owner'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='contactgroup',
index=models.Index(fields=['tree_id', 'lft'], name='tenancy_contactgroup_tree_d2ce'),
),
migrations.AddIndex(
model_name='tenantgroup',
index=models.Index(fields=['tree_id', 'lft'], name='tenancy_tenantgroup_tree_ifebc'),
),
]

View File

@@ -22,6 +22,9 @@ class ContactGroup(NestedGroupModel):
"""
class Meta:
ordering = ['name']
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),

View File

@@ -29,6 +29,9 @@ class TenantGroup(NestedGroupModel):
class Meta:
ordering = ['name']
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
verbose_name = _('tenant group')
verbose_name_plural = _('tenant groups')

View File

@@ -355,6 +355,8 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
ContactAssignment.objects.bulk_create(assignments)
def test_object_type(self):
params = {'object_type': ['dcim.site']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'object_type_id': ObjectType.objects.get_by_natural_key('dcim', 'site')}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-12 05:28+0000\n"
"POT-Creation-Date: 2026-02-13 05:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -12716,67 +12716,67 @@ msgstr ""
msgid "Cannot delete stores from registry"
msgstr ""
#: netbox/netbox/settings.py:837
#: netbox/netbox/settings.py:847
msgid "Czech"
msgstr ""
#: netbox/netbox/settings.py:838
#: netbox/netbox/settings.py:848
msgid "Danish"
msgstr ""
#: netbox/netbox/settings.py:839
#: netbox/netbox/settings.py:849
msgid "German"
msgstr ""
#: netbox/netbox/settings.py:840
#: netbox/netbox/settings.py:850
msgid "English"
msgstr ""
#: netbox/netbox/settings.py:841
#: netbox/netbox/settings.py:851
msgid "Spanish"
msgstr ""
#: netbox/netbox/settings.py:842
#: netbox/netbox/settings.py:852
msgid "French"
msgstr ""
#: netbox/netbox/settings.py:843
#: netbox/netbox/settings.py:853
msgid "Italian"
msgstr ""
#: netbox/netbox/settings.py:844
#: netbox/netbox/settings.py:854
msgid "Japanese"
msgstr ""
#: netbox/netbox/settings.py:845
#: netbox/netbox/settings.py:855
msgid "Latvian"
msgstr ""
#: netbox/netbox/settings.py:846
#: netbox/netbox/settings.py:856
msgid "Dutch"
msgstr ""
#: netbox/netbox/settings.py:847
#: netbox/netbox/settings.py:857
msgid "Polish"
msgstr ""
#: netbox/netbox/settings.py:848
#: netbox/netbox/settings.py:858
msgid "Portuguese"
msgstr ""
#: netbox/netbox/settings.py:849
#: netbox/netbox/settings.py:859
msgid "Russian"
msgstr ""
#: netbox/netbox/settings.py:850
#: netbox/netbox/settings.py:860
msgid "Turkish"
msgstr ""
#: netbox/netbox/settings.py:851
#: netbox/netbox/settings.py:861
msgid "Ukrainian"
msgstr ""
#: netbox/netbox/settings.py:852
#: netbox/netbox/settings.py:862
msgid "Chinese"
msgstr ""

View File

@@ -14,22 +14,26 @@ class OwnerFilterMixin(django_filters.FilterSet):
"""
owner_group_id = django_filters.ModelMultipleChoiceFilter(
queryset=OwnerGroup.objects.all(),
distinct=False,
field_name='owner__group',
label=_('Owner Group (ID)'),
)
owner_group = django_filters.ModelMultipleChoiceFilter(
queryset=OwnerGroup.objects.all(),
distinct=False,
field_name='owner__group__name',
to_field_name='name',
label=_('Owner Group (name)'),
)
owner_id = django_filters.ModelMultipleChoiceFilter(
queryset=Owner.objects.all(),
distinct=False,
label=_('Owner (ID)'),
)
owner = django_filters.ModelMultipleChoiceFilter(
field_name='owner__name',
queryset=Owner.objects.all(),
distinct=False,
to_field_name='name',
label=_('Owner (name)'),
)

View File

@@ -6,7 +6,7 @@ from core.models import ObjectType
from extras.models import NotificationGroup
from netbox.filtersets import BaseFilterSet
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
from utilities.filters import ContentTypeFilter
from utilities.filters import MultiValueContentTypeFilter
from utilities.filtersets import register_filterset
__all__ = (
@@ -131,11 +131,13 @@ class TokenFilterSet(BaseFilterSet):
user_id = django_filters.ModelMultipleChoiceFilter(
field_name='user',
queryset=User.objects.all(),
distinct=False,
label=_('User'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
distinct=False,
to_field_name='username',
label=_('User (name)'),
)
@@ -194,7 +196,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
queryset=ObjectType.objects.all(),
field_name='object_types'
)
object_type = ContentTypeFilter(
object_type = MultiValueContentTypeFilter(
field_name='object_types'
)
can_view = django_filters.BooleanFilter(
@@ -280,11 +282,13 @@ class OwnerFilterSet(BaseFilterSet):
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=OwnerGroup.objects.all(),
distinct=False,
label=_('Group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__name',
queryset=OwnerGroup.objects.all(),
distinct=False,
to_field_name='name',
label=_('Group (name)'),
)

View File

@@ -113,6 +113,7 @@ class UserConfig(models.Model):
if commit:
self.save()
set.alters_data = True
def clear(self, path, commit=False):
"""
@@ -140,3 +141,4 @@ class UserConfig(models.Model):
if commit:
self.save()
clear.alters_data = True

View File

@@ -1,6 +1,7 @@
import django_filters
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django_filters.constants import EMPTY_VALUES
from drf_spectacular.types import OpenApiTypes
@@ -10,6 +11,7 @@ __all__ = (
'ContentTypeFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter',
'MultiValueContentTypeFilter',
'MultiValueDateFilter',
'MultiValueDateTimeFilter',
'MultiValueDecimalFilter',
@@ -171,3 +173,27 @@ class ContentTypeFilter(django_filters.CharFilter):
f'{self.field_name}__model': model
}
)
class MultiValueContentTypeFilter(MultiValueCharFilter):
"""
A multi-value version of ContentTypeFilter.
"""
def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs
content_types = []
for key in value:
try:
app_label, model = key.lower().split('.')
ct = ContentType.objects.get_by_natural_key(app_label, model)
content_types.append(ct)
except (ValueError, ContentType.DoesNotExist):
continue
return qs.filter(
**{
f'{self.field_name}__in': content_types,
}
)

View File

@@ -10,7 +10,7 @@ from mptt.models import MPTTModel
from taggit.managers import TaggableManager
from extras.filters import TagFilter
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
__all__ = (
'BaseFilterSetTests',
@@ -75,7 +75,7 @@ class BaseFilterSetTests:
# Standardize on object_type for filter name even though it's technically a ContentType
filter_name = 'object_type'
return [
(filter_name, ContentTypeFilter),
(filter_name, MultiValueContentTypeFilter),
(f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter),
]

View File

@@ -1,6 +1,8 @@
import django_filters
import netaddr
from django.db.models import Q
from django.utils.translation import gettext as _
from netaddr.core import AddrFormatError
from dcim.base_filtersets import ScopedFilterSet
from dcim.filtersets import CommonInterfaceFilterSet
@@ -47,26 +49,31 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet):
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=ClusterGroup.objects.all(),
distinct=False,
label=_('Parent group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=ClusterGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Parent group (slug)'),
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ClusterType.objects.all(),
distinct=False,
label=_('Cluster type (ID)'),
)
type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug',
queryset=ClusterType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Cluster type (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=ClusterStatusChoices,
distinct=False,
null_value=None
)
@@ -94,51 +101,61 @@ class VirtualMachineFilterSet(
):
status = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStatusChoices,
distinct=False,
null_value=None
)
start_on_boot = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStartOnBootChoices,
distinct=False,
null_value=None
)
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group',
queryset=ClusterGroup.objects.all(),
distinct=False,
label=_('Cluster group (ID)'),
)
cluster_group = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group__slug',
queryset=ClusterGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Cluster group (slug)'),
)
cluster_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__type',
queryset=ClusterType.objects.all(),
distinct=False,
label=_('Cluster type (ID)'),
)
cluster_type = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__type__slug',
queryset=ClusterType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Cluster type (slug)'),
)
cluster_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
distinct=False,
label=_('Cluster (ID)'),
)
cluster = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__name',
queryset=Cluster.objects.all(),
distinct=False,
to_field_name='name',
label=_('Cluster'),
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
distinct=False,
label=_('Device (ID)'),
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
distinct=False,
to_field_name='name',
label=_('Device'),
)
@@ -170,11 +187,13 @@ class VirtualMachineFilterSet(
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
distinct=False,
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Site (slug)'),
)
@@ -216,6 +235,7 @@ class VirtualMachineFilterSet(
)
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
distinct=False,
label=_('Config template (ID)'),
)
@@ -229,14 +249,22 @@ class VirtualMachineFilterSet(
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
qs_filter = Q(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value) |
Q(serial__icontains=value)
)
# If the given value looks like an IP address, look for primary IPv4/IPv6 assignments
try:
ipaddress = netaddr.IPNetwork(value)
if ipaddress.version == 4:
qs_filter |= Q(primary_ip4__address__host__inet=ipaddress.ip)
elif ipaddress.version == 6:
qs_filter |= Q(primary_ip6__address__host__inet=ipaddress.ip)
except (AddrFormatError, ValueError):
pass
return queryset.filter(qs_filter)
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
@@ -250,33 +278,39 @@ class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxMod
cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__cluster',
queryset=Cluster.objects.all(),
distinct=False,
label=_('Cluster (ID)'),
)
cluster = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__cluster__name',
queryset=Cluster.objects.all(),
distinct=False,
to_field_name='name',
label=_('Cluster'),
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine',
queryset=VirtualMachine.objects.all(),
distinct=False,
label=_('Virtual machine (ID)'),
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__name',
queryset=VirtualMachine.objects.all(),
distinct=False,
to_field_name='name',
label=_('Virtual machine'),
)
parent_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent',
queryset=VMInterface.objects.all(),
distinct=False,
label=_('Parent interface (ID)'),
)
bridge_id = django_filters.ModelMultipleChoiceFilter(
field_name='bridge',
queryset=VMInterface.objects.all(),
distinct=False,
label=_('Bridged interface (ID)'),
)
mac_address = MultiValueMACAddressFilter(
@@ -286,11 +320,13 @@ class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxMod
primary_mac_address_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_mac_address',
queryset=MACAddress.objects.all(),
distinct=False,
label=_('Primary MAC address (ID)'),
)
primary_mac_address = django_filters.ModelMultipleChoiceFilter(
field_name='primary_mac_address__mac_address',
queryset=MACAddress.objects.all(),
distinct=False,
to_field_name='mac_address',
label=_('Primary MAC address'),
)
@@ -313,11 +349,13 @@ class VirtualDiskFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine',
queryset=VirtualMachine.objects.all(),
distinct=False,
label=_('Virtual machine (ID)'),
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__name',
queryset=VirtualMachine.objects.all(),
distinct=False,
to_field_name='name',
label=_('Virtual machine'),
)

View File

@@ -320,6 +320,7 @@ class ClusterAddDevicesView(generic.ObjectEditView):
# Assign the selected Devices to the Cluster
for device in Device.objects.filter(pk__in=device_pks):
device.snapshot()
device.cluster = cluster
device.save()

View File

@@ -7,7 +7,7 @@ from dcim.models import Device, Interface
from ipam.models import IPAddress, RouteTarget, VLAN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from utilities.filters import MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter
from utilities.filtersets import register_filterset
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
@@ -38,28 +38,34 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
@register_filterset
class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=TunnelStatusChoices
choices=TunnelStatusChoices,
distinct=False,
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=TunnelGroup.objects.all(),
distinct=False,
label=_('Tunnel group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=TunnelGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Tunnel group (slug)'),
)
encapsulation = django_filters.MultipleChoiceFilter(
choices=TunnelEncapsulationChoices
choices=TunnelEncapsulationChoices,
distinct=False,
)
ipsec_profile_id = django_filters.ModelMultipleChoiceFilter(
queryset=IPSecProfile.objects.all(),
distinct=False,
label=_('IPSec profile (ID)'),
)
ipsec_profile = django_filters.ModelMultipleChoiceFilter(
field_name='ipsec_profile__name',
queryset=IPSecProfile.objects.all(),
distinct=False,
to_field_name='name',
label=_('IPSec profile (name)'),
)
@@ -83,18 +89,21 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet):
tunnel_id = django_filters.ModelMultipleChoiceFilter(
field_name='tunnel',
queryset=Tunnel.objects.all(),
distinct=False,
label=_('Tunnel (ID)'),
)
tunnel = django_filters.ModelMultipleChoiceFilter(
field_name='tunnel__name',
queryset=Tunnel.objects.all(),
distinct=False,
to_field_name='name',
label=_('Tunnel (name)'),
)
role = django_filters.MultipleChoiceFilter(
choices=TunnelTerminationRoleChoices
choices=TunnelTerminationRoleChoices,
distinct=False,
)
termination_type = ContentTypeFilter()
termination_type = MultiValueContentTypeFilter()
interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name',
queryset=Interface.objects.all(),
@@ -120,6 +129,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet):
outside_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='outside_ip',
queryset=IPAddress.objects.all(),
distinct=False,
label=_('Outside IP (ID)'),
)
@@ -142,16 +152,20 @@ class IKEProposalFilterSet(PrimaryModelFilterSet):
label=_('IKE policy (name)'),
)
authentication_method = django_filters.MultipleChoiceFilter(
choices=AuthenticationMethodChoices
choices=AuthenticationMethodChoices,
distinct=False,
)
encryption_algorithm = django_filters.MultipleChoiceFilter(
choices=EncryptionAlgorithmChoices
choices=EncryptionAlgorithmChoices,
distinct=False,
)
authentication_algorithm = django_filters.MultipleChoiceFilter(
choices=AuthenticationAlgorithmChoices
choices=AuthenticationAlgorithmChoices,
distinct=False,
)
group = django_filters.MultipleChoiceFilter(
choices=DHGroupChoices
choices=DHGroupChoices,
distinct=False,
)
class Meta:
@@ -171,10 +185,12 @@ class IKEProposalFilterSet(PrimaryModelFilterSet):
@register_filterset
class IKEPolicyFilterSet(PrimaryModelFilterSet):
version = django_filters.MultipleChoiceFilter(
choices=IKEVersionChoices
choices=IKEVersionChoices,
distinct=False,
)
mode = django_filters.MultipleChoiceFilter(
choices=IKEModeChoices
choices=IKEModeChoices,
distinct=False,
)
ike_proposal_id = django_filters.ModelMultipleChoiceFilter(
field_name='proposals',
@@ -214,10 +230,12 @@ class IPSecProposalFilterSet(PrimaryModelFilterSet):
label=_('IPSec policy (name)'),
)
encryption_algorithm = django_filters.MultipleChoiceFilter(
choices=EncryptionAlgorithmChoices
choices=EncryptionAlgorithmChoices,
distinct=False,
)
authentication_algorithm = django_filters.MultipleChoiceFilter(
choices=AuthenticationAlgorithmChoices
choices=AuthenticationAlgorithmChoices,
distinct=False,
)
class Meta:
@@ -237,7 +255,8 @@ class IPSecProposalFilterSet(PrimaryModelFilterSet):
@register_filterset
class IPSecPolicyFilterSet(PrimaryModelFilterSet):
pfs_group = django_filters.MultipleChoiceFilter(
choices=DHGroupChoices
choices=DHGroupChoices,
distinct=False,
)
ipsec_proposal_id = django_filters.ModelMultipleChoiceFilter(
field_name='proposals',
@@ -266,25 +285,30 @@ class IPSecPolicyFilterSet(PrimaryModelFilterSet):
@register_filterset
class IPSecProfileFilterSet(PrimaryModelFilterSet):
mode = django_filters.MultipleChoiceFilter(
choices=IPSecModeChoices
choices=IPSecModeChoices,
distinct=False,
)
ike_policy_id = django_filters.ModelMultipleChoiceFilter(
queryset=IKEPolicy.objects.all(),
distinct=False,
label=_('IKE policy (ID)'),
)
ike_policy = django_filters.ModelMultipleChoiceFilter(
field_name='ike_policy__name',
queryset=IKEPolicy.objects.all(),
distinct=False,
to_field_name='name',
label=_('IKE policy (name)'),
)
ipsec_policy_id = django_filters.ModelMultipleChoiceFilter(
queryset=IPSecPolicy.objects.all(),
distinct=False,
label=_('IPSec policy (ID)'),
)
ipsec_policy = django_filters.ModelMultipleChoiceFilter(
field_name='ipsec_policy__name',
queryset=IPSecPolicy.objects.all(),
distinct=False,
to_field_name='name',
label=_('IPSec policy (name)'),
)
@@ -307,10 +331,12 @@ class IPSecProfileFilterSet(PrimaryModelFilterSet):
class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=L2VPNTypeChoices,
distinct=False,
null_value=None
)
status = django_filters.MultipleChoiceFilter(
choices=L2VPNStatusChoices,
distinct=False,
)
import_target_id = django_filters.ModelMultipleChoiceFilter(
field_name='import_targets',
@@ -354,11 +380,13 @@ class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilter
class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
queryset=L2VPN.objects.all(),
distinct=False,
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn__slug',
queryset=L2VPN.objects.all(),
distinct=False,
to_field_name='slug',
label=_('L2VPN (slug)'),
)
@@ -443,9 +471,10 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
)
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.all(),
distinct=False,
field_name='assigned_object_type'
)
assigned_object_type = ContentTypeFilter()
assigned_object_type = MultiValueContentTypeFilter()
class Meta:
model = L2VPNTermination

View File

@@ -268,9 +268,9 @@ class TunnelTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_termination_type(self):
params = {'termination_type': 'dcim.interface'}
params = {'termination_type': ['dcim.interface']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'termination_type': 'virtualization.vminterface'}
params = {'termination_type': ['virtualization.vminterface']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_interface(self):
@@ -902,7 +902,7 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_termination_type(self):
params = {'assigned_object_type': 'ipam.vlan'}
params = {'assigned_object_type': ['ipam.vlan']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_interface(self):

View File

@@ -22,11 +22,13 @@ __all__ = (
@register_filterset
class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all()
queryset=WirelessLANGroup.objects.all(),
distinct=False,
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=WirelessLANGroup.objects.all(),
distinct=False,
to_field_name='slug'
)
ancestor_id = TreeNodeMultipleChoiceFilter(
@@ -60,20 +62,24 @@ class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilter
to_field_name='slug'
)
status = django_filters.MultipleChoiceFilter(
choices=WirelessLANStatusChoices
choices=WirelessLANStatusChoices,
distinct=False,
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all()
queryset=VLAN.objects.all(),
distinct=False,
)
interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all(),
field_name='interfaces'
)
auth_type = django_filters.MultipleChoiceFilter(
choices=WirelessAuthTypeChoices
choices=WirelessAuthTypeChoices,
distinct=False,
)
auth_cipher = django_filters.MultipleChoiceFilter(
choices=WirelessAuthCipherChoices
choices=WirelessAuthCipherChoices,
distinct=False,
)
class Meta:
@@ -93,19 +99,24 @@ class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilter
@register_filterset
class WirelessLinkFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
interface_a_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all()
queryset=Interface.objects.all(),
distinct=False,
)
interface_b_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all()
queryset=Interface.objects.all(),
distinct=False,
)
status = django_filters.MultipleChoiceFilter(
choices=LinkStatusChoices
choices=LinkStatusChoices,
distinct=False,
)
auth_type = django_filters.MultipleChoiceFilter(
choices=WirelessAuthTypeChoices
choices=WirelessAuthTypeChoices,
distinct=False,
)
auth_cipher = django_filters.MultipleChoiceFilter(
choices=WirelessAuthCipherChoices
choices=WirelessAuthCipherChoices,
distinct=False,
)
class Meta:

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.10 on 2026-02-13 13:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0134_owner'),
('users', '0015_owner'),
('wireless', '0017_gfk_indexes'),
]
operations = [
migrations.AddIndex(
model_name='wirelesslangroup',
index=models.Index(fields=['tree_id', 'lft'], name='wireless_wirelesslangroup_fbcd'),
),
]

View File

@@ -63,6 +63,9 @@ class WirelessLANGroup(NestedGroupModel):
class Meta:
ordering = ('name', 'pk')
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),

View File

@@ -305,7 +305,7 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_scope_type(self):
params = {'scope_type': 'dcim.location'}
params = {'scope_type': ['dcim.location']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -5,7 +5,7 @@ django-debug-toolbar==6.2.0
django-filter==25.2
django-graphiql-debug-toolbar==0.2.0
django-htmx==1.27.0
django-mptt==0.17.0
django-mptt==0.18.0
django-pglocks==1.0.4
django-prometheus==2.4.1
django-redis==6.0.0

View File

@@ -2,7 +2,7 @@ exclude = [
"netbox/project-static/**"
]
line-length = 120
target-version = "py310"
target-version = "py312"
[lint]
extend-select = ["E1", "E2", "E3", "E501", "W"]