Merge pull request #2103 from digitalocean/132-taggit

132-taggit
This commit is contained in:
Jeremy Stretch 2018-05-22 13:01:27 -04:00 committed by GitHub
commit 63100b683d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 772 additions and 50 deletions

View File

@ -1,13 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from circuits.constants import CIRCUIT_STATUS_CHOICES from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer
# #
@ -15,11 +16,12 @@ from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, Writa
# #
class ProviderSerializer(CustomFieldModelSerializer): class ProviderSerializer(CustomFieldModelSerializer):
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
@ -60,12 +62,13 @@ class CircuitSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False) status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False)
type = NestedCircuitTypeSerializer() type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Circuit model = Circuit
fields = [ fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'comments', 'custom_fields', 'created', 'last_updated', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -28,6 +28,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Provider model = Provider
@ -103,6 +106,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Circuit model = Circuit

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from taggit.forms import TagField
from dcim.models import Site, Device, Interface, Rack from dcim.models import Site, Device, Interface, Rack
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@ -22,10 +23,11 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderForm(BootstrapMixin, CustomFieldForm): class ProviderForm(BootstrapMixin, CustomFieldForm):
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Provider model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags']
widgets = { widgets = {
'noc_contact': SmallTextarea(attrs={'rows': 5}), 'noc_contact': SmallTextarea(attrs={'rows': 5}),
'admin_contact': SmallTextarea(attrs={'rows': 5}), 'admin_contact': SmallTextarea(attrs={'rows': 5}),
@ -102,12 +104,13 @@ class CircuitTypeCSVForm(forms.ModelForm):
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Circuit model = Circuit
fields = [ fields = [
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'cid': "Unique circuit ID", 'cid': "Unique circuit ID",

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.constants import STATUS_CLASSES from dcim.constants import STATUS_CLASSES
from dcim.fields import ASNField from dcim.fields import ASNField
@ -56,6 +57,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class Meta: class Meta:
@ -166,6 +169,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = [ csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
] ]

View File

@ -4,6 +4,7 @@ from collections import OrderedDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.constants import ( from dcim.constants import (
@ -21,7 +22,8 @@ from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from users.api.serializers import NestedUserSerializer from users.api.serializers import NestedUserSerializer
from utilities.api import ( from utilities.api import (
ChoiceFieldSerializer, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer, ChoiceFieldSerializer, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
@ -55,14 +57,15 @@ class SiteSerializer(CustomFieldModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True) region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False) time_zone = TimeZoneField(required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks',
'count_circuits', 'count_devices', 'count_circuits',
] ]
@ -124,12 +127,13 @@ class RackSerializer(CustomFieldModelSerializer):
role = NestedRackRoleSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False) type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False)
width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False) width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', 'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
# prevents facility_id from being interpreted as a required field. # prevents facility_id from being interpreted as a required field.
@ -223,12 +227,13 @@ class DeviceTypeSerializer(CustomFieldModelSerializer):
interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False) interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False)
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False) subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False)
instance_count = serializers.IntegerField(source='instances.count', read_only=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields',
'instance_count', 'instance_count',
] ]
@ -401,13 +406,14 @@ class DeviceSerializer(CustomFieldModelSerializer):
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'last_updated',
] ]
validators = [] validators = []

View File

@ -82,6 +82,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Tenant (slug)', label='Tenant (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Site model = Site
@ -179,6 +182,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Rack model = Rack
@ -286,6 +292,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = DeviceType model = DeviceType
@ -497,6 +506,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)', label='Virtual chassis (ID)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Device model = Device

View File

@ -7,6 +7,7 @@ from django.contrib.auth.models import User
from django.contrib.postgres.forms.array import SimpleArrayField from django.contrib.postgres.forms.array import SimpleArrayField
from django.db.models import Count, Q from django.db.models import Count, Q
from mptt.forms import TreeNodeChoiceField from mptt.forms import TreeNodeChoiceField
from taggit.forms import TagField
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'tags',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@ -274,12 +277,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'u_height', 'desc_units', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
@ -485,11 +489,14 @@ class ManufacturerCSVForm(forms.ModelForm):
class DeviceTypeForm(BootstrapMixin, CustomFieldForm): class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
slug = SlugField(slug_source='model') slug = SlugField(slug_source='model')
tags = TagField(required=False)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', fields = [
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags',
]
labels = { labels = {
'interface_ordering': 'Order interfaces by', 'interface_ordering': 'Order interfaces by',
} }
@ -772,12 +779,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'device_role': "The function this device serves", 'device_role': "The function this device serves",

View File

@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from circuits.models import Circuit from circuits.models import Circuit
@ -161,6 +162,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
) )
objects = SiteManager() objects = SiteManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@ -388,6 +390,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
) )
objects = RackManager() objects = RackManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
@ -746,6 +749,8 @@ class DeviceType(models.Model, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = [ csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
@ -1231,6 +1236,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
) )
objects = DeviceManager() objects = DeviceManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site from dcim.models import Device, Rack, Site
@ -62,6 +63,18 @@ class TopologyMapSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
#
# Tags
#
class TagSerializer(ValidatedModelSerializer):
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'tagged_items']
# #
# Image attachments # Image attachments
# #

View File

@ -28,6 +28,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet)
# Topology maps # Topology maps
router.register(r'topology-maps', views.TopologyMapViewSet) router.register(r'topology-maps', views.TopologyMapViewSet)
# Tags
router.register(r'tags', views.TagViewSet)
# Image attachments # Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet) router.register(r'image-attachments', views.ImageAttachmentViewSet)

View File

@ -1,12 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from taggit.models import Tag
from extras import filters from extras import filters
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
@ -109,6 +111,16 @@ class TopologyMapViewSet(ModelViewSet):
return response return response
#
# Tags
#
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
serializer_class = serializers.TagSerializer
filter_class = filters.TagFilter
# #
# Image attachments # Image attachments
# #

View File

@ -3,6 +3,8 @@ from __future__ import unicode_literals
import django_filters import django_filters
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.db.models import Q
from taggit.models import Tag
from dcim.models import Site from dcim.models import Site
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
@ -85,6 +87,25 @@ class ExportTemplateFilter(django_filters.FilterSet):
fields = ['content_type', 'name'] fields = ['content_type', 'name']
class TagFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = Tag
fields = ['name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(slug__icontains=value)
)
class TopologyMapFilter(django_filters.FilterSet): class TopologyMapFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
name='site', name='site',

View File

@ -4,12 +4,17 @@ from collections import OrderedDict
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from taggit.models import Tag
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField, SlugField
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .models import CustomField, CustomFieldValue, ImageAttachment from .models import CustomField, CustomFieldValue, ImageAttachment
#
# Custom fields
#
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
""" """
Retrieve all CustomFields applicable to the given ContentType Retrieve all CustomFields applicable to the given ContentType
@ -162,6 +167,23 @@ class CustomFieldFilterForm(forms.Form):
self.fields[name] = field self.fields[name] = field
#
# Tags
#
#
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = ['name', 'slug']
#
# Image attachments
#
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:

28
netbox/extras/tables.py Normal file
View File

@ -0,0 +1,28 @@
from __future__ import unicode_literals
import django_tables2 as tables
from taggit.models import Tag
from utilities.tables import BaseTable, ToggleColumn
TAG_ACTIONS = """
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
{% if perms.taggit.delete_tag %}
<a href="{% url 'extras:tag_delete' slug=record.slug %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
{% endif %}
"""
class TagTable(BaseTable):
pk = ToggleColumn()
actions = tables.TemplateColumn(
template_code=TAG_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = Tag
fields = ('pk', 'name', 'items')

View File

@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from taggit.models import Tag
from dcim.models import Device from dcim.models import Device
from extras.constants import GRAPH_TYPE_SITE from extras.constants import GRAPH_TYPE_SITE
@ -226,3 +227,96 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ExportTemplate.objects.count(), 2) self.assertEqual(ExportTemplate.objects.count(), 2)
class TagTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3')
def test_get_tag(self):
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tag1.name)
def test_list_tags(self):
url = reverse('extras-api:tag-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_tag(self):
data = {
'name': 'Test Tag 4',
'slug': 'test-tag-4',
}
url = reverse('extras-api:tag-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tag.objects.count(), 4)
tag4 = Tag.objects.get(pk=response.data['id'])
self.assertEqual(tag4.name, data['name'])
self.assertEqual(tag4.slug, data['slug'])
def test_create_tag_bulk(self):
data = [
{
'name': 'Test Tag 4',
'slug': 'test-tag-4',
},
{
'name': 'Test Tag 5',
'slug': 'test-tag-5',
},
{
'name': 'Test Tag 6',
'slug': 'test-tag-6',
},
]
url = reverse('extras-api:tag-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tag.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_tag(self):
data = {
'name': 'Test Tag X',
'slug': 'test-tag-x',
}
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Tag.objects.count(), 3)
tag1 = Tag.objects.get(pk=response.data['id'])
self.assertEqual(tag1.name, data['name'])
self.assertEqual(tag1.slug, data['slug'])
def test_delete_tag(self):
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Tag.objects.count(), 2)

View File

@ -7,6 +7,12 @@ from extras import views
app_name = 'extras' app_name = 'extras'
urlpatterns = [ urlpatterns = [
# Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
# Image attachments # Image attachments
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),

View File

@ -2,16 +2,52 @@ from __future__ import unicode_literals
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from taggit.models import Tag
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import ObjectDeleteView, ObjectEditView from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from .forms import ImageAttachmentForm from .forms import ImageAttachmentForm, TagForm
from .models import ImageAttachment, ReportResult, UserAction from .models import ImageAttachment, ReportResult, UserAction
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .tables import TagTable
#
# Tags
#
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
table = TagTable
template_name = 'extras/tag_list.html'
class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'taggit.change_tag'
model = Tag
model_form = TagForm
def get_return_url(self, request, obj):
return reverse('extras:tag', kwargs={'slug': obj.slug})
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'taggit.delete_tag'
model = Tag
default_return_url = 'extras:tag_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = Tag
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
table = TagTable
default_return_url = 'extras:tag_list'
# #

View File

@ -5,6 +5,7 @@ from collections import OrderedDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
from dcim.models import Interface from dcim.models import Interface
@ -14,7 +15,9 @@ from ipam.constants import (
) )
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer from utilities.api import (
ChoiceFieldSerializer, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer,
)
from virtualization.api.serializers import NestedVirtualMachineSerializer from virtualization.api.serializers import NestedVirtualMachineSerializer
@ -24,12 +27,13 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
class VRFSerializer(CustomFieldModelSerializer): class VRFSerializer(CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = VRF model = VRF
fields = [ fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
'last_updated', 'created', 'last_updated',
] ]
@ -85,11 +89,13 @@ class NestedRIRSerializer(WritableNestedSerializer):
class AggregateSerializer(CustomFieldModelSerializer): class AggregateSerializer(CustomFieldModelSerializer):
rir = NestedRIRSerializer() rir = NestedRIRSerializer()
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = [ fields = [
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
'last_updated',
] ]
read_only_fields = ['family'] read_only_fields = ['family']
@ -147,11 +153,12 @@ class VLANSerializer(CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False) status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = VLAN model = VLAN
fields = [ fields = [
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
validators = [] validators = []
@ -190,12 +197,13 @@ class PrefixSerializer(CustomFieldModelSerializer):
vlan = NestedVLANSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False) status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Prefix model = Prefix
fields = [ fields = [
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
read_only_fields = ['family'] read_only_fields = ['family']
@ -252,12 +260,13 @@ class IPAddressSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False) status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False) role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True) interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
'nat_outside', 'custom_fields', 'created', 'last_updated', 'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
read_only_fields = ['family'] read_only_fields = ['family']

View File

@ -30,6 +30,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Tenant (slug)', label='Tenant (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -69,6 +72,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='RIR (slug)', label='RIR (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Aggregate model = Aggregate
@ -167,6 +173,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=PREFIX_STATUS_CHOICES, choices=PREFIX_STATUS_CHOICES,
null_value=None null_value=None
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Prefix model = Prefix
@ -289,6 +298,9 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
role = django_filters.MultipleChoiceFilter( role = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_ROLE_CHOICES choices=IPADDRESS_ROLE_CHOICES
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = IPAddress model = IPAddress
@ -394,6 +406,9 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=VLAN_STATUS_CHOICES, choices=VLAN_STATUS_CHOICES,
null_value=None null_value=None
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = VLAN model = VLAN

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count from django.db.models import Count
from taggit.forms import TagField
from dcim.models import Site, Rack, Device, Interface from dcim.models import Site, Rack, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@ -32,10 +33,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)]
# #
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
tags = TagField(required=False)
class Meta: class Meta:
model = VRF model = VRF
fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant'] fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags']
labels = { labels = {
'rd': "RD", 'rd': "RD",
} }
@ -121,10 +123,11 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
# #
class AggregateForm(BootstrapMixin, CustomFieldForm): class AggregateForm(BootstrapMixin, CustomFieldForm):
tags = TagField(required=False)
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description'] fields = ['prefix', 'rir', 'date_added', 'description', 'tags']
help_texts = { help_texts = {
'prefix': "IPv4 or IPv6 network", 'prefix': "IPv4 or IPv6 network",
'rir': "Regional Internet Registry responsible for this prefix", 'rir': "Regional Internet Registry responsible for this prefix",
@ -228,10 +231,14 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name' api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name'
) )
) )
tags = TagField(required=False)
class Meta: class Meta:
model = Prefix model = Prefix
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant'] fields = [
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant',
'tags',
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -455,12 +462,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
) )
) )
primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM') primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
tags = TagField(required=False)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -780,10 +788,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/ipam/vlan-groups/?site_id={{site}}', api_url='/api/ipam/vlan-groups/?site_id={{site}}',
) )
) )
tags = TagField(required=False)
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant'] fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags']
help_texts = { help_texts = {
'site': "Leave blank if this VLAN spans multiple sites", 'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)", 'group': "VLAN group (optional)",

View File

@ -10,6 +10,7 @@ from django.db.models import Q
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Interface from dcim.models import Interface
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
@ -56,6 +57,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta: class Meta:
@ -155,6 +158,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = ['prefix', 'rir', 'date_added', 'description'] csv_headers = ['prefix', 'rir', 'date_added', 'description']
class Meta: class Meta:
@ -325,6 +330,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
) )
objects = PrefixQuerySet.as_manager() objects = PrefixQuerySet.as_manager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@ -564,6 +570,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
) )
objects = IPAddressManager() objects = IPAddressManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
@ -759,6 +766,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
class Meta: class Meta:

View File

@ -133,6 +133,7 @@ INSTALLED_APPS = (
'django_tables2', 'django_tables2',
'mptt', 'mptt',
'rest_framework', 'rest_framework',
'taggit',
'timezone_field', 'timezone_field',
'circuits', 'circuits',
'dcim', 'dcim',

View File

@ -2,10 +2,11 @@ from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer from dcim.api.serializers import NestedDeviceSerializer
from secrets.models import Secret, SecretRole from secrets.models import Secret, SecretRole
from utilities.api import ValidatedModelSerializer, WritableNestedSerializer from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
# #
@ -35,10 +36,11 @@ class SecretSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
role = NestedSecretRoleSerializer() role = NestedSecretRoleSerializer()
plaintext = serializers.CharField() plaintext = serializers.CharField()
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Secret model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated']
validators = [] validators = []
def validate(self, data): def validate(self, data):

View File

@ -41,6 +41,9 @@ class SecretFilter(django_filters.FilterSet):
to_field_name='name', to_field_name='name',
label='Device (name)', label='Device (name)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Secret model = Secret

View File

@ -4,6 +4,7 @@ from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from taggit.forms import TagField
from dcim.models import Device from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
@ -70,10 +71,11 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
label='Plaintext (verify)', label='Plaintext (verify)',
widget=forms.PasswordInput() widget=forms.PasswordInput()
) )
tags = TagField(required=False)
class Meta: class Meta:
model = Secret model = Secret
fields = ['role', 'name', 'plaintext', 'plaintext2'] fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_bytes, python_2_unicode_compatible from django.utils.encoding import force_bytes, python_2_unicode_compatible
from taggit.managers import TaggableManager
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey from .exceptions import InvalidKey
@ -336,6 +337,8 @@ class Secret(CreatedUpdatedModel):
editable=False editable=False
) )
tags = TaggableManager()
plaintext = None plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext'] csv_headers = ['device', 'role', 'name', 'plaintext']

View File

@ -110,6 +110,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in circuit.tags.all %}
{% tag 'circuits:circuit_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
{% with circuit.get_custom_fields as custom_fields %} {% with circuit.get_custom_fields as custom_fields %}

View File

@ -44,6 +44,12 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -102,6 +102,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in provider.tags.all %}
{% tag 'circuits:provider_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr> <tr>
<td>Circuits</td> <td>Circuits</td>
<td> <td>

View File

@ -33,4 +33,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -96,6 +96,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in device.tags.all %}
{% tag 'dcim:device_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
{% if vc_members %} {% if vc_members %}

View File

@ -83,4 +83,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -73,6 +73,16 @@
<td>Interface Ordering</td> <td>Interface Ordering</td>
<td>{{ devicetype.get_interface_ordering_display }}</td> <td>{{ devicetype.get_interface_ordering_display }}</td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in devicetype.tags.all %}
{% tag 'dcim:devicetype_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr> <tr>
<td>Instances</td> <td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td> <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

View File

@ -37,4 +37,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -114,6 +114,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in rack.tags.all %}
{% tag 'dcim:rack_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr> <tr>
<td>Devices</td> <td>Devices</td>
<td> <td>

View File

@ -43,4 +43,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -133,6 +133,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in site.tags.all %}
{% tag 'dcim:site_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -46,4 +46,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,11 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<h1>{% block title %}Tags{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -16,7 +16,7 @@
<div id="navbar" class="navbar-collapse collapse"> <div id="navbar" class="navbar-collapse collapse">
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %} {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/reports/' %} active{% endif %}"> <li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/tags/,/extras/reports/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li class="dropdown-header">Sites</li> <li class="dropdown-header">Sites</li>
@ -60,6 +60,9 @@
</li> </li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li> <li class="dropdown-header">Miscellaneous</li>
<li>
<a href="{% url 'extras:tag_list' %}">Tags</a>
</li>
<li> <li>
<a href="{% url 'extras:report_list' %}">Reports</a> <a href="{% url 'extras:report_list' %}">Reports</a>
</li> </li>

View File

@ -0,0 +1,13 @@
{% load helpers %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="fa fa-tags" aria-hidden="true"></span>
<strong>Tags</strong>
</div>
<div class="panel-body text-center">
{% for tag in tags %}
<a href="{% querystring request tag=tag.slug %}" class="btn btn-sm {% if tag.slug in request.GET.tag %}btn-primary{% else %}btn-link{% endif %}">{{ tag }} <span class="badge">{{ tag.count }}</span></a>
{% endfor %}
</div>
</div>

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -81,6 +82,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in aggregate.tags.all %}
{% tag 'ipam:aggregate_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -19,4 +19,10 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -17,6 +17,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong><i class="fa fa-bar-chart"></i> Statistics</strong> <strong><i class="fa fa-bar-chart"></i> Statistics</strong>

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -133,6 +134,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in ipaddress.tags.all %}
{% tag 'ipam:ipaddress_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
{% with ipaddress.get_custom_fields as custom_fields %} {% with ipaddress.get_custom_fields as custom_fields %}

View File

@ -66,6 +66,12 @@
{% render_field form.nat_inside %} {% render_field form.nat_inside %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -121,6 +121,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in prefix.tags.all %}
{% tag 'ipam:prefix_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr> <tr>
<td>Utilization</td> <td>Utilization</td>
<td>{% utilization_graph prefix.get_utilization %}</td> <td>{% utilization_graph prefix.get_utilization %}</td>

View File

@ -28,6 +28,12 @@
{% render_field form.tenant %} {% render_field form.tenant %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -21,6 +21,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %}
{% block content %} {% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %} {% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
@ -80,6 +81,16 @@
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
{% endif %} {% endif %}
</td> </td>
</tr>
<tr>
<td>Tags</td>
<td>
{% for tag in vlan.tags.all %}
{% tag 'ipam:vlan_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -21,6 +21,12 @@
{% render_field form.tenant %} {% render_field form.tenant %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -77,6 +78,16 @@
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
{% endif %} {% endif %}
</td> </td>
</tr>
<tr>
<td>Tags</td>
<td>
{% for tag in vrf.tags.all %}
{% tag 'ipam:vrf_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -18,6 +18,12 @@
{% render_field form.tenant %} {% render_field form.tenant %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static from staticfiles %} {% load static from staticfiles %}
{% load helpers %}
{% load secret_helpers %} {% load secret_helpers %}
{% block content %} {% block content %}
@ -55,6 +56,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in secret.tags.all %}
{% tag 'secrets:secret_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -54,6 +54,12 @@
{% render_field form.plaintext2 %} {% render_field form.plaintext2 %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">

View File

@ -14,6 +14,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -68,6 +68,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in tenant.tags.all %}
{% tag 'tenancy:tenant_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
{% with tenant.get_custom_fields as custom_fields %} {% with tenant.get_custom_fields as custom_fields %}

View File

@ -26,4 +26,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1 @@
<a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default">{{ tag }}</span></a>

View File

@ -76,6 +76,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in cluster.tags.all %}
{% tag 'virtualization:cluster_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr> <tr>
<td>Virtual Machines</td> <td>Virtual Machines</td>
<td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td> <td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>

View File

@ -0,0 +1,34 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Cluster</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.type %}
{% render_field form.group %}
{% render_field form.site %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">
{% render_field form.comments %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -121,6 +121,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tags</td>
<td>
{% for tag in vm.tags.all %}
{% tag 'virtualization:virtualmachine_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table> </table>
</div> </div>
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %} {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}

View File

@ -54,4 +54,10 @@
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,10 +1,11 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.api import ValidatedModelSerializer, WritableNestedSerializer from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
# #
@ -32,10 +33,14 @@ class NestedTenantGroupSerializer(WritableNestedSerializer):
class TenantSerializer(CustomFieldModelSerializer): class TenantSerializer(CustomFieldModelSerializer):
group = NestedTenantGroupSerializer(required=False) group = NestedTenantGroupSerializer(required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] fields = [
'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
class NestedTenantSerializer(WritableNestedSerializer): class NestedTenantSerializer(WritableNestedSerializer):

View File

@ -31,6 +31,9 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Group (slug)', label='Group (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Tenant model = Tenant

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from taggit.forms import TagField
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import ( from utilities.forms import (
@ -40,10 +41,11 @@ class TenantGroupCSVForm(forms.ModelForm):
class TenantForm(BootstrapMixin, CustomFieldForm): class TenantForm(BootstrapMixin, CustomFieldForm):
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['name', 'slug', 'group', 'description', 'comments'] fields = ['name', 'slug', 'group', 'description', 'comments', 'tags']
class TenantCSVForm(forms.ModelForm): class TenantCSVForm(forms.ModelForm):

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
@ -74,6 +75,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = ['name', 'slug', 'group', 'description', 'comments'] csv_headers = ['name', 'slug', 'group', 'description', 'comments']
class Meta: class Meta:

View File

@ -13,7 +13,7 @@ from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError
from rest_framework.viewsets import GenericViewSet, ViewSet from rest_framework.viewsets import GenericViewSet, ViewSet
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@ -42,6 +42,21 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
# Fields # Fields
# #
class TagField(RelatedField):
"""
Represent a writable list of Tags associated with an object (use with many=True).
"""
def to_internal_value(self, data):
obj = self.parent.parent.instance
content_type = ContentType.objects.get_for_model(obj)
tag, _ = Tag.objects.get_or_create(content_type=content_type, object_id=obj.pk, name=data)
return tag
def to_representation(self, value):
return value.name
class ChoiceFieldSerializer(Field): class ChoiceFieldSerializer(Field):
""" """
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.

View File

@ -1,7 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime import datetime
import pytz
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -160,3 +159,14 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
'warning_threshold': warning_threshold, 'warning_threshold': warning_threshold,
'danger_threshold': danger_threshold, 'danger_threshold': danger_threshold,
} }
@register.inclusion_tag('utilities/templatetags/tag.html')
def tag(url_name, tag):
"""
Display a link to the given object list filtered by a specific Tag slug.
"""
return {
'url_name': url_name,
'tag': tag,
}

View File

@ -8,7 +8,7 @@ from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ProtectedError from django.db.models import Count, ProtectedError
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
@ -119,6 +119,12 @@ class ObjectListView(View):
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk') table.columns.show('pk')
# Construct queryset for tags list
if hasattr(model, 'tags'):
tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('-count', 'name')
else:
tags = None
# Apply the request context # Apply the request context
paginate = { paginate = {
'klass': EnhancedPaginator, 'klass': EnhancedPaginator,
@ -131,6 +137,7 @@ class ObjectListView(View):
'table': table, 'table': table,
'permissions': permissions, 'permissions': permissions,
'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None, 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
'tags': tags,
} }
context.update(self.extra_context()) context.update(self.extra_context())
@ -195,13 +202,16 @@ class ObjectEditView(GetReturnURLMixin, View):
obj_created = not form.instance.pk obj_created = not form.instance.pk
obj = form.save() obj = form.save()
msg = 'Created ' if obj_created else 'Modified ' msg = '{} {}'.format(
msg += self.model._meta.verbose_name 'Created' if obj_created else 'Modified',
self.model._meta.verbose_name
)
if hasattr(obj, 'get_absolute_url'): if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj)) msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
else: else:
msg = '{} {}'.format(msg, escape(obj)) msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
if obj_created: if obj_created:
UserAction.objects.log_create(request.user, obj, msg) UserAction.objects.log_create(request.user, obj, msg)
else: else:

View File

@ -1,14 +1,15 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES from dcim.constants import IFACE_MODE_CHOICES
from dcim.models import Interface from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer
from virtualization.constants import VM_STATUS_CHOICES from virtualization.constants import VM_STATUS_CHOICES
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -59,10 +60,13 @@ class ClusterSerializer(CustomFieldModelSerializer):
type = NestedClusterTypeSerializer() type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True) group = NestedClusterGroupSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Cluster model = Cluster
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] fields = [
'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
class NestedClusterSerializer(WritableNestedSerializer): class NestedClusterSerializer(WritableNestedSerializer):
@ -95,12 +99,13 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
primary_ip = VirtualMachineIPAddressSerializer(read_only=True) primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -63,6 +63,9 @@ class ClusterFilter(CustomFieldFilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = Cluster model = Cluster
@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet):
to_field_name='slug', to_field_name='slug',
label='Platform (slug)', label='Platform (slug)',
) )
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine

View File

@ -4,6 +4,7 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Count from django.db.models import Count
from mptt.forms import TreeNodeChoiceField from mptt.forms import TreeNodeChoiceField
from taggit.forms import TagField
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.forms import INTERFACE_MODE_HELP_TEXT
@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm):
class ClusterForm(BootstrapMixin, CustomFieldForm): class ClusterForm(BootstrapMixin, CustomFieldForm):
comments = CommentField(widget=SmallTextarea) comments = CommentField(widget=SmallTextarea)
tags = TagField(required=False)
class Meta: class Meta:
model = Cluster model = Cluster
fields = ['name', 'type', 'group', 'site', 'comments'] fields = ['name', 'type', 'group', 'site', 'comments', 'tags']
class ClusterCSVForm(forms.ModelForm): class ClusterCSVForm(forms.ModelForm):
@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
) )
) )
tags = TagField(required=False)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'vcpus', 'memory', 'disk', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Device from dcim.models import Device
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
@ -124,6 +125,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = ['name', 'type', 'group', 'site', 'comments'] csv_headers = ['name', 'type', 'group', 'site', 'comments']
class Meta: class Meta:
@ -242,6 +245,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager()
csv_headers = [ csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
] ]

View File

@ -126,6 +126,7 @@ class ClusterView(View):
class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_cluster' permission_required = 'virtualization.add_cluster'
template_name = 'virtualization/cluster_edit.html'
model = Cluster model = Cluster
model_form = forms.ClusterForm model_form = forms.ClusterForm

View File

@ -4,6 +4,7 @@ django-debug-toolbar>=1.9.0
django-filter>=1.1.0 django-filter>=1.1.0
django-mptt>=0.9.0 django-mptt>=0.9.0
django-tables2>=1.19.0 django-tables2>=1.19.0
django-taggit>=0.22.2
django-timezone-field>=2.0 django-timezone-field>=2.0
djangorestframework>=3.7.7,<3.8.2 djangorestframework>=3.7.7,<3.8.2
drf-yasg[validation]>=1.4.4 drf-yasg[validation]>=1.4.4