mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-16 22:07:42 +01:00
Compare commits
32 Commits
feature
...
21371-fail
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add9c4c23d | ||
|
|
816c5d4bea | ||
|
|
f4c3c90bab | ||
|
|
862593f2dd | ||
|
|
f4c27fd494 | ||
|
|
ae736ef407 | ||
|
|
d95b1186fb | ||
|
|
d6b9d30086 | ||
|
|
9be5aa188c | ||
|
|
f113557e81 | ||
|
|
de812a5a85 | ||
|
|
0b7375136d | ||
|
|
1190adde2b | ||
|
|
2330874a8c | ||
|
|
dc738c7102 | ||
|
|
76fd3e3c61 | ||
|
|
4ee64a7731 | ||
|
|
0bb22dee0c | ||
|
|
6c383f293c | ||
|
|
5bf516c63d | ||
|
|
7df062d590 | ||
|
|
4b22be03a0 | ||
|
|
24769ce127 | ||
|
|
164e9db98d | ||
|
|
23f1c86e9c | ||
|
|
02ffdd9d5d | ||
|
|
5013297326 | ||
|
|
584e0a9b8c | ||
|
|
3ac9d0b8bf | ||
|
|
b387ea5f58 | ||
|
|
ba9f6bf359 | ||
|
|
ee6cbdcefe |
@@ -200,6 +200,48 @@ REDIS = {
|
||||
!!! note
|
||||
It is permissible to use Sentinel for only one database and not the other.
|
||||
|
||||
### SSL Configuration
|
||||
|
||||
If you need to configure SSL/TLS for Redis beyond the basic `SSL`, `CA_CERT_PATH`, and `INSECURE_SKIP_TLS_VERIFY` options (for example, client certificates, a specific TLS version, or custom ciphers), you can pass additional parameters via the `KWARGS` key in either the `tasks` or `caching` subsection.
|
||||
|
||||
NetBox already maps `CA_CERT_PATH` to `ssl_ca_certs` and (for caching) `INSECURE_SKIP_TLS_VERIFY` to `ssl_cert_reqs`; only add `KWARGS` when you need to override or extend those settings (for example, to supply client certificates or restrict TLS version or ciphers).
|
||||
|
||||
* `KWARGS` - Optional dictionary of additional SSL/TLS (or other) parameters passed to the Redis client. These are passed directly to the underlying Redis client: for `tasks` to [redis-py](https://redis-py.readthedocs.io/en/stable/connections.html), and for `caching` to the [django-redis](https://github.com/jazzband/django-redis#configure-as-cache-backend) connection pool.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
REDIS = {
|
||||
'tasks': {
|
||||
'HOST': 'redis.example.com',
|
||||
'PORT': 1234,
|
||||
'SSL': True,
|
||||
'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
|
||||
'KWARGS': {
|
||||
'ssl_certfile': '/path/to/client-cert.pem',
|
||||
'ssl_keyfile': '/path/to/client-key.pem',
|
||||
'ssl_min_version': ssl.TLSVersion.TLSv1_2,
|
||||
'ssl_ciphers': 'HIGH:!aNULL',
|
||||
},
|
||||
},
|
||||
'caching': {
|
||||
'HOST': 'redis.example.com',
|
||||
'PORT': 1234,
|
||||
'SSL': True,
|
||||
'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
|
||||
'KWARGS': {
|
||||
'ssl_certfile': '/path/to/client-cert.pem',
|
||||
'ssl_keyfile': '/path/to/client-key.pem',
|
||||
'ssl_min_version': ssl.TLSVersion.TLSv1_2,
|
||||
'ssl_ciphers': 'HIGH:!aNULL',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
!!! note
|
||||
If you use `ssl.TLSVersion` in your configuration (e.g. `ssl_min_version`), add `import ssl` at the top of your configuration file.
|
||||
|
||||
---
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
@@ -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)'),
|
||||
)
|
||||
|
||||
@@ -91,13 +91,13 @@ class ProviderNetworkForm(PrimaryModelForm):
|
||||
|
||||
class CircuitTypeForm(OrganizationalModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
|
||||
FieldSet('name', 'slug', 'color', 'description', 'tags'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
'name', 'slug', 'color', 'description', 'comments', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import platform
|
||||
|
||||
from copy import deepcopy
|
||||
from django import __version__ as django_version
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -310,6 +311,22 @@ class ConfigRevisionListView(generic.ObjectListView):
|
||||
class ConfigRevisionView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
"""
|
||||
Retrieve additional context for a given request and instance.
|
||||
"""
|
||||
# Copy the revision data to avoid modifying the original
|
||||
config = deepcopy(instance.data or {})
|
||||
|
||||
# Serialize any JSON-based classes
|
||||
for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
|
||||
if attr in config:
|
||||
config[attr] = json.dumps(config[attr], cls=ConfigJSONEncoder, indent=4)
|
||||
|
||||
return {
|
||||
'config': config,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision, 'add', detail=False)
|
||||
class ConfigRevisionEditView(generic.ObjectEditView):
|
||||
@@ -617,8 +634,8 @@ class SystemView(UserPassesTestMixin, View):
|
||||
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
|
||||
return response
|
||||
|
||||
# Serialize any CustomValidator classes
|
||||
for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
|
||||
# Serialize any JSON-based classes
|
||||
for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
|
||||
if hasattr(config, attr) and getattr(config, attr, None):
|
||||
setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
|
||||
|
||||
|
||||
@@ -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
49
netbox/dcim/migrations/0226_add_mptt_tree_indexes.py
Normal file
49
netbox/dcim/migrations/0226_add_mptt_tree_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -373,7 +373,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
|
||||
super().clean()
|
||||
|
||||
# Validate location/site assignment
|
||||
if self.site and self.location and self.location.site != self.site:
|
||||
if self.site_id and self.location_id and self.location.site_id != self.site_id:
|
||||
raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
|
||||
|
||||
# Validate outer dimensions and unit
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -584,6 +584,15 @@ class BaseInterfaceTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('IP Addresses')
|
||||
)
|
||||
primary_mac_address = tables.Column(
|
||||
verbose_name=_('Primary MAC'),
|
||||
linkify=True
|
||||
)
|
||||
mac_addresses = columns.ManyToManyColumn(
|
||||
orderable=False,
|
||||
linkify_item=True,
|
||||
verbose_name=_('MAC Addresses')
|
||||
)
|
||||
fhrp_groups = tables.TemplateColumn(
|
||||
accessor=Accessor('fhrp_group_assignments'),
|
||||
template_code=INTERFACE_FHRPGROUPS,
|
||||
@@ -615,10 +624,6 @@ class BaseInterfaceTable(NetBoxTable):
|
||||
verbose_name=_('Q-in-Q SVLAN'),
|
||||
linkify=True
|
||||
)
|
||||
primary_mac_address = tables.Column(
|
||||
verbose_name=_('MAC Address'),
|
||||
linkify=True
|
||||
)
|
||||
|
||||
def value_ip_addresses(self, value):
|
||||
return ",".join([str(obj.address) for obj in value.all()])
|
||||
@@ -681,11 +686,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
||||
model = models.Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
|
||||
'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
|
||||
'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
|
||||
'vlan_translation_policy',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@@ -746,10 +752,11 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
model = models.Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
|
||||
'mgmt_only', 'mtu', 'mode', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
|
||||
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
|
||||
'mgmt_only', 'mtu', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
|
||||
'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
|
||||
@@ -880,24 +887,36 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
role = columns.ColoredLabelColumn(
|
||||
accessor=Accessor('installed_device__role'),
|
||||
verbose_name=_('Role')
|
||||
)
|
||||
device_type = tables.Column(
|
||||
accessor=Accessor('installed_device__device_type'),
|
||||
linkify=True,
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
verbose_name=_('Status'),
|
||||
template_code=DEVICEBAY_STATUS,
|
||||
order_by=Accessor('installed_device__status')
|
||||
)
|
||||
installed_device = tables.Column(
|
||||
verbose_name=_('Installed device'),
|
||||
verbose_name=_('Installed Device'),
|
||||
linkify=True
|
||||
)
|
||||
installed_role = columns.ColoredLabelColumn(
|
||||
accessor=Accessor('installed_device__role'),
|
||||
verbose_name=_('Installed Role')
|
||||
)
|
||||
installed_device_type = tables.Column(
|
||||
accessor=Accessor('installed_device__device_type'),
|
||||
linkify=True,
|
||||
verbose_name=_('Installed Type')
|
||||
)
|
||||
installed_description = tables.Column(
|
||||
accessor=Accessor('installed_device__description'),
|
||||
verbose_name=_('Installed Description')
|
||||
)
|
||||
installed_serial = tables.Column(
|
||||
accessor=Accessor('installed_device__serial'),
|
||||
verbose_name=_('Installed Serial')
|
||||
)
|
||||
installed_asset_tag = tables.Column(
|
||||
accessor=Accessor('installed_device__asset_tag'),
|
||||
verbose_name=_('Installed Asset Tag')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:devicebay_list'
|
||||
)
|
||||
@@ -905,8 +924,9 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.DeviceBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'role', 'device_type', 'installed_device', 'description',
|
||||
'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
|
||||
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
@@ -1199,4 +1219,6 @@ class MACAddressTable(PrimaryModelTable):
|
||||
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')
|
||||
default_columns = (
|
||||
'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,7 +90,6 @@ class DevicePanel(panels.ObjectAttributesPanel):
|
||||
parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
|
||||
gps_coordinates = attrs.GPSCoordinatesAttr()
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
|
||||
description = attrs.TextAttr('description')
|
||||
airflow = attrs.ChoiceAttr('airflow')
|
||||
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
|
||||
@@ -122,10 +121,19 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
|
||||
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
|
||||
|
||||
|
||||
class DeviceDeviceTypePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Device Type')
|
||||
|
||||
manufacturer = attrs.RelatedObjectAttr('device_type.manufacturer', linkify=True)
|
||||
model = attrs.RelatedObjectAttr('device_type', linkify=True)
|
||||
height = attrs.TemplatedAttr('device_type.u_height', template_name='dcim/devicetype/attrs/height.html')
|
||||
front_image = attrs.ImageAttr('device_type.front_image')
|
||||
rear_image = attrs.ImageAttr('device_type.rear_image')
|
||||
|
||||
|
||||
class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Dimensions')
|
||||
|
||||
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
|
||||
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
|
||||
|
||||
|
||||
@@ -135,7 +143,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
|
||||
part_number = attrs.TextAttr('part_number')
|
||||
default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
|
||||
height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
|
||||
exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
|
||||
full_depth = attrs.BooleanAttr('is_full_depth')
|
||||
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
|
||||
|
||||
@@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from dcim.ui import panels
|
||||
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
@@ -44,6 +43,7 @@ from .choices import DeviceFaceChoices, InterfaceModeChoices
|
||||
from .models import *
|
||||
from .models.device_components import PortMapping
|
||||
from .object_actions import BulkAddComponents, BulkDisconnect
|
||||
from .ui import panels
|
||||
|
||||
CABLE_TERMINATION_TYPES = {
|
||||
'dcim.consoleport': ConsolePort,
|
||||
@@ -2470,6 +2470,7 @@ class DeviceView(generic.ObjectView):
|
||||
],
|
||||
),
|
||||
ImageAttachmentsPanel(),
|
||||
panels.DeviceDeviceTypePanel(),
|
||||
panels.DeviceDimensionsPanel(),
|
||||
TemplatePanel('dcim/panels/device_rack_elevations.html'),
|
||||
],
|
||||
|
||||
@@ -113,6 +113,17 @@ def enqueue_event(queue, instance, request, event_type):
|
||||
def process_event_rules(event_rules, object_type, event):
|
||||
"""
|
||||
Process a list of EventRules against an event.
|
||||
|
||||
Notes on event sources:
|
||||
- Object change events (created/updated/deleted) are enqueued via
|
||||
enqueue_event() during an HTTP request.
|
||||
These events include a request object and legacy request
|
||||
attributes (e.g. username, request_id) for backward compatibility.
|
||||
- Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by
|
||||
job_start/job_end signal handlers and may not include a request
|
||||
context.
|
||||
Consumers must not assume that fields like `username` are always
|
||||
present.
|
||||
"""
|
||||
|
||||
for event_rule in event_rules:
|
||||
@@ -132,16 +143,22 @@ def process_event_rules(event_rules, object_type, event):
|
||||
queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
|
||||
rq_queue = get_queue(queue_name)
|
||||
|
||||
# For job lifecycle events, `username` may be absent because
|
||||
# there is no request context.
|
||||
# Prefer the associated user object when present, falling
|
||||
# back to the legacy username attribute.
|
||||
username = getattr(event.get('user'), 'username', None) or event.get('username')
|
||||
|
||||
# Compile the task parameters
|
||||
params = {
|
||||
"event_rule": event_rule,
|
||||
"object_type": object_type,
|
||||
"event_type": event['event_type'],
|
||||
"data": event_data,
|
||||
"snapshots": event.get('snapshots'),
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"username": event['username'],
|
||||
"retry": get_rq_retry()
|
||||
'event_rule': event_rule,
|
||||
'object_type': object_type,
|
||||
'event_type': event['event_type'],
|
||||
'data': event_data,
|
||||
'snapshots': event.get('snapshots'),
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'username': username,
|
||||
'retry': get_rq_retry(),
|
||||
}
|
||||
if 'request' in event:
|
||||
# Exclude FILES - webhooks don't need uploaded files,
|
||||
@@ -158,11 +175,12 @@ def process_event_rules(event_rules, object_type, event):
|
||||
|
||||
# Enqueue a Job to record the script's execution
|
||||
from extras.jobs import ScriptJob
|
||||
|
||||
params = {
|
||||
"instance": event_rule.action_object,
|
||||
"name": script.name,
|
||||
"user": event['user'],
|
||||
"data": event_data
|
||||
'instance': event_rule.action_object,
|
||||
'name': script.name,
|
||||
'user': event['user'],
|
||||
'data': event_data,
|
||||
}
|
||||
if 'snapshots' in event:
|
||||
params['snapshots'] = event['snapshots']
|
||||
@@ -179,7 +197,7 @@ def process_event_rules(event_rules, object_type, event):
|
||||
object_type=object_type,
|
||||
object_id=event_data['id'],
|
||||
object_repr=event_data.get('display'),
|
||||
event_type=event['event_type']
|
||||
event_type=event['event_type'],
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,9 +39,20 @@ __all__ = (
|
||||
)
|
||||
|
||||
IMAGEATTACHMENT_IMAGE = """
|
||||
{% load thumbnail %}
|
||||
{% if record.image %}
|
||||
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
|
||||
<i class="mdi mdi-image"></i></a>
|
||||
{% thumbnail record.image "400x400" as tn %}
|
||||
<a href="{{ record.get_absolute_url }}"
|
||||
class="image-preview"
|
||||
data-preview-url="{{ tn.url }}"
|
||||
data-bs-placement="left"
|
||||
title="{{ record.filename }}"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
aria-label="{{ record.filename }}">
|
||||
<i class="mdi mdi-image"></i>
|
||||
</a>
|
||||
{% endthumbnail %}
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
|
||||
"""
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import django_rq
|
||||
from django.http import HttpResponse
|
||||
@@ -15,7 +15,8 @@ from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.choices import EventRuleActionChoices
|
||||
from extras.events import enqueue_event, flush_events, serialize_for_event
|
||||
from extras.models import EventRule, Tag, Webhook
|
||||
from extras.models import EventRule, Script, Tag, Webhook
|
||||
from extras.signals import process_job_end_event_rules
|
||||
from extras.webhooks import generate_signature, send_webhook
|
||||
from netbox.context_managers import event_tracking
|
||||
from utilities.testing import APITestCase
|
||||
@@ -395,6 +396,36 @@ class EventRuleTest(APITestCase):
|
||||
with patch.object(Session, 'send', dummy_send):
|
||||
send_webhook(**job.kwargs)
|
||||
|
||||
def test_job_completed_webhook_username_fallback(self):
|
||||
"""
|
||||
Ensure job_end event processing can enqueue a webhook even when the EventContext
|
||||
lacks legacy request attributes (e.g. `username`).
|
||||
|
||||
The job_start/job_end signal receivers only populate `user` and `data`, so webhook
|
||||
processing must derive the username from the user object (or tolerate it being unset).
|
||||
"""
|
||||
script_type = ObjectType.objects.get_for_model(Script)
|
||||
webhook_type = ObjectType.objects.get_for_model(Webhook)
|
||||
webhook = Webhook.objects.get(name='Webhook 1')
|
||||
event_rule = EventRule.objects.create(
|
||||
name='Event Rule Job Completed',
|
||||
event_types=[JOB_COMPLETED],
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=webhook_type,
|
||||
action_object_id=webhook.pk,
|
||||
)
|
||||
event_rule.object_types.set([script_type])
|
||||
# Mimic the `core.job_end` signal sender expected by extras.signals.process_job_end_event_rules
|
||||
# (notably: no request, and thus no legacy `username`)
|
||||
sender = Mock(object_type=script_type, data={}, user=self.user)
|
||||
process_job_end_event_rules(sender)
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['event_rule'], event_rule)
|
||||
self.assertEqual(job.kwargs['event_type'], JOB_COMPLETED)
|
||||
self.assertEqual(job.kwargs['object_type'], script_type)
|
||||
self.assertEqual(job.kwargs['username'], self.user.username)
|
||||
|
||||
def test_duplicate_triggers(self):
|
||||
"""
|
||||
Test for erroneous duplicate event triggers resulting from saving an object multiple times
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)'),
|
||||
)
|
||||
|
||||
@@ -13,10 +13,11 @@ def set_vid_ranges(apps, schema_editor):
|
||||
VLANGroup = apps.get_model('ipam', 'VLANGroup')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for group in VLANGroup.objects.using(db_alias).all():
|
||||
vlan_groups = VLANGroup.objects.using(db_alias).only('id', 'min_vid', 'max_vid')
|
||||
for group in vlan_groups:
|
||||
group.vid_ranges = [NumericRange(group.min_vid, group.max_vid, bounds='[]')]
|
||||
group._total_vlan_ids = group.max_vid - group.min_vid + 1
|
||||
group.save()
|
||||
VLANGroup.objects.using(db_alias).bulk_update(vlan_groups, ['vid_ranges', '_total_vlan_ids'], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,14 +11,10 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.utils import field_mapping
|
||||
from strawberry_django import pagination
|
||||
from strawberry_django.fields.field import StrawberryDjangoField
|
||||
|
||||
from core.exceptions import IncompatiblePluginError
|
||||
from netbox.config import PARAMS as CONFIG_PARAMS
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
import storages.utils # type: ignore
|
||||
@@ -28,21 +24,6 @@ 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
|
||||
|
||||
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
|
||||
pagination.OffsetPaginationInput = OffsetPaginationInput
|
||||
|
||||
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
|
||||
StrawberryDjangoField.apply_pagination = apply_pagination
|
||||
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
#
|
||||
@@ -408,6 +389,11 @@ if CACHING_REDIS_CA_CERT_PATH:
|
||||
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
|
||||
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH
|
||||
|
||||
# Merge in KWARGS for additional parameters
|
||||
if caching_redis_kwargs := REDIS['caching'].get('KWARGS'):
|
||||
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
|
||||
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS'].update(caching_redis_kwargs)
|
||||
|
||||
|
||||
#
|
||||
# Sessions
|
||||
@@ -773,7 +759,7 @@ SPECTACULAR_SETTINGS = {
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
'REDOC_DIST': 'SIDECAR',
|
||||
'SERVERS': [{
|
||||
'url': BASE_PATH,
|
||||
'url': '',
|
||||
'description': 'NetBox',
|
||||
}],
|
||||
'SWAGGER_UI_DIST': 'SIDECAR',
|
||||
@@ -817,6 +803,11 @@ if TASKS_REDIS_CA_CERT_PATH:
|
||||
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
|
||||
RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH
|
||||
|
||||
# Merge in KWARGS for additional parameters
|
||||
if tasks_redis_kwargs := TASKS_REDIS.get('KWARGS'):
|
||||
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
|
||||
RQ_PARAMS['REDIS_CLIENT_KWARGS'].update(tasks_redis_kwargs)
|
||||
|
||||
# Define named RQ queues
|
||||
RQ_QUEUES = {
|
||||
RQ_QUEUE_HIGH: RQ_PARAMS,
|
||||
@@ -959,6 +950,26 @@ for plugin_name in PLUGINS:
|
||||
raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
|
||||
|
||||
|
||||
#
|
||||
# Monkey-patching
|
||||
#
|
||||
|
||||
from rest_framework.utils import field_mapping # noqa: E402
|
||||
from strawberry_django import pagination # noqa: E402
|
||||
from strawberry_django.fields.field import StrawberryDjangoField # noqa: E402
|
||||
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination # noqa: E402
|
||||
|
||||
# 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
|
||||
|
||||
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
|
||||
pagination.OffsetPaginationInput = OffsetPaginationInput
|
||||
|
||||
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
|
||||
StrawberryDjangoField.apply_pagination = apply_pagination
|
||||
|
||||
|
||||
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
|
||||
try:
|
||||
from .local_settings import *
|
||||
|
||||
@@ -103,7 +103,7 @@ class TextAttr(ObjectAttribute):
|
||||
def get_value(self, obj):
|
||||
value = resolve_attr_path(obj, self.accessor)
|
||||
# Apply format string (if any)
|
||||
if value and self.format_string:
|
||||
if value is not None and value != '' and self.format_string:
|
||||
return self.format_string.format(value)
|
||||
return value
|
||||
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -150,20 +150,22 @@ function initSidebarAccordions(): void {
|
||||
*/
|
||||
function initImagePreview(): void {
|
||||
for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
|
||||
// Generate a max-width that's a quarter of the screen's width (note - the actual element
|
||||
// width will be slightly larger due to the popover body's padding).
|
||||
const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
|
||||
// Prefer a thumbnail URL for the popover (so we don't preload full-size images),
|
||||
// but fall back to the link target if no thumbnail was provided.
|
||||
const previewUrl = element.dataset.previewUrl ?? element.href;
|
||||
const image = createElement('img', { src: previewUrl });
|
||||
|
||||
// Create an image element that uses the linked image as its `src`.
|
||||
const image = createElement('img', { src: element.href });
|
||||
image.style.maxWidth = maxWidth;
|
||||
// Ensure lazy loading and async decoding
|
||||
image.loading = 'lazy';
|
||||
image.decoding = 'async';
|
||||
|
||||
// Create a container for the image.
|
||||
const content = createElement('div', null, null, [image]);
|
||||
|
||||
// Initialize the Bootstrap Popper instance.
|
||||
new Popover(element, {
|
||||
// Attach this custom class to the popover so that it styling can be controlled via CSS.
|
||||
// Attach this custom class to the popover so that its styling
|
||||
// can be controlled via CSS.
|
||||
customClass: 'image-preview-popover',
|
||||
trigger: 'hover',
|
||||
html: true,
|
||||
|
||||
@@ -89,6 +89,29 @@ img.plugin-icon {
|
||||
}
|
||||
}
|
||||
|
||||
// Image preview popover (rendered for <a.image-preview> by initImagePreview())
|
||||
.image-preview-popover {
|
||||
--bs-popover-max-width: clamp(240px, 25vw, 640px);
|
||||
|
||||
.popover-header {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.popover-body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: clamp(160px, 33vh, 640px);
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
body[data-bs-theme=dark] {
|
||||
// Assuming icon is black/white line art, invert it and tone down brightness
|
||||
img.plugin-icon {
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
// TODO: Remove when Tabler releases fix for https://github.com/tabler/tabler/issues/2271
|
||||
// and NetBox upgrades to that version. Fix merged to Tabler dev branch in PR #2548.
|
||||
:root,
|
||||
:host {
|
||||
@include media-breakpoint-up(lg) {
|
||||
margin-left: 0;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore default foreground & background colors for <pre> blocks
|
||||
pre {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Configuration Data" %}</h2>
|
||||
{% include 'core/inc/config_data.html' with config=object.data %}
|
||||
{% include 'core/inc/config_data.html' %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<tr>
|
||||
<th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
|
||||
{% if config.CUSTOM_VALIDATORS %}
|
||||
<td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
|
||||
<td><pre class="p-0">{{ config.CUSTOM_VALIDATORS }}</pre></td>
|
||||
{% else %}
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
@@ -103,7 +103,7 @@
|
||||
<tr>
|
||||
<th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
|
||||
{% if config.PROTECTION_RULES %}
|
||||
<td class="border-0"><pre>{{ config.PROTECTION_RULES }}</pre></td>
|
||||
<td class="border-0"><pre class="p-0">{{ config.PROTECTION_RULES }}</pre></td>
|
||||
{% else %}
|
||||
<td class="border-0">{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
@@ -116,7 +116,7 @@
|
||||
<tr>
|
||||
<th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
|
||||
{% if config.DEFAULT_USER_PREFERENCES %}
|
||||
<td class="border-0"><pre>{{ config.DEFAULT_USER_PREFERENCES|json }}</pre></td>
|
||||
<td class="border-0"><pre class="p-0">{{ config.DEFAULT_USER_PREFERENCES }}</pre></td>
|
||||
{% else %}
|
||||
<td class="border-0">{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
|
||||
1
netbox/templates/dcim/devicetype/attrs/height.html
Normal file
1
netbox/templates/dcim/devicetype/attrs/height.html
Normal file
@@ -0,0 +1 @@
|
||||
{{ value|floatformat }}U
|
||||
@@ -0,0 +1,34 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Resources" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
|
||||
<td>{{ object.vcpus|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if object.memory %}
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
|
||||
</th>
|
||||
<td>
|
||||
{% if object.disk %}
|
||||
{{ object.disk|humanize_disk_megabytes }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,199 +1 @@
|
||||
{% extends 'virtualization/virtualmachine/base.html' %}
|
||||
{% load buttons %}
|
||||
{% load static %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row my-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Virtual Machine" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Start on boot" %}</th>
|
||||
<td>{% badge object.get_start_on_boot_display bg_color=object.get_start_on_boot_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Role" %}</th>
|
||||
<td>{{ object.role|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Platform" %}</th>
|
||||
<td>{{ object.platform|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Serial Number" %}</th>
|
||||
<td>{{ object.serial|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Config Template" %}</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Primary IPv4" %}</th>
|
||||
<td>
|
||||
{% if object.primary_ip4 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
|
||||
{% if object.primary_ip4.nat_inside %}
|
||||
({% trans "NAT for" %} <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
||||
{% elif object.primary_ip4.nat_outside.exists %}
|
||||
({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||
{% endif %}
|
||||
{% copy_content "primary_ip4" %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Primary IPv6" %}</th>
|
||||
<td>
|
||||
{% if object.primary_ip6 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
|
||||
{% if object.primary_ip6.nat_inside %}
|
||||
({% trans "NAT for" %} <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
|
||||
{% elif object.primary_ip6.nat_outside.exists %}
|
||||
({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||
{% endif %}
|
||||
{% copy_content "primary_ip6" %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Cluster" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{{ object.site|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Cluster" %}</th>
|
||||
<td>
|
||||
{% if object.cluster.group %}
|
||||
{{ object.cluster.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.cluster|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Cluster Type" %}</th>
|
||||
<td>
|
||||
{{ object.cluster.type|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Device" %}</th>
|
||||
<td>
|
||||
{{ object.device|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Resources" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
|
||||
<td>{{ object.vcpus|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if object.memory %}
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
|
||||
</th>
|
||||
<td>
|
||||
{% if object.disk %}
|
||||
{{ object.disk|humanize_disk_megabytes }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Application Services" %}
|
||||
{% if perms.ipam.add_service %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'ipam:service_add' %}?parent_object_type={{ object|content_type_id }}&parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an application service" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'ipam:service_list' virtual_machine_id=object.pk %}
|
||||
</div>
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Virtual Disks" %}
|
||||
{% if perms.virtualization.add_virtualdisk %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Disk" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'virtualization:virtualdisk_list' virtual_machine_id=object.pk %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
|
||||
{% if value.nat_inside %}
|
||||
({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
|
||||
{% elif value.nat_outside.exists %}
|
||||
({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</a>
|
||||
@@ -78,8 +78,8 @@
|
||||
<tr>
|
||||
<th scope="row">{% trans "MAC Address" %}</th>
|
||||
<td>
|
||||
{% if object.mac_address %}
|
||||
<span class="font-monospace">{{ object.mac_address }}</span>
|
||||
{% if object.primary_mac_address %}
|
||||
<span class="font-monospace">{{ object.primary_mac_address|linkify }}</span>
|
||||
<span class="badge text-bg-primary">{% trans "Primary" %}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
23
netbox/tenancy/migrations/0023_add_mptt_tree_indexes.py
Normal file
23
netbox/tenancy/migrations/0023_add_mptt_tree_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)'),
|
||||
)
|
||||
|
||||
@@ -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)'),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ class TokenTable(NetBoxTable):
|
||||
token = columns.TemplateColumn(
|
||||
verbose_name=_('token'),
|
||||
template_code=TOKEN,
|
||||
orderable=False,
|
||||
)
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled')
|
||||
|
||||
24
netbox/users/tests/test_tables.py
Normal file
24
netbox/users/tests/test_tables.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.test import RequestFactory, tag, TestCase
|
||||
|
||||
from users.models import Token
|
||||
from users.tables import TokenTable
|
||||
|
||||
|
||||
class TokenTableTest(TestCase):
|
||||
@tag('regression')
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
tokens = Token.objects.all()
|
||||
disallowed = {'actions'}
|
||||
|
||||
orderable_columns = [
|
||||
column.name for column in TokenTable(tokens).columns
|
||||
if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get("/")
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
with self.subTest(col=col, direction=direction):
|
||||
table = TokenTable(tokens)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
@@ -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',
|
||||
@@ -163,11 +165,35 @@ class ContentTypeFilter(django_filters.CharFilter):
|
||||
|
||||
try:
|
||||
app_label, model = value.lower().split('.')
|
||||
except ValueError:
|
||||
content_type = ContentType.objects.get_by_natural_key(app_label, model)
|
||||
except (ValueError, ContentType.DoesNotExist):
|
||||
return qs.none()
|
||||
return qs.filter(
|
||||
**{
|
||||
f'{self.field_name}__app_label': app_label,
|
||||
f'{self.field_name}__model': model
|
||||
f'{self.field_name}': content_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
0
netbox/virtualization/ui/__init__.py
Normal file
0
netbox/virtualization/ui/__init__.py
Normal file
34
netbox/virtualization/ui/panels.py
Normal file
34
netbox/virtualization/ui/panels.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import attrs, panels
|
||||
|
||||
|
||||
class VirtualMachinePanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
start_on_boot = attrs.ChoiceAttr('start_on_boot')
|
||||
role = attrs.RelatedObjectAttr('role', linkify=True)
|
||||
platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
|
||||
description = attrs.TextAttr('description')
|
||||
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
|
||||
primary_ip4 = attrs.TemplatedAttr(
|
||||
'primary_ip4',
|
||||
label=_('Primary IPv4'),
|
||||
template_name='virtualization/virtualmachine/attrs/ipaddress.html',
|
||||
)
|
||||
primary_ip6 = attrs.TemplatedAttr(
|
||||
'primary_ip6',
|
||||
label=_('Primary IPv6'),
|
||||
template_name='virtualization/virtualmachine/attrs/ipaddress.html',
|
||||
)
|
||||
|
||||
|
||||
class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Cluster')
|
||||
|
||||
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
|
||||
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
|
||||
cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
@@ -10,12 +10,15 @@ from dcim.filtersets import DeviceFilterSet
|
||||
from dcim.forms import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
from dcim.tables import DeviceTable
|
||||
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import IPAddress, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.object_actions import (
|
||||
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename, DeleteObject, EditObject,
|
||||
)
|
||||
from netbox.ui import actions, layout
|
||||
from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel
|
||||
from netbox.views import generic
|
||||
from utilities.query import count_related
|
||||
from utilities.query_functions import CollateAsChar
|
||||
@@ -23,6 +26,7 @@ from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
from .object_actions import BulkAddComponents
|
||||
from .ui import panels
|
||||
|
||||
|
||||
#
|
||||
@@ -316,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()
|
||||
|
||||
@@ -336,6 +341,7 @@ class ClusterAddDevicesView(generic.ObjectEditView):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
|
||||
@register_model_view(VirtualMachine, 'list', path='', detail=False)
|
||||
class VirtualMachineListView(generic.ObjectListView):
|
||||
queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
|
||||
@@ -348,6 +354,44 @@ class VirtualMachineListView(generic.ObjectListView):
|
||||
@register_model_view(VirtualMachine)
|
||||
class VirtualMachineView(generic.ObjectView):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VirtualMachinePanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.VirtualMachineClusterPanel(),
|
||||
TemplatePanel('virtualization/panels/virtual_machine_resources.html'),
|
||||
ObjectsTablePanel(
|
||||
model='ipam.Service',
|
||||
title=_('Application Services'),
|
||||
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'ipam.Service',
|
||||
url_params={
|
||||
'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
|
||||
'parent': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
ImageAttachmentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='virtualization.VirtualDisk',
|
||||
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'virtualization.VirtualDisk', url_params={'virtual_machine': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VirtualMachine, 'interfaces')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
19
netbox/wireless/migrations/0018_add_mptt_tree_indexes.py
Normal file
19
netbox/wireless/migrations/0018_add_mptt_tree_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user