Merge pull request #14057 from netbox-community/develop

Release v3.6.4
This commit is contained in:
Jeremy Stretch 2023-10-17 13:04:39 -04:00 committed by GitHub
commit d195f9c6ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 279 additions and 86 deletions

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.6.3
placeholder: v3.6.4
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.6.3
placeholder: v3.6.4
validations:
required: true
- type: dropdown

View File

@ -31,15 +31,15 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
@ -47,7 +47,7 @@ jobs:
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: yarn

View File

@ -14,7 +14,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v3
- uses: dessant/lock-threads@v4
with:
issue-inactive-days: 90
pr-inactive-days: 30

View File

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v6
- uses: actions/stale@v8
with:
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an

View File

@ -23,8 +23,9 @@ django-filter
django-graphiql-debug-toolbar
# Modified Preorder Tree Traversal (recursive nesting of objects)
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
django-mptt
django-mptt==0.14.0
# Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
@ -120,6 +121,10 @@ psycopg[binary,pool]
# https://github.com/yaml/pyyaml/blob/master/CHANGES
PyYAML
# Requests
# https://github.com/psf/requests/blob/main/HISTORY.md
requests
# Sentry SDK
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
sentry-sdk

View File

@ -80,6 +80,14 @@ changes in the database indefinitely.
---
## DATA_UPLOAD_MAX_MEMORY_SIZE
Default: `2621440` (2.5 MB)
The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
---
## ENFORCE_GLOBAL_UNIQUE
!!! tip "Dynamic Configuration Parameter"
@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
---
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
## FILE_UPLOAD_MAX_MEMORY_SIZE
Default: `2621440` (2.5 MB).
Default: `2621440` (2.5 MB)
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.

View File

@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
## Running Custom Scripts
!!! note
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
## Running Reports
!!! note
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@ -1,5 +1,37 @@
# NetBox v3.6
## v3.6.4 (2023-10-17)
### Enhancements
* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
### Bug Fixes
* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
---
## v3.6.3 (2023-09-26)
### Enhancements

View File

@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
@ -98,7 +98,7 @@ class PassThroughPortMixin(object):
# Regions
#
class RegionViewSet(NetBoxModelViewSet):
class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet):
# Site groups
#
class SiteGroupViewSet(NetBoxModelViewSet):
class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet):
# Locations
#
class LocationViewSet(NetBoxModelViewSet):
class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceBayTemplateFilterSet
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
serializer_class = serializers.InventoryItemTemplateSerializer
filterset_class = filtersets.InventoryItemTemplateFilterSet
@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
brief_prefetch_fields = ['device']
class InventoryItemViewSet(NetBoxModelViewSet):
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
serializer_class = serializers.InventoryItemSerializer
filterset_class = filtersets.InventoryItemFilterSet

View File

@ -1745,6 +1745,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
method='filter_by_cable_end_b',
field_name='terminations__termination_id'
)
unterminated = django_filters.BooleanFilter(
method='_unterminated',
label=_('Unterminated'),
)
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@ -1812,6 +1816,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
# Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
def _unterminated(self, queryset, name, value):
if value:
terminated_ids = (
queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
.filter(terminations__cable_end=CableEndChoices.SIDE_B)
.values("id")
)
return queryset.exclude(id__in=terminated_ids)
else:
return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
terminations__cable_end=CableEndChoices.SIDE_B
)
class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()

View File

@ -1192,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None:
if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")

View File

@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Device type')
)
role_id = DynamicModelMultipleChoiceField(
device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Device role')
@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
unterminated = forms.NullBooleanField(
label=_('Unterminated'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(_('PoE'), ('poe_mode', 'poe_type')),
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
vdc_id = DynamicModelMultipleChoiceField(
@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')),
)
model = FrontPort
@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')),
)
type = forms.MultipleChoiceField(
@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'position')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
position = forms.CharField(
@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),

View File

@ -160,6 +160,8 @@ class CableTraceSVG:
elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}'
labels.append(instance.provider)
if instance.description:
labels.append(instance.description)
elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}')

