Compare commits

..

32 Commits

Author SHA1 Message Date
Martin Hauser
add9c4c23d fix(extras): Handle username fallback for job events
Fallback to the associated user when username is missing from job
lifecycle event contexts. Add a regression test to ensure JOB_COMPLETED
webhooks are enqueued without a request context.

Fixes #21371
2026-02-16 20:24:41 +01:00
Jeremy Stretch
816c5d4bea Fixes #21412: Defer monkey-patching until after settings have been loaded (#21415) 2026-02-16 18:17:50 +01:00
Martin Hauser
f4c3c90bab perf(filters): Avoid ContentType join in ContentTypeFilter
Resolve the ContentType via get_by_natural_key() and filter by the
FK value to prevent an unnecessary join to django_content_type.

Fixes #21420
2026-02-16 12:06:31 -05:00
Martin Hauser
862593f2dd fix(circuits): Persist CircuitType owner field
CircuitTypeForm rendered `owner` twice and did not persist ownership
because the displayed fields didn't match the fields processed by the
form. Remove `owner` from the fieldset and include it in `Meta.fields`
to keep rendering and form processing in sync.

Fixes #21397
2026-02-16 08:54:34 -05:00
Martin Hauser
f4c27fd494 fix(ipam): Use bulk_update in VLANGroup VID range migration
Replace per-row `save()` calls with `bulk_update` when populating
VLANGroup VLAN ID ranges during migration.

This avoids triggering post_save handlers (e.g. search cache/indexing)
on existing VLANGroup records and updates only the relevant fields,
improving both reliability and performance on larger databases.

Fixes #21375
2026-02-16 08:53:16 -05:00
Martin Hauser
ae736ef407 fix(dcim): Render device height as rack units via floatformat
Use `TemplatedAttr` for device height and render using Django's
`floatformat` filter so 0.0 is displayed as `0U` (and whole-U values
omit the decimal).

Fixes #21267
2026-02-16 08:37:50 -05:00
github-actions
d95b1186fb Update source translation strings 2026-02-14 05:18:04 +00:00
Jason Novinger
d6b9d30086 Fixes #20442: Mark template-accessible methods with alters_data=True (#21431)
Add alters_data=True to methods that modify database or filesystem state
and are accessible from Jinja2 sandbox template contexts:

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

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

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

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

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

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

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

* cleanup

* cleanup

* cleanup

* Update netbox/netbox/settings.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/netbox/settings.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* document in REDIS config section

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-02-12 08:35:20 -05:00
Jason Novinger
6c383f293c Fixes #20435: Fix navigation margin issue when scrollbar appears (#21403)
Override Tabler's problematic margin-left: calc(100vw - 100%) rule that
causes a gap between the sidebar and main content when vertical scrollbar
is present on Windows/Linux browsers.

Uses scrollbar-gutter: stable to match the upstream fix in Tabler PR #2548.
2026-02-12 11:30:33 +01:00
github-actions
5bf516c63d Update source translation strings 2026-02-12 05:28:54 +00:00
Aditya Sharma
7df062d590 Fixes #21358: Prevent exception when sorting by Token column (#21391)
Mark the `token` TemplateColumn as non-orderable since it maps to a
Python property rather than a database field, causing a FieldError
when django-tables2 attempts to sort by it.

Add a regression test for TokenTable following the existing pattern
in circuits and vpn test suites.
2026-02-12 00:21:49 +01:00
Aditya Sharma
4b22be03a0 Fixes #21354: Fix Swagger-UI generating wrong URLs when BASE_PATH is set (#21392) 2026-02-11 11:35:13 -08:00
Dylan Lucci
24769ce127 Closes #21266: Add installed device table columns to DeviceBay table (#21348)
Expose additional properties of the device installed in each bay as
configurable table columns.

- Rename `role` → `installed_role`
- Rename `device_type` → `installed_device_type`
- Add `installed_description`, `installed_serial`, and
  `installed_asset_tag` columns to `DeviceBayTable`

---------

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
2026-02-11 13:55:37 +01:00
github-actions
164e9db98d Update source translation strings 2026-02-11 05:29:43 +00:00
Martin Hauser
23f1c86e9c Closes #20211: Use thumbnails for ImageAttachment hover previews to improve page load performance (#21386) 2026-02-10 11:01:33 -06:00
Martin Hauser
02ffdd9d5d Closes #21268: Add Device Type details panel to Device view (#21368) 2026-02-10 10:37:35 -06:00
Martin Hauser
5013297326 feat(virtualization): Refactor VirtualMachine view to UI layout
Migrate the VirtualMachine detail view to SimpleLayout with standardized
panels for attributes, clusters, and resources. Modularize templates
to improve maintainability and reuse.

Fixes #21337
2026-02-10 10:22:18 -05:00
github-actions
584e0a9b8c Update source translation strings 2026-02-10 05:29:34 +00:00
Martin Hauser
3ac9d0b8bf Closes #20981: Enhance JSON rendering for Custom Validators and Protection Rules in Config Revision View (#21376)
* feat(config): Add extra context to ConfigRevisionView

Introduces `get_extra_context` method for `ConfigRevisionView` to
format JSON-based attributes like `CUSTOM_VALIDATORS`,
`DEFAULT_USER_PREFERENCES`, and `PROTECTION_RULES`.
This ensures clearer rendering of configuration data in the UI.

Fixes #20981

* Reduce padding on JSON blocks

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-02-09 09:48:39 -05:00
github-actions
b387ea5f58 Update source translation strings 2026-02-06 05:22:42 +00:00
bctiemann
ba9f6bf359 Fixes: #19129 - Richer display of MAC addresses in InterfaceTable when multiple MACs are present (#21270)
* Richer display of MAC addresses in InterfaceTable when multiple MACs are present

* Fix docstring

* Fix docstring

* Use mac_address_display in interface detail page

* Ensure "-" null placeholder still shows up on detail page

* Also include vminterface.html

* Simplify Multiple MAC addresses with additional selectable column for tables in list view and detail view

* Use ManyToManyColumn
2026-02-05 11:16:31 -05:00
Martin Hauser
ee6cbdcefe Fixes #21320: Prevent Rack validation errors when site or optional fields are missing during import (#21321) 2026-02-03 09:32:07 -06:00
76 changed files with 2054 additions and 1341 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
{{ value|floatformat }}U

View File

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

View File

@@ -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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ class TokenTable(NetBoxTable):
token = columns.TemplateColumn(
verbose_name=_('token'),
template_code=TOKEN,
orderable=False,
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled')

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

View File

@@ -1,6 +1,7 @@
import django_filters
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django_filters.constants import EMPTY_VALUES
from drf_spectacular.types import OpenApiTypes
@@ -10,6 +11,7 @@ __all__ = (
'ContentTypeFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter',
'MultiValueContentTypeFilter',
'MultiValueDateFilter',
'MultiValueDateTimeFilter',
'MultiValueDecimalFilter',
@@ -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,
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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