Merge pull request #7922 from netbox-community/develop

Release v3.0.11
This commit is contained in:
Jeremy Stretch 2021-11-24 13:51:17 -05:00 committed by GitHub
commit 869808b3f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 512 additions and 281 deletions

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.0.10
placeholder: v3.0.11
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.0.10
placeholder: v3.0.11
validations:
required: true
- type: dropdown

View File

@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note

View File

@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@ -1,5 +1,32 @@
# NetBox v3.0
## v3.0.11 (2021-11-24)
### Enhancements
* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
### Bug Fixes
* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
---
## v3.0.10 (2021-11-12)
### Enhancements
@ -422,7 +449,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
### Bug Fixes (from v3.2-beta2)
### Bug Fixes (from v3.0-beta2)
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations

View File

@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
fields = ['id', 'name', 'url', 'master', 'member_count']
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
#

View File

@ -356,7 +356,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
required=False
)
power_port = NestedPowerPortTemplateSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@ -538,7 +539,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@ -562,7 +563,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@ -585,7 +586,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
required=False
)
power_port = NestedPowerPortSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,

View File

@ -428,7 +428,7 @@ class PowerPortTypeChoices(ChoiceSet):
)),
('International/ITA', (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@ -640,8 +640,8 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8464C, 'CS8464C'),
)),
('ITA/International', (
(TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
(TYPE_ITA_H, 'ITA Type H'),
(TYPE_ITA_I, 'ITA Type I'),
@ -739,6 +739,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax'
TYPE_802151 = 'ieee802.15.1'
# Cellular
TYPE_GSM = 'gsm'
@ -850,6 +851,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
)
),
(

View File

@ -861,6 +861,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),
label='Virtual Chassis (ID)'
)
virtual_chassis = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis__name',
queryset=VirtualChassis.objects.all(),
to_field_name='name',
label='Virtual Chassis',
)
tag = TagFilter()
def search(self, queryset, name, value):
@ -1394,6 +1405,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
#
class ConnectionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
@ -1416,6 +1431,15 @@ class ConnectionFilterSet(BaseFilterSet):
return queryset
return queryset.filter(**{f'{name}__in': value})
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(device__name__icontains=value) |
Q(cable__label__icontains=value)
)
return queryset.filter(qs_filter)
class ConsoleConnectionFilterSet(ConnectionFilterSet):

View File

@ -215,8 +215,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
@ -277,8 +276,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit', 'tags',

View File

@ -92,12 +92,19 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Location'),
fetch_trigger='open'
)
virtual_chassis_id = DynamicModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
required=False,
label=_('Virtual Chassis'),
fetch_trigger='open'
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id'
},
label=_('Device'),
fetch_trigger='open'
@ -888,7 +895,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@ -908,7 +915,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@ -928,7 +935,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
@ -943,7 +950,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
@ -958,7 +965,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
@ -993,7 +1000,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
model = FrontPort
type = forms.MultipleChoiceField(
@ -1012,7 +1019,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
@ -1030,7 +1037,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
tag = TagFilterField(model)
@ -1040,7 +1047,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@ -1068,6 +1075,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -1095,6 +1107,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -1122,6 +1139,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@ -442,15 +442,16 @@ class CableTraceSVG:
parent_objects.append(parent_object)
# Near end termination
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
if near_end is not None:
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
# Connector (either a Cable or attachment to a ProviderNetwork)
if connector is not None:

View File

@ -584,6 +584,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
)
PowerPortTemplate.objects.bulk_create(power_port_templates)
power_outlet_templates = (
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
@ -595,14 +601,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 4',
'power_port': power_port_templates[0].pk,
},
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 5',
'power_port': power_port_templates[1].pk,
},
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 6',
'power_port': None,
},
]
@ -1033,14 +1042,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
{
'device': device.pk,
'name': 'Console Port 4',
'speed': 9600,
},
{
'device': device.pk,
'name': 'Console Port 5',
'speed': 115200,
},
{
'device': device.pk,
'name': 'Console Port 6',
'speed': None,
},
]
@ -1072,14 +1084,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
{
'device': device.pk,
'name': 'Console Server Port 4',
'speed': 9600,
},
{
'device': device.pk,
'name': 'Console Server Port 5',
'speed': 115200,
},
{
'device': device.pk,
'name': 'Console Server Port 6',
'speed': None,
},
]
@ -1139,6 +1154,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
power_ports = (
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
)
PowerPort.objects.bulk_create(power_ports)
power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1'),
PowerOutlet(device=device, name='Power Outlet 2'),
@ -1150,14 +1171,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
{
'device': device.pk,
'name': 'Power Outlet 4',
'power_port': power_ports[0].pk,
},
{
'device': device.pk,
'name': 'Power Outlet 5',
'power_port': power_ports[1].pk,
},
{
'device': device.pk,
'name': 'Power Outlet 6',
'power_port': None,
},
]
@ -1524,7 +1548,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):

