mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-11 19:37:45 +01:00
Compare commits
62 Commits
20068-impo
...
v4.4.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8a9ef9de | ||
|
|
6beb079b97 | ||
|
|
c8aad24a1b | ||
|
|
42bd876604 | ||
|
|
f903442cb9 | ||
|
|
5a64cb712d | ||
|
|
4d90d559be | ||
|
|
19de058f94 | ||
|
|
dc00e19c3c | ||
|
|
6ed6da49d9 | ||
|
|
7154d4ae2e | ||
|
|
bc26529be8 | ||
|
|
da64c564ae | ||
|
|
6199b3e039 | ||
|
|
2a391253a5 | ||
|
|
914653d63e | ||
|
|
3813aad8b1 | ||
|
|
ea5371040e | ||
|
|
6c824cc48f | ||
|
|
f510e40428 | ||
|
|
860db9590b | ||
|
|
7c63d001b1 | ||
|
|
93119f52c3 | ||
|
|
ee2aa35cba | ||
|
|
7896a48075 | ||
|
|
eb87c3f304 | ||
|
|
3acbb0a08c | ||
|
|
f67cc47def | ||
|
|
f7219e0672 | ||
|
|
e5a975176d | ||
|
|
83ee4fb593 | ||
|
|
db8271c904 | ||
|
|
5a24f99c9d | ||
|
|
9318c91405 | ||
|
|
5c6aaf2388 | ||
|
|
265f375595 | ||
|
|
d95fa8dbb2 | ||
|
|
2699149016 | ||
|
|
f371004809 | ||
|
|
ad29402b87 | ||
|
|
598f8d034d | ||
|
|
ec13a79907 | ||
|
|
21f4036782 | ||
|
|
ce3738572c | ||
|
|
cbb979934e | ||
|
|
642d83a4c6 | ||
|
|
a06c12c6b8 | ||
|
|
60fce84c96 | ||
|
|
59afa0b41d | ||
|
|
14b246cb8a | ||
|
|
f0507d00bf | ||
|
|
77b389f105 | ||
|
|
970f2bd4ed | ||
|
|
a4ee323cb6 | ||
|
|
17e5184a11 | ||
|
|
e1548bb290 | ||
|
|
269112a565 | ||
|
|
c6672538ac | ||
|
|
9ae53fc232 | ||
|
|
6efb258b9f | ||
|
|
da1e0f4b53 | ||
|
|
7f39f75d3d |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.7
|
||||
placeholder: v4.4.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.7
|
||||
placeholder: v4.4.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-16-blue" alt="Languages supported" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||
<p>
|
||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||
|
||||
3624
contrib/openapi.json
3624
contrib/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@ While permissions are typically assigned to specific groups and/or users, it is
|
||||
|
||||
### Viewing Objects
|
||||
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted permission to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
|
||||
If the permission _has_ been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
|
||||
|
||||
@@ -102,9 +102,9 @@ If the permission _has_ been granted, NetBox will compile any specified constrai
|
||||
This grants the user access to view any device that is assigned to a site named NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints are equivalent to the following ORM query:
|
||||
|
||||
```no-highlight
|
||||
Site.objects.filter(
|
||||
Device.objects.filter(
|
||||
Q(site__name__in=['NYC1', 'NYC2']),
|
||||
Q(status='active', tenant__isnull=True)
|
||||
Q(status='offline', tenant__isnull=True)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ The plugin source directory contains all the actual Python code and other resour
|
||||
|
||||
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
|
||||
|
||||
```python
|
||||
```python title="__init__.py"
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
class FooBarConfig(PluginConfig):
|
||||
@@ -151,7 +151,7 @@ Any additional apps must be installed within the same Python environment as NetB
|
||||
|
||||
An example `pyproject.toml` is below:
|
||||
|
||||
```
|
||||
```toml title="pyproject.toml"
|
||||
# See PEP 518 for the spec of this file
|
||||
# https://www.python.org/dev/peps/pep-0518/
|
||||
|
||||
@@ -179,11 +179,24 @@ classifiers=[
|
||||
]
|
||||
|
||||
requires-python = ">=3.10.0"
|
||||
|
||||
```
|
||||
|
||||
Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
Consider adding a file named `COMPATIBILITY.md` to your plugin project root (alongside `pyproject.toml`). This file should contain a table listing the minimum and maximum supported versions of NetBox (`min_version` and `max_version`) for each release. This serves as a handy reference for users who are upgrading from a previous version of your plugin. An example is shown below:
|
||||
|
||||
```markdown title="COMPATIBILITY.md"
|
||||
# Compatibility Matrix
|
||||
|
||||
| Release | Minimum NetBox Version | Maximum NetBox Version |
|
||||
|---------|------------------------|------------------------|
|
||||
| 0.2.0 | 4.4.0 | 4.5.x |
|
||||
| 0.1.1 | 4.3.0 | 4.4.x |
|
||||
| 0.1.0 | 4.3.0 | 4.4.x |
|
||||
```
|
||||
|
||||
## Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like;however, it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
# NetBox v4.4
|
||||
|
||||
## v4.4.10 (2026-01-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20953](https://github.com/netbox-community/netbox/issues/20953) - Show reverse bridge relationships on interface detail pages
|
||||
* [#21071](https://github.com/netbox-community/netbox/issues/21071) - Include request method & URL when displaying server errors
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19506](https://github.com/netbox-community/netbox/issues/19506) - Add filter forms for component templates to ensure object selector support
|
||||
* [#20044](https://github.com/netbox-community/netbox/issues/20044) - Fix dark mode support for rack elevations
|
||||
* [#20320](https://github.com/netbox-community/netbox/issues/20320) - Restore support for selecting related interfaces when bulk editing device interfaces
|
||||
* [#20817](https://github.com/netbox-community/netbox/issues/20817) - Re-enable sync button when disabling scheduled syncing for a data source
|
||||
* [#21045](https://github.com/netbox-community/netbox/issues/21045) - Fix `ValueError` exception when saving a site with an assigned prefix
|
||||
* [#21049](https://github.com/netbox-community/netbox/issues/21049) - Ignore stale custom field data when validating an object
|
||||
* [#21063](https://github.com/netbox-community/netbox/issues/21063) - Check for duplicate choice values when validating a custom field choice set
|
||||
* [#21064](https://github.com/netbox-community/netbox/issues/21064) - Ensures that extra choices in custom field choice sets preserve escaped colons
|
||||
|
||||
---
|
||||
|
||||
## v4.4.9 (2025-12-23)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20309](https://github.com/netbox-community/netbox/issues/20309) - Support ASDOT notation for ASN ranges
|
||||
* [#20720](https://github.com/netbox-community/netbox/issues/20720) - Add Latvian translations
|
||||
* [#20900](https://github.com/netbox-community/netbox/issues/20900) - Allow filtering custom choice fields by multiple values in the UI
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17976](https://github.com/netbox-community/netbox/issues/17976) - Remove `devicetype_count` from nested manufacturer to correct OpenAPI schema
|
||||
* [#20011](https://github.com/netbox-community/netbox/issues/20011) - Provide a clear message when encountering duplicate object IDs during bulk import
|
||||
* [#20114](https://github.com/netbox-community/netbox/issues/20114) - Preserve `parent_bay` during device bulk import when tags are present
|
||||
* [#20491](https://github.com/netbox-community/netbox/issues/20491) - Improve handling of numeric ranges in tests
|
||||
* [#20873](https://github.com/netbox-community/netbox/issues/20873) - Fix `AttributeError` exception triggered by event rules associated with an object that supports file attachments
|
||||
* [#20875](https://github.com/netbox-community/netbox/issues/20875) - Ensure that parent object relations are cached (for filtering) on device/module components during instantiation
|
||||
* [#20876](https://github.com/netbox-community/netbox/issues/20876) - Allow editing an IP address that resides within a range marked as populated
|
||||
* [#20912](https://github.com/netbox-community/netbox/issues/20912) - Fix inconsistent clearing of `module` field on ModuleBay
|
||||
* [#20944](https://github.com/netbox-community/netbox/issues/20944) - Ensure cached scope is updated on child objects when a parent region/site/location is changed
|
||||
* [#20948](https://github.com/netbox-community/netbox/issues/20948) - Handle the deletion of related objects with `on_delete=RESTRICT` the same as `CASCADE`
|
||||
* [#20969](https://github.com/netbox-community/netbox/issues/20969) - Fix querying of front port templates by `rear_port_id`
|
||||
* [#21011](https://github.com/netbox-community/netbox/issues/21011) - Avoid writing to the database when loading active ConfigRevision
|
||||
* [#21032](https://github.com/netbox-community/netbox/issues/21032) - Avoid SQL subquery in RestrictedQuerySet where unnecessary
|
||||
|
||||
---
|
||||
|
||||
## v4.4.8 (2025-12-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
|
||||
* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
|
||||
* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
|
||||
* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
|
||||
* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
|
||||
* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
|
||||
|
||||
---
|
||||
|
||||
## v4.4.7 (2025-11-25)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -63,16 +63,20 @@ class ConfigRevision(models.Model):
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('core:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
def activate(self, update_db=True):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
|
||||
Parameters:
|
||||
update_db: Mark the ConfigRevision as active in the database (default: True)
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
if update_db:
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
|
||||
activate.alters_data = True
|
||||
|
||||
|
||||
@@ -131,6 +131,19 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# If recurring sync is disabled for an existing DataSource, clear any pending sync jobs for it and reset its
|
||||
# "queued" status
|
||||
if not self._state.adding and not self.sync_interval:
|
||||
self.jobs.filter(status=JobStatusChoices.STATUS_PENDING).delete()
|
||||
if self.status == DataSourceStatusChoices.QUEUED and self.last_synced:
|
||||
self.status = DataSourceStatusChoices.COMPLETED
|
||||
elif self.status == DataSourceStatusChoices.QUEUED:
|
||||
self.status = DataSourceStatusChoices.NEW
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from threading import local
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db.models import CASCADE
|
||||
from django.db.models import CASCADE, RESTRICT
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
@@ -221,7 +221,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
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 is not CASCADE:
|
||||
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
|
||||
setattr(obj, related_field_name, None)
|
||||
obj.save()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -183,6 +183,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge_interfaces = NestedInterfaceSerializer(many=True, read_only=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||
@@ -222,13 +223,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
|
||||
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
|
||||
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
|
||||
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
|
||||
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
@@ -20,4 +20,4 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||
|
||||
@@ -875,7 +875,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
null_value=None
|
||||
)
|
||||
rear_port_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RearPort.objects.all()
|
||||
queryset=RearPortTemplate.objects.all()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1626,6 +1626,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
choices=DeviceStatusChoices,
|
||||
field_name='device__status',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label=_('Tenant (ID)'),
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__tenant__slug',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Tenant (slug)'),
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -10,6 +10,7 @@ from ipam.models import ASN, VRF, VLANTranslationPolicy
|
||||
from netbox.choices import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from tenancy.models import Tenant
|
||||
from users.models import User
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
@@ -23,35 +24,45 @@ __all__ = (
|
||||
'CableFilterForm',
|
||||
'ConsoleConnectionFilterForm',
|
||||
'ConsolePortFilterForm',
|
||||
'ConsolePortTemplateFilterForm',
|
||||
'ConsoleServerPortFilterForm',
|
||||
'ConsoleServerPortTemplateFilterForm',
|
||||
'DeviceBayFilterForm',
|
||||
'DeviceBayTemplateFilterForm',
|
||||
'DeviceFilterForm',
|
||||
'DeviceRoleFilterForm',
|
||||
'DeviceTypeFilterForm',
|
||||
'FrontPortFilterForm',
|
||||
'FrontPortTemplateFilterForm',
|
||||
'InterfaceConnectionFilterForm',
|
||||
'InterfaceFilterForm',
|
||||
'InterfaceTemplateFilterForm',
|
||||
'InventoryItemFilterForm',
|
||||
'InventoryItemTemplateFilterForm',
|
||||
'InventoryItemRoleFilterForm',
|
||||
'LocationFilterForm',
|
||||
'MACAddressFilterForm',
|
||||
'ManufacturerFilterForm',
|
||||
'ModuleFilterForm',
|
||||
'ModuleBayFilterForm',
|
||||
'ModuleBayTemplateFilterForm',
|
||||
'ModuleTypeFilterForm',
|
||||
'ModuleTypeProfileFilterForm',
|
||||
'PlatformFilterForm',
|
||||
'PowerConnectionFilterForm',
|
||||
'PowerFeedFilterForm',
|
||||
'PowerOutletFilterForm',
|
||||
'PowerOutletTemplateFilterForm',
|
||||
'PowerPanelFilterForm',
|
||||
'PowerPortFilterForm',
|
||||
'PowerPortTemplateFilterForm',
|
||||
'RackFilterForm',
|
||||
'RackElevationFilterForm',
|
||||
'RackReservationFilterForm',
|
||||
'RackRoleFilterForm',
|
||||
'RackTypeFilterForm',
|
||||
'RearPortFilterForm',
|
||||
'RearPortTemplateFilterForm',
|
||||
'RegionFilterForm',
|
||||
'SiteFilterForm',
|
||||
'SiteGroupFilterForm',
|
||||
@@ -120,6 +131,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
)
|
||||
tenant_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -128,7 +144,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id',
|
||||
'device_type_id': '$device_type_id',
|
||||
'role_id': '$role_id'
|
||||
'role_id': '$role_id',
|
||||
'tenant_id': '$tenant_id'
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
@@ -1283,6 +1300,23 @@ class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceComponentTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device type'),
|
||||
)
|
||||
|
||||
|
||||
class ModularDeviceComponentTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
module_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
required=False,
|
||||
query_params={'manufacturer_id': '$manufacturer_id'},
|
||||
label=_('Module Type'),
|
||||
)
|
||||
|
||||
|
||||
class CabledFilterForm(forms.Form):
|
||||
cabled = forms.NullBooleanField(
|
||||
label=_('Cabled'),
|
||||
@@ -1317,31 +1351,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
speed = forms.MultipleChoiceField(
|
||||
label=_('Speed'),
|
||||
choices=ConsolePortSpeedChoices,
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
@@ -1359,6 +1369,59 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsolePortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
speed = forms.MultipleChoiceField(
|
||||
label=_('Speed'),
|
||||
choices=ConsolePortSpeedChoices,
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsoleServerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerPort
|
||||
fieldsets = (
|
||||
@@ -1366,7 +1429,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
)
|
||||
@@ -1378,6 +1442,20 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerPortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
@@ -1385,7 +1463,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
@@ -1407,6 +1485,20 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerOutletTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerOutletTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
@@ -1418,7 +1510,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'vdc_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
@@ -1533,13 +1626,59 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = InterfaceTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('rf_role', name=_('Wireless')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=InterfaceTypeChoices,
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
label=_('Management only'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
poe_mode = forms.MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label=_('PoE mode')
|
||||
)
|
||||
poe_type = forms.MultipleChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
label=_('Wireless role')
|
||||
)
|
||||
|
||||
|
||||
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
)
|
||||
@@ -1556,6 +1695,24 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = FrontPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
@@ -1563,7 +1720,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
@@ -1580,6 +1737,24 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = RearPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
@@ -1587,7 +1762,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
)
|
||||
@@ -1598,6 +1773,19 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ModuleBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
position = forms.CharField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
@@ -1605,13 +1793,22 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = DeviceBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
|
||||
|
||||
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
@@ -1622,7 +1819,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
)
|
||||
@@ -1659,6 +1856,25 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = InventoryItemTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'role_id', 'manufacturer_id', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
#
|
||||
|
||||
@@ -681,8 +681,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.name,
|
||||
label=self.label,
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -1222,6 +1222,8 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
|
||||
def save(self, *args, **kwargs):
|
||||
if self.module:
|
||||
self.parent = self.module.module_bay
|
||||
else:
|
||||
self.parent = None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -957,6 +957,11 @@ class Device(
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
# Set denormalized references
|
||||
for component in components:
|
||||
component._site = self.site
|
||||
component._location = self.location
|
||||
component._rack = self.rack
|
||||
components = model.objects.bulk_create(components)
|
||||
# Prefetch related objects to minimize queries needed during post_save
|
||||
prefetch_fields = get_prefetchable_fields(model)
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
from dcim.utils import update_interface_bridges
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -316,6 +315,12 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
for component in create_instances:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
# Set denormalized references
|
||||
for component in create_instances:
|
||||
component._site = self.device.site
|
||||
component._location = self.device.location
|
||||
component._rack = self.device.rack
|
||||
|
||||
if component_model is not ModuleBay:
|
||||
component_model.objects.bulk_create(create_instances)
|
||||
# Emit the post_save signal for each newly created object
|
||||
@@ -331,7 +336,6 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
# ModuleBays must be saved individually for MPTT
|
||||
for instance in create_instances:
|
||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||
instance.save()
|
||||
|
||||
update_fields = ['module']
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from dcim.choices import CableEndChoices, LinkStatusChoices
|
||||
from virtualization.models import VMInterface
|
||||
from ipam.models import Prefix
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
|
||||
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
|
||||
InventoryItem, Location, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
@@ -44,6 +46,9 @@ def handle_location_site_change(instance, created, **kwargs):
|
||||
Device.objects.filter(location__in=locations).update(site=instance.site)
|
||||
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
|
||||
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
|
||||
# Update component models for devices in these locations
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device__location__in=locations).update(_site=instance.site)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rack)
|
||||
@@ -53,6 +58,12 @@ def handle_rack_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
if not created:
|
||||
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
||||
# Update component models for devices in this rack
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device__rack=instance).update(
|
||||
_site=instance.site,
|
||||
_location=instance.location,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Device)
|
||||
@@ -171,3 +182,40 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
|
||||
if created and not raw and instance.primary_mac_address:
|
||||
instance.primary_mac_address.assigned_object = instance
|
||||
instance.primary_mac_address.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Location)
|
||||
@receiver(post_save, sender=Site)
|
||||
def sync_cached_scope_fields(instance, created, **kwargs):
|
||||
"""
|
||||
Rebuild cached scope fields for all CachedScopeMixin-based models
|
||||
affected by a change in a Region, SiteGroup, Site, or Location.
|
||||
|
||||
This method is safe to run for objects created in the past and does
|
||||
not rely on incremental updates. Cached fields are recomputed from
|
||||
authoritative relationships.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
|
||||
if isinstance(instance, Location):
|
||||
filters = {'_location': instance}
|
||||
elif isinstance(instance, Site):
|
||||
filters = {'_site': instance}
|
||||
else:
|
||||
return
|
||||
|
||||
# These models are explicitly listed because they all subclass CachedScopeMixin
|
||||
# and therefore require their cached scope fields to be recomputed.
|
||||
for model in (Prefix, Cluster, WirelessLAN):
|
||||
qs = model.objects.filter(**filters)
|
||||
|
||||
for obj in qs:
|
||||
# Recompute cache using the same logic as save()
|
||||
obj.cache_related_objects()
|
||||
obj.save(update_fields=[
|
||||
'_location',
|
||||
'_site',
|
||||
'_site_group',
|
||||
'_region',
|
||||
])
|
||||
|
||||
@@ -531,7 +531,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Manufacturer
|
||||
brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Manufacturer 4',
|
||||
|
||||
@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
|
||||
params = {'device_status': ['active', 'planned']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceComponentTemplateFilterSetTests:
|
||||
|
||||
@@ -3377,9 +3384,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -3389,6 +3404,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -3398,6 +3414,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -3617,9 +3634,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -3629,6 +3654,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -3638,6 +3664,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -3857,9 +3884,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -3869,6 +3904,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -3878,6 +3914,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -4111,9 +4148,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -4123,6 +4168,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -4132,6 +4178,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -4390,9 +4437,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
virtual_chassis = VirtualChassis(name='Virtual Chassis')
|
||||
virtual_chassis.save()
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1A',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -4405,6 +4460,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 1B',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -4417,6 +4473,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -4426,6 +4483,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5011,9 +5069,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5023,6 +5089,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5032,6 +5099,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5302,9 +5370,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5314,6 +5390,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5323,6 +5400,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5579,9 +5657,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5591,6 +5677,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5600,6 +5687,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5752,9 +5840,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5764,6 +5860,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5773,6 +5870,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
|
||||
@@ -6,6 +6,7 @@ from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from ipam.models import Prefix
|
||||
from netbox.choices import WeightUnitChoices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.data import drange
|
||||
@@ -792,8 +793,80 @@ class ModuleBayTestCase(TestCase):
|
||||
)
|
||||
device.consoleports.first()
|
||||
|
||||
def test_nested_module_token(self):
|
||||
pass
|
||||
@tag('regression') # #19918
|
||||
def test_nested_module_bay_label_resolution(self):
|
||||
"""Test that nested module bay labels properly resolve {module} placeholders"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
# Create device type with module bay template (position='A')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Device with Bays',
|
||||
slug='device-with-bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Bay A',
|
||||
position='A'
|
||||
)
|
||||
|
||||
# Create module type with nested bay template using {module} placeholder
|
||||
module_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Module with Nested Bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='SFP {module}-21',
|
||||
label='{module}-21',
|
||||
position='21'
|
||||
)
|
||||
|
||||
# Create device and install module
|
||||
device = Device.objects.create(
|
||||
name='Test Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
module_bay = device.modulebays.get(name='Bay A')
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=module_bay,
|
||||
module_type=module_type
|
||||
)
|
||||
|
||||
# Verify nested bay label resolves {module} to parent position
|
||||
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||
self.assertEqual(nested_bay.label, 'A-21')
|
||||
|
||||
@tag('regression') # #20912
|
||||
def test_module_bay_parent_cleared_when_module_removed(self):
|
||||
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
|
||||
device = Device.objects.first()
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type')
|
||||
bay1 = ModuleBay.objects.create(device=device, name='Test Bay 1')
|
||||
bay2 = ModuleBay.objects.create(device=device, name='Test Bay 2')
|
||||
|
||||
# Install a module in bay1
|
||||
module1 = Module.objects.create(device=device, module_bay=bay1, module_type=module_type)
|
||||
|
||||
# Assign bay2 to module1 and verify parent is now set to bay1 (module1's bay)
|
||||
bay2.module = module1
|
||||
bay2.save()
|
||||
bay2.refresh_from_db()
|
||||
self.assertEqual(bay2.parent, bay1)
|
||||
self.assertEqual(bay2.module, module1)
|
||||
|
||||
# Clear the module assignment (return bay2 to device level) Verify parent is cleared
|
||||
bay2.module = None
|
||||
bay2.save()
|
||||
bay2.refresh_from_db()
|
||||
self.assertIsNone(bay2.parent)
|
||||
self.assertIsNone(bay2.module)
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
@@ -1120,3 +1193,14 @@ class VirtualChassisTestCase(TestCase):
|
||||
device2.vc_position = 1
|
||||
with self.assertRaises(ValidationError):
|
||||
device2.full_clean()
|
||||
|
||||
|
||||
class SiteSignalTestCase(TestCase):
|
||||
|
||||
@tag('regression')
|
||||
def test_edit_site_with_prefix_no_vrf(self):
|
||||
site = Site.objects.create(name='Test Site', slug='test-site')
|
||||
Prefix.objects.create(prefix='192.0.2.0/24', scope=site, vrf=None)
|
||||
|
||||
# Regression test for #21045: should not raise ValueError
|
||||
site.save()
|
||||
|
||||
@@ -2322,6 +2322,32 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
url = reverse('dcim:device_inventory', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_bulk_import_duplicate_ids_error_message(self):
|
||||
device = Device.objects.first()
|
||||
csv_data = (
|
||||
"id,role",
|
||||
f"{device.pk},Device Role 1",
|
||||
f"{device.pk},Device Role 2",
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_device', 'dcim.change_device')
|
||||
response = self.client.post(
|
||||
self._get_url('bulk_import'),
|
||||
{
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
},
|
||||
follow=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
f'Duplicate objects found: Device with ID(s) {device.pk} appears multiple times',
|
||||
response.content.decode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
class ModuleTestCase(
|
||||
# Module does not support bulk renaming (no name field) or
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.object_actions import *
|
||||
from netbox.views import generic
|
||||
@@ -2454,11 +2454,12 @@ class DeviceBulkImportView(generic.BulkImportView):
|
||||
model_form = forms.DeviceImportForm
|
||||
|
||||
def save_object(self, object_form, request):
|
||||
parent_bay = getattr(object_form.instance, 'parent_bay', None)
|
||||
obj = object_form.save()
|
||||
|
||||
# For child devices, save the reverse relation to the parent device bay
|
||||
if getattr(obj, 'parent_bay', None):
|
||||
device_bay = obj.parent_bay
|
||||
if parent_bay:
|
||||
device_bay = parent_bay
|
||||
device_bay.installed_device = obj
|
||||
device_bay.save()
|
||||
|
||||
@@ -2899,6 +2900,7 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
'bridge_interfaces': bridge_interfaces,
|
||||
'bridge_interfaces_table': bridge_interfaces_table,
|
||||
'child_interfaces_table': child_interfaces_table,
|
||||
'vlan_table': vlan_table,
|
||||
|
||||
@@ -119,7 +119,9 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
params["request"] = copy_safe_request(request)
|
||||
# Exclude FILES - webhooks don't need uploaded files,
|
||||
# which can cause pickle errors with Pillow.
|
||||
params["request"] = copy_safe_request(request, include_files=False)
|
||||
|
||||
# Enqueue the task
|
||||
rq_queue.enqueue(
|
||||
|
||||
@@ -189,22 +189,22 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, forms.ModelForm):
|
||||
# if standardize these, we can simplify this code
|
||||
|
||||
# Convert extra_choices Array Field from model to CharField for form
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
extra_choices = self.initial['extra_choices']
|
||||
if extra_choices := self.initial.get('extra_choices', None):
|
||||
if isinstance(extra_choices, str):
|
||||
extra_choices = [extra_choices]
|
||||
choices = ""
|
||||
choices = []
|
||||
for choice in extra_choices:
|
||||
# Setup choices in Add Another use case
|
||||
if isinstance(choice, str):
|
||||
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
|
||||
choices += choice_str + "\n"
|
||||
choices.append(choice_str)
|
||||
# Setup choices in Edit use case
|
||||
elif isinstance(choice, list):
|
||||
choice_str = ":".join(choice)
|
||||
choices += choice_str + "\n"
|
||||
value = choice[0].replace(':', '\\:')
|
||||
label = choice[1].replace(':', '\\:')
|
||||
choices.append(f'{value}:{label}')
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
self.initial['extra_choices'] = '\n'.join(choices)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
data = []
|
||||
|
||||
@@ -2,11 +2,14 @@ import logging
|
||||
import traceback
|
||||
from contextlib import ExitStack
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.signals import clear_events
|
||||
from dcim.models import Device
|
||||
from extras.models import Script as ScriptModel
|
||||
from netbox.context_managers import event_tracking
|
||||
from netbox.jobs import JobRunner
|
||||
from netbox.registry import registry
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
|
||||
# A script can modify multiple models so need to do an atomic lock on
|
||||
# both the default database (for non ChangeLogged models) and potentially
|
||||
# any other database (for ChangeLogged models)
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
changeloged_db = router.db_for_write(Device)
|
||||
with transaction.atomic(using=DEFAULT_DB_ALIAS):
|
||||
# If branch database is different from default, wrap in a second atomic transaction
|
||||
# Note: Don't add any extra code between the two atomic transactions,
|
||||
# otherwise the changes might get committed to the default database
|
||||
# if there are any raised exceptions.
|
||||
if changeloged_db != DEFAULT_DB_ALIAS:
|
||||
with transaction.atomic(using=changeloged_db):
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
else:
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||
if script.failed:
|
||||
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
|
||||
script.request = request
|
||||
self.logger.debug(f"Request ID: {request.id if request else None}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
self.logger.info("Executing script (commit enabled)")
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
else:
|
||||
self.logger.warning("Executing script (commit disabled)")
|
||||
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
if not commit and request_processor is event_tracking:
|
||||
continue
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
|
||||
@@ -449,7 +449,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
return model.objects.filter(pk__in=value)
|
||||
return value
|
||||
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
|
||||
def to_form_field(
|
||||
self,
|
||||
set_initial=True,
|
||||
enforce_required=True,
|
||||
enforce_visibility=True,
|
||||
for_csv_import=False,
|
||||
for_filterset_form=False,
|
||||
):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
@@ -457,6 +464,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
for_filterset_form: Return a form field suitable for use in a FilterSet form.
|
||||
"""
|
||||
initial = self.default if set_initial else None
|
||||
required = self.required if enforce_required else False
|
||||
@@ -519,7 +527,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
field_class = CSVMultipleChoiceField
|
||||
field = field_class(choices=choices, required=required, initial=initial)
|
||||
else:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and not for_filterset_form:
|
||||
field_class = DynamicChoiceField
|
||||
widget_class = APISelect
|
||||
else:
|
||||
@@ -870,6 +878,16 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
||||
if not self.base_choices and not self.extra_choices:
|
||||
raise ValidationError(_("Must define base or extra choices."))
|
||||
|
||||
# Check for duplicate values in extra_choices
|
||||
choice_values = [c[0] for c in self.extra_choices] if self.extra_choices else []
|
||||
if len(set(choice_values)) != len(choice_values):
|
||||
# At least one duplicate value is present. Find the first one and raise an error.
|
||||
_seen = []
|
||||
for value in choice_values:
|
||||
if value in _seen:
|
||||
raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
|
||||
_seen.append(value)
|
||||
|
||||
# Check whether any choices have been removed. If so, check whether any of the removed
|
||||
# choices are still set in custom field data for any object.
|
||||
original_choices = set([
|
||||
|
||||
@@ -1506,19 +1506,18 @@ class CustomFieldModelTest(TestCase):
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""
|
||||
Setting custom field data for a non-applicable (or non-existent) CustomField should raise a ValidationError.
|
||||
Any invalid or stale custom field data should be removed from the instance.
|
||||
"""
|
||||
site = Site(name='Test Site', slug='test-site')
|
||||
|
||||
# Set custom field data
|
||||
site.custom_field_data['foo'] = 'abc'
|
||||
site.custom_field_data['bar'] = 'def'
|
||||
with self.assertRaises(ValidationError):
|
||||
site.clean()
|
||||
|
||||
del site.custom_field_data['bar']
|
||||
site.clean()
|
||||
|
||||
self.assertIn('foo', site.custom_field_data)
|
||||
self.assertNotIn('bar', site.custom_field_data)
|
||||
|
||||
def test_missing_required_field(self):
|
||||
"""
|
||||
Check that a ValidationError is raised if any required custom fields are not present.
|
||||
|
||||
@@ -5,6 +5,7 @@ from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.forms.model_forms import CustomFieldChoiceSetForm
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
|
||||
|
||||
@@ -90,6 +91,31 @@ class CustomFieldModelFormTest(TestCase):
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFormTest(TestCase):
|
||||
|
||||
def test_escaped_colons_preserved_on_edit(self):
|
||||
choice_set = CustomFieldChoiceSet.objects.create(
|
||||
name='Test Choice Set',
|
||||
extra_choices=[['foo:bar', 'label'], ['value', 'label:with:colons']]
|
||||
)
|
||||
|
||||
form = CustomFieldChoiceSetForm(instance=choice_set)
|
||||
initial_choices = form.initial['extra_choices']
|
||||
|
||||
# colons are re-escaped
|
||||
self.assertEqual(initial_choices, 'foo\\:bar:label\nvalue:label\\:with\\:colons')
|
||||
|
||||
form = CustomFieldChoiceSetForm(
|
||||
{'name': choice_set.name, 'extra_choices': initial_choices},
|
||||
instance=choice_set
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
updated = form.save()
|
||||
|
||||
# cleaned extra choices are correct, which does actually mean a list of tuples
|
||||
self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
|
||||
|
||||
|
||||
class SavedFilterFormTest(TestCase):
|
||||
|
||||
def test_basic_submit(self):
|
||||
|
||||
@@ -16,6 +16,7 @@ __all__ = (
|
||||
# BGP ASN bounds
|
||||
BGP_ASN_MIN = 1
|
||||
BGP_ASN_MAX = 2**32 - 1
|
||||
BGP_ASN_ASDOT_BASE = 2**16
|
||||
|
||||
|
||||
class BaseIPField(models.Field):
|
||||
@@ -126,3 +127,16 @@ class ASNField(models.BigIntegerField):
|
||||
}
|
||||
defaults.update(**kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def to_asdot(value) -> str:
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
if value >= BGP_ASN_ASDOT_BASE:
|
||||
hi, lo = divmod(value, BGP_ASN_ASDOT_BASE)
|
||||
return f'{hi}.{lo}'
|
||||
return str(value)
|
||||
|
||||
@@ -230,10 +230,6 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||
})
|
||||
# Don't Forget to include VLANs without a site in the filter
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
||||
})
|
||||
|
||||
if vlan_group:
|
||||
query &= Q(**{
|
||||
|
||||
@@ -55,13 +55,6 @@ class ASNRange(OrganizationalModel):
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.range_as_string()})'
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
return range(self.start, self.end + 1)
|
||||
|
||||
def range_as_string(self):
|
||||
return f'{self.start}-{self.end}'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -72,7 +65,45 @@ class ASNRange(OrganizationalModel):
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
"""
|
||||
Return a range of integers representing the ASN range.
|
||||
"""
|
||||
return range(self.start, self.end + 1)
|
||||
|
||||
@property
|
||||
def start_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
return ASNField.to_asdot(self.start)
|
||||
|
||||
@property
|
||||
def end_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
return ASNField.to_asdot(self.end)
|
||||
|
||||
def range_as_string(self):
|
||||
"""
|
||||
Return a string representation of the ASN range.
|
||||
"""
|
||||
return f'{self.start}-{self.end}'
|
||||
|
||||
def range_as_string_with_asdot(self):
|
||||
"""
|
||||
Return a string representation of the ASN range, including ASDOT notation.
|
||||
"""
|
||||
if self.end >= 65536:
|
||||
return f'{self.range_as_string()} ({self.start_asdot}-{self.end_asdot})'
|
||||
return self.range_as_string()
|
||||
|
||||
def get_child_asns(self):
|
||||
"""
|
||||
Return all child ASNs (ASNs within the range).
|
||||
"""
|
||||
return ASN.objects.filter(
|
||||
asn__gte=self.start,
|
||||
asn__lte=self.end
|
||||
@@ -131,20 +162,20 @@ class ASN(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn // 65536}.{self.asn % 65536}'
|
||||
return self.asn
|
||||
return ASNField.to_asdot(self.asn)
|
||||
|
||||
@property
|
||||
def asn_with_asdot(self):
|
||||
"""
|
||||
Return both plain and ASDOT notation, where applicable.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
|
||||
else:
|
||||
return self.asn
|
||||
if self.asn >= 65536:
|
||||
return f'{self.asn} ({self.asn_asdot})'
|
||||
return str(self.asn)
|
||||
|
||||
@property
|
||||
def prefixed_name(self):
|
||||
"""
|
||||
Return the ASN with ASDOT notation prefixed with "AS".
|
||||
"""
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
@@ -910,13 +910,13 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
})
|
||||
|
||||
# Disallow the creation of IPAddresses within an IPRange with mark_populated=True
|
||||
parent_range = IPRange.objects.filter(
|
||||
parent_range_qs = IPRange.objects.filter(
|
||||
start_address__lte=self.address,
|
||||
end_address__gte=self.address,
|
||||
vrf=self.vrf,
|
||||
mark_populated=True
|
||||
).first()
|
||||
if parent_range:
|
||||
)
|
||||
if not self.pk and (parent_range := parent_range_qs.first()):
|
||||
raise ValidationError({
|
||||
'address': _(
|
||||
"Cannot create IP address {ip} inside range {range}."
|
||||
|
||||
@@ -20,6 +20,16 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name=_('RIR'),
|
||||
linkify=True
|
||||
)
|
||||
start_asdot = tables.Column(
|
||||
accessor=tables.A('start_asdot'),
|
||||
order_by=tables.A('start'),
|
||||
verbose_name=_('Start (ASDOT)')
|
||||
)
|
||||
end_asdot = tables.Column(
|
||||
accessor=tables.A('end_asdot'),
|
||||
order_by=tables.A('end'),
|
||||
verbose_name=_('End (ASDOT)')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asnrange_list'
|
||||
)
|
||||
@@ -30,8 +40,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ASNRange
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'rir', 'start', 'end', 'asn_count', 'tenant', 'tenant_group', 'description', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
'pk', 'name', 'slug', 'rir', 'start', 'start_asdot', 'end', 'end_asdot', 'asn_count', 'tenant',
|
||||
'tenant_group', 'description', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rir', 'start', 'end', 'tenant', 'asn_count', 'description')
|
||||
|
||||
|
||||
@@ -1071,14 +1071,17 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'name': 'VLAN Group 4',
|
||||
'slug': 'vlan-group-4',
|
||||
'vid_ranges': [[1, 4094]]
|
||||
},
|
||||
{
|
||||
'name': 'VLAN Group 5',
|
||||
'slug': 'vlan-group-5',
|
||||
'vid_ranges': [[1, 4094]]
|
||||
},
|
||||
{
|
||||
'name': 'VLAN Group 6',
|
||||
'slug': 'vlan-group-6',
|
||||
'vid_ranges': [[1, 4094]]
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
|
||||
@@ -564,6 +564,82 @@ vlan: 102
|
||||
self.assertEqual(prefix.vlan.vid, 102)
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_site_multiple_vlans_same_vid(self):
|
||||
"""
|
||||
Test import when multiple VLANs exist with the same vid but different sites.
|
||||
Ref: #20560
|
||||
"""
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
|
||||
# Create VLANs with the same vid but different sites
|
||||
vlan1 = VLAN.objects.create(vid=1, name='VLAN1-Site1', site=site1)
|
||||
VLAN.objects.create(vid=1, name='VLAN1-Site2', site=site2) # Create ambiguity
|
||||
|
||||
# Import prefix with vlan_site specified
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.11.0.0/22
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_id: {site1.pk}
|
||||
vlan_site: {site1.name}
|
||||
vlan: 1
|
||||
description: LOC02-MGMT
|
||||
"""
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Verify the prefix was created with the correct VLAN
|
||||
prefix = Prefix.objects.get(prefix='10.11.0.0/22')
|
||||
self.assertEqual(prefix.vlan, vlan1)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_site_and_global_vlan(self):
|
||||
"""
|
||||
Test import when a global VLAN (no site) and site-specific VLAN exist with same vid.
|
||||
When vlan_site is specified, should prefer the site-specific VLAN.
|
||||
Ref: #20560
|
||||
"""
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
|
||||
# Create a global VLAN (no site) and a site-specific VLAN with the same vid
|
||||
VLAN.objects.create(vid=10, name='VLAN10-Global', site=None) # Create ambiguity
|
||||
vlan_site = VLAN.objects.create(vid=10, name='VLAN10-Site1', site=site1)
|
||||
|
||||
# Import prefix with vlan_site specified
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.12.0.0/22
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_id: {site1.pk}
|
||||
vlan_site: {site1.name}
|
||||
vlan: 10
|
||||
description: Test Site-Specific VLAN
|
||||
"""
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Verify the prefix was created with the site-specific VLAN (not the global one)
|
||||
prefix = Prefix.objects.get(prefix='10.12.0.0/22')
|
||||
self.assertEqual(prefix.vlan, vlan_site)
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPRange
|
||||
|
||||
@@ -80,22 +80,21 @@ class Config:
|
||||
try:
|
||||
# Enforce the creation date as the ordering parameter
|
||||
revision = ConfigRevision.objects.get(active=True)
|
||||
logger.debug(f"Loaded active configuration revision #{revision.pk}")
|
||||
logger.debug(f"Loaded active configuration revision (#{revision.pk})")
|
||||
except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
|
||||
logger.debug("No active configuration revision found - falling back to most recent")
|
||||
revision = ConfigRevision.objects.order_by('-created').first()
|
||||
if revision is None:
|
||||
logger.debug("No previous configuration found in database; proceeding with default values")
|
||||
logger.debug("No configuration found in database; proceeding with default values")
|
||||
return
|
||||
logger.debug(f"Using fallback configuration revision #{revision.pk}")
|
||||
logger.debug(f"No active configuration revision found; falling back to most recent (#{revision.pk})")
|
||||
except DatabaseError:
|
||||
# The database may not be available yet (e.g. when running a management command)
|
||||
logger.warning("Skipping config initialization (database unavailable)")
|
||||
return
|
||||
|
||||
revision.activate()
|
||||
logger.debug("Filled cache with data from latest ConfigRevision")
|
||||
revision.activate(update_db=False)
|
||||
self._populate_from_cache()
|
||||
logger.debug("Filled cache with data from latest ConfigRevision")
|
||||
|
||||
|
||||
class ConfigItem:
|
||||
|
||||
@@ -205,4 +205,6 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)
|
||||
return customfield.to_form_field(
|
||||
set_initial=False, enforce_required=False, enforce_visibility=False, for_filterset_form=True
|
||||
)
|
||||
|
||||
@@ -288,12 +288,13 @@ class CustomFieldsMixin(models.Model):
|
||||
cf.name: cf for cf in CustomField.objects.get_for_model(self)
|
||||
}
|
||||
|
||||
# Remove any stale custom field data
|
||||
self.custom_field_data = {
|
||||
k: v for k, v in self.custom_field_data.items() if k in custom_fields.keys()
|
||||
}
|
||||
|
||||
# Validate all field values
|
||||
for field_name, value in self.custom_field_data.items():
|
||||
if field_name not in custom_fields:
|
||||
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
|
||||
name=field_name
|
||||
))
|
||||
try:
|
||||
custom_fields[field_name].validate(value)
|
||||
except ValidationError as e:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import ForeignKey
|
||||
from django.template import loader
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -175,6 +176,21 @@ class BulkEdit(ObjectAction):
|
||||
permissions_required = {'change'}
|
||||
template_name = 'buttons/bulk_edit.html'
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, model):
|
||||
url_params = super().get_url_params(context)
|
||||
|
||||
# If this is a child object, pass the parent's PK as a URL parameter
|
||||
if parent := context.get('object'):
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, ForeignKey) and field.remote_field.model == parent.__class__:
|
||||
url_params[field.name] = parent.pk
|
||||
break
|
||||
|
||||
return {
|
||||
'url_params': url_params,
|
||||
}
|
||||
|
||||
|
||||
class BulkRename(ObjectAction):
|
||||
"""
|
||||
|
||||
@@ -827,6 +827,7 @@ LANGUAGES = (
|
||||
('fr', _('French')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('lv', _('Latvian')),
|
||||
('nl', _('Dutch')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from dcim.models import Device
|
||||
from netbox.object_actions import AddObject, BulkImport
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
from dcim.models import Device, DeviceType, Manufacturer
|
||||
from netbox.object_actions import AddObject, BulkEdit, BulkImport
|
||||
|
||||
|
||||
class ObjectActionTest(TestCase):
|
||||
@@ -20,9 +19,11 @@ class ObjectActionTest(TestCase):
|
||||
url = BulkImport.get_url(obj)
|
||||
self.assertEqual(url, '/dcim/devices/import/')
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
|
||||
def test_get_url_plugin_model(self):
|
||||
"""Test URL generation for plugin models includes plugins: namespace"""
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
|
||||
obj = DummyNetBoxModel()
|
||||
|
||||
url = AddObject.get_url(obj)
|
||||
@@ -30,3 +31,29 @@ class ObjectActionTest(TestCase):
|
||||
|
||||
url = BulkImport.get_url(obj)
|
||||
self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/')
|
||||
|
||||
def test_bulk_edit_get_context_child_object(self):
|
||||
"""
|
||||
Test that the parent object's PK is included in the context for child objects.
|
||||
|
||||
Ensure that BulkEdit.get_context() correctly identifies and
|
||||
includes the parent object's PK when rendering a child object's
|
||||
action button.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
# Mock context containing the parent object (DeviceType)
|
||||
request = RequestFactory().get('/')
|
||||
context = {
|
||||
'request': request,
|
||||
'object': device_type,
|
||||
}
|
||||
|
||||
# Get context for the child model (Device)
|
||||
action_context = BulkEdit.get_context(context, Device)
|
||||
|
||||
# Verify that 'device_type' (the FK field name) is present in
|
||||
# url_params with the parent's PK
|
||||
self.assertIn('url_params', action_context)
|
||||
self.assertEqual(action_context['url_params'].get('device_type'), device_type.pk)
|
||||
|
||||
@@ -52,6 +52,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
type_, error = sys.exc_info()[:2]
|
||||
|
||||
return HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'error': error,
|
||||
'exception': str(type_),
|
||||
'netbox_version': settings.RELEASE.full_version,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
@@ -33,6 +34,7 @@ from utilities.jobs import is_background_request, process_request_as_job
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import reapply_model_ordering
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.string import title
|
||||
from utilities.tables import get_table_configs
|
||||
from utilities.views import GetReturnURLMixin, get_action_url
|
||||
from .base import BaseMultiObjectView
|
||||
@@ -443,6 +445,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
# Prefetch objects to be updated, if any
|
||||
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
|
||||
|
||||
# check for duplicate IDs
|
||||
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
|
||||
if duplicate_pks:
|
||||
error_msg = _(
|
||||
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
|
||||
).format(
|
||||
model=title(self.queryset.model._meta.verbose_name),
|
||||
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
|
||||
)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
prefetched_objects = {
|
||||
obj.pk: obj
|
||||
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
10
netbox/project-static/dist/netbox.js
vendored
10
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -27,10 +27,10 @@
|
||||
"bootstrap": "5.3.8",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.3.3",
|
||||
"gridstack": "12.4.2",
|
||||
"htmx.org": "2.0.8",
|
||||
"query-string": "9.3.1",
|
||||
"sass": "1.94.2",
|
||||
"sass": "1.97.2",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* Move selected options from one select element to another.
|
||||
* Move selected options from one select element to another, preserving optgroup structure.
|
||||
*
|
||||
* @param source Select Element
|
||||
* @param target Select Element
|
||||
@@ -9,14 +9,42 @@ import { getElements } from '../util';
|
||||
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
||||
for (const option of Array.from(source.options)) {
|
||||
if (option.selected) {
|
||||
target.appendChild(option.cloneNode(true));
|
||||
// Check if option is inside an optgroup
|
||||
const parentOptgroup = option.parentElement as HTMLElement;
|
||||
|
||||
if (parentOptgroup.tagName === 'OPTGROUP') {
|
||||
// Find or create matching optgroup in target
|
||||
const groupLabel = parentOptgroup.getAttribute('label');
|
||||
let targetOptgroup = Array.from(target.children).find(
|
||||
child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
|
||||
) as HTMLOptGroupElement;
|
||||
|
||||
if (!targetOptgroup) {
|
||||
// Create new optgroup in target
|
||||
targetOptgroup = document.createElement('optgroup');
|
||||
targetOptgroup.setAttribute('label', groupLabel!);
|
||||
target.appendChild(targetOptgroup);
|
||||
}
|
||||
|
||||
// Move option to target optgroup
|
||||
targetOptgroup.appendChild(option.cloneNode(true));
|
||||
} else {
|
||||
// Option is not in an optgroup, append directly
|
||||
target.appendChild(option.cloneNode(true));
|
||||
}
|
||||
|
||||
option.remove();
|
||||
|
||||
// Clean up empty optgroups in source
|
||||
if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
|
||||
parentOptgroup.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected options of a select element up in order.
|
||||
* Move selected options of a select element up in order, respecting optgroup boundaries.
|
||||
*
|
||||
* Adapted from:
|
||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
||||
for (let i = 1; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
if (option.selected) {
|
||||
element.removeChild(option);
|
||||
element.insertBefore(option, element.options[i - 1]);
|
||||
const parent = option.parentElement as HTMLElement;
|
||||
const previousOption = element.options[i - 1];
|
||||
const previousParent = previousOption.parentElement as HTMLElement;
|
||||
|
||||
// Only move if previous option is in the same parent (optgroup or select)
|
||||
if (parent === previousParent) {
|
||||
parent.removeChild(option);
|
||||
parent.insertBefore(option, previousOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected options of a select element down in order.
|
||||
* Move selected options of a select element down in order, respecting optgroup boundaries.
|
||||
*
|
||||
* Adapted from:
|
||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
||||
function moveOptionDown(element: HTMLSelectElement): void {
|
||||
const options = Array.from(element.options);
|
||||
for (let i = options.length - 2; i >= 0; i--) {
|
||||
let option = options[i];
|
||||
const option = options[i];
|
||||
if (option.selected) {
|
||||
let next = element.options[i + 1];
|
||||
option = element.removeChild(option);
|
||||
next = element.replaceChild(option, next);
|
||||
element.insertBefore(next, option);
|
||||
const parent = option.parentElement as HTMLElement;
|
||||
const nextOption = element.options[i + 1];
|
||||
const nextParent = nextOption.parentElement as HTMLElement;
|
||||
|
||||
// Only move if next option is in the same parent (optgroup or select)
|
||||
if (parent === nextParent) {
|
||||
const optionClone = parent.removeChild(option);
|
||||
const nextClone = parent.replaceChild(optionClone, nextOption);
|
||||
parent.insertBefore(nextClone, optionClone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,27 @@ function updateElements(targetMode: ColorMode): void {
|
||||
}
|
||||
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
const svg = elevation.contentDocument?.querySelector('svg') ?? null;
|
||||
if (svg !== null) {
|
||||
const svg = elevation.firstElementChild ?? null;
|
||||
if (svg !== null && svg.nodeName == 'svg') {
|
||||
svg.setAttribute(`data-bs-theme`, targetMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the color mode to light of elevations after an htmx call.
|
||||
* Pulls current color mode from document
|
||||
*
|
||||
* @param event htmx listener event details. See: https://htmx.org/events/#htmx:afterSwap
|
||||
*/
|
||||
function updateElevations(evt: CustomEvent, ): void {
|
||||
const swappedElement = evt.detail.elt
|
||||
if (swappedElement.nodeName == 'svg') {
|
||||
const currentMode = localStorage.getItem(COLOR_MODE_KEY);
|
||||
swappedElement.setAttribute('data-bs-theme', currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all functions necessary to update the color mode across the UI.
|
||||
*
|
||||
@@ -115,6 +129,7 @@ function initColorModeToggle(): void {
|
||||
*/
|
||||
export function initColorMode(): void {
|
||||
window.addEventListener('load', defaultColorMode);
|
||||
window.addEventListener('htmx:afterSwap', updateElevations as EventListener); // Uses a custom event from HTMX
|
||||
for (const func of [initColorModeToggle]) {
|
||||
func();
|
||||
}
|
||||
|
||||
@@ -32,3 +32,16 @@ form.object-edit {
|
||||
border: 1px solid $red;
|
||||
}
|
||||
}
|
||||
|
||||
// Make optgroup labels sticky when scrolling through select elements
|
||||
select[multiple] {
|
||||
optgroup {
|
||||
top: 0;
|
||||
background-color: var(--bs-body-bg);
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
option {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2178,10 +2178,10 @@ graphql@16.10.0:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
|
||||
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
|
||||
|
||||
gridstack@12.3.3:
|
||||
version "12.3.3"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.3.3.tgz#0c4fc3cdf6e1c16e6095bc79ff7240a590d2c200"
|
||||
integrity sha512-Bboi4gj7HXGnx1VFXQNde4Nwi5srdUSuCCnOSszKhFjBs8EtMEWhsKX02BjIKkErq/FjQUkNUbXUYeQaVMQ0jQ==
|
||||
gridstack@12.4.2:
|
||||
version "12.4.2"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.4.2.tgz#188de180b6cda77e48b1414aac1d778a38f48f04"
|
||||
integrity sha512-aXbJrQpi3LwpYXYOr4UriPM5uc/dPcjK01SdOE5PDpx2vi8tnLhU7yBg/1i4T59UhNkG/RBfabdFUObuN+gMnw==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.2.1"
|
||||
|
||||
sass@1.94.2:
|
||||
version "1.94.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.2.tgz#198511fc6fdd2fc0a71b8d1261735c12608d4ef3"
|
||||
integrity sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==
|
||||
sass@1.97.2:
|
||||
version "1.97.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
|
||||
integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.4.7"
|
||||
version: "4.4.10"
|
||||
edition: "Community"
|
||||
published: "2025-11-25"
|
||||
published: "2026-01-06"
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
|
||||
{{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
|
||||
</pre>
|
||||
<p>
|
||||
{% trans "The request which yielded the above error is shown below:" %}
|
||||
</p>
|
||||
<p>
|
||||
<code>{{ request.method }} {{ request.build_absolute_uri }}</code>
|
||||
</p>
|
||||
<p>
|
||||
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
|
||||
</p>
|
||||
|
||||
@@ -112,6 +112,19 @@
|
||||
<th scope="row">{% trans "Bridge" %}</th>
|
||||
<td>{{ object.bridge|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Bridged Interfaces" %}</th>
|
||||
<td>
|
||||
{% if bridge_interfaces %}
|
||||
{% for interface in bridge_interfaces %}
|
||||
{{ interface|linkify }}
|
||||
{% if not forloop.last %}<br />{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "LAG" %}</th>
|
||||
<td>{{ object.lag|linkify|placeholder }}</td>
|
||||
@@ -435,13 +448,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.is_bridge %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridged Interfaces" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Range" %}</th>
|
||||
<td>{{ object.range_as_string }}</td>
|
||||
<td>{{ object.range_as_string_with_asdot }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
netbox/translations/lv/LC_MESSAGES/django.mo
Normal file
BIN
netbox/translations/lv/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
17724
netbox/translations/lv/LC_MESSAGES/django.po
Normal file
17724
netbox/translations/lv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
@@ -21,6 +23,7 @@ from utilities.forms.fields import (
|
||||
DynamicModelMultipleChoiceField,
|
||||
JSONField,
|
||||
)
|
||||
from utilities.string import title
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
||||
from utilities.permissions import qs_filter_from_constraints
|
||||
@@ -283,10 +286,24 @@ class GroupForm(forms.ModelForm):
|
||||
|
||||
|
||||
def get_object_types_choices():
|
||||
return [
|
||||
(ot.pk, str(ot))
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
|
||||
]
|
||||
"""
|
||||
Generate choices for object types grouped by app label using optgroups.
|
||||
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
|
||||
"""
|
||||
app_label_map = {
|
||||
app_config.label: app_config.verbose_name
|
||||
for app_config in apps.get_app_configs()
|
||||
}
|
||||
choices_by_app = defaultdict(list)
|
||||
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
|
||||
app_label = app_label_map.get(ot.app_label, ot.app_label)
|
||||
|
||||
model_class = ot.model_class()
|
||||
model_name = model_class._meta.verbose_name if model_class else ot.model
|
||||
choices_by_app[app_label].append((ot.pk, title(model_name)))
|
||||
|
||||
return list(choices_by_app.items())
|
||||
|
||||
|
||||
class ObjectPermissionForm(forms.ModelForm):
|
||||
|
||||
@@ -66,17 +66,45 @@ class SelectWithPK(forms.Select):
|
||||
option_template_name = 'widgets/select_option_with_pk.html'
|
||||
|
||||
|
||||
class AvailableOptions(forms.SelectMultiple):
|
||||
class SelectMultipleBase(forms.SelectMultiple):
|
||||
"""
|
||||
Base class for select widgets that filter choices based on selected values.
|
||||
Subclasses should set `include_selected` to control filtering behavior.
|
||||
"""
|
||||
include_selected = False
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
filtered_choices = []
|
||||
include_selected = self.include_selected
|
||||
|
||||
for choice in self.choices:
|
||||
if isinstance(choice[1], (list, tuple)): # optgroup
|
||||
group_label, group_choices = choice
|
||||
filtered_group = [
|
||||
c for c in group_choices if (str(c[0]) in value) == include_selected
|
||||
]
|
||||
|
||||
if filtered_group: # Only include optgroup if it has choices left
|
||||
filtered_choices.append((group_label, filtered_group))
|
||||
else: # option, e.g. flat choice
|
||||
if (str(choice[0]) in value) == include_selected:
|
||||
filtered_choices.append(choice)
|
||||
|
||||
self.choices = filtered_choices
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||
option['attrs']['title'] = label # Add title attribute to show full text on hover
|
||||
return option
|
||||
|
||||
|
||||
class AvailableOptions(SelectMultipleBase):
|
||||
"""
|
||||
Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
|
||||
will be empty.) Employed by SplitMultiSelectWidget.
|
||||
"""
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.choices = [
|
||||
choice for choice in self.choices if str(choice[0]) not in value
|
||||
]
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
|
||||
return context
|
||||
|
||||
|
||||
class SelectedOptions(forms.SelectMultiple):
|
||||
class SelectedOptions(SelectMultipleBase):
|
||||
"""
|
||||
Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
|
||||
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
||||
"""
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.choices = [
|
||||
choice for choice in self.choices if str(choice[0]) in value
|
||||
]
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
include_selected = True
|
||||
|
||||
|
||||
class SplitMultiSelectWidget(forms.MultiWidget):
|
||||
|
||||
@@ -50,21 +50,21 @@ class RestrictedQuerySet(QuerySet):
|
||||
|
||||
# Bypass restriction for superusers and exempt views
|
||||
if user and user.is_superuser or permission_is_exempt(permission_required):
|
||||
qs = self
|
||||
return self
|
||||
|
||||
# User is anonymous or has not been granted the requisite permission
|
||||
elif user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
|
||||
qs = self.none()
|
||||
if user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
|
||||
return self.none()
|
||||
|
||||
# Filter the queryset to include only objects with allowed attributes
|
||||
else:
|
||||
tokens = {
|
||||
CONSTRAINT_TOKEN_USER: user,
|
||||
}
|
||||
attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens)
|
||||
constraints = user._object_perm_cache[permission_required]
|
||||
tokens = {
|
||||
CONSTRAINT_TOKEN_USER: user,
|
||||
}
|
||||
if attrs := qs_filter_from_constraints(constraints, tokens):
|
||||
# #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
|
||||
# DISTINCT acts globally on the entire request, which may not be desirable.
|
||||
allowed_objects = self.model.objects.filter(attrs)
|
||||
qs = self.filter(pk__in=allowed_objects)
|
||||
return self.filter(pk__in=allowed_objects)
|
||||
|
||||
return qs
|
||||
return self
|
||||
|
||||
@@ -35,27 +35,34 @@ class NetBoxFakeRequest:
|
||||
# Utility functions
|
||||
#
|
||||
|
||||
def copy_safe_request(request):
|
||||
def copy_safe_request(request, include_files=True):
|
||||
"""
|
||||
Copy selected attributes from a request object into a new fake request object. This is needed in places where
|
||||
thread safe pickling of the useful request data is needed.
|
||||
|
||||
Args:
|
||||
request: The original request object
|
||||
include_files: Whether to include request.FILES.
|
||||
"""
|
||||
meta = {
|
||||
k: request.META[k]
|
||||
for k in HTTP_REQUEST_META_SAFE_COPY
|
||||
if k in request.META and isinstance(request.META[k], str)
|
||||
}
|
||||
return NetBoxFakeRequest({
|
||||
data = {
|
||||
'META': meta,
|
||||
'COOKIES': request.COOKIES,
|
||||
'POST': request.POST,
|
||||
'GET': request.GET,
|
||||
'FILES': request.FILES,
|
||||
'user': request.user,
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||
})
|
||||
}
|
||||
if include_files:
|
||||
data['FILES'] = request.FILES
|
||||
|
||||
return NetBoxFakeRequest(data)
|
||||
|
||||
|
||||
def get_client_ip(request, additional_headers=()):
|
||||
|
||||
@@ -141,8 +141,8 @@ class ModelTestCase(TestCase):
|
||||
elif value and type(field) is GenericForeignKey:
|
||||
model_dict[key] = value.pk
|
||||
|
||||
# Handle API output
|
||||
elif api:
|
||||
|
||||
# Replace ContentType numeric IDs with <app_label>.<model>
|
||||
if type(getattr(instance, key)) in (ContentType, ObjectType):
|
||||
object_type = ObjectType.objects.get(pk=value)
|
||||
@@ -152,9 +152,13 @@ class ModelTestCase(TestCase):
|
||||
elif type(value) is IPNetwork:
|
||||
model_dict[key] = str(value)
|
||||
|
||||
else:
|
||||
field = instance._meta.get_field(key)
|
||||
# Normalize arrays of numeric ranges (e.g. VLAN IDs or port ranges).
|
||||
# DB uses canonical half-open [lo, hi) via NumericRange; API uses inclusive [lo, hi].
|
||||
# Convert to inclusive pairs for stable API comparisons.
|
||||
elif type(field) is ArrayField and issubclass(type(field.base_field), RangeField):
|
||||
model_dict[key] = [[r.lower, r.upper - 1] for r in value]
|
||||
|
||||
else:
|
||||
# Convert ArrayFields to CSV strings
|
||||
if type(field) is ArrayField:
|
||||
if getattr(field.base_field, 'choices', None):
|
||||
|
||||
@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.forms.fields.csv import CSVSelectWidget
|
||||
from utilities.forms.forms import BulkRenameForm
|
||||
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
|
||||
|
||||
|
||||
class ExpandIPAddress(TestCase):
|
||||
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
|
||||
widget = CSVSelectWidget()
|
||||
data = {'test_field': 'valid_value'}
|
||||
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
|
||||
|
||||
|
||||
class SelectMultipleWidgetTest(TestCase):
|
||||
"""
|
||||
Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
|
||||
"""
|
||||
|
||||
def test_available_options_flat_choices(self):
|
||||
"""AvailableOptions should exclude selected values from flat choices"""
|
||||
widget = AvailableOptions(choices=[
|
||||
(1, 'Option 1'),
|
||||
(2, 'Option 2'),
|
||||
(3, 'Option 3'),
|
||||
])
|
||||
widget.optgroups('test', ['2'], None)
|
||||
|
||||
self.assertEqual(len(widget.choices), 2)
|
||||
self.assertEqual(widget.choices[0], (1, 'Option 1'))
|
||||
self.assertEqual(widget.choices[1], (3, 'Option 3'))
|
||||
|
||||
def test_available_options_optgroups(self):
|
||||
"""AvailableOptions should exclude selected values from optgroups"""
|
||||
widget = AvailableOptions(choices=[
|
||||
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
|
||||
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
|
||||
])
|
||||
|
||||
# Select options 2 and 3
|
||||
widget.optgroups('test', ['2', '3'], None)
|
||||
|
||||
# Should have 2 groups with filtered choices
|
||||
self.assertEqual(len(widget.choices), 2)
|
||||
self.assertEqual(widget.choices[0][0], 'Group A')
|
||||
self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
|
||||
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||
self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
|
||||
|
||||
def test_selected_options_flat_choices(self):
|
||||
"""SelectedOptions should include only selected values from flat choices"""
|
||||
widget = SelectedOptions(choices=[
|
||||
(1, 'Option 1'),
|
||||
(2, 'Option 2'),
|
||||
(3, 'Option 3'),
|
||||
])
|
||||
|
||||
# Select option 2
|
||||
widget.optgroups('test', ['2'], None)
|
||||
|
||||
# Should only have option 2
|
||||
self.assertEqual(len(widget.choices), 1)
|
||||
self.assertEqual(widget.choices[0], (2, 'Option 2'))
|
||||
|
||||
def test_selected_options_optgroups(self):
|
||||
"""SelectedOptions should include only selected values from optgroups"""
|
||||
widget = SelectedOptions(choices=[
|
||||
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
|
||||
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
|
||||
])
|
||||
|
||||
# Select options 2 and 3
|
||||
widget.optgroups('test', ['2', '3'], None)
|
||||
|
||||
# Should have 2 groups with only selected choices
|
||||
self.assertEqual(len(widget.choices), 2)
|
||||
self.assertEqual(widget.choices[0][0], 'Group A')
|
||||
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
|
||||
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
[project]
|
||||
name = "netbox"
|
||||
version = "4.4.7"
|
||||
version = "4.4.10"
|
||||
requires-python = ">=3.10"
|
||||
description = "The premier source of truth powering network automation."
|
||||
readme = "README.md"
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
colorama==0.4.6
|
||||
Django==5.2.8
|
||||
Django==5.2.9
|
||||
django-cors-headers==4.9.0
|
||||
django-debug-toolbar==6.1.0
|
||||
django-filter==25.2
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-htmx==1.26.0
|
||||
django-htmx==1.27.0
|
||||
django-mptt==0.17.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.4.1
|
||||
django-redis==6.0.0
|
||||
django-rich==2.2.0
|
||||
django-rq==3.2.1
|
||||
django-rq==3.2.2
|
||||
django-storages==1.14.6
|
||||
django-tables2==2.8.0
|
||||
django-taggit==6.1.0
|
||||
django-timezone-field==7.1
|
||||
django-timezone-field==7.2.1
|
||||
djangorestframework==3.16.1
|
||||
drf-spectacular==0.29.0
|
||||
drf-spectacular-sidecar==2025.10.1
|
||||
drf-spectacular-sidecar==2026.1.1
|
||||
feedparser==6.0.12
|
||||
gunicorn==23.0.0
|
||||
Jinja2==3.1.6
|
||||
jsonschema==4.25.1
|
||||
Markdown==3.10
|
||||
mkdocs-material==9.7.0
|
||||
mkdocstrings==0.30.1
|
||||
mkdocstrings-python==1.19.0
|
||||
mkdocs-material==9.7.1
|
||||
mkdocstrings==1.0.0
|
||||
mkdocstrings-python==2.0.1
|
||||
netaddr==1.3.0
|
||||
nh3==0.3.2
|
||||
Pillow==12.0.0
|
||||
psycopg[c,pool]==3.2.13
|
||||
Pillow==12.1.0
|
||||
psycopg[c,pool]==3.3.2
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
rq==2.6.1
|
||||
social-auth-app-django==5.6.0
|
||||
social-auth-core==4.8.1
|
||||
social-auth-app-django==5.7.0
|
||||
social-auth-core==4.8.3
|
||||
sorl-thumbnail==12.11.0
|
||||
strawberry-graphql==0.287.0
|
||||
strawberry-graphql-django==0.67.2
|
||||
strawberry-graphql==0.288.2
|
||||
strawberry-graphql-django==0.73.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.9.0
|
||||
tzdata==2025.2
|
||||
tzdata==2025.3
|
||||
|
||||
Reference in New Issue
Block a user