View File

@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(interfaces)
@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
# Cable for unterminated test
Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_unterminated(self):
params = {'unterminated': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'unterminated': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = PowerPanel.objects.all()

View File

@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
if form.is_valid():
with transaction.atomic():
count = 0
cable_ids = set()
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
if obj.cable is None:
continue
obj.cable.delete()
count += 1
if obj.cable:
cable_ids.add(obj.cable.pk)
count += 1
for cable in Cable.objects.filter(pk__in=cable_ids):
cable.delete()
messages.success(request, "Disconnected {} {}".format(
count, self.queryset.model._meta.verbose_name_plural
messages.success(request, _("Disconnected {count} {type}").format(
count=count,
type=self.queryset.model._meta.verbose_name_plural
))
return redirect(return_url)

View File

@ -232,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
return self.choice_set.choices
return []
def get_choice_label(self, value):
if not hasattr(self, '_choice_map'):
self._choice_map = dict(self.choices)
return self._choice_map.get(value, value)
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or

View File

@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
def get_module_and_report(module_name, report_name):
module = ReportModule.objects.get(file_path=f'{module_name}.py')
report = module.reports.get(report_name)
report = module.reports.get(report_name)()
return module, report

View File

@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
})
def get_report_module(module, request):
return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
class ReportView(ContentTypePermissionRequiredMixin, View):
"""
Display a single Report and its associated Job (if any).
@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report'
def get(self, request, module, name):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
module = get_report_module(module, request)
report = module.reports[name]()
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
if not request.user.has_perm('extras.run_report'):
return HttpResponseForbidden()
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
module = get_report_module(module, request)
report = module.reports[name]()
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report'
def get(self, request, module, name):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
module = get_report_module(module, request)
report = module.reports[name]()
return render(request, 'extras/report/source.html', {
@ -1062,7 +1066,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report'
def get(self, request, module, name):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
module = get_report_module(module, request)
report = module.reports[name]()
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
})
def get_script_module(module, request):
return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
class ScriptView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.view_script'
def get(self, request, module, name):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
module = get_script_module(module, request)
script = module.scripts[name]()
form = script.as_form(initial=normalize_querydict(request.GET))
@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
if not request.user.has_perm('extras.run_script'):
return HttpResponseForbidden()
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
module = get_script_module(module, request)
script = module.scripts[name]()
form = script.as_form(request.POST, request.FILES)
@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script'
def get(self, request, module, name):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
module = get_script_module(module, request)
script = module.scripts[name]()
return render(request, 'extras/script/source.html', {
@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script'
def get(self, request, module, name):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
module = get_script_module(module, request)
script = module.scripts[name]()
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')

View File

@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
# Normalize request data to a list of objects
requested_objects = request.data if isinstance(request.data, list) else [request.data]
limit = len(requested_objects)
# Serialize and validate the request data
serializer = self.write_serializer_class(data=requested_objects, many=True, context={
@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
)
with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
available_objects = self.get_available_objects(parent)
available_objects = self.get_available_objects(parent, limit)
# Determine if the requested number of objects is available
if not self.check_sufficient_available(serializer.validated_data, available_objects):
@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
)
# Prepare object data for deserialization
requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent)
requested_objects = self.prep_object_data(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

@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPAddress
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')),
(_('VRF'), ('vrf_id', 'present_in_vrf_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
dns_name = forms.CharField(
required=False,
label=_('DNS Name')
)
tag = TagFilterField(model)

View File

@ -3,6 +3,8 @@ import logging
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import ProtectedError
from django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
@ -157,3 +159,22 @@ class NetBoxModelViewSet(
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
return super().perform_destroy(instance)
class MPTTLockedMixin:
"""
Puts pglock on objects that derive from MPTTModel for parallel API calling.
Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
"""
def create(self, request, *args, **kwargs):
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
return super().create(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
return super().destroy(request, *args, **kwargs)

View File

@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low'
# When adding a new key, pick something arbitrary and unique so that it is easily searchable in
# query logs.
ADVISORY_LOCK_KEYS = {
# Available object locks
'available-prefixes': 100100,
'available-ips': 100200,
'available-vlans': 100300,
'available-asns': 100400,
# MPTT locks
'region': 105100,
'sitegroup': 105200,
'location': 105300,
'tenantgroup': 105400,
'contactgroup': 105500,
'wirelesslangroup': 105600,
'inventoryitem': 105700,
'inventoryitemtemplate': 105800,
}

View File

@ -1,5 +1,6 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
if ct_value and fk_value:
klass = getattr(self, field.ct_field).model_class()
if not klass.objects.filter(pk=fk_value).exists():
try:
obj = klass.objects.get(pk=fk_value)
except ObjectDoesNotExist:
raise ValidationError({
field.fk_field: f"Related object not found using the provided value: {fk_value}."
})
# update the GFK field value
setattr(self, field.name, obj)
#
# NetBox internal base models

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6.3'
VERSION = '3.6.4'
# Hostname
HOSTNAME = platform.node()
@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False)
@ -355,6 +356,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'django.forms',
'corsheaders',
'debug_toolbar',
'graphiql_debug_toolbar',
@ -430,6 +432,9 @@ TEMPLATES = [
},
]
# This allows us to override Django's stock form widget templates
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
# Set up authentication backends
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]

View File

@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column):
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.customfield.get_choice_label(value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
return ', '.join(v for v in value)
return ', '.join(self.customfield.get_choice_label(v) for v in value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return mark_safe(', '.join(
self._linkify_item(obj) for obj in self.customfield.deserialize(value)

Binary file not shown.

Binary file not shown.

View File

@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
import { getElements } from './util';
export function initClipboard(): void {
for (const element of getElements('a.copy-content')) {
for (const element of getElements('.copy-content')) {
new Clipboard(element);
}
}

View File

@ -15,11 +15,6 @@
{% block content %}
<div class="row">
<div class="col col-md-12">
{% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
<div class="alert alert-danger" role="alert">
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
<h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body">

View File

@ -13,7 +13,7 @@
{% block extra_controls %}
{% if perms.dcim.add_device %}
<a href="{% url 'dcim:device_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
<a href="{% url 'dcim:device_add' %}?platform={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %}
</a>
{% endif %}

View File

@ -0,0 +1,6 @@
{% comment %}
Include a hidden field of the same name to ensure that unchecked checkboxes
are always included in the submitted form data.
{% endcomment %}
<input type="hidden" name="{{ widget.name }}" value="">
{% include "django/forms/widgets/input.html" %}

View File

@ -67,6 +67,7 @@ Context:
<input type="hidden" name="import_method" value="upload" />
{% render_field form.upload_file %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group">
<div class="col col-md-12 text-end">
<button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>
@ -88,6 +89,7 @@ Context:
{% render_field form.data_source %}
{% render_field form.data_file %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group">
<div class="col col-md-12 text-end">
<button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>

View File

@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView
from circuits.models import Circuit
from dcim.models import Device, Rack, Site
from ipam.models import IPAddress, Prefix, VLAN, VRF
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from tenancy import filtersets
from tenancy.models import *
from utilities.utils import count_related
@ -23,7 +23,7 @@ class TenancyRootView(APIRootView):
# Tenants
#
class TenantGroupViewSet(NetBoxModelViewSet):
class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
Tenant,
@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet):
# Contacts
#
class ContactGroupViewSet(NetBoxModelViewSet):
class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
Contact,

View File

@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm):
help_text=_(
'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
'submitting this form, as it may no longer be accessible once the token has been created.'
),
widget=forms.TextInput(
attrs={'data-clipboard': 'true'}
)
)
allowed_ips = SimpleArrayField(

View File

@ -52,7 +52,7 @@ class UserTable(NetBoxTable):
model = NetBoxUser
fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
'is_superuser',
'is_superuser', 'last_login',
)
default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')

View File

@ -62,7 +62,7 @@ def post_save_receiver(sender, instance, created, **kwargs):
update_counter(parent_model, new_pk, counter_name, 1)
def post_delete_receiver(sender, instance, **kwargs):
def post_delete_receiver(sender, instance, origin, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
@ -72,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs):
# Decrement the parent's counter by one
if parent_pk is not None:
update_counter(parent_model, parent_pk, counter_name, -1)
# 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)
#

View File

@ -29,6 +29,14 @@
{{ label }}
</label>
</div>
{# Include a copy-to-clipboard button #}
{% elif 'data-clipboard' in field.field.widget.attrs %}
<div class="input-group">
{{ field }}
<button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark border-input copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<i class="mdi mdi-content-copy"></i>
</button>
</div>
{# Default field rendering #}
{% else %}
{{ field }}

View File

@ -1,6 +1,7 @@
from django import template
from django.http import QueryDict
from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict
__all__ = (
@ -38,6 +39,11 @@ def customfield_value(customfield, value):
customfield: A CustomField instance
value: The custom field value applied to an object
"""
if value:
if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
value = customfield.get_choice_label(value)
elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
value = [customfield.get_choice_label(v) for v in value]
return {
'customfield': customfield,
'value': value,

View File

@ -1,7 +1,11 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from dcim.models import *
from utilities.testing.utils import create_test_device
from users.models import ObjectPermission
from utilities.testing.base import TestCase
from utilities.testing.utils import create_test_device, create_test_user
class CountersTest(TestCase):
@ -10,7 +14,6 @@ class CountersTest(TestCase):
"""
@classmethod
def setUpTestData(cls):
# Create devices
device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2')
@ -79,3 +82,25 @@ class CountersTest(TestCase):
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_mptt_child_delete(self):
device1, device2 = Device.objects.all()
inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 2)
# Setup bulk_delete for the inventory items
self.add_permissions('dcim.delete_inventoryitem')
pk_list = device1.inventoryitems.values_list('pk', flat=True)
data = {
'pk': pk_list,
'confirm': True,
'_confirm': True, # Form button
}
# Try POST with model-level permission
self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 0)

View File

@ -1,6 +1,6 @@
from rest_framework.routers import APIRootView
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from wireless import filtersets
from wireless.models import *
from . import serializers
@ -14,7 +14,7 @@ class WirelessRootView(APIRootView):
return 'Wireless'
class WirelessLANGroupViewSet(NetBoxModelViewSet):
class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,

View File

@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES

View File

@ -1,34 +1,35 @@
bleach==6.0.0
Django==4.2.5
django-cors-headers==4.2.0
bleach==6.1.0
Django==4.2.6
django-cors-headers==4.3.0
django-debug-toolbar==4.2.0
django-filter==23.3
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14
django-mptt==0.14.0
django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.3.0
django-rich==1.7.0
django-redis==5.4.0
django-rich==1.8.0
django-rq==2.8.1
django-tables2==2.6.0
django-taggit==4.0.0
django-timezone-field==6.0.1
djangorestframework==3.14.0
drf-spectacular==0.26.4
drf-spectacular-sidecar==2023.9.1
drf-spectacular==0.26.5
drf-spectacular-sidecar==2023.10.1
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.4.2
mkdocs-material==9.4.6
mkdocstrings[python-legacy]==0.23.0
netaddr==0.9.0
Pillow==10.0.1
psycopg[binary,pool]==3.1.11
Pillow==10.1.0
psycopg[binary,pool]==3.1.12
PyYAML==6.0.1
sentry-sdk==1.31.0
social-auth-app-django==5.3.0
requests==2.31.0
sentry-sdk==1.32.0
social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3
tablib==3.5.0