Merge pull request #6470 from netbox-community/5121-filter-tags-content-type

Closes #5121: Add object type filters for Tags
This commit is contained in:
Jeremy Stretch 2021-05-21 17:22:30 -04:00 committed by GitHub
commit f3dfa81811
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 91 additions and 36 deletions

View File

@ -20,7 +20,7 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Provider(PrimaryModel): class Provider(PrimaryModel):
""" """
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@ -96,7 +96,7 @@ class Provider(PrimaryModel):
# Provider networks # Provider networks
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ProviderNetwork(PrimaryModel): class ProviderNetwork(PrimaryModel):
""" """
This represents a provider network which exists outside of NetBox, the details of which are unknown or This represents a provider network which exists outside of NetBox, the details of which are unknown or
@ -189,7 +189,7 @@ class CircuitType(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Circuit(PrimaryModel): class Circuit(PrimaryModel):
""" """
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple

View File

@ -30,7 +30,7 @@ __all__ = (
# Cables # Cables
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Cable(PrimaryModel): class Cable(PrimaryModel):
""" """
A physical connection between two endpoints. A physical connection between two endpoints.

View File

@ -211,7 +211,7 @@ class PathEndpoint(models.Model):
# Console ports # Console ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsolePort(ComponentModel, CableTermination, PathEndpoint): class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@ -254,7 +254,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
# Console server ports # Console server ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@ -297,7 +297,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
# Power ports # Power ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPort(ComponentModel, CableTermination, PathEndpoint): class PowerPort(ComponentModel, CableTermination, PathEndpoint):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@ -408,7 +408,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
# Power outlets # Power outlets
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerOutlet(ComponentModel, CableTermination, PathEndpoint): class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
@ -512,7 +512,7 @@ class BaseInterface(models.Model):
return self.ip_addresses.count() return self.ip_addresses.count()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
@ -683,7 +683,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
# Pass-through ports # Pass-through ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FrontPort(ComponentModel, CableTermination): class FrontPort(ComponentModel, CableTermination):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
@ -748,7 +748,7 @@ class FrontPort(ComponentModel, CableTermination):
}) })
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RearPort(ComponentModel, CableTermination): class RearPort(ComponentModel, CableTermination):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
@ -801,7 +801,7 @@ class RearPort(ComponentModel, CableTermination):
# Device bays # Device bays
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceBay(ComponentModel): class DeviceBay(ComponentModel):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
@ -860,7 +860,7 @@ class DeviceBay(ComponentModel):
# Inventory items # Inventory items
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel): class InventoryItem(MPTTModel, ComponentModel):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.

View File

@ -75,7 +75,7 @@ class Manufacturer(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceType(PrimaryModel): class DeviceType(PrimaryModel):
""" """
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@ -468,7 +468,7 @@ class Platform(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Device(PrimaryModel, ConfigContextModel): class Device(PrimaryModel, ConfigContextModel):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@ -922,7 +922,7 @@ class Device(PrimaryModel, ConfigContextModel):
# Virtual chassis # Virtual chassis
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VirtualChassis(PrimaryModel): class VirtualChassis(PrimaryModel):
""" """
A collection of Devices which operate with a shared control plane (e.g. a switch stack). A collection of Devices which operate with a shared control plane (e.g. a switch stack).

View File

@ -21,7 +21,7 @@ __all__ = (
# Power # Power
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPanel(PrimaryModel): class PowerPanel(PrimaryModel):
""" """
A distribution point for electrical power; e.g. a data center RPP. A distribution point for electrical power; e.g. a data center RPP.
@ -71,7 +71,7 @@ class PowerPanel(PrimaryModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
""" """
An electrical circuit delivered from a PowerPanel. An electrical circuit delivered from a PowerPanel.

View File

@ -78,7 +78,7 @@ class RackRole(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Rack(PrimaryModel): class Rack(PrimaryModel):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@ -467,7 +467,7 @@ class Rack(PrimaryModel):
return int(allocated_draw_total / available_power_total * 100) return int(allocated_draw_total / available_power_total * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackReservation(PrimaryModel): class RackReservation(PrimaryModel):
""" """
One or more reserved units within a Rack. One or more reserved units within a Rack.

View File

@ -130,7 +130,7 @@ class SiteGroup(NestedGroupModel):
# Sites # Sites
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Site(PrimaryModel): class Site(PrimaryModel):
""" """
A Site represents a geographic location within a network; typically a building or campus. The optional facility A Site represents a geographic location within a network; typically a building or campus. The optional facility

View File

@ -7,5 +7,6 @@ EXTRAS_FEATURES = [
'custom_links', 'custom_links',
'export_templates', 'export_templates',
'job_results', 'job_results',
'tags',
'webhooks' 'webhooks'
] ]

View File

@ -6,7 +6,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.filters import ContentTypeFilter from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import * from .models import *
@ -114,6 +114,12 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
content_type = MultiValueCharFilter(
method='_content_type'
)
content_type_id = MultiValueNumberFilter(
method='_content_type_id'
)
class Meta: class Meta:
model = Tag model = Tag
@ -127,6 +133,32 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
Q(slug__icontains=value) Q(slug__icontains=value)
) )
def _content_type(self, queryset, name, values):
ct_filter = Q()
# Compile list of app_label & model pairings
for value in values:
try:
app_label, model = value.lower().split('.')
ct_filter |= Q(
app_label=app_label,
model=model
)
except ValueError:
pass
# Get ContentType instances
content_types = ContentType.objects.filter(ct_filter)
return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
def _content_type_id(self, queryset, name, values):
# Get ContentType instances
content_types = ContentType.objects.filter(pk__in=values)
return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
class ConfigContextFilterSet(ChangeLoggedModelFilterSet): class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(

View File

@ -8,12 +8,13 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
BOOLEAN_WITH_BLANK_CHOICES, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
from .utils import FeatureQuery
# #
@ -180,6 +181,11 @@ class TagFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
label=_('Search') label=_('Search')
) )
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
required=False,
label=_('Tagged object type')
)
class TagBulkEditForm(BootstrapMixin, BulkEditForm): class TagBulkEditForm(BootstrapMixin, BulkEditForm):

View File

@ -5,6 +5,7 @@ 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 TestCase from django.test import TestCase
from circuits.models import Provider
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
from extras.filtersets import * from extras.filtersets import *
@ -537,6 +538,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)
# Apply some tags so we can filter by content type
site = Site.objects.create(name='Site 1', slug='site-1')
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
site.tags.set(tags[0])
provider.tags.set(tags[1])
def test_name(self): def test_name(self):
params = {'name': ['Tag 1', 'Tag 2']} params = {'name': ['Tag 1', 'Tag 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -549,6 +557,14 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']} params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self):
params = {'content_type': ['dcim.site', 'circuits.provider']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
site_ct = ContentType.objects.get_for_model(Site).pk
provider_ct = ContentType.objects.get_for_model(Provider).pk
params = {'content_type_id': [site_ct, provider_ct]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ObjectChangeTestCase(TestCase, BaseFilterSetTests): class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.all()

View File

@ -77,7 +77,7 @@ class RIR(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(PrimaryModel): class Aggregate(PrimaryModel):
""" """
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@ -228,7 +228,7 @@ class Role(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Prefix(PrimaryModel): class Prefix(PrimaryModel):
""" """
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@ -477,7 +477,7 @@ class Prefix(PrimaryModel):
return int(float(child_count) / prefix_size * 100) return int(float(child_count) / prefix_size * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class IPAddress(PrimaryModel): class IPAddress(PrimaryModel):
""" """
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is

View File

@ -17,7 +17,7 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Service(PrimaryModel): class Service(PrimaryModel):
""" """
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may

View File

@ -100,7 +100,7 @@ class VLANGroup(OrganizationalModel):
return None return None
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VLAN(PrimaryModel): class VLAN(PrimaryModel):
""" """
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned

View File

@ -13,7 +13,7 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VRF(PrimaryModel): class VRF(PrimaryModel):
""" """
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@ -92,7 +92,7 @@ class VRF(PrimaryModel):
return self.name return self.name
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RouteTarget(PrimaryModel): class RouteTarget(PrimaryModel):
""" """
A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.

View File

@ -273,7 +273,7 @@ class SecretRole(OrganizationalModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Secret(PrimaryModel): class Secret(PrimaryModel):
""" """
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible

View File

@ -57,7 +57,7 @@ class TenantGroup(NestedGroupModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Tenant(PrimaryModel): class Tenant(PrimaryModel):
""" """
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal

View File

@ -116,7 +116,7 @@ class ClusterGroup(OrganizationalModel):
# Clusters # Clusters
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Cluster(PrimaryModel): class Cluster(PrimaryModel):
""" """
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
@ -199,7 +199,7 @@ class Cluster(PrimaryModel):
# Virtual machines # Virtual machines
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VirtualMachine(PrimaryModel, ConfigContextModel): class VirtualMachine(PrimaryModel, ConfigContextModel):
""" """
A virtual machine which runs inside a Cluster. A virtual machine which runs inside a Cluster.
@ -380,7 +380,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
# Interfaces # Interfaces
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VMInterface(PrimaryModel, BaseInterface): class VMInterface(PrimaryModel, BaseInterface):
virtual_machine = models.ForeignKey( virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine', to='virtualization.VirtualMachine',