View File

@ -2048,6 +2048,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
@ -2157,6 +2162,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -2,8 +2,9 @@ from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
from utilities.utils import curry
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
from netbox import thread_locals
from netbox.request_context import set_request
from .webhooks import flush_webhooks
@ -15,12 +16,8 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set
"""
webhook_queue = []
# Curry signals receivers to pass the current request
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
set_request(request)
thread_locals.webhook_queue = []
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
@ -38,5 +35,8 @@ def change_logging(request):
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ
flush_webhooks(webhook_queue)
del webhook_queue
flush_webhooks(thread_locals.webhook_queue)
del thread_locals.webhook_queue
# Clear the request from thread-local storage
set_request(None)

View File

@ -35,6 +35,10 @@ EXACT_FILTER_TYPES = (
class WebhookFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
@ -47,30 +51,81 @@ class WebhookFilterSet(BaseFilterSet):
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(payload_url__icontains=value)
)
class CustomFieldFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(label__icontains=value) |
Q(description__icontains=value)
)
class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = CustomLink
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(link_text__icontains=value) |
Q(link_url__icontains=value) |
Q(group_name__icontains=value)
)
class ExportTemplateFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = ExportTemplate
fields = ['id', 'content_type', 'name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
created = django_filters.DateTimeFilter()
content_type = ContentTypeFilter()
@ -78,6 +133,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
model = ImageAttachment
fields = ['id', 'content_type_id', 'object_id', 'name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(

View File

@ -3,6 +3,7 @@ import json
import logging
import os
import pkgutil
import sys
import traceback
from collections import OrderedDict
@ -477,6 +478,10 @@ def get_scripts(use_names=False):
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Remove cached module to ensure consistency with filesystem
if module_name in sys.modules:
del sys.modules[module_name]
module = importer.find_module(module_name).load_module(module_name)
if use_names and hasattr(module, 'name'):
module_name = module.name

View File

@ -6,6 +6,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates
from netbox import thread_locals
from netbox.request_context import get_request
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import CustomField, ObjectChange
@ -20,10 +22,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal()
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
m2m_changed = False
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
@ -31,11 +39,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
request.id == webhook_data['request_id']
)
if not hasattr(instance, 'to_objectchange'):
return
m2m_changed = False
# Determine the type of change being made
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
@ -65,6 +68,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
webhook_queue = thread_locals.webhook_queue
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
@ -79,13 +83,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc()
def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
@ -94,19 +100,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# Enqueue webhooks
webhook_queue = thread_locals.webhook_queue
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
def _clear_webhook_queue(webhook_queue, sender, **kwargs):
def clear_webhook_queue(sender, **kwargs):
"""
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('webhooks')
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue = thread_locals.webhook_queue
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()

View File

@ -11,7 +11,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.tables import paginate_table
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables
from .choices import JobResultStatusChoices
@ -754,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
def get(self, request, module, name):
script = self._get_script(name, module)
form = script.as_form(initial=request.GET)
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending JobResult (use the latest one by creation timestamp)
script_content_type = ContentType.objects.get(app_label='extras', model='script')

View File

@ -206,6 +206,11 @@ class PrefixTable(BaseTable):
site = tables.Column(
linkify=True
)
vlan_group = tables.Column(
accessor='vlan__group',
linkify=True,
verbose_name='VLAN Group'
)
vlan = tables.Column(
linkify=True,
verbose_name='VLAN'
@ -230,8 +235,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description', 'tags',
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@ -318,7 +323,7 @@ class IPAddressTable(BaseTable):
verbose_name='NAT (Inside)'
)
assigned = BooleanColumn(
accessor='assigned_object',
accessor='assigned_object_id',
linkify=True,
verbose_name='Assigned'
)

View File

@ -0,0 +1,3 @@
import threading
thread_locals = threading.local()

View File

@ -34,7 +34,7 @@ class ObjectPermissionMixin():
object_permissions = ObjectPermission.objects.filter(
self.get_permission_filter(user_obj),
enabled=True
).prefetch_related('object_types')
).order_by('id').distinct('id').prefetch_related('object_types')
# Create a dictionary mapping permissions to their constraints
perms = defaultdict(list)

View File

