mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-26 03:38:15 +01:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b933a5aa | ||
|
|
07da3f6d33 | ||
|
|
0613e8e95c | ||
|
|
113c60a44a | ||
|
|
8a237561ef | ||
|
|
cc0fc03ec3 | ||
|
|
b955751349 | ||
|
|
d6c8d1581c | ||
|
|
e6642b5f5b | ||
|
|
a67236fc3c | ||
|
|
634681a72e | ||
|
|
031b7540b3 | ||
|
|
43909ee33f | ||
|
|
99467e8f66 | ||
|
|
0d08205ab1 | ||
|
|
c289dda649 | ||
|
|
169207058f | ||
|
|
e5c565cbf4 | ||
|
|
f0b9008529 | ||
|
|
8dfec7e2b2 | ||
|
|
c1cf037eaf | ||
|
|
3f4a65cc5c | ||
|
|
12beac4f1a | ||
|
|
ec245b968f | ||
|
|
f1d4011b40 | ||
|
|
4cdc30a7c5 | ||
|
|
8d39181842 | ||
|
|
c81869c795 | ||
|
|
929d4d2c95 | ||
|
|
d14e4ab52b | ||
|
|
8a4233aca1 | ||
|
|
5508e125ba | ||
|
|
69bf1472d2 | ||
|
|
b93735861d | ||
|
|
6939ae4a47 | ||
|
|
81fa4265da | ||
|
|
35be4f05ef | ||
|
|
2ef023a160 | ||
|
|
9d7192202d | ||
|
|
95a8415e2d | ||
|
|
e59ee3e01e | ||
|
|
92bdaa2120 | ||
|
|
fe3f21105c | ||
|
|
32264ac3e3 | ||
|
|
b34daeaacb | ||
|
|
d2c3a39ebb | ||
|
|
d10ac9b4a7 | ||
|
|
b21ed6a334 | ||
|
|
9d09916f6e | ||
|
|
28080e9b14 | ||
|
|
04fd45581d | ||
|
|
0a8eb7fcbe | ||
|
|
ac3fc25dfd | ||
|
|
82591ad8a1 | ||
|
|
6dddb6c9d2 | ||
|
|
290aae592d | ||
|
|
ff021a8e4e | ||
|
|
3a3d43911c | ||
|
|
c43c63a817 | ||
|
|
792b353f64 | ||
|
|
01ba4ce129 | ||
|
|
fc7d6e1387 | ||
|
|
080da68b6a | ||
|
|
7d413ea3c2 | ||
|
|
40763b58bd | ||
|
|
d52a6d3b10 |
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -10,16 +10,25 @@ body:
|
||||
installation. If you're having trouble with installation or just looking for
|
||||
assistance with using NetBox, please visit our
|
||||
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running NetBox?
|
||||
options:
|
||||
- Self-hosted
|
||||
- NetBox Cloud
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox version
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.5
|
||||
placeholder: v3.6.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Python version
|
||||
label: Python Version
|
||||
description: What version of Python are you currently running?
|
||||
options:
|
||||
- "3.8"
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.5
|
||||
placeholder: v3.6.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
|
||||
|
||||
## :bug: Reporting Bugs
|
||||
|
||||
:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal.
|
||||
|
||||
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
|
||||
|
||||
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
|
||||
|
||||
@@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact
|
||||
|
||||
Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
|
||||
|
||||
!!! note
|
||||
NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
|
||||
|
||||
## Saved Filters
|
||||
|
||||
Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.
|
||||
|
||||
@@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates
|
||||
|
||||
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
|
||||
|
||||
|
||||
!!! info
|
||||
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
|
||||
|
||||
@@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files:
|
||||
* Export templates
|
||||
|
||||
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
|
||||
|
||||
!!! note "Permissions"
|
||||
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
|
||||
|
||||
!!! note "Permissions"
|
||||
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group.
|
||||
|
||||
The following features support the use of synchronized data:
|
||||
|
||||
* [Configuration templates](../features/configuration-rendering.md)
|
||||
|
||||
@@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional).
|
||||
|
||||
### Name
|
||||
|
||||
The inventory item's name. Must be unique to the parent device.
|
||||
The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item).
|
||||
|
||||
### Label
|
||||
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.8 (2023-12-27)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view
|
||||
* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script
|
||||
* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs
|
||||
* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment
|
||||
* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields
|
||||
* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null
|
||||
* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables
|
||||
* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command
|
||||
* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field
|
||||
* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view
|
||||
* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted
|
||||
* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command
|
||||
* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs
|
||||
* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table
|
||||
* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI
|
||||
|
||||
---
|
||||
|
||||
## v3.6.7 (2023-12-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget
|
||||
* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column
|
||||
* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag
|
||||
* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices
|
||||
* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects
|
||||
* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6
|
||||
* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI
|
||||
* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints
|
||||
* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero
|
||||
* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device
|
||||
* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites
|
||||
* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations
|
||||
* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode
|
||||
* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts
|
||||
* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group
|
||||
* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list
|
||||
|
||||
---
|
||||
|
||||
## v3.6.6 (2023-11-29)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV
|
||||
* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report
|
||||
* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name
|
||||
* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter
|
||||
* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments
|
||||
* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format
|
||||
* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports
|
||||
* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API
|
||||
* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column
|
||||
* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API
|
||||
* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources
|
||||
* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster
|
||||
|
||||
---
|
||||
|
||||
## v3.6.5 (2023-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -110,6 +110,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.choices import DataSourceStatusChoices
|
||||
from core.models import DataSource
|
||||
|
||||
|
||||
@@ -33,9 +34,13 @@ class Command(BaseCommand):
|
||||
for i, datasource in enumerate(datasources, start=1):
|
||||
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
|
||||
self.stdout.flush()
|
||||
datasource.sync()
|
||||
self.stdout.write(datasource.get_status_display())
|
||||
self.stdout.flush()
|
||||
try:
|
||||
datasource.sync()
|
||||
self.stdout.write(datasource.get_status_display())
|
||||
self.stdout.flush()
|
||||
except Exception as e:
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
raise e
|
||||
|
||||
if len(options['name']) > 1:
|
||||
self.stdout.write(f"Finished.")
|
||||
|
||||
@@ -122,6 +122,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -84,6 +85,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
self.file_path = os.path.basename(self.data_path)
|
||||
self.data_file.write_to_disk(self.full_path, overwrite=True)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure that the file root and path make a unique pair
|
||||
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(
|
||||
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete file from disk
|
||||
try:
|
||||
|
||||
@@ -229,7 +229,7 @@ class Job(models.Model):
|
||||
model_name=self.object_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
@@ -153,9 +154,11 @@ class ConfigView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
if config := self.queryset.first():
|
||||
return config
|
||||
# Instantiate a dummy default config if none has been created yet
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
revision_id = cache.get('config_version')
|
||||
try:
|
||||
return ConfigRevision.objects.get(pk=revision_id)
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
)
|
||||
|
||||
@@ -1018,6 +1018,7 @@ class DeviceFilterSet(
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(description_icontains=value.strip()) |
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
|
||||
@@ -164,6 +164,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
choices=SiteStatusChoices,
|
||||
@@ -247,6 +248,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -419,6 +421,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -543,6 +546,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -619,6 +623,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class PlatformFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Platform
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -653,6 +658,7 @@ class DeviceFilterForm(
|
||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
||||
))
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -996,6 +1002,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -1227,6 +1234,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id')
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-31 15:47
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -12,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='starting_unit',
|
||||
field=models.PositiveSmallIntegerField(default=1),
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
|
||||
22
netbox/dcim/migrations/0182_zero_length_cable_fix.py
Normal file
22
netbox/dcim/migrations/0182_zero_length_cable_fix.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_cable_lengths(apps, schema_editor):
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
|
||||
# Set the absolute length for any zero-length Cables
|
||||
Cable.objects.filter(length=0).update(_abs_length=0)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0181_rename_device_role_device_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=update_cable_lengths,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -201,7 +201,7 @@ class Cable(PrimaryModel):
|
||||
_created = self.pk is None
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
if self.length is not None and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ConfigContextModel
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
@@ -985,11 +985,17 @@ class Device(
|
||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||
(default). Otherwise, save() will be called on each instance individually.
|
||||
"""
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
|
||||
# Set default values for any applicable custom fields
|
||||
model = queryset.model.component_model
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
model = components[0]._meta.model
|
||||
model.objects.bulk_create(components)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
for component in components:
|
||||
@@ -1002,8 +1008,7 @@ class Device(
|
||||
update_fields=None
|
||||
)
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
for component in components:
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
"Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
|
||||
@@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
starting_unit = models.PositiveSmallIntegerField(
|
||||
default=RACK_STARTING_UNIT_DEFAULT,
|
||||
verbose_name=_('starting unit'),
|
||||
validators=[MinValueValidator(1),],
|
||||
help_text=_('Starting unit for rack')
|
||||
)
|
||||
desc_units = models.BooleanField(
|
||||
|
||||
@@ -274,7 +274,7 @@ class CableTraceSVG:
|
||||
if cable.type:
|
||||
# Include the cable type in the tooltip
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
description.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
else:
|
||||
@@ -285,7 +285,7 @@ class CableTraceSVG:
|
||||
description = []
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
|
||||
|
||||
@@ -1078,7 +1078,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
comments = columns.MarkdownColumn()
|
||||
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:vdc_list'
|
||||
url_name='dcim:virtualdevicecontext_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import *
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from tenancy.models import Tenant
|
||||
from utilities.utils import drange
|
||||
|
||||
@@ -255,6 +257,23 @@ class DeviceTestCase(TestCase):
|
||||
)
|
||||
DeviceRole.objects.bulk_create(roles)
|
||||
|
||||
# Create a CustomField with a default value & assign it to all component models
|
||||
cf1 = CustomField.objects.create(name='cf1', default='foo')
|
||||
cf1.content_types.set(
|
||||
ContentType.objects.filter(app_label='dcim', model__in=[
|
||||
'consoleport',
|
||||
'consoleserverport',
|
||||
'powerport',
|
||||
'poweroutlet',
|
||||
'interface',
|
||||
'rearport',
|
||||
'frontport',
|
||||
'modulebay',
|
||||
'devicebay',
|
||||
'inventoryitem',
|
||||
])
|
||||
)
|
||||
|
||||
# Create DeviceType components
|
||||
ConsolePortTemplate(
|
||||
device_type=device_type,
|
||||
@@ -266,18 +285,18 @@ class DeviceTestCase(TestCase):
|
||||
name='Console Server Port 1'
|
||||
).save()
|
||||
|
||||
ppt = PowerPortTemplate(
|
||||
powerport = PowerPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
)
|
||||
ppt.save()
|
||||
powerport.save()
|
||||
|
||||
PowerOutletTemplate(
|
||||
device_type=device_type,
|
||||
name='Power Outlet 1',
|
||||
power_port=ppt,
|
||||
power_port=powerport,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
).save()
|
||||
|
||||
@@ -288,19 +307,19 @@ class DeviceTestCase(TestCase):
|
||||
mgmt_only=True
|
||||
).save()
|
||||
|
||||
rpt = RearPortTemplate(
|
||||
rearport = RearPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
)
|
||||
rpt.save()
|
||||
rearport.save()
|
||||
|
||||
FrontPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rpt,
|
||||
rear_port=rearport,
|
||||
rear_port_position=2
|
||||
).save()
|
||||
|
||||
@@ -314,73 +333,93 @@ class DeviceTestCase(TestCase):
|
||||
name='Device Bay 1'
|
||||
).save()
|
||||
|
||||
InventoryItemTemplate(
|
||||
device_type=device_type,
|
||||
name='Inventory Item 1'
|
||||
).save()
|
||||
|
||||
def test_device_creation(self):
|
||||
"""
|
||||
Ensure that all Device components are copied automatically from the DeviceType.
|
||||
"""
|
||||
d = Device(
|
||||
device = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
d.save()
|
||||
device.save()
|
||||
|
||||
ConsolePort.objects.get(
|
||||
device=d,
|
||||
consoleport = ConsolePort.objects.get(
|
||||
device=device,
|
||||
name='Console Port 1'
|
||||
)
|
||||
self.assertEqual(consoleport.cf['cf1'], 'foo')
|
||||
|
||||
ConsoleServerPort.objects.get(
|
||||
device=d,
|
||||
consoleserverport = ConsoleServerPort.objects.get(
|
||||
device=device,
|
||||
name='Console Server Port 1'
|
||||
)
|
||||
self.assertEqual(consoleserverport.cf['cf1'], 'foo')
|
||||
|
||||
pp = PowerPort.objects.get(
|
||||
device=d,
|
||||
powerport = PowerPort.objects.get(
|
||||
device=device,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
)
|
||||
self.assertEqual(powerport.cf['cf1'], 'foo')
|
||||
|
||||
PowerOutlet.objects.get(
|
||||
device=d,
|
||||
poweroutlet = PowerOutlet.objects.get(
|
||||
device=device,
|
||||
name='Power Outlet 1',
|
||||
power_port=pp,
|
||||
power_port=powerport,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
)
|
||||
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
|
||||
|
||||
Interface.objects.get(
|
||||
device=d,
|
||||
interface = Interface.objects.get(
|
||||
device=device,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
)
|
||||
self.assertEqual(interface.cf['cf1'], 'foo')
|
||||
|
||||
rp = RearPort.objects.get(
|
||||
device=d,
|
||||
rearport = RearPort.objects.get(
|
||||
device=device,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
)
|
||||
self.assertEqual(rearport.cf['cf1'], 'foo')
|
||||
|
||||
FrontPort.objects.get(
|
||||
device=d,
|
||||
frontport = FrontPort.objects.get(
|
||||
device=device,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rp,
|
||||
rear_port=rearport,
|
||||
rear_port_position=2
|
||||
)
|
||||
self.assertEqual(frontport.cf['cf1'], 'foo')
|
||||
|
||||
ModuleBay.objects.get(
|
||||
device=d,
|
||||
modulebay = ModuleBay.objects.get(
|
||||
device=device,
|
||||
name='Module Bay 1'
|
||||
)
|
||||
self.assertEqual(modulebay.cf['cf1'], 'foo')
|
||||
|
||||
DeviceBay.objects.get(
|
||||
device=d,
|
||||
devicebay = DeviceBay.objects.get(
|
||||
device=device,
|
||||
name='Device Bay 1'
|
||||
)
|
||||
self.assertEqual(devicebay.cf['cf1'], 'foo')
|
||||
|
||||
inventoryitem = InventoryItem.objects.get(
|
||||
device=device,
|
||||
name='Inventory Item 1'
|
||||
)
|
||||
self.assertEqual(inventoryitem.cf['cf1'], 'foo')
|
||||
|
||||
def test_multiple_unnamed_devices(self):
|
||||
|
||||
|
||||
@@ -695,8 +695,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
|
||||
label=_('Reservations'),
|
||||
badge=lambda obj: obj.reservations.count(),
|
||||
permission='dcim.view_rackreservation',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
weight=510
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
|
||||
@@ -283,7 +283,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
module, report_cls = self._get_report(pk)
|
||||
report = report_cls()
|
||||
report = report_cls
|
||||
input_serializer = serializers.ReportInputSerializer(
|
||||
data=request.data,
|
||||
context={'report': report}
|
||||
|
||||
@@ -122,8 +122,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(extra_choices__contains=value)
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def filter_by_choice(self, queryset, name, value):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
@@ -76,7 +78,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
extra_choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
help_text=_('Comma-separated list of field choices')
|
||||
help_text=_(
|
||||
'Quoted string of comma-separated field choices with optional labels separated by colon: '
|
||||
'"choice1:First Choice,choice2:Second Choice"'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -85,6 +90,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||
)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
if isinstance(self.cleaned_data['extra_choices'], list):
|
||||
data = []
|
||||
for line in self.cleaned_data['extra_choices']:
|
||||
try:
|
||||
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||
value = value.replace('\\:', ':')
|
||||
label = label.replace('\\:', ':')
|
||||
except ValueError:
|
||||
value, label = line, line
|
||||
data.append((value, label))
|
||||
return data
|
||||
|
||||
|
||||
class CustomLinkImportForm(CSVModelForm):
|
||||
content_types = CSVMultipleContentTypeField(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -95,19 +96,33 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||
required=False,
|
||||
help_text=mark_safe(_(
|
||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||
'comma. Example:'
|
||||
) + ' <code>choice1,First Choice</code>')
|
||||
'colon. Example:'
|
||||
) + ' <code>choice1:First Choice</code>')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
# Escape colons in extra_choices
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
choices = []
|
||||
for choice in self.initial['extra_choices']:
|
||||
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
||||
choices.append(choice)
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
|
||||
def clean_extra_choices(self):
|
||||
data = []
|
||||
for line in self.cleaned_data['extra_choices'].splitlines():
|
||||
try:
|
||||
value, label = line.split(',', maxsplit=1)
|
||||
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||
value = value.replace('\\:', ':')
|
||||
label = label.replace('\\:', ':')
|
||||
except ValueError:
|
||||
value, label = line, line
|
||||
data.append((value, label))
|
||||
|
||||
@@ -114,7 +114,7 @@ class Command(BaseCommand):
|
||||
# Create the job
|
||||
job = Job.objects.create(
|
||||
object=module,
|
||||
name=script.name,
|
||||
name=script.class_name,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -57,6 +56,15 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
def get_defaults_for_model(self, model):
|
||||
"""
|
||||
Return a dictionary of serialized default values for all CustomFields applicable to the given model.
|
||||
"""
|
||||
custom_fields = self.get_for_model(model).filter(default__isnull=False)
|
||||
return {
|
||||
cf.name: cf.default for cf in custom_fields
|
||||
}
|
||||
|
||||
|
||||
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
@@ -562,8 +570,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Multiselect
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
filter_class = filters.MultiValueCharFilter
|
||||
kwargs['lookup_expr'] = 'has_key'
|
||||
filter_class = filters.MultiValueArrayFilter
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
|
||||
@@ -315,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;!')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
|
||||
@@ -62,21 +62,20 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
else:
|
||||
return
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(instance),
|
||||
changed_object_id=instance.pk,
|
||||
request_id=request.id
|
||||
).update(
|
||||
postchange_data=instance.to_objectchange(action).postchange_data
|
||||
)
|
||||
else:
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
# Record an ObjectChange
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(instance),
|
||||
changed_object_id=instance.pk,
|
||||
request_id=request.id
|
||||
).update(
|
||||
postchange_data=instance.to_objectchange(action).postchange_data
|
||||
)
|
||||
else:
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
queue = webhooks_queue.get()
|
||||
|
||||
265
netbox/extras/tests/test_custom_validation.py
Normal file
265
netbox/extras/tests/test_custom_validation.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
|
||||
from circuits.api.serializers import ProviderSerializer
|
||||
from circuits.forms import ProviderForm
|
||||
from circuits.models import Provider
|
||||
from ipam.models import ASN, RIR
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
|
||||
|
||||
|
||||
class ModelFormCustomValidationTest(TestCase):
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'tags': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_tags_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for tag assignment.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
form = ProviderForm(data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
tags = create_tags('Tag1', 'Tag2', 'Tag3')
|
||||
data['tags'] = [tag.pk for tag in tags]
|
||||
form = ProviderForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_m2m_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for many-to-many fields.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
form = ProviderForm(data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
asns = ASN.objects.bulk_create((
|
||||
ASN(rir=rir, asn=65001),
|
||||
ASN(rir=rir, asn=65002),
|
||||
ASN(rir=rir, asn=65003),
|
||||
))
|
||||
data['asns'] = [asn.pk for asn in asns]
|
||||
form = ProviderForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
|
||||
class BulkEditCustomValidationTest(ModelViewTestCase):
|
||||
model = Provider
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
asns = ASN.objects.bulk_create((
|
||||
ASN(rir=rir, asn=65001),
|
||||
ASN(rir=rir, asn=65002),
|
||||
ASN(rir=rir, asn=65003),
|
||||
))
|
||||
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1'),
|
||||
Provider(name='Provider 2', slug='provider-2'),
|
||||
Provider(name='Provider 3', slug='provider-3'),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
for provider in providers:
|
||||
provider.asns.set(asns)
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_bulk_edit_without_m2m(self):
|
||||
"""
|
||||
Check that custom validation rules do not interfere with bulk editing.
|
||||
"""
|
||||
data = {
|
||||
'pk': list(Provider.objects.values_list('pk', flat=True)),
|
||||
'_apply': '',
|
||||
'description': 'New description',
|
||||
}
|
||||
self.add_permissions(
|
||||
'circuits.view_provider',
|
||||
'circuits.change_provider',
|
||||
)
|
||||
|
||||
# Bulk edit the description without changing ASN assignments
|
||||
request = {
|
||||
'path': self._get_url('bulk_edit'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertEqual(
|
||||
Provider.objects.filter(description=data['description']).count(),
|
||||
len(data['pk'])
|
||||
)
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_bulk_edit_m2m(self):
|
||||
"""
|
||||
Test that custom validation rules are enforced during bulk editing.
|
||||
"""
|
||||
data = {
|
||||
'pk': list(Provider.objects.values_list('pk', flat=True)),
|
||||
'_apply': '',
|
||||
'description': 'New description',
|
||||
}
|
||||
self.add_permissions(
|
||||
'circuits.view_provider',
|
||||
'circuits.change_provider',
|
||||
'ipam.view_asn',
|
||||
)
|
||||
|
||||
# Change the ASN assignments
|
||||
asn = ASN.objects.first()
|
||||
data['asns'] = [asn.pk]
|
||||
request = {
|
||||
'path': self._get_url('bulk_edit'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
for provider in Provider.objects.all():
|
||||
self.assertEqual(len(provider.asns.all()), 1)
|
||||
|
||||
# Attempt to remove the ASN assignments
|
||||
data.pop('asns')
|
||||
data['_nullify'] = 'asns'
|
||||
request = {
|
||||
'path': self._get_url('bulk_edit'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 200)
|
||||
for provider in Provider.objects.all():
|
||||
self.assertTrue(provider.asns.exists())
|
||||
|
||||
|
||||
class BulkImportCustomValidationTest(ModelViewTestCase):
|
||||
model = Provider
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
create_tags('Tag1', 'Tag2', 'Tag3')
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'tags': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_bulk_import_invalid(self):
|
||||
"""
|
||||
Test that custom validation rules are enforced during bulk import.
|
||||
"""
|
||||
csv_data = (
|
||||
"name,slug",
|
||||
"Provider 1,provider-1",
|
||||
"Provider 2,provider-2",
|
||||
"Provider 3,provider-3",
|
||||
)
|
||||
data = {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.COMMA,
|
||||
}
|
||||
self.add_permissions(
|
||||
'circuits.view_provider',
|
||||
'circuits.add_provider',
|
||||
'extras.view_tag',
|
||||
)
|
||||
|
||||
# Attempt to import providers without tags
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 200)
|
||||
self.assertFalse(Provider.objects.exists())
|
||||
|
||||
# Import providers successfully with tag assignments
|
||||
csv_data = (
|
||||
"name,slug,tags",
|
||||
"Provider 1,provider-1,tag1",
|
||||
"Provider 2,provider-2,tag2",
|
||||
"Provider 3,provider-3,tag3",
|
||||
)
|
||||
data['data'] = '\n'.join(csv_data)
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertTrue(Provider.objects.exists())
|
||||
|
||||
|
||||
class APISerializerCustomValidationTest(APITestCase):
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'tags': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_tags_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for tag assignment.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
|
||||
tags = create_tags('Tag1', 'Tag2', 'Tag3')
|
||||
data['tags'] = [tag.pk for tag in tags]
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_m2m_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for many-to-many fields.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
asns = ASN.objects.bulk_create((
|
||||
ASN(rir=rir, asn=65001),
|
||||
ASN(rir=rir, asn=65002),
|
||||
ASN(rir=rir, asn=65003),
|
||||
))
|
||||
data['asns'] = [asn.pk for asn in asns]
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
@@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
|
||||
choice_set = CustomFieldChoiceSet.objects.create(
|
||||
name='Custom Field Choice Set 1',
|
||||
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
|
||||
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
|
||||
)
|
||||
|
||||
# Integer filtering
|
||||
@@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf7': 'http://a.example.com',
|
||||
'cf8': 'http://a.example.com',
|
||||
'cf9': 'A',
|
||||
'cf10': ['A', 'X'],
|
||||
'cf10': ['A', 'B'],
|
||||
'cf11': manufacturers[0].pk,
|
||||
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
|
||||
}),
|
||||
@@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf7': 'http://b.example.com',
|
||||
'cf8': 'http://b.example.com',
|
||||
'cf9': 'B',
|
||||
'cf10': ['B', 'X'],
|
||||
'cf10': ['B', 'C'],
|
||||
'cf11': manufacturers[1].pk,
|
||||
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
|
||||
}),
|
||||
@@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf7': 'http://c.example.com',
|
||||
'cf8': 'http://c.example.com',
|
||||
'cf9': 'C',
|
||||
'cf10': ['C', 'X'],
|
||||
'cf10': None,
|
||||
'cf11': manufacturers[2].pk,
|
||||
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
|
||||
}),
|
||||
@@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_object(self):
|
||||
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
|
||||
|
||||
@@ -93,19 +93,24 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
name='Choice Set 3',
|
||||
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
|
||||
),
|
||||
CustomFieldChoiceSet(
|
||||
name='Choice Set 4',
|
||||
extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
|
||||
),
|
||||
)
|
||||
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Choice Set X',
|
||||
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
|
||||
'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,extra_choices',
|
||||
'Choice Set 4,"D1,D2,D3"',
|
||||
'Choice Set 5,"E1,E2,E3"',
|
||||
'Choice Set 6,"F1,F2,F3"',
|
||||
'Choice Set 5,"D1,D2,D3"',
|
||||
'Choice Set 6,"E1,E2,E3"',
|
||||
'Choice Set 7,"F1,F2,F3"',
|
||||
'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -113,12 +118,20 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
f'{choice_sets[0].pk},"A,B,C"',
|
||||
f'{choice_sets[1].pk},"A,B,C"',
|
||||
f'{choice_sets[2].pk},"A,B,C"',
|
||||
f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
# This is here as extra_choices field splits on colon, but is returned
|
||||
# from DB as comma separated.
|
||||
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||
if 'extra_choices' in data:
|
||||
data['extra_choices'] = data['extra_choices'].replace(':', ',')
|
||||
return super().assertInstanceEqual(instance, data, exclude, api)
|
||||
|
||||
|
||||
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = CustomLink
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# NOTE: As this module may be imported by configuration.py, we cannot import
|
||||
# anything from NetBox itself.
|
||||
@@ -66,8 +67,7 @@ class CustomValidator:
|
||||
def __call__(self, instance):
|
||||
# Validate instance attributes per validation rules
|
||||
for attr_name, rules in self.validation_rules.items():
|
||||
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
|
||||
attr = getattr(instance, attr_name)
|
||||
attr = self._getattr(instance, attr_name)
|
||||
for descriptor, value in rules.items():
|
||||
validator = self.get_validator(descriptor, value)
|
||||
try:
|
||||
@@ -79,6 +79,26 @@ class CustomValidator:
|
||||
# Execute custom validation logic (if any)
|
||||
self.validate(instance)
|
||||
|
||||
@staticmethod
|
||||
def _getattr(instance, name):
|
||||
# Attempt to resolve many-to-many fields to their stored values
|
||||
m2m_fields = [f.name for f in instance._meta.local_many_to_many]
|
||||
if name in m2m_fields:
|
||||
if name in getattr(instance, '_m2m_values', []):
|
||||
return instance._m2m_values[name]
|
||||
if instance.pk:
|
||||
return list(getattr(instance, name).all())
|
||||
return []
|
||||
|
||||
# Raise a ValidationError for unknown attributes
|
||||
if not hasattr(instance, name):
|
||||
raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
|
||||
name=name,
|
||||
model=instance.__class__.__name__
|
||||
))
|
||||
|
||||
return getattr(instance, name)
|
||||
|
||||
def get_validator(self, descriptor, value):
|
||||
"""
|
||||
Instantiate and return the appropriate validator based on the descriptor given. For
|
||||
|
||||
@@ -1073,7 +1073,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name
|
||||
name=report.class_name
|
||||
)
|
||||
|
||||
jobs_table = JobTable(
|
||||
|
||||
@@ -115,7 +115,7 @@ def flush_webhooks(queue):
|
||||
event=data['event'],
|
||||
data=data['data'],
|
||||
snapshots=data['snapshots'],
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=data['username'],
|
||||
request_id=data['request_id'],
|
||||
retry=get_rq_retry()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -290,7 +292,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
)
|
||||
|
||||
# Prepare object data for deserialization
|
||||
requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
|
||||
requested_objects = self.prep_object_data(deepcopy(requested_objects), available_objects, parent)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||
|
||||
@@ -950,6 +950,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
choices=VLANStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
available_at_site = django_filters.ModelChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
method='get_for_site'
|
||||
)
|
||||
available_on_device = django_filters.ModelChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
method='get_for_device'
|
||||
@@ -984,6 +988,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_site(self, queryset, name, value):
|
||||
return queryset.get_for_site(value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device(self, queryset, name, value):
|
||||
return queryset.get_for_device(value)
|
||||
|
||||
@@ -300,6 +300,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||
parent = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@@ -452,6 +453,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -818,7 +818,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = ('l2vpn', )
|
||||
fields = ('l2vpn', 'tags')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
|
||||
@@ -224,11 +224,11 @@ class VLAN(PrimaryModel):
|
||||
|
||||
# Validate VLAN group (if assigned)
|
||||
if self.group and self.site and self.group.scope != self.site:
|
||||
raise ValidationError({
|
||||
'group': _(
|
||||
raise ValidationError(
|
||||
_(
|
||||
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
|
||||
).format(group=self.group, scope=self.group.scope, site=self.site)
|
||||
})
|
||||
)
|
||||
|
||||
# Validate group min/max VIDs
|
||||
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
|
||||
|
||||
@@ -69,6 +69,35 @@ class VLANGroupQuerySet(RestrictedQuerySet):
|
||||
|
||||
class VLANQuerySet(RestrictedQuerySet):
|
||||
|
||||
def get_for_site(self, site):
|
||||
"""
|
||||
Return all VLANs in the specified site
|
||||
"""
|
||||
from .models import VLANGroup
|
||||
q = Q()
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
|
||||
scope_id=site.pk
|
||||
)
|
||||
|
||||
if site.region:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
||||
scope_id__in=site.region.get_ancestors(include_self=True)
|
||||
)
|
||||
if site.group:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
|
||||
scope_id__in=site.group.get_ancestors(include_self=True)
|
||||
)
|
||||
|
||||
return self.filter(
|
||||
Q(group__in=VLANGroup.objects.filter(q)) |
|
||||
Q(site=site) |
|
||||
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
|
||||
Q(group__isnull=True, site__isnull=True) # Global VLANs
|
||||
)
|
||||
|
||||
def get_for_device(self, device):
|
||||
"""
|
||||
Return all VLANs available to the specified Device.
|
||||
|
||||
@@ -56,8 +56,12 @@ def clear_primary_ip(instance, **kwargs):
|
||||
"""
|
||||
field_name = f'primary_ip{instance.family}'
|
||||
if device := Device.objects.filter(**{field_name: instance}).first():
|
||||
device.snapshot()
|
||||
setattr(device, field_name, None)
|
||||
device.save()
|
||||
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
|
||||
virtualmachine.snapshot()
|
||||
setattr(virtualmachine, field_name, None)
|
||||
virtualmachine.save()
|
||||
|
||||
|
||||
@@ -67,4 +71,6 @@ def clear_oob_ip(instance, **kwargs):
|
||||
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
|
||||
"""
|
||||
if device := Device.objects.filter(oob_ip=instance).first():
|
||||
device.snapshot()
|
||||
device.oob_ip = None
|
||||
device.save()
|
||||
|
||||
@@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
order_by=tables.A('asn'),
|
||||
verbose_name=_('ASDOT')
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
|
||||
@@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Object Site')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:l2vpntermination_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = L2VPNTermination
|
||||
fields = (
|
||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
|
||||
'actions',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',
|
||||
|
||||
@@ -1359,6 +1359,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
|
||||
VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
|
||||
VLANGroup(name='VLAN Group 4', slug='vlan-group-4'),
|
||||
)
|
||||
VLANGroup.objects.bulk_create(groups)
|
||||
|
||||
@@ -1415,6 +1416,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
|
||||
VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
|
||||
|
||||
# Create one globally available VLAN on a VLAN group
|
||||
VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
|
||||
|
||||
# Create one globally available VLAN
|
||||
VLAN(vid=1000, name='Global VLAN'),
|
||||
)
|
||||
@@ -1488,12 +1492,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_available_on_device(self):
|
||||
device_id = Device.objects.first().pk
|
||||
params = {'available_on_device': device_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
|
||||
|
||||
def test_available_on_virtualmachine(self):
|
||||
vm_id = VirtualMachine.objects.first().pk
|
||||
params = {'available_on_virtualmachine': vm_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
|
||||
|
||||
def test_available_at_site(self):
|
||||
site_id = Site.objects.first().pk
|
||||
params = {'available_at_site': site_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global
|
||||
|
||||
|
||||
class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@@ -661,6 +661,26 @@ class IPRangeListView(generic.ObjectListView):
|
||||
class IPRangeView(generic.ObjectView):
|
||||
queryset = IPRange.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
Q(prefix__net_contains_or_equals=str(instance.start_address.ip)),
|
||||
Q(prefix__net_contains_or_equals=str(instance.end_address.ip)),
|
||||
vrf=instance.vrf
|
||||
).prefetch_related(
|
||||
'site', 'role', 'tenant', 'vlan', 'role'
|
||||
)
|
||||
parent_prefixes_table = tables.PrefixTable(
|
||||
list(parent_prefixes),
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
|
||||
class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
@@ -953,7 +973,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
if not get_table_ordering(request, self.table):
|
||||
return add_available_vlans(parent.get_child_vlans(), parent)
|
||||
return add_available_vlans(queryset, parent)
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
@@ -23,16 +23,16 @@ class ValidatedModelSerializer(BaseModelSerializer):
|
||||
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
|
||||
"""
|
||||
def validate(self, data):
|
||||
|
||||
# Remove custom fields data and tags (if any) prior to model validation
|
||||
attrs = data.copy()
|
||||
|
||||
# Remove custom field data (if any) prior to model validation
|
||||
attrs.pop('custom_fields', None)
|
||||
attrs.pop('tags', None)
|
||||
|
||||
# Skip ManyToManyFields
|
||||
for field in self.Meta.model._meta.get_fields():
|
||||
if isinstance(field, ManyToManyField):
|
||||
attrs.pop(field.name, None)
|
||||
m2m_values = {}
|
||||
for field in self.Meta.model._meta.local_many_to_many:
|
||||
if field.name in attrs:
|
||||
m2m_values[field.name] = attrs.pop(field.name)
|
||||
|
||||
# Run clean() on an instance of the model
|
||||
if self.instance is None:
|
||||
@@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
|
||||
instance = self.instance
|
||||
for k, v in attrs.items():
|
||||
setattr(instance, k, v)
|
||||
instance._m2m_values = m2m_values
|
||||
instance.full_clean()
|
||||
|
||||
return data
|
||||
|
||||
@@ -56,8 +56,15 @@ class BriefModeMixin:
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
serializer_class = self.get_serializer_class()
|
||||
|
||||
# Clear any annotations for fields not present on the nested serializer
|
||||
for annotation in list(qs.query.annotations.keys()):
|
||||
if annotation not in serializer_class().fields:
|
||||
qs.query.annotations.pop(annotation)
|
||||
|
||||
# Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return qs
|
||||
|
||||
@@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
|
||||
|
||||
return super().clean()
|
||||
|
||||
def _post_clean(self):
|
||||
"""
|
||||
Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
|
||||
"""
|
||||
self.instance._m2m_values = {}
|
||||
for field in self.instance._meta.local_many_to_many:
|
||||
if field.name in self.cleaned_data:
|
||||
self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
|
||||
|
||||
return super()._post_clean()
|
||||
|
||||
|
||||
class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
"""
|
||||
@@ -145,12 +156,16 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi
|
||||
model: The model class associated with the form
|
||||
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||
selector_fields: An iterable of names of fields to display by default when rendering the form as
|
||||
a selector widget
|
||||
"""
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
selector_fields = ('filter_id', 'q')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.5'
|
||||
VERSION = '3.6.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
||||
@@ -394,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
if instance.pk and hasattr(instance, 'snapshot'):
|
||||
instance.snapshot()
|
||||
|
||||
# Instantiate the model form for the object
|
||||
model_form_kwargs = {
|
||||
'data': record,
|
||||
@@ -553,6 +557,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
elif name in form.changed_data:
|
||||
obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
|
||||
|
||||
# Store M2M values for validation
|
||||
obj._m2m_values = {}
|
||||
for field in obj._meta.local_many_to_many:
|
||||
if value := form.cleaned_data.get(field.name):
|
||||
obj._m2m_values[field.name] = list(value)
|
||||
elif field.name in nullified_fields:
|
||||
obj._m2m_values[field.name] = []
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
updated_objects.append(obj)
|
||||
|
||||
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -264,6 +264,11 @@ export class APISelect {
|
||||
switch (this.trigger) {
|
||||
case 'collapse':
|
||||
if (collapse !== null) {
|
||||
// If the element is collapsible but already shown, load the data immediately.
|
||||
if (collapse.classList.contains('show')) {
|
||||
Promise.all([this.loadData()]);
|
||||
}
|
||||
|
||||
// If this element is part of a collapsible element, only load the data when the
|
||||
// collapsible element is shown.
|
||||
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<tr>
|
||||
<th scope="row">{% trans "Length" %}</th>
|
||||
<td>
|
||||
{% if object.length %}
|
||||
{% if object.length is not None %}
|
||||
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -15,16 +16,7 @@
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.site.region %}
|
||||
{% for region in object.site.region.get_ancestors %}
|
||||
{{ region|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.site.region|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.site.region %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
@@ -32,16 +24,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
{{ location|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.location %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% load static %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -15,26 +16,18 @@
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.site|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
{{ location|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.location %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Facility ID" %}</th>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% load static %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
@@ -20,25 +21,24 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% with rack=object.rack %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if rack.site.region %}
|
||||
{{ rack.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ rack.site|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ rack.location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td>{{ rack|linkify }}</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.rack.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.rack.site|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ object.rack.location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td>{{ object.rack|linkify }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load plugins %}
|
||||
{% load tz %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
@@ -29,27 +30,13 @@
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.region %}
|
||||
{% for region in object.region.get_ancestors %}
|
||||
{{ region|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.region|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% nested_tree object.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
<td>
|
||||
{% if object.group %}
|
||||
{% for group in object.group.get_ancestors %}
|
||||
{{ group|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.group|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% nested_tree object.group %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% comment %}
|
||||
Include a hidden field of the same name to ensure that unchecked checkboxes
|
||||
are always included in the submitted form data.
|
||||
are always included in the submitted form data. Omit fields names
|
||||
_selected_action to avoid breaking the admin UI.
|
||||
{% endcomment %}
|
||||
<input type="hidden" name="{{ widget.name }}" value="">
|
||||
{% if widget.name != '_selected_action' %}<input type="hidden" name="{{ widget.name }}" value="">{% endif %}
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% for field in form.visible_fields %}
|
||||
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
|
||||
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
||||
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 or field.name in form.selector_fields %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
||||
{{ field.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, keyup from:#id_q delay:500ms">
|
||||
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, change, keyup from:#id_q delay:500ms">
|
||||
<input type="hidden" name="_search" value="true" />
|
||||
<div class="tab-content p-1">
|
||||
{% for field in form.visible_fields %}
|
||||
<div class="collapse{% if forloop.counter < 3 %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
||||
<div class="collapse{% if field.name in form.selector_fields %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -44,18 +45,17 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if object.site %}
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN" %}</th>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -13,18 +14,17 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if object.site %}
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
|
||||
@@ -91,6 +91,19 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
queryset=Contact.objects.all(),
|
||||
label=_('Contact (ID)'),
|
||||
)
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contact__group',
|
||||
lookup_expr='in',
|
||||
label=_('Contact group (ID)'),
|
||||
)
|
||||
group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contact__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Contact group (slug)'),
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContactRole.objects.all(),
|
||||
label=_('Contact role (ID)'),
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import TagsMixin
|
||||
from netbox.models.features import ExportTemplatesMixin, TagsMixin
|
||||
from tenancy.choices import *
|
||||
|
||||
__all__ = (
|
||||
@@ -109,7 +109,7 @@ class Contact(PrimaryModel):
|
||||
return reverse('tenancy:contact', args=[self.pk])
|
||||
|
||||
|
||||
class ContactAssignment(ChangeLoggedModel, TagsMixin):
|
||||
class ContactAssignment(ChangeLoggedModel, ExportTemplatesMixin, TagsMixin):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Manufacturer, Site
|
||||
from tenancy.filtersets import *
|
||||
from tenancy.models import *
|
||||
from utilities.testing import ChangeLoggedFilterSetTests
|
||||
@@ -192,3 +194,72 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [group[0].slug, group[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ContactAssignment.objects.all()
|
||||
filterset = ContactAssignmentFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
contact_groups = (
|
||||
ContactGroup(name='Contact Group 1', slug='contact-group-1'),
|
||||
ContactGroup(name='Contact Group 2', slug='contact-group-2'),
|
||||
ContactGroup(name='Contact Group 3', slug='contact-group-3'),
|
||||
)
|
||||
for contactgroup in contact_groups:
|
||||
contactgroup.save()
|
||||
|
||||
contact_roles = (
|
||||
ContactRole(name='Contact Role 1', slug='contact-role-1'),
|
||||
ContactRole(name='Contact Role 2', slug='contact-role-2'),
|
||||
ContactRole(name='Contact Role 3', slug='contact-role-3'),
|
||||
)
|
||||
ContactRole.objects.bulk_create(contact_roles)
|
||||
|
||||
contacts = (
|
||||
Contact(name='Contact 1', group=contact_groups[0]),
|
||||
Contact(name='Contact 2', group=contact_groups[1]),
|
||||
Contact(name='Contact 3', group=contact_groups[2]),
|
||||
)
|
||||
Contact.objects.bulk_create(contacts)
|
||||
|
||||
assignments = (
|
||||
ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),
|
||||
ContactAssignment(object=sites[1], contact=contacts[1], role=contact_roles[1]),
|
||||
ContactAssignment(object=sites[2], contact=contacts[2], role=contact_roles[2]),
|
||||
ContactAssignment(object=manufacturer, contact=contacts[2], role=contact_roles[2]),
|
||||
)
|
||||
ContactAssignment.objects.bulk_create(assignments)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_contact(self):
|
||||
contacts = Contact.objects.all()[:2]
|
||||
params = {'contact_id': [contacts[0].pk, contacts[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_group(self):
|
||||
group = ContactGroup.objects.all()[:2]
|
||||
params = {'group_id': [group[0].pk, group[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [group[0].slug, group[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
role = ContactRole.objects.all()[:2]
|
||||
params = {'role_id': [role[0].pk, role[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'role': [role[0].slug, role[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -52,6 +52,16 @@ class UserSerializer(ValidatedModelSerializer):
|
||||
|
||||
return user
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Ensure proper updated password hash generation.
|
||||
"""
|
||||
password = validated_data.pop('password', None)
|
||||
if password is not None:
|
||||
instance.set_password(password)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_display(self, obj):
|
||||
if full_name := obj.get_full_name():
|
||||
|
||||
@@ -218,6 +218,7 @@ class UserConfig(models.Model):
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@receiver(post_save, sender=NetBoxUser)
|
||||
def create_userconfig(instance, created, raw=False, **kwargs):
|
||||
"""
|
||||
Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.
|
||||
|
||||
@@ -54,6 +54,38 @@ class UserTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
|
||||
def test_that_password_is_changed(self):
|
||||
"""
|
||||
Test that password is changed
|
||||
"""
|
||||
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
|
||||
|
||||
user_credentials = {
|
||||
'username': 'user1',
|
||||
'password': 'abc123',
|
||||
}
|
||||
user = User.objects.create_user(**user_credentials)
|
||||
|
||||
data = {
|
||||
'password': 'newpassword'
|
||||
}
|
||||
url = reverse('users-api:user-detail', kwargs={'pk': user.id})
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
updated_user = User.objects.get(id=user.id)
|
||||
|
||||
self.assertTrue(updated_user.check_password(data['password']))
|
||||
|
||||
|
||||
class GroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Group
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.apps import apps
|
||||
from django.db.models import F, Count, OuterRef, Subquery
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
|
||||
from netbox.registry import registry
|
||||
from .fields import CounterCacheField
|
||||
@@ -62,6 +62,12 @@ def post_save_receiver(sender, instance, created, **kwargs):
|
||||
update_counter(parent_model, new_pk, counter_name, 1)
|
||||
|
||||
|
||||
def pre_delete_receiver(sender, instance, origin, **kwargs):
|
||||
model = instance._meta.model
|
||||
if not model.objects.filter(pk=instance.pk).exists():
|
||||
instance._previously_removed = True
|
||||
|
||||
|
||||
def post_delete_receiver(sender, instance, origin, **kwargs):
|
||||
"""
|
||||
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
|
||||
@@ -71,10 +77,8 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
|
||||
parent_pk = getattr(instance, field_name, None)
|
||||
|
||||
# Decrement the parent's counter by one
|
||||
if parent_pk is not None:
|
||||
# MPTT sends two delete signals for child elements so guard against multiple decrements
|
||||
if not origin or origin == instance:
|
||||
update_counter(parent_model, parent_pk, counter_name, -1)
|
||||
if parent_pk is not None and not hasattr(instance, "_previously_removed"):
|
||||
update_counter(parent_model, parent_pk, counter_name, -1)
|
||||
|
||||
|
||||
#
|
||||
@@ -106,6 +110,12 @@ def connect_counters(*models):
|
||||
weak=False,
|
||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
||||
)
|
||||
pre_delete.connect(
|
||||
pre_delete_receiver,
|
||||
sender=to_model,
|
||||
weak=False,
|
||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
||||
)
|
||||
post_delete.connect(
|
||||
post_delete_receiver,
|
||||
sender=to_model,
|
||||
|
||||
@@ -9,6 +9,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
__all__ = (
|
||||
'ContentTypeFilter',
|
||||
'MACAddressFilter',
|
||||
'MultiValueArrayFilter',
|
||||
'MultiValueCharFilter',
|
||||
'MultiValueDateFilter',
|
||||
'MultiValueDateTimeFilter',
|
||||
@@ -85,6 +86,21 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
|
||||
field_class = multivalue_field_factory(forms.TimeField)
|
||||
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
class MultiValueArrayFilter(django_filters.MultipleChoiceFilter):
|
||||
field_class = multivalue_field_factory(forms.CharField)
|
||||
|
||||
def __init__(self, *args, lookup_expr='contains', **kwargs):
|
||||
# Set default lookup_expr to 'contains'
|
||||
super().__init__(*args, lookup_expr=lookup_expr, **kwargs)
|
||||
|
||||
def get_filter_predicate(self, v):
|
||||
# If filtering for null values, ignore lookup_expr
|
||||
if v is None:
|
||||
return {self.field_name: None}
|
||||
return super().get_filter_predicate(v)
|
||||
|
||||
|
||||
class MACAddressFilter(django_filters.CharFilter):
|
||||
pass
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class DynamicMultipleChoiceField(forms.MultipleChoiceField):
|
||||
|
||||
if data is not None:
|
||||
self.choices = [
|
||||
choice for choice in self.choices if choice[0] in data
|
||||
choice for choice in self.choices if choice[0] and choice[0] in data
|
||||
]
|
||||
|
||||
return bound_field
|
||||
|
||||
@@ -40,7 +40,7 @@ def parse_numeric_range(string, base=10):
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
values.extend(range(begin, end))
|
||||
return list(set(values))
|
||||
return sorted(set(values))
|
||||
|
||||
|
||||
def parse_alphanumeric_range(string):
|
||||
|
||||
@@ -65,5 +65,5 @@ class ChoicesWidget(forms.Textarea):
|
||||
if not value:
|
||||
return None
|
||||
if type(value) is list:
|
||||
return '\n'.join([f'{k},{v}' for k, v in value])
|
||||
return '\n'.join([f'{k}:{v}' for k, v in value])
|
||||
return value
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from netaddr import IPAddress
|
||||
from netaddr import AddrFormatError, IPAddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
__all__ = (
|
||||
'get_client_ip',
|
||||
@@ -17,11 +18,18 @@ def get_client_ip(request, additional_headers=()):
|
||||
)
|
||||
for header in HTTP_HEADERS:
|
||||
if header in request.META:
|
||||
client_ip = request.META[header].split(',')[0].partition(':')[0]
|
||||
ip = request.META[header].split(',')[0].strip()
|
||||
try:
|
||||
return IPAddress(client_ip)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address set for {header}: {client_ip}")
|
||||
return IPAddress(ip)
|
||||
except AddrFormatError:
|
||||
# Parse the string with urlparse() to remove port number or any other cruft
|
||||
ip = urlparse(f'//{ip}').hostname
|
||||
|
||||
try:
|
||||
return IPAddress(ip)
|
||||
except AddrFormatError:
|
||||
# We did our best
|
||||
raise ValueError(f"Invalid IP address set for {header}: {ip}")
|
||||
|
||||
# Could not determine the client IP address from request headers
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<a class="btn btn-sm {{ color }} copy-content" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
||||
<a class="btn btn-sm {{ color }} copy-content {{ classes }}" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</a>
|
||||
|
||||
@@ -87,13 +87,14 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
|
||||
|
||||
|
||||
@register.inclusion_tag('builtins/copy_content.html')
|
||||
def copy_content(target, prefix=None, color='primary'):
|
||||
def copy_content(target, prefix=None, color='primary', classes=None):
|
||||
"""
|
||||
Display a copy button to copy the content of a field.
|
||||
"""
|
||||
return {
|
||||
'target': f'#{prefix or ""}{target}',
|
||||
'color': f'btn-{color}'
|
||||
'color': f'btn-{color}',
|
||||
'classes': classes or '',
|
||||
}
|
||||
|
||||
|
||||
|
||||
20
netbox/utilities/templatetags/mptt.py
Normal file
20
netbox/utilities/templatetags/mptt.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def nested_tree(obj):
|
||||
"""
|
||||
Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup).
|
||||
"""
|
||||
if not obj:
|
||||
return mark_safe('—')
|
||||
|
||||
nodes = obj.get_ancestors(include_self=True)
|
||||
return mark_safe(
|
||||
' / '.join(
|
||||
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
|
||||
)
|
||||
)
|
||||
28
netbox/utilities/tests/test_request.py
Normal file
28
netbox/utilities/tests/test_request.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from netaddr import IPAddress
|
||||
from utilities.request import get_client_ip
|
||||
|
||||
|
||||
class GetClientIPTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_ipv4_address(self):
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1:8080')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
|
||||
|
||||
def test_ipv6_address(self):
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='2001:db8::8a2e:370:7334')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]:8080')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||
|
||||
def test_invalid_ip_address(self):
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip')
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_ip(request)
|
||||
@@ -294,9 +294,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
for interface in interfaces:
|
||||
vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site
|
||||
if site is None:
|
||||
site = interface.virtual_machine.cluster.site
|
||||
elif interface.virtual_machine.cluster.site is not site:
|
||||
site = vm_site
|
||||
elif vm_site is not site:
|
||||
site = None
|
||||
break
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'group_id')
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterType.objects.all(),
|
||||
required=False,
|
||||
@@ -186,6 +187,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
||||
(_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')),
|
||||
(_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'virtual_machine_id')
|
||||
cluster_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
bleach==6.1.0
|
||||
Django==4.2.7
|
||||
django-cors-headers==4.3.0
|
||||
Django==4.2.8
|
||||
django-cors-headers==4.3.1
|
||||
django-debug-toolbar==4.2.0
|
||||
django-filter==23.3
|
||||
django-filter==23.5
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.14.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.3.1
|
||||
django-redis==5.4.0
|
||||
django-rich==1.8.0
|
||||
django-rq==2.8.1
|
||||
django-tables2==2.6.0
|
||||
django-rq==2.10.1
|
||||
django-tables2==2.7.0
|
||||
django-taggit==4.0.0
|
||||
django-timezone-field==6.0.1
|
||||
django-timezone-field==6.1.0
|
||||
djangorestframework==3.14.0
|
||||
drf-spectacular==0.26.5
|
||||
drf-spectacular-sidecar==2023.10.1
|
||||
feedparser==6.0.10
|
||||
drf-spectacular==0.27.0
|
||||
drf-spectacular-sidecar==2023.12.1
|
||||
feedparser==6.0.11
|
||||
graphene-django==3.0.0
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.4.8
|
||||
mkdocstrings[python-legacy]==0.23.0
|
||||
mkdocs-material==9.5.3
|
||||
mkdocstrings[python-legacy]==0.24.0
|
||||
netaddr==0.9.0
|
||||
Pillow==10.1.0
|
||||
psycopg[binary,pool]==3.1.12
|
||||
psycopg[binary,pool]==3.1.16
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
sentry-sdk==1.34.0
|
||||
sentry-sdk==1.39.1
|
||||
social-auth-app-django==5.4.0
|
||||
social-auth-core[openidconnect]==4.5.0
|
||||
social-auth-core[openidconnect]==4.5.1
|
||||
svgwrite==1.4.3
|
||||
tablib==3.5.0
|
||||
tzdata==2023.3
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
# Python 3.8 or later.
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
NETBOX_VERSION="$(grep ^VERSION netbox/netbox/settings.py | cut -d\' -f2)"
|
||||
echo "You are installing (or upgrading to) NetBox version ${NETBOX_VERSION}"
|
||||
|
||||
VIRTUALENV="$(pwd -P)/venv"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user