1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #9623: Implement saved filters (#10801)

* Initial work on saved filters

* Return only enabled/shared filters

* Add tests

* Clean up filtering of usable SavedFilters
This commit is contained in:
Jeremy Stretch
2022-11-02 12:27:53 -04:00
committed by GitHub
parent ea61a540cd
commit 484efdaf75
37 changed files with 821 additions and 138 deletions

View File

@ -20,7 +20,7 @@ __all__ = (
class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)), ('ASN', ('asn',)),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
model = ProviderNetwork model = ProviderNetwork
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('provider_id', 'service_id')), ('Attributes', ('provider_id', 'service_id')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit model = Circuit
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Provider', ('provider_id', 'provider_network_id')), ('Provider', ('provider_id', 'provider_network_id')),
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),

View File

@ -116,7 +116,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region model = Region
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'parent_id')), (None, ('q', 'filter', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@ -130,7 +130,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'parent_id')), (None, ('q', 'filter', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Site model = Site
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
@ -174,7 +174,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Location model = Location
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
@ -222,7 +222,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Rack model = Rack
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')), ('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
@ -306,7 +306,7 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('User', ('user_id',)), ('User', ('user_id',)),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
@ -362,7 +362,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer model = Manufacturer
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Contacts', ('contact', 'contact_role', 'contact_group')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -371,7 +371,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
model = DeviceType model = DeviceType
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
('Images', ('has_front_image', 'has_rear_image')), ('Images', ('has_front_image', 'has_rear_image')),
('Components', ( ('Components', (
@ -486,7 +486,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
class ModuleTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Hardware', ('manufacturer_id', 'part_number')), ('Hardware', ('manufacturer_id', 'part_number')),
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@ -578,7 +578,7 @@ class DeviceFilterForm(
): ):
model = Device model = Device
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
@ -731,7 +731,7 @@ class DeviceFilterForm(
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = Module model = Module
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
@ -761,7 +761,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = VirtualChassis model = VirtualChassis
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -790,7 +790,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable model = Cable
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
@ -862,7 +862,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = PowerPanel model = PowerPanel
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
@ -900,7 +900,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class PowerFeedFilterForm(NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm):
model = PowerFeed model = PowerFeed
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
) )
@ -1002,7 +1002,7 @@ class PathEndpointFilterForm(CabledFilterForm):
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort model = ConsolePort
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), ('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
@ -1021,7 +1021,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort model = ConsoleServerPort
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), ('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
@ -1040,7 +1040,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort model = PowerPort
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'type')), ('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
@ -1055,7 +1055,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'type')), ('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
@ -1070,7 +1070,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface model = Interface
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')), ('PoE', ('poe_mode', 'poe_type')),
@ -1159,7 +1159,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), ('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')), ('Cable', ('cabled', 'occupied')),
@ -1178,7 +1178,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort model = RearPort
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), ('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')), ('Cable', ('cabled', 'occupied')),
@ -1196,7 +1196,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
class ModuleBayFilterForm(DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay model = ModuleBay
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'position')), ('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
) )
@ -1209,7 +1209,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
class DeviceBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay model = DeviceBay
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label')), ('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
) )
@ -1219,7 +1219,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
class InventoryItemFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
) )

View File

@ -13,6 +13,7 @@ __all__ = [
'NestedImageAttachmentSerializer', 'NestedImageAttachmentSerializer',
'NestedJobResultSerializer', 'NestedJobResultSerializer',
'NestedJournalEntrySerializer', 'NestedJournalEntrySerializer',
'NestedSavedFilterSerializer',
'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedTagSerializer', # Defined in netbox.api.serializers
'NestedWebhookSerializer', 'NestedWebhookSerializer',
] ]
@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name'] fields = ['id', 'url', 'display', 'name']
class NestedSavedFilterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
class Meta:
model = models.SavedFilter
fields = ['id', 'url', 'display', 'name']
class NestedImageAttachmentSerializer(WritableNestedSerializer): class NestedImageAttachmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')

View File

@ -39,6 +39,7 @@ __all__ = (
'ReportDetailSerializer', 'ReportDetailSerializer',
'ReportSerializer', 'ReportSerializer',
'ReportInputSerializer', 'ReportInputSerializer',
'SavedFilterSerializer',
'ScriptDetailSerializer', 'ScriptDetailSerializer',
'ScriptInputSerializer', 'ScriptInputSerializer',
'ScriptLogMessageSerializer', 'ScriptLogMessageSerializer',
@ -149,6 +150,25 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
] ]
#
# Saved filters
#
class SavedFilterSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
content_types = ContentTypeField(
queryset=ContentType.objects.all(),
many=True
)
class Meta:
model = SavedFilter
fields = [
'id', 'url', 'display', 'content_types', 'name', 'description', 'user', 'weight',
'enabled', 'shared', 'parameters', 'created', 'last_updated',
]
# #
# Tags # Tags
# #

View File

@ -5,43 +5,19 @@ from . import views
router = NetBoxRouter() router = NetBoxRouter()
router.APIRootView = views.ExtrasRootView router.APIRootView = views.ExtrasRootView
# Webhooks
router.register('webhooks', views.WebhookViewSet) router.register('webhooks', views.WebhookViewSet)
# Custom fields
router.register('custom-fields', views.CustomFieldViewSet) router.register('custom-fields', views.CustomFieldViewSet)
# Custom links
router.register('custom-links', views.CustomLinkViewSet) router.register('custom-links', views.CustomLinkViewSet)
# Export templates
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet)
# Tags
router.register('tags', views.TagViewSet) router.register('tags', views.TagViewSet)
# Image attachments
router.register('image-attachments', views.ImageAttachmentViewSet) router.register('image-attachments', views.ImageAttachmentViewSet)
# Journal entries
router.register('journal-entries', views.JournalEntryViewSet) router.register('journal-entries', views.JournalEntryViewSet)
# Config contexts
router.register('config-contexts', views.ConfigContextViewSet) router.register('config-contexts', views.ConfigContextViewSet)
# Reports
router.register('reports', views.ReportViewSet, basename='report') router.register('reports', views.ReportViewSet, basename='report')
# Scripts
router.register('scripts', views.ScriptViewSet, basename='script') router.register('scripts', views.ScriptViewSet, basename='script')
# Change logging
router.register('object-changes', views.ObjectChangeViewSet) router.register('object-changes', views.ObjectChangeViewSet)
# Job Results
router.register('job-results', views.JobResultViewSet) router.register('job-results', views.JobResultViewSet)
# ContentTypes
router.register('content-types', views.ContentTypeViewSet) router.register('content-types', views.ContentTypeViewSet)
app_name = 'extras-api' app_name = 'extras-api'

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -98,6 +99,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet):
filterset_class = filtersets.ExportTemplateFilterSet filterset_class = filtersets.ExportTemplateFilterSet
#
# Saved filters
#
class SavedFilterViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = SavedFilter.objects.all()
serializer_class = serializers.SavedFilterSerializer
filterset_class = filtersets.SavedFilterFilterSet
# #
# Tags # Tags
# #

View File

@ -23,6 +23,7 @@ __all__ = (
'JournalEntryFilterSet', 'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'SavedFilterFilterSet',
'TagFilterSet', 'TagFilterSet',
'WebhookFilterSet', 'WebhookFilterSet',
) )
@ -138,6 +139,55 @@ class ExportTemplateFilterSet(BaseFilterSet):
) )
class SavedFilterFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label='User (ID)',
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
label='User (name)',
)
usable = django_filters.BooleanFilter(
method='_usable'
)
class Meta:
model = SavedFilter
fields = ['id', 'content_types', 'name', 'description', 'enabled', 'shared', 'weight']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
def _usable(self, queryset, name, value):
"""
Return only SavedFilters that are both enabled and are shared (or belong to the current user).
"""
user = self.request.user if self.request else None
if not user or user.is_anonymous:
if value:
return queryset.filter(enabled=True, shared=True)
return queryset.filter(Q(enabled=False) | Q(shared=False))
if value:
return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user))
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
class ImageAttachmentFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -2,6 +2,6 @@ from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .customfields import * from .mixins import *
from .config import * from .config import *
from .scripts import * from .scripts import *

View File

@ -1,11 +1,9 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect,
) )
__all__ = ( __all__ = (
@ -14,6 +12,7 @@ __all__ = (
'CustomLinkBulkEditForm', 'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm', 'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm', 'JournalEntryBulkEditForm',
'SavedFilterBulkEditForm',
'TagBulkEditForm', 'TagBulkEditForm',
'WebhookBulkEditForm', 'WebhookBulkEditForm',
) )
@ -96,6 +95,30 @@ class ExportTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('description', 'mime_type', 'file_extension') nullable_fields = ('description', 'mime_type', 'file_extension')
class SavedFilterBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SavedFilter.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
weight = forms.IntegerField(
required=False
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
shared = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
class WebhookBulkEditForm(BulkEditForm): class WebhookBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Webhook.objects.all(), queryset=Webhook.objects.all(),

View File

@ -12,6 +12,7 @@ __all__ = (
'CustomFieldCSVForm', 'CustomFieldCSVForm',
'CustomLinkCSVForm', 'CustomLinkCSVForm',
'ExportTemplateCSVForm', 'ExportTemplateCSVForm',
'SavedFilterCSVForm',
'TagCSVForm', 'TagCSVForm',
'WebhookCSVForm', 'WebhookCSVForm',
) )
@ -81,6 +82,19 @@ class ExportTemplateCSVForm(CSVModelForm):
) )
class SavedFilterCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
help_text="One or more assigned object types"
)
class Meta:
model = SavedFilter
fields = (
'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters',
)
class WebhookCSVForm(CSVModelForm): class WebhookCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),

View File

@ -15,6 +15,7 @@ from utilities.forms import (
StaticSelect, TagFilterField, StaticSelect, TagFilterField,
) )
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
from .mixins import SavedFiltersMixin
__all__ = ( __all__ = (
'ConfigContextFilterForm', 'ConfigContextFilterForm',
@ -25,14 +26,15 @@ __all__ = (
'JournalEntryFilterForm', 'JournalEntryFilterForm',
'LocalConfigContextFilterForm', 'LocalConfigContextFilterForm',
'ObjectChangeFilterForm', 'ObjectChangeFilterForm',
'SavedFilterFilterForm',
'TagFilterForm', 'TagFilterForm',
'WebhookFilterForm', 'WebhookFilterForm',
) )
class CustomFieldFilterForm(FilterForm): class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'filter')),
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
@ -66,9 +68,9 @@ class CustomFieldFilterForm(FilterForm):
) )
class JobResultFilterForm(FilterForm): class JobResultFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'filter')),
('Attributes', ('obj_type', 'status')), ('Attributes', ('obj_type', 'status')),
('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after', ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
'scheduled_time__before', 'scheduled_time__after', 'user')), 'scheduled_time__before', 'scheduled_time__after', 'user')),
@ -118,9 +120,9 @@ class JobResultFilterForm(FilterForm):
) )
class CustomLinkFilterForm(FilterForm): class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'filter')),
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
@ -145,9 +147,9 @@ class CustomLinkFilterForm(FilterForm):
) )
class ExportTemplateFilterForm(FilterForm): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'filter')),
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
@ -170,9 +172,36 @@ class ExportTemplateFilterForm(FilterForm):
) )
class WebhookFilterForm(FilterForm): class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'filter')),
('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
required=False
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
shared = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
weight = forms.IntegerField(
required=False
)
class WebhookFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter')),
('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Attributes', ('content_type_id', 'http_method', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')), ('Events', ('type_create', 'type_update', 'type_delete')),
) )
@ -213,7 +242,7 @@ class WebhookFilterForm(FilterForm):
) )
class TagFilterForm(FilterForm): class TagFilterForm(SavedFiltersMixin, FilterForm):
model = Tag model = Tag
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
@ -222,9 +251,9 @@ class TagFilterForm(FilterForm):
) )
class ConfigContextFilterForm(FilterForm): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'tag_id')), (None, ('q', 'filter', 'tag_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Device', ('device_type_id', 'platform_id', 'role_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')),
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
@ -311,7 +340,7 @@ class LocalConfigContextFilterForm(forms.Form):
class JournalEntryFilterForm(NetBoxModelFilterSetForm): class JournalEntryFilterForm(NetBoxModelFilterSetForm):
model = JournalEntry model = JournalEntry
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Creation', ('created_before', 'created_after', 'created_by_id')), ('Creation', ('created_before', 'created_after', 'created_by_id')),
('Attributes', ('assigned_object_type_id', 'kind')) ('Attributes', ('assigned_object_type_id', 'kind'))
) )
@ -349,10 +378,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class ObjectChangeFilterForm(FilterForm): class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
model = ObjectChange model = ObjectChange
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q', 'filter')),
('Time', ('time_before', 'time_after')), ('Time', ('time_before', 'time_after')),
('Attributes', ('action', 'user_id', 'changed_object_type_id')), ('Attributes', ('action', 'user_id', 'changed_object_type_id')),
) )

View File

@ -1,10 +1,13 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django import forms
from extras.models import * from extras.models import *
from extras.choices import CustomFieldVisibilityChoices from extras.choices import CustomFieldVisibilityChoices
from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = ( __all__ = (
'CustomFieldsMixin', 'CustomFieldsMixin',
'SavedFiltersMixin',
) )
@ -57,3 +60,14 @@ class CustomFieldsMixin:
if customfield.group_name not in self.custom_field_groups: if customfield.group_name not in self.custom_field_groups:
self.custom_field_groups[customfield.group_name] = [] self.custom_field_groups[customfield.group_name] = []
self.custom_field_groups[customfield.group_name].append(field_name) self.custom_field_groups[customfield.group_name].append(field_name)
class SavedFiltersMixin(forms.Form):
filter = DynamicModelMultipleChoiceField(
queryset=SavedFilter.objects.all(),
required=False,
label='Saved Filter',
query_params={
'usable': True,
}
)

View File

@ -1,5 +1,6 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import QueryDict
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
@ -20,6 +21,7 @@ __all__ = (
'ExportTemplateForm', 'ExportTemplateForm',
'ImageAttachmentForm', 'ImageAttachmentForm',
'JournalEntryForm', 'JournalEntryForm',
'SavedFilterForm',
'TagForm', 'TagForm',
'WebhookForm', 'WebhookForm',
) )
@ -108,6 +110,34 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class SavedFilterForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all()
)
fieldsets = (
('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')),
('Parameters', ('parameters',)),
)
class Meta:
model = SavedFilter
exclude = ('user',)
widgets = {
'parameters': forms.Textarea(attrs={'class': 'font-monospace'}),
}
def __init__(self, *args, initial=None, **kwargs):
# Convert any parameters delivered via initial data to a dictionary
if initial and 'parameters' in initial:
if type(initial['parameters']) is str:
# TODO: Make a utility function for this
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
super().__init__(*args, initial=initial, **kwargs)
class WebhookForm(BootstrapMixin, forms.ModelForm): class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),

View File

@ -20,6 +20,9 @@ class ExtrasQuery(graphene.ObjectType):
image_attachment = ObjectField(ImageAttachmentType) image_attachment = ObjectField(ImageAttachmentType)
image_attachment_list = ObjectListField(ImageAttachmentType) image_attachment_list = ObjectListField(ImageAttachmentType)
saved_filter = ObjectField(SavedFilterType)
saved_filter_list = ObjectListField(SavedFilterType)
journal_entry = ObjectField(JournalEntryType) journal_entry = ObjectField(JournalEntryType)
journal_entry_list = ObjectListField(JournalEntryType) journal_entry_list = ObjectListField(JournalEntryType)

View File

@ -10,6 +10,7 @@ __all__ = (
'ImageAttachmentType', 'ImageAttachmentType',
'JournalEntryType', 'JournalEntryType',
'ObjectChangeType', 'ObjectChangeType',
'SavedFilterType',
'TagType', 'TagType',
'WebhookType', 'WebhookType',
) )
@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType):
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet
class SavedFilterType(ObjectType):
class Meta:
model = models.SavedFilter
exclude = ('content_types', )
filterset_class = filtersets.SavedFilterFilterSet
class TagType(ObjectType): class TagType(ObjectType):
class Meta: class Meta:

View File

@ -0,0 +1,36 @@
# Generated by Django 4.1.1 on 2022-10-27 18:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0082_exporttemplate_content_types'),
]
operations = [
migrations.CreateModel(
name='SavedFilter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('weight', models.PositiveSmallIntegerField(default=100)),
('enabled', models.BooleanField(default=True)),
('shared', models.BooleanField(default=True)),
('parameters', models.JSONField()),
('content_types', models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('weight', 'name'),
},
),
]

View File

@ -18,6 +18,7 @@ __all__ = (
'JournalEntry', 'JournalEntry',
'ObjectChange', 'ObjectChange',
'Report', 'Report',
'SavedFilter',
'Script', 'Script',
'Tag', 'Tag',
'TaggedItem', 'TaggedItem',

View File

@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse from django.http import HttpResponse, QueryDict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
@ -34,6 +34,7 @@ __all__ = (
'JobResult', 'JobResult',
'JournalEntry', 'JournalEntry',
'Report', 'Report',
'SavedFilter',
'Script', 'Script',
'Webhook', 'Webhook',
) )
@ -350,6 +351,69 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
return response return response
class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
"""
A set of predefined keyword parameters that can be reused to filter for specific objects.
"""
content_types = models.ManyToManyField(
to=ContentType,
related_name='saved_filters',
help_text='The object type(s) to which this filter applies.'
)
name = models.CharField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
blank=True,
null=True
)
weight = models.PositiveSmallIntegerField(
default=100
)
enabled = models.BooleanField(
default=True
)
shared = models.BooleanField(
default=True
)
parameters = models.JSONField()
clone_fields = (
'enabled', 'weight',
)
class Meta:
ordering = ('weight', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:savedfilter', args=[self.pk])
def clean(self):
super().clean()
# Verify that `parameters` is a JSON object
if type(self.parameters) is not dict:
raise ValidationError(
{'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'}
)
@property
def url_params(self):
qd = QueryDict(mutable=True)
qd.update(self.parameters)
return qd.urlencode()
class ImageAttachment(WebhooksMixin, ChangeLoggedModel): class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.

View File

@ -13,16 +13,13 @@ __all__ = (
'ExportTemplateTable', 'ExportTemplateTable',
'JournalEntryTable', 'JournalEntryTable',
'ObjectChangeTable', 'ObjectChangeTable',
'SavedFilterTable',
'TaggedItemTable', 'TaggedItemTable',
'TagTable', 'TagTable',
'WebhookTable', 'WebhookTable',
) )
#
# Custom fields
#
class CustomFieldTable(NetBoxTable): class CustomFieldTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
@ -40,10 +37,6 @@ class CustomFieldTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
#
# Custom fields
#
class JobResultTable(NetBoxTable): class JobResultTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
@ -61,10 +54,6 @@ class JobResultTable(NetBoxTable):
default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',) default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
#
# Custom links
#
class CustomLinkTable(NetBoxTable): class CustomLinkTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
@ -82,10 +71,6 @@ class CustomLinkTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
#
# Export templates
#
class ExportTemplateTable(NetBoxTable): class ExportTemplateTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
@ -104,9 +89,24 @@ class ExportTemplateTable(NetBoxTable):
) )
# class SavedFilterTable(NetBoxTable):
# Webhooks name = tables.Column(
# linkify=True
)
content_types = columns.ContentTypesColumn()
enabled = columns.BooleanColumn()
shared = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
model = SavedFilter
fields = (
'pk', 'id', 'name', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
)
class WebhookTable(NetBoxTable): class WebhookTable(NetBoxTable):
name = tables.Column( name = tables.Column(
@ -139,10 +139,6 @@ class WebhookTable(NetBoxTable):
) )
#
# Tags
#
class TagTable(NetBoxTable): class TagTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True

View File

@ -3,7 +3,6 @@ from unittest import skipIf
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from django_rq.queues import get_connection from django_rq.queues import get_connection
@ -17,7 +16,6 @@ from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
rq_worker_running = Worker.count(get_connection('default')) rq_worker_running = Worker.count(get_connection('default'))
@ -192,6 +190,73 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
custom_link.content_types.set([site_ct]) custom_link.content_types.set([site_ct])
class SavedFilterTest(APIViewTestCases.APIViewTestCase):
model = SavedFilter
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'content_types': ['dcim.site'],
'name': 'Saved Filter 4',
'weight': 100,
'enabled': True,
'shared': True,
'parameters': {'status': ['active']},
},
{
'content_types': ['dcim.site'],
'name': 'Saved Filter 5',
'weight': 200,
'enabled': True,
'shared': True,
'parameters': {'status': ['planned']},
},
{
'content_types': ['dcim.site'],
'name': 'Saved Filter 6',
'weight': 300,
'enabled': True,
'shared': True,
'parameters': {'status': ['retired']},
},
]
bulk_update_data = {
'weight': 1000,
'enabled': False,
'shared': False,
}
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
saved_filters = (
SavedFilter(
name='Saved Filter 1',
weight=100,
enabled=True,
shared=True,
parameters={'status': ['active']}
),
SavedFilter(
name='Saved Filter 2',
weight=200,
enabled=True,
shared=True,
parameters={'status': ['planned']}
),
SavedFilter(
name='Saved Filter 3',
weight=300,
enabled=True,
shared=True,
parameters={'status': ['retired']}
),
)
SavedFilter.objects.bulk_create(saved_filters)
for i, savedfilter in enumerate(saved_filters):
savedfilter.content_types.set([site_ct])
class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate model = ExportTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']

View File

@ -222,6 +222,92 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class SavedFilterTestCase(TestCase, BaseFilterSetTests):
queryset = SavedFilter.objects.all()
filterset = SavedFilterFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
saved_filters = (
SavedFilter(
name='Saved Filter 1',
user=users[0],
weight=100,
enabled=True,
shared=True,
parameters={'status': ['active']}
),
SavedFilter(
name='Saved Filter 2',
user=users[1],
weight=200,
enabled=True,
shared=True,
parameters={'status': ['planned']}
),
SavedFilter(
name='Saved Filter 3',
user=users[2],
weight=300,
enabled=False,
shared=False,
parameters={'status': ['retired']}
),
)
SavedFilter.objects.bulk_create(saved_filters)
for i, savedfilter in enumerate(saved_filters):
savedfilter.content_types.set([content_types[i]])
def test_name(self):
params = {'name': ['Saved Filter 1', 'Saved Filter 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_user(self):
users = User.objects.filter(username__startswith='User')
params = {'user': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_shared(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_usable(self):
# Filtering for an anonymous user
params = {'usable': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'usable': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ExportTemplateTestCase(TestCase, BaseFilterSetTests): class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet filterset = ExportTemplateFilterSet

View File

@ -107,6 +107,58 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = SavedFilter
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
saved_filters = (
SavedFilter(name='Saved Filter 1', user=users[0], weight=100, parameters={'status': ['active']}),
SavedFilter(name='Saved Filter 2', user=users[1], weight=200, parameters={'status': ['planned']}),
SavedFilter(name='Saved Filter 3', user=users[2], weight=300, parameters={'status': ['retired']}),
)
SavedFilter.objects.bulk_create(saved_filters)
for i, savedfilter in enumerate(saved_filters):
savedfilter.content_types.set([site_ct])
cls.form_data = {
'name': 'Saved Filter X',
'content_types': [site_ct.pk],
'description': 'Foo',
'weight': 1000,
'enabled': True,
'shared': True,
'parameters': '{"foo": 123}',
}
cls.csv_data = (
'name,content_types,weight,enabled,shared,parameters',
'Saved Filter 4,dcim.device,400,True,True,{"foo": "a"}',
'Saved Filter 5,dcim.device,500,True,True,{"foo": "b"}',
'Saved Filter 6,dcim.device,600,True,True,{"foo": "c"}',
)
cls.csv_update_data = (
"id,name",
f"{saved_filters[0].pk},Saved Filter 7",
f"{saved_filters[1].pk},Saved Filter 8",
f"{saved_filters[2].pk},Saved Filter 9",
)
cls.bulk_edit_data = {
'weight': 999,
}
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ExportTemplate model = ExportTemplate

View File

@ -31,6 +31,14 @@ urlpatterns = [
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))), path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
# Saved filters
path('saved-filters/', views.SavedFilterListView.as_view(), name='savedfilter_list'),
path('saved-filters/add/', views.SavedFilterEditView.as_view(), name='savedfilter_add'),
path('saved-filters/import/', views.SavedFilterBulkImportView.as_view(), name='savedfilter_import'),
path('saved-filters/edit/', views.SavedFilterBulkEditView.as_view(), name='savedfilter_bulk_edit'),
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
# Webhooks # Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

View File

@ -9,7 +9,6 @@ from django_rq.queues import get_connection
from rq import Worker from rq import Worker
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
@ -159,6 +158,74 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
table = tables.ExportTemplateTable table = tables.ExportTemplateTable
#
# Saved filters
#
class SavedFilterMixin:
def get_queryset(self, request):
"""
Return only shared SavedFilters, or those owned by the current user, unless
this is a superuser.
"""
queryset = SavedFilter.objects.all()
user = request.user
if user.is_superuser:
return queryset
if user.is_anonymous:
return queryset.filter(shared=True)
return queryset.filter(
Q(shared=True) | Q(user=user)
)
class SavedFilterListView(SavedFilterMixin, generic.ObjectListView):
filterset = filtersets.SavedFilterFilterSet
filterset_form = forms.SavedFilterFilterForm
table = tables.SavedFilterTable
@register_model_view(SavedFilter)
class SavedFilterView(SavedFilterMixin, generic.ObjectView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'edit')
class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
queryset = SavedFilter.objects.all()
form = forms.SavedFilterForm
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
obj.user = request.user
return obj
@register_model_view(SavedFilter, 'delete')
class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all()
class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterCSVForm
table = tables.SavedFilterTable
class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable
form = forms.SavedFilterBulkEditForm
class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable
# #
# Webhooks # Webhooks
# #

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.models import Location, Rack, Region, Site, SiteGroup, Device from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
@ -11,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from utilities.forms import ( from utilities.forms import (
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple, MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -46,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = VRF model = VRF
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Route Targets', ('import_target_id', 'export_target_id')), ('Route Targets', ('import_target_id', 'export_target_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -66,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RouteTarget model = RouteTarget
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('VRF', ('importing_vrf_id', 'exporting_vrf_id')), ('VRF', ('importing_vrf_id', 'exporting_vrf_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -98,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Aggregate model = Aggregate
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('family', 'rir_id')), ('Attributes', ('family', 'rir_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -119,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = ASN model = ASN
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Assignment', ('rir_id', 'site_id')), ('Assignment', ('rir_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -144,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Prefix model = Prefix
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')),
('VRF', ('vrf_id', 'present_in_vrf_id')), ('VRF', ('vrf_id', 'present_in_vrf_id')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
@ -233,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange model = IPRange
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')), ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -265,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPAddress model = IPAddress
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
('VRF', ('vrf_id', 'present_in_vrf_id')), ('VRF', ('vrf_id', 'present_in_vrf_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
@ -334,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
model = FHRPGroup model = FHRPGroup
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('name', 'protocol', 'group_id')), ('Attributes', ('name', 'protocol', 'group_id')),
('Authentication', ('auth_type', 'auth_key')), ('Authentication', ('auth_type', 'auth_key')),
) )
@ -364,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')),
('VLAN ID', ('min_vid', 'max_vid')), ('VLAN ID', ('min_vid', 'max_vid')),
) )
@ -412,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = VLAN model = VLAN
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Attributes', ('group_id', 'status', 'role_id', 'vid')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
@ -465,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
model = ServiceTemplate model = ServiceTemplate
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('protocol', 'port')), ('Attributes', ('protocol', 'port')),
) )
protocol = forms.ChoiceField( protocol = forms.ChoiceField(
@ -486,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm):
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = L2VPN model = L2VPN
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Attributes', ('type', 'import_target_id', 'export_target_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -511,8 +510,10 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
model = L2VPNTermination model = L2VPNTermination
fieldsets = ( fieldsets = (
(None, ('l2vpn_id', )), (None, ('filter', 'l2vpn_id',)),
('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), ('Assigned Object', (
'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
)),
) )
l2vpn_id = DynamicModelChoiceField( l2vpn_id = DynamicModelChoiceField(
queryset=L2VPN.objects.all(), queryset=L2VPN.objects.all(),

View File

@ -4,10 +4,11 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django_filters.exceptions import FieldLookupError from django_filters.exceptions import FieldLookupError
from django_filters.utils import get_model_field, resolve_field from django_filters.utils import get_model_field, resolve_field
from django.shortcuts import get_object_or_404
from extras.choices import CustomFieldFilterLogicChoices from extras.choices import CustomFieldFilterLogicChoices
from extras.filters import TagFilter from extras.filters import TagFilter
from extras.models import CustomField from extras.models import CustomField, SavedFilter
from utilities.constants import ( from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
FILTER_NUMERIC_BASED_LOOKUP_MAP FILTER_NUMERIC_BASED_LOOKUP_MAP
@ -80,12 +81,28 @@ class BaseFilterSet(django_filters.FilterSet):
}, },
}) })
def __init__(self, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
# bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
# however FilterSet Factory is setup before this which creates the # however FilterSet Factory is setup before this which creates the
# initial filters. This recreates the filters so Empty is picked up correctly. # initial filters. This recreates the filters so Empty is picked up correctly.
self.base_filters = self.__class__.get_filters() self.base_filters = self.__class__.get_filters()
super().__init__(*args, **kwargs)
# Apply any referenced SavedFilters
if data and 'filter' in data:
data = data.copy() # Get a mutable copy
saved_filters = SavedFilter.objects.filter(pk__in=data.pop('filter'))
for sf in saved_filters:
for key, value in sf.parameters.items():
# QueryDicts are... fun
if type(value) not in (list, tuple):
value = [value]
if key in data:
for v in value:
data.appendlist(key, v)
else:
data.setlist(key, value)
super().__init__(data, *args, **kwargs)
@staticmethod @staticmethod
def _get_filter_lookup_dict(existing_filter): def _get_filter_lookup_dict(existing_filter):

View File

@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.customfields import CustomFieldsMixin from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
from extras.models import CustomField, Tag from extras.models import CustomField, Tag
from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms import BootstrapMixin, CSVModelForm
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
@ -114,7 +114,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form):
""" """
Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the
corresponding FilterSet *must* provide a `q` filter. corresponding FilterSet *must* provide a `q` filter.
@ -129,6 +129,15 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
label='Search' label='Search'
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit saved filters to those applicable to the form's model
content_type = ContentType.objects.get_for_model(self.model)
self.fields['filter'].widget.add_query_params({
'content_type_id': content_type.pk,
})
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):
return super()._get_custom_fields(content_type).exclude( return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |

View File

@ -278,6 +278,7 @@ OTHER_MENU = Menu(
get_model_item('extras', 'customfield', 'Custom Fields'), get_model_item('extras', 'customfield', 'Custom Fields'),
get_model_item('extras', 'customlink', 'Custom Links'), get_model_item('extras', 'customlink', 'Custom Links'),
get_model_item('extras', 'exporttemplate', 'Export Templates'), get_model_item('extras', 'exporttemplate', 'Export Templates'),
get_model_item('extras', 'savedfilter', 'Saved Filters'),
), ),
), ),
MenuGroup( MenuGroup(

View File

@ -4,17 +4,17 @@ from copy import deepcopy
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError from django.db.models import ManyToManyField, ProtectedError
from django.db.models.fields.reverse_related import ManyToManyRel from django.db.models.fields.reverse_related import ManyToManyRel
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_tables2.export import TableExport
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2.export import TableExport
from extras.models import ExportTemplate from extras.models import ExportTemplate, SavedFilter
from extras.signals import clear_webhooks from extras.signals import clear_webhooks
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
return headers, records return headers, records
def _update_objects(self, form, request, headers, records): def _update_objects(self, form, request, headers, records):
from utilities.forms import CSVModelChoiceField
updated_objs = [] updated_objs = []
ids = [int(record["id"]) for record in records] ids = [int(record["id"]) for record in records]

View File

@ -0,0 +1,70 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">Saved Filter</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">User</th>
<td>{{ object.user|placeholder }}</td>
</tr>
<tr>
<th scope="row">Enabled</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">Shared</th>
<td>{% checkmark object.shared %}</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>{{ object.weight }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Assigned Models</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-7">
<div class="card">
<h5 class="card-header">
Parameters
</h5>
<div class="card-body">
<pre>{{ object.parameters }}</pre>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -64,7 +64,7 @@ Context:
{# Applied filters #} {# Applied filters #}
{% if filter_form %} {% if filter_form %}
{% applied_filters filter_form request.GET %} {% applied_filters model filter_form request.GET %}
{% endif %} {% endif %}
{# "Select all" form #} {# "Select all" form #}

View File

@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm):
class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Tenant model = Tenant
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'group_id')), (None, ('q', 'filter', 'tag', 'group_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
group_id = DynamicModelMultipleChoiceField( group_id = DynamicModelMultipleChoiceField(

View File

@ -10,5 +10,10 @@
<i class="mdi mdi-tag-off"></i> Clear all <i class="mdi mdi-tag-off"></i> Clear all
</a> </a>
{% endif %} {% endif %}
{% if save_link %}
<a href="{{ save_link }}" class="badge rounded-pill bg-success text-decoration-none me-1">
<i class="mdi mdi-content-save"></i> Save
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,9 +1,11 @@
import datetime import datetime
import decimal import decimal
from urllib.parse import quote
from typing import Dict, Any from typing import Dict, Any
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import date from django.template.defaultfilters import date
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils import timezone from django.utils import timezone
@ -278,12 +280,13 @@ def table_config_form(table, table_name=None):
} }
@register.inclusion_tag('helpers/applied_filters.html') @register.inclusion_tag('helpers/applied_filters.html', takes_context=True)
def applied_filters(form, query_params): def applied_filters(context, model, form, query_params):
""" """
Display the active filters for a given filter form. Display the active filters for a given filter form.
""" """
form.is_valid() user = context['request'].user
form.is_valid() # Ensure cleaned_data has been set
applied_filters = [] applied_filters = []
for filter_name in form.changed_data: for filter_name in form.changed_data:
@ -305,6 +308,14 @@ def applied_filters(form, query_params):
'link_text': f'{bound_field.label}: {display_value}', 'link_text': f'{bound_field.label}: {display_value}',
}) })
save_link = None
if user.has_perm('extras.add_savedfilter') and 'filter' not in context['request'].GET:
content_type = ContentType.objects.get_for_model(model).pk
parameters = context['request'].GET.urlencode()
url = reverse('extras:savedfilter_add')
save_link = f"{url}?content_types={content_type}&parameters={quote(parameters)}"
return { return {
'applied_filters': applied_filters, 'applied_filters': applied_filters,
'save_link': save_link,
} }

View File

@ -1,8 +1,10 @@
import json
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models import ManyToManyField from django.db.models import ManyToManyField, JSONField
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase from django.test import Client, TestCase as _TestCase
from netaddr import IPNetwork from netaddr import IPNetwork
@ -132,6 +134,10 @@ class ModelTestCase(TestCase):
if type(instance._meta.get_field(key)) is ArrayField: if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value]) model_dict[key] = ','.join([str(v) for v in value])
# JSON
if type(instance._meta.get_field(key)) is JSONField and value is not None:
model_dict[key] = json.dumps(value)
return model_dict return model_dict
# #

View File

@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ClusterGroup model = ClusterGroup
tag = TagFilterField(model) tag = TagFilterField(model)
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Cluster model = Cluster
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('group_id', 'type_id', 'status')), ('Attributes', ('group_id', 'type_id', 'status')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
@ -90,7 +90,7 @@ class VirtualMachineFilterForm(
): ):
model = VirtualMachine model = VirtualMachine
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
@ -175,7 +175,7 @@ class VirtualMachineFilterForm(
class VMInterfaceFilterForm(NetBoxModelFilterSetForm): class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
model = VMInterface model = VMInterface
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Virtual Machine', ('cluster_id', 'virtual_machine_id')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
('Attributes', ('enabled', 'mac_address', 'vrf_id')), ('Attributes', ('enabled', 'mac_address', 'vrf_id')),
) )

View File

@ -28,7 +28,7 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm):
class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = WirelessLAN model = WirelessLAN
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('ssid', 'group_id',)), ('Attributes', ('ssid', 'group_id',)),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
@ -62,7 +62,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = WirelessLink model = WirelessLink
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('ssid', 'status',)), ('Attributes', ('ssid', 'status',)),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),