Compare commits

..

42 Commits

Author SHA1 Message Date
Jeremy Stretch
f1d4011b40 Merge pull request #14542 from netbox-community/develop
Release v3.6.7
2023-12-15 16:44:46 -05:00
Jeremy Stretch
4cdc30a7c5 Release v3.6.7 2023-12-15 16:25:24 -05:00
kkthxbye
8d39181842 Fixes #12751 - Usability improvements for object selector (#14387)
* Usability improvements for object selector:
* Adds preselected filters
* Applies the filter on selection instead of requiring the search button to be pushed

* Declare selector_fields on base form class

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-15 16:07:15 -05:00
Jeremy Stretch
c81869c795 Fixes #14533: Fix quick search under VLAN group VLANs list 2023-12-15 13:59:31 -05:00
Jeremy Stretch
929d4d2c95 Fixes #14522: Fix filtering contact assignments by group 2023-12-15 13:58:50 -05:00
Jeremy Stretch
d14e4ab52b Changelog for #13983, #14081, #14148, #14467, #14505, #14512, #14515 2023-12-14 17:12:29 -05:00
Daniel Sheppard
8a4233aca1 Update create_userconfig to receive signals from NetBoxUser model in addition to User model. 2023-12-14 17:07:57 -05:00
Jeremy Stretch
5508e125ba Fixes #14512: Omit unused queryset annotations for REST API requests using brief mode 2023-12-14 16:49:18 -05:00
Arthur Hanson
69bf1472d2 13983 Add nested arrays for extra_choices in CustomFieldChoiceSet (#14470)
* 13983 split array fields in CSV data for CustomFieldChoices

* 13983 fix help text

* 13983 update tests

* 13983 use re for split

* 13983 replace escaped chars

* 13983 fix escape handling

* 13983 fix escape handling

* 13983 fix escape handling
2023-12-14 15:18:56 -05:00
Arthur Hanson
b93735861d Fixes #14081: Fix cached counters on delete for parent-child items (#14131)
* 14081 fixed cached counters on delete for parent-child items

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-12 16:53:04 -05:00
Arthur Hanson
6939ae4a47 14467 change ChoiceField separator from comma to colon (#14469)
* 14467 change ChoiceField separator from comma to colon

* 14467 fix test

* 14467 fix test

* 14467 use regex for colon detection

* 14467 update tests
2023-12-12 14:31:39 -05:00
Prince Kumar
81fa4265da add tags field in L2VPN Termination 2023-12-12 14:23:16 -05:00
Jeremy Stretch
35be4f05ef Add note to bug reports section 2023-12-11 10:10:28 -05:00
Jeremy Stretch
2ef023a160 Changelog for #14249, #14390, #14392, #14397, #14401, #14432, #14448 2023-12-07 16:34:49 -05:00
Jeremy Stretch
9d7192202d Fixes #14392: Fix admin UI bulk actions 2023-12-07 16:31:21 -05:00
Jeremy Stretch
95a8415e2d Add deployment type to bug report template 2023-12-07 16:21:15 -05:00
Jeremy Stretch
e59ee3e01e Fixes #14397: Pass a mutable copy of request data when provisioning available IPs 2023-12-07 11:20:03 -05:00
Abhimanyu Saharan
92bdaa2120 Fixes IPv6 detection from headers (#14456)
* fixes client ip detection for v6

* adds test for get_client_ip

* Employ urlparse() to strip port numbers from IPs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-07 09:45:30 -05:00
Jeremy Stretch
fe3f21105c Fixes #14448: Fix exception when creating a power feed with rack and panel in different sites 2023-12-06 15:28:47 -05:00
Jeremy Stretch
32264ac3e3 Fixes #14322: Populate default custom field values when instantiating templated device components 2023-12-06 15:21:34 -05:00
Arthur
b34daeaacb 14401 review changes - remove migration 2023-12-06 15:16:03 -05:00
Arthur
d2c3a39ebb 14401 validate rack startion position > 0 2023-12-06 15:16:03 -05:00
Jeremy Stretch
d10ac9b4a7 Closes #12623: Document need for core.sync_datasource permission 2023-12-05 14:03:38 -05:00
Abhimanyu Saharan
b21ed6a334 adds optional classes parameter #14390 2023-12-05 13:51:28 -05:00
Jeremy Stretch
9d09916f6e PRVB 2023-11-29 19:32:45 -05:00
Jeremy Stretch
28080e9b14 Merge pull request #14386 from netbox-community/develop
Release v3.6.6
2023-11-29 19:30:47 -05:00
Jeremy Stretch
04fd45581d Release v3.6.6 2023-11-29 19:16:30 -05:00
Jeremy Stretch
0a8eb7fcbe Update changelog 2023-11-29 17:25:10 -05:00
Jeremy Stretch
ac3fc25dfd Fixes #14239: Fix CustomFieldChoiceSet search filter 2023-11-29 17:20:18 -05:00
Jeremy Stretch
82591ad8a1 Fixes #14056: Record a pre-change snapshot when bulk editing objects via CSV 2023-11-29 17:19:35 -05:00
Jeremy Stretch
6dddb6c9d2 Fixes #14199: Fix jobs count for reports with a custom name 2023-11-29 17:19:02 -05:00
Abhimanyu Saharan
290aae592d Raises validation error if file path and root are not unique (#14232)
* raises validation error if file path and root are not unique #14187

* review changes #14187
2023-11-29 16:25:16 -05:00
Abhimanyu Saharan
ff021a8e4e Adds region hierarchy in templates (#14213)
* initial work to render hierarchical region #13735

* adds site display #13735

* cleanup #13735

* adds display region tag #13735

* refactored region hierarchy #13735

* refactored region hierarchy #13735

* renamed display_region to nested_tree #13735

* Make render_tree suitable for generic use

* Remove errant item from __all__

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-29 16:20:59 -05:00
Vincent Simonin
3a3d43911c Fixed password was not hashed on REST API update (#14340)
* Fixed password was not hashed on REST API update

* When we updated a user password with a REST API call the password was
  stored in clear in plain text in the database.

* Following code review

* Move test on UserTest class
* Call `super().update()` in overriding `update` method

* Return directly the result of `super().update()`
2023-11-29 15:59:54 -05:00
Josef Johansson
c43c63a817 14346 fix missing function call convert
In PR #13958 (commit 8224644) _get_report was modified to do the call on the variable without changing the call later on.

This commit fixes that and removes the call on the variable.

Signed-off-by: Josef Johansson <josef@oderland.se>
2023-11-29 15:58:14 -05:00
Jeremy Stretch
792b353f64 Fixes #14363: Fix bulk editing of interfaces assigned to VM with no cluster 2023-11-29 15:23:35 -05:00
Jeremy Stretch
01ba4ce129 Fixes #14242: Enable export templates for contact assignments 2023-11-29 15:22:41 -05:00
Jeremy Stretch
fc7d6e1387 Fixes #14325: Ensure expanded numeric arrays are ordered (#14370)
* Fixes #14325: Ensure expanded numeric arrays are ordered

* Remove redundant casting to
2023-11-28 17:04:10 -05:00
Jeremy Stretch
080da68b6a Fixes #14349: Fix custom validation support for DataSource 2023-11-28 17:02:52 -05:00
Jeremy Stretch
7d413ea3c2 Fixes #14343: Set order_by accessor for asn_asdot column (#14369) 2023-11-28 17:02:07 -05:00
Arthur Hanson
40763b58bd 14299 change webhook timestamp to isoformat (#14331)
* 14299 change timestamp to isoformat

* Omit redundant str() casting

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-28 17:01:03 -05:00
Jeremy Stretch
d52a6d3b10 PRVB 2023-11-09 16:04:38 -05:00
62 changed files with 566 additions and 192 deletions

View File

@@ -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.7
validations:
required: true
- type: dropdown
attributes:
label: Python version
label: Python Version
description: What version of Python are you currently running?
options:
- "3.8"

View File

@@ -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.7
validations:
required: true
- type: dropdown

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,55 @@
# NetBox v3.6
## 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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,12 +145,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)

View File

@@ -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.7'
# Hostname
HOSTNAME = platform.node()

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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('&mdash;')
nodes = obj.get_ancestors(include_self=True)
return mark_safe(
' / '.join(
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
)
)

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

View File

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

View File

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

View File

@@ -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.9.0
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.2
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.15
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