mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
commit
63100b683d
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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 = []
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
|
@ -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
28
netbox/extras/tables.py
Normal 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')
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
@ -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'
|
||||
|
||||
|
||||
#
|
||||
|
@ -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']
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)",
|
||||
|
@ -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:
|
||||
|
@ -133,6 +133,7 @@ INSTALLED_APPS = (
|
||||
'django_tables2',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'taggit',
|
||||
'timezone_field',
|
||||
'circuits',
|
||||
'dcim',
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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']
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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">
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
11
netbox/templates/extras/tag_list.html
Normal file
11
netbox/templates/extras/tag_list.html
Normal 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 %}
|
@ -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>
|
||||
|
13
netbox/templates/inc/tags_panel.html
Normal file
13
netbox/templates/inc/tags_panel.html
Normal 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>
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -21,6 +21,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
1
netbox/templates/utilities/templatetags/tag.html
Normal file
1
netbox/templates/utilities/templatetags/tag.html
Normal file
@ -0,0 +1 @@
|
||||
<a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default">{{ tag }}</span></a>
|
@ -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>
|
||||
|
34
netbox/templates/virtualization/cluster_edit.html
Normal file
34
netbox/templates/virtualization/cluster_edit.html
Normal 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 %}
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/tags_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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>}.
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user