@ -1,10 +1,10 @@
import logging
import uuid
from urllib import parse
import logging
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.contrib import auth
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect
@ -114,7 +114,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return groups
class ObjectChangeMiddleware(object):
class ObjectChangeMiddleware:
"""
This middleware performs three functions in response to an object being created, updated, or deleted:

View File

@ -0,0 +1,9 @@
from netbox import thread_locals
def set_request(request):
thread_locals.request = request
def get_request():
return getattr(thread_locals, 'request', None)

View File

@ -17,7 +17,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '3.0.10'
VERSION = '3.0.11'
# Hostname
HOSTNAME = platform.node()

View File

@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
def get_table(self, request, permissions):
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
return table
def export_yaml(self):
"""
Export the queryset of objects as concatenated YAML documents.
@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
)
def get(self, request):
def export_template(self, template, request):
"""
Render an ExportTemplate using the current queryset.
:param template: ExportTemplate instance
:param request: The current request
"""
try:
return template.render_to_response(self.queryset)
except Exception as e:
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
return redirect(request.path)
def get(self, request):
model = self.queryset.model
content_type = ContentType.objects.get_for_model(model)
@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name)
# Export template/YAML rendering
if 'export' in request.GET and request.GET['export'] != 'table':
if 'export' in request.GET:
# An export template has been specified
if request.GET['export']:
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
try:
return et.render_to_response(self.queryset)
except Exception as e:
messages.error(
request,
"There was an error rendering the selected export template ({}): {}".format(
et.name, e
)
)
# Export the current table view
if request.GET['export'] == 'table':
table = self.get_table(request, permissions)
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
# Check for YAML export support
# Render an ExportTemplate
elif request.GET['export']:
template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
return self.export_template(template, request)
# Check for YAML export support on the model
elif hasattr(model, 'to_yaml'):
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Construct the objects table
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
# Fall back to default table/YAML export
else:
table = self.get_table(request, permissions)
return self.export_table(table)
# Handle table-based exports (current view or static CSV-based)
if request.GET.get('export') == 'table':
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
elif 'export' in request.GET:
return self.export_table(table)
# Paginate the objects table
# Render the objects table
table = self.get_table(request, permissions)
paginate_table(table, request)
context = {

View File

@ -3,108 +3,94 @@
{% block title %}Reports{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-9">
{% if reports %}
{% for module, module_reports in reports %}
<div class="card">
<h5 class="card-header"><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h5>
<div class="card-body">
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th class="text-end">Last Run</th>
<th></th>
</tr>
</thead>
<tbody>
{% for report in module_reports %}
<tr>
<td>
<a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=report.result %}
</td>
<td>{{ report.description|render_markdown|placeholder }}</td>
<td class="text-end">
{% if report.result %}
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</td>
<td>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% for method, stats in report.result.data.items %}
<tr>
<td colspan="4" class="method">
{{ method }}
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">Reports</a>
</li>
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% if reports %}
{% for module, module_reports in reports %}
<div class="card">
<h5 class="card-header">
<a name="module.{{ module }}"></a>
<i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
</h5>
<div class="card-body">
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th width="110">Status</th>
<th>Description</th>
<th width="150" class="text-end">Last Run</th>
<th width="120"></th>
</tr>
</thead>
<tbody>
{% for report in module_reports %}
<tr>
<td>
<a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=report.result %}
</td>
<td>{{ report.description|render_markdown|placeholder }}</td>
<td class="text-end">
{% if report.result %}
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</td>
<td>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
</div>
{% endif %}
</td>
</tr>
{% for method, stats in report.result.data.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endfor %}
{% else %}
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">No Reports Found</h4>
Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
<hr/>
<small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
</div>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col col-md-3">
{% if reports %}
<div class="card">
<div class="card-body">
{% for module, module_reports in reports %}
<h5>{{ module|bettertitle }}</h5>
<div class="small mb-2">
<ul class="list-group list-group-flush">
{% for report in module_reports %}
<a href="#{{ report.module }}.{{ report.class_name }}" class="list-group-item">
<i class="mdi mdi-file-chart-outline"></i> {{ report.name }}
<div class="float-end">
{% include 'extras/inc/job_label.html' with result=report.result %}
</div>
</a>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% endfor %}
{% else %}
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">No Reports Found</h4>
Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
<hr/>
<small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
</div>
{% endif %}
</div>
{% endblock content-wrapper %}

View File

@ -3,74 +3,66 @@
{% block title %}Scripts{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-9">
{% if scripts %}
{% for module, module_scripts in scripts.items %}
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th class="text-end">Last Run</th>
</tr>
</thead>
<tbody>
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=script.result %}
</td>
<td>{{ script.Meta.description|render_markdown }}</td>
{% if script.result %}
<td class="text-end">
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
</td>
{% else %}
<td class="text-end text-muted">Never</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">Scripts</a>
</li>
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% if scripts %}
{% for module, module_scripts in scripts.items %}
<div class="card">
<h5 class="card-header">
<a name="module.{{ module }}"></a>
<i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
</h5>
<div class="card-body">
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th width="110">Status</th>
<th>Description</th>
<th class="text-end">Last Run</th>
</tr>
</thead>
<tbody>
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=script.result %}
</td>
<td>
{{ script.Meta.description|render_markdown|placeholder }}
</td>
{% if script.result %}
<td class="text-end">
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
</td>
{% else %}
<td class="text-end text-muted">Never</td>
{% endif %}
</tr>
{% endfor %}
{% else %}
<div class="alert alert-info">
<h4 class="alert-heading">No Scripts Found</h4>
Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
<hr/>
This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
</div>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col col-md-3">
{% if scripts %}
<div class="card">
<div class="card-body">
{% for module, module_scripts in scripts.items %}
<h5>{{ module|bettertitle }}</h5>
<div class="small mb-2">
<ul class="list-group list-group-flush">
{% for class_name, script in module_scripts.items %}
<a href="#script.{{ class_name }}" class="list-group-item">
<i class="mdi mdi-file-chart-outline"></i> {{ script.name }}
<div class="float-end">
{% include 'extras/inc/job_label.html' with result=script.result %}
</div>
</a>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% endfor %}
{% else %}
<div class="alert alert-info">
<h4 class="alert-heading">No Scripts Found</h4>
Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
<hr/>
This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
</div>
{% endif %}
</div>
{% endblock content-wrapper %}

View File

@ -137,7 +137,11 @@
Additional Headers
</h5>
<div class="card-body">
<pre>{{ object.additional_headers }}</pre>
{% if object.additional_headers %}
<pre>{{ object.additional_headers }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
<div class="card">
@ -145,7 +149,11 @@
Body Template
</h5>
<div class="card-body">
<pre>{{ object.body_template }}</pre>
{% if object.body_template %}
<pre>{{ object.body_template }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
{% plugin_right_page object %}

View File

@ -1,6 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
@ -68,7 +69,7 @@
VLANs
</h5>
<div class="card-body">
{% include 'inc/table.html' with table=vlans_table %}
{% render_table vlans_table 'inc/table.html' %}
</div>
{% if perms.ipam.add_vlan %}
<div class="card-footer text-end noprint">

View File

@ -99,8 +99,20 @@ class TokenFilterSet(BaseFilterSet):
model = Token
fields = ['id', 'key', 'write_enabled']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user__username__icontains=value) |
Q(description__icontains=value)
)
class ObjectPermissionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users',
queryset=User.objects.all(),
@ -127,3 +139,11 @@ class ObjectPermissionFilterSet(BaseFilterSet):
class Meta:
model = ObjectPermission
fields = ['id', 'name', 'enabled', 'object_types']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)

View File

@ -0,0 +1,16 @@
import markdown
from markdown.inlinepatterns import SimpleTagPattern
STRIKE_RE = r'(~{2})(.+?)(~{2})'
class StrikethroughExtension(markdown.Extension):
"""
A python-markdown extension which support strikethrough formatting (e.g. "~~text~~").
"""
def extendMarkdown(self, md):
md.inlinePatterns.register(
markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del'),
'strikethrough',
200
)

View File

@ -15,6 +15,7 @@ from django.utils.safestring import mark_safe
from markdown import markdown
from utilities.forms import get_selected_values, TableConfigForm
from utilities.markdown import StrikethroughExtension
from utilities.utils import foreground_color
register = template.Library()
@ -54,7 +55,7 @@ def render_markdown(value):
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
# Render Markdown
html = markdown(value, extensions=['fenced_code', 'tables'])
html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
return mark_safe(html)

View File

@ -327,13 +327,6 @@ def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict:
return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()}
# Taken from django.utils.functional (<3.0)
def curry(_curried_func, *args, **kwargs):
def _curried(*moreargs, **morekwargs):
return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs})
return _curried
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.

View File

@ -144,6 +144,12 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf
queryset=Cluster.objects.all(),
label='Cluster (ID)',
)
cluster = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__name',
queryset=Cluster.objects.all(),
to_field_name='name',
label='Cluster',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='cluster__site__region',

View File

@ -324,9 +324,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
clusters = Cluster.objects.all()[:2]
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: 'cluster' should match on name
# params = {'cluster': [clusters[0].name, clusters[1].name]}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cluster': [clusters[0].name, clusters[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]

View File

@ -7,7 +7,7 @@ django-mptt==0.13.4
django-pglocks==1.0.4
django-prometheus==2.1.0
django-redis==5.0.0
django-rq==2.4.1
django-rq==2.5.1
django-tables2==2.4.1
django-taggit==1.5.1
django-timezone-field==4.2.1
@ -16,7 +16,7 @@ drf-yasg[validation]==1.20.0
graphene_django==2.15.0
gunicorn==20.1.0
Jinja2==3.0.3
Markdown==3.3.4
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==7.3.6
netaddr==0.8.0