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 rest_framework import serializers
from taggit.models import Tag
from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from extras.api.customfields import CustomFieldModelSerializer
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):
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Provider
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',
]
@ -60,12 +62,13 @@ class CircuitSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Circuit
fields = [
'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',
label='Site (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Provider
@ -103,6 +106,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Circuit

View File

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

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.constants import STATUS_CLASSES
from dcim.fields import ASNField
@ -56,6 +57,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class Meta:
@ -166,6 +169,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = [
'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.validators import UniqueTogetherValidator
from taggit.models import Tag
from circuits.models import Circuit, CircuitTermination
from dcim.constants import (
@ -21,7 +22,8 @@ from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer
from users.api.serializers import NestedUserSerializer
from utilities.api import (
ChoiceFieldSerializer, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer,
ChoiceFieldSerializer, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer,
)
from virtualization.models import Cluster
@ -55,14 +57,15 @@ class SiteSerializer(CustomFieldModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
'count_circuits',
'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks',
'count_devices', 'count_circuits',
]
@ -124,12 +127,13 @@ class RackSerializer(CustomFieldModelSerializer):
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False)
width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Rack
fields = [
'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
# 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)
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False)
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = DeviceType
fields = [
'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',
]
@ -401,13 +406,14 @@ class DeviceSerializer(CustomFieldModelSerializer):
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(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:
model = Device
fields = [
'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',
'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',
]
validators = []

View File

@ -82,6 +82,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Site
@ -179,6 +182,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Rack
@ -286,6 +292,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = DeviceType
@ -497,6 +506,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
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.db.models import Count, Q
from mptt.forms import TreeNodeChoiceField
from taggit.forms import TagField
from timezone_field import TimeZoneFormField
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
slug = SlugField()
comments = CommentField()
tags = TagField(required=False)
class Meta:
model = Site
fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'tags',
]
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
@ -274,12 +277,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
)
)
comments = CommentField()
tags = TagField(required=False)
class Meta:
model = Rack
fields = [
'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 = {
'site': "The site at which the rack exists",
@ -485,11 +489,14 @@ class ManufacturerCSVForm(forms.ModelForm):
class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
slug = SlugField(slug_source='model')
tags = TagField(required=False)
class Meta:
model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
fields = [
'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 = {
'interface_ordering': 'Order interfaces by',
}
@ -772,12 +779,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
)
)
comments = CommentField()
tags = TagField(required=False)
class Meta:
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments',
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
]
help_texts = {
'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.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
from circuits.models import Circuit
@ -161,6 +162,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
)
objects = SiteManager()
tags = TaggableManager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@ -388,6 +390,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
)
objects = RackManager()
tags = TaggableManager()
csv_headers = [
'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'
)
tags = TaggableManager()
csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
@ -1231,6 +1236,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
)
objects = DeviceManager()
tags = TaggableManager()
csv_headers = [
'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 rest_framework import serializers
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site
@ -62,6 +63,18 @@ class TopologyMapSerializer(ValidatedModelSerializer):
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
#

View File

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

View File

@ -1,12 +1,14 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from taggit.models import Tag
from extras import filters
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
@ -109,6 +111,16 @@ class TopologyMapViewSet(ModelViewSet):
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
#

View File

@ -3,6 +3,8 @@ from __future__ import unicode_literals
import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from taggit.models import Tag
from dcim.models import Site
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']
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):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',

View File

@ -4,12 +4,17 @@ from collections import OrderedDict
from django import forms
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 .models import CustomField, CustomFieldValue, ImageAttachment
#
# Custom fields
#
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
"""
Retrieve all CustomFields applicable to the given ContentType
@ -162,6 +167,23 @@ class CustomFieldFilterForm(forms.Form):
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 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 rest_framework import status
from rest_framework.test import APITestCase
from taggit.models import Tag
from dcim.models import Device
from extras.constants import GRAPH_TYPE_SITE
@ -226,3 +227,96 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
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'
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
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'),

View File

@ -2,16 +2,52 @@ from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
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.views.generic import View
from taggit.models import Tag
from utilities.forms import ConfirmationForm
from utilities.views import ObjectDeleteView, ObjectEditView
from .forms import ImageAttachmentForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from .forms import ImageAttachmentForm, TagForm
from .models import ImageAttachment, ReportResult, UserAction
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.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
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 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
@ -24,12 +27,13 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
class VRFSerializer(CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = VRF
fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
'last_updated',
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
'created', 'last_updated',
]
@ -85,11 +89,13 @@ class NestedRIRSerializer(WritableNestedSerializer):
class AggregateSerializer(CustomFieldModelSerializer):
rir = NestedRIRSerializer()
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Aggregate
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']
@ -147,11 +153,12 @@ class VLANSerializer(CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = VLAN
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',
]
validators = []
@ -190,12 +197,13 @@ class PrefixSerializer(CustomFieldModelSerializer):
vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Prefix
fields = [
'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']
@ -252,12 +260,13 @@ class IPAddressSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = IPAddress
fields = [
'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']

View File

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

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django import forms
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count
from taggit.forms import TagField
from dcim.models import Site, Rack, Device, Interface
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):
tags = TagField(required=False)
class Meta:
model = VRF
fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags']
labels = {
'rd': "RD",
}
@ -121,10 +123,11 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
#
class AggregateForm(BootstrapMixin, CustomFieldForm):
tags = TagField(required=False)
class Meta:
model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description']
fields = ['prefix', 'rir', 'date_added', 'description', 'tags']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'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'
)
)
tags = TagField(required=False)
class Meta:
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):
@ -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')
tags = TagField(required=False)
class Meta:
model = IPAddress
fields = [
'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):
@ -780,10 +788,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
tags = TagField(required=False)
class Meta:
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 = {
'site': "Leave blank if this VLAN spans multiple sites",
'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.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Interface
from extras.models import CustomFieldModel
@ -56,6 +57,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta:
@ -155,6 +158,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['prefix', 'rir', 'date_added', 'description']
class Meta:
@ -325,6 +330,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
)
objects = PrefixQuerySet.as_manager()
tags = TaggableManager()
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@ -564,6 +570,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
)
objects = IPAddressManager()
tags = TaggableManager()
csv_headers = [
'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'
)
tags = TaggableManager()
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
class Meta:

View File

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

View File

@ -2,10 +2,11 @@ from __future__ import unicode_literals
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer
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()
role = NestedSecretRoleSerializer()
plaintext = serializers.CharField()
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated']
validators = []
def validate(self, data):

View File

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

View File

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

View File

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

View File

@ -110,6 +110,16 @@
{% endif %}
</td>
</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>
</div>
{% with circuit.get_custom_fields as custom_fields %}

View File

@ -44,6 +44,12 @@
{% 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 %}
{% block javascript %}

View File

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

View File

@ -102,6 +102,16 @@
{% endif %}
</td>
</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>
<td>Circuits</td>
<td>

View File

@ -33,4 +33,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -96,6 +96,16 @@
{% endif %}
</td>
</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>
</div>
{% if vc_members %}

View File

@ -83,4 +83,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -73,6 +73,16 @@
<td>Interface Ordering</td>
<td>{{ devicetype.get_interface_ordering_display }}</td>
</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>
<td>Instances</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 %}
</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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -114,6 +114,16 @@
{% endif %}
</td>
</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>
<td>Devices</td>
<td>

View File

@ -43,4 +43,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -133,6 +133,16 @@
{% endif %}
</td>
</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>
</div>
<div class="panel panel-default">

View File

@ -46,4 +46,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% 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">
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
<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>
<ul class="dropdown-menu">
<li class="dropdown-header">Sites</li>
@ -60,6 +60,9 @@
</li>
<li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li>
<li>
<a href="{% url 'extras:tag_list' %}">Tags</a>
</li>
<li>
<a href="{% url 'extras:report_list' %}">Reports</a>
</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' %}
{% load helpers %}
{% block content %}
<div class="row">
@ -81,6 +82,16 @@
{% endif %}
</td>
</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>
</div>
</div>

View File

@ -19,4 +19,10 @@
</div>
</div>
{% 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 %}

View File

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

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
@ -133,6 +134,16 @@
{% endif %}
</td>
</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>
</div>
{% with ipaddress.get_custom_fields as custom_fields %}

View File

@ -66,6 +66,12 @@
{% render_field form.nat_inside %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -121,6 +121,16 @@
{% endif %}
</td>
</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>
<td>Utilization</td>
<td>{% utilization_graph prefix.get_utilization %}</td>

View File

@ -28,6 +28,12 @@
{% render_field form.tenant %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
@ -80,6 +81,16 @@
<span class="text-muted">N/A</span>
{% endif %}
</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>
</table>
</div>

View File

@ -21,6 +21,12 @@
{% render_field form.tenant %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
@ -77,6 +78,16 @@
<span class="text-muted">N/A</span>
{% endif %}
</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>
</table>
</div>

View File

@ -18,6 +18,12 @@
{% render_field form.tenant %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load helpers %}
{% load secret_helpers %}
{% block content %}
@ -55,6 +56,16 @@
{% endif %}
</td>
</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>
</div>
</div>

View File

@ -54,6 +54,12 @@
{% render_field form.plaintext2 %}
</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 class="row">

View File

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

View File

@ -68,6 +68,16 @@
{% endif %}
</td>
</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>
</div>
{% with tenant.get_custom_fields as custom_fields %}

View File

@ -26,4 +26,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% 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 %}
</td>
</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>
<td>Virtual Machines</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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -121,6 +121,16 @@
{% endif %}
</td>
</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>
</div>
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}

View File

@ -54,4 +54,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,10 +1,11 @@
from __future__ import unicode_literals
from rest_framework import serializers
from taggit.models import Tag
from extras.api.customfields import CustomFieldModelSerializer
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):
group = NestedTenantGroupSerializer(required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
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):

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField
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
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@ -42,6 +42,21 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
# 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):
"""
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.

View File

@ -1,7 +1,6 @@
from __future__ import unicode_literals
import datetime
import pytz
from django import template
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,
'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.core.exceptions import ValidationError
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.shortcuts import get_object_or_404, redirect, render
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']):
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
paginate = {
'klass': EnhancedPaginator,
@ -131,6 +137,7 @@ class ObjectListView(View):
'table': table,
'permissions': permissions,
'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
'tags': tags,
}
context.update(self.extra_context())
@ -195,13 +202,16 @@ class ObjectEditView(GetReturnURLMixin, View):
obj_created = not form.instance.pk
obj = form.save()
msg = 'Created ' if obj_created else 'Modified '
msg += self.model._meta.verbose_name
msg = '{} {}'.format(
'Created' if obj_created else 'Modified',
self.model._meta.verbose_name
)
if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
if obj_created:
UserAction.objects.log_create(request.user, obj, msg)
else:

View File

@ -1,14 +1,15 @@
from __future__ import unicode_literals
from rest_framework import serializers
from taggit.models import Tag
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 extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN
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.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -59,10 +60,13 @@ class ClusterSerializer(CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
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):
@ -95,12 +99,13 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
primary_ip4 = 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:
model = VirtualMachine
fields = [
'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',
label='Site (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Cluster
@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Platform (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = VirtualMachine

View File

@ -4,6 +4,7 @@ from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count
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.forms import INTERFACE_MODE_HELP_TEXT
@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm):
class ClusterForm(BootstrapMixin, CustomFieldForm):
comments = CommentField(widget=SmallTextarea)
tags = TagField(required=False)
class Meta:
model = Cluster
fields = ['name', 'type', 'group', 'site', 'comments']
fields = ['name', 'type', 'group', 'site', 'comments', 'tags']
class ClusterCSVForm(forms.ModelForm):
@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
)
)
tags = TagField(required=False)
class Meta:
model = VirtualMachine
fields = [
'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):

View File

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

View File

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

View File

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