{{ obj }}
. Links '
- 'which render as empty text will not be displayed.',
- 'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}
.',
+ 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}
. '
+ 'Links which render as empty text will not be displayed.',
+ 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}
.',
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Format ContentType choices
- order_content_types(self.fields['content_type'])
- self.fields['content_type'].choices.insert(0, ('', '---------'))
-
@admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin):
@@ -158,7 +149,7 @@ class CustomLinkAdmin(admin.ModelAdmin):
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
}),
('Templates', {
- 'fields': ('text', 'url'),
+ 'fields': ('link_text', 'link_url'),
'classes': ('monospace',)
})
)
@@ -176,24 +167,21 @@ class CustomLinkAdmin(admin.ModelAdmin):
#
class ExportTemplateForm(forms.ModelForm):
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_links')
+ )
class Meta:
model = ExportTemplate
exclude = []
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Format ContentType choices
- order_content_types(self.fields['content_type'])
- self.fields['content_type'].choices.insert(0, ('', '---------'))
-
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
fieldsets = (
('Export Template', {
- 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
+ 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment')
}),
('Content', {
'fields': ('template_code',),
@@ -201,7 +189,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
})
)
list_display = [
- 'name', 'content_type', 'description', 'mime_type', 'file_extension',
+ 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
]
list_filter = [
'content_type',
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index c8c4ba89ee7..5cb1fc27618 100644
--- a/netbox/extras/api/customfields.py
+++ b/netbox/extras/api/customfields.py
@@ -1,9 +1,7 @@
from django.contrib.contenttypes.models import ContentType
-from rest_framework.fields import CreateOnlyDefault, Field
+from rest_framework.fields import Field
-from extras.choices import *
from extras.models import CustomField
-from netbox.api import ValidatedModelSerializer
#
@@ -56,34 +54,3 @@ def to_internal_value(self, data):
data = {**self.parent.instance.custom_field_data, **data}
return data
-
-
-class CustomFieldModelSerializer(ValidatedModelSerializer):
- """
- Extends ModelSerializer to render any CustomFields and their values associated with an object.
- """
- custom_fields = CustomFieldsDataField(
- source='custom_field_data',
- default=CreateOnlyDefault(CustomFieldDefaultValues())
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- if self.instance is not None:
-
- # Retrieve the set of CustomFields which apply to this type of object
- content_type = ContentType.objects.get_for_model(self.Meta.model)
- fields = CustomField.objects.filter(content_types=content_type)
-
- # Populate CustomFieldValues for each instance from database
- if type(self.instance) in (list, tuple):
- for obj in self.instance:
- self._populate_custom_fields(obj, fields)
- else:
- self._populate_custom_fields(self.instance, fields)
-
- def _populate_custom_fields(self, instance, custom_fields):
- instance.custom_fields = {}
- for field in custom_fields:
- instance.custom_fields[field.name] = instance.cf.get(field.name)
diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py
index 5635f401b8a..4acde31ab7d 100644
--- a/netbox/extras/api/nested_serializers.py
+++ b/netbox/extras/api/nested_serializers.py
@@ -2,24 +2,44 @@
from extras import choices, models
from netbox.api import ChoiceField, WritableNestedSerializer
+from netbox.api.serializers import NestedTagSerializer
from users.api.nested_serializers import NestedUserSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedCustomFieldSerializer',
+ 'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
'NestedImageAttachmentSerializer',
'NestedJobResultSerializer',
- 'NestedTagSerializer',
+ 'NestedJournalEntrySerializer',
+ 'NestedTagSerializer', # Defined in netbox.api.serializers
+ 'NestedWebhookSerializer',
]
+class NestedWebhookSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
+
+ class Meta:
+ model = models.Webhook
+ fields = ['id', 'url', 'display', 'name']
+
+
class NestedCustomFieldSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
class Meta:
model = models.CustomField
- fields = ['id', 'url', 'name']
+ fields = ['id', 'url', 'display', 'name']
+
+
+class NestedCustomLinkSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+
+ class Meta:
+ model = models.CustomLink
+ fields = ['id', 'url', 'display', 'name']
class NestedConfigContextSerializer(WritableNestedSerializer):
@@ -27,7 +47,7 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
class Meta:
model = models.ConfigContext
- fields = ['id', 'url', 'name']
+ fields = ['id', 'url', 'display', 'name']
class NestedExportTemplateSerializer(WritableNestedSerializer):
@@ -35,7 +55,7 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.ExportTemplate
- fields = ['id', 'url', 'name']
+ fields = ['id', 'url', 'display', 'name']
class NestedImageAttachmentSerializer(WritableNestedSerializer):
@@ -43,15 +63,15 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
class Meta:
model = models.ImageAttachment
- fields = ['id', 'url', 'name', 'image']
+ fields = ['id', 'url', 'display', 'name', 'image']
-class NestedTagSerializer(WritableNestedSerializer):
- url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
+class NestedJournalEntrySerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
class Meta:
- model = models.Tag
- fields = ['id', 'url', 'name', 'slug', 'color']
+ model = models.JournalEntry
+ fields = ['id', 'url', 'display', 'created']
class NestedJobResultSerializer(serializers.ModelSerializer):
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index a85ca05b775..66627bfbc30 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -4,17 +4,16 @@
from rest_framework import serializers
from dcim.api.nested_serializers import (
- NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
- NestedRegionSerializer, NestedSiteSerializer,
+ NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer,
+ NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
)
-from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
+from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup
from extras.choices import *
-from extras.models import (
- ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
-)
+from extras.models import *
from extras.utils import FeatureQuery
-from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
+from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.exceptions import SerializerNotFound
+from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer
@@ -23,6 +22,46 @@
from virtualization.models import Cluster, ClusterGroup
from .nested_serializers import *
+__all__ = (
+ 'ConfigContextSerializer',
+ 'ContentTypeSerializer',
+ 'CustomFieldSerializer',
+ 'CustomLinkSerializer',
+ 'ExportTemplateSerializer',
+ 'ImageAttachmentSerializer',
+ 'JobResultSerializer',
+ 'ObjectChangeSerializer',
+ 'ReportDetailSerializer',
+ 'ReportSerializer',
+ 'ScriptDetailSerializer',
+ 'ScriptInputSerializer',
+ 'ScriptLogMessageSerializer',
+ 'ScriptOutputSerializer',
+ 'ScriptSerializer',
+ 'TagSerializer',
+ 'WebhookSerializer',
+)
+
+
+#
+# Webhooks
+#
+
+class WebhookSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
+ content_types = ContentTypeField(
+ queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+ many=True
+ )
+
+ class Meta:
+ model = Webhook
+ fields = [
+ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
+ 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
+ 'ssl_verification', 'ca_file_path',
+ ]
+
#
# Custom fields
@@ -40,11 +79,29 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class Meta:
model = CustomField
fields = [
- 'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
+ 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
]
+#
+# Custom links
+#
+
+class CustomLinkSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+ content_type = ContentTypeField(
+ queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query())
+ )
+
+ class Meta:
+ model = CustomLink
+ fields = [
+ 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
+ 'button_class', 'new_window',
+ ]
+
+
#
# Export templates
#
@@ -57,7 +114,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
- fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
+ fields = [
+ 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
+ 'file_extension', 'as_attachment',
+ ]
#
@@ -70,39 +130,7 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
- fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
-
-
-class TaggedObjectSerializer(serializers.Serializer):
- tags = NestedTagSerializer(many=True, required=False)
-
- def create(self, validated_data):
- tags = validated_data.pop('tags', None)
- instance = super().create(validated_data)
-
- if tags is not None:
- return self._save_tags(instance, tags)
- return instance
-
- def update(self, instance, validated_data):
- tags = validated_data.pop('tags', None)
-
- # Cache tags on instance for change logging
- instance._tags = tags or []
-
- instance = super().update(instance, validated_data)
-
- if tags is not None:
- return self._save_tags(instance, tags)
- return instance
-
- def _save_tags(self, instance, tags):
- if tags:
- instance.tags.set(*[t.name for t in tags])
- else:
- instance.tags.clear()
-
- return instance
+ fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
#
@@ -119,8 +147,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
class Meta:
model = ImageAttachment
fields = [
- 'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width',
- 'created',
+ 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
+ 'image_width', 'created',
]
def validate(self, data):
@@ -154,6 +182,51 @@ def get_parent(self, obj):
return serializer(obj.parent, context={'request': self.context['request']}).data
+#
+# Journal entries
+#
+
+class JournalEntrySerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
+ assigned_object_type = ContentTypeField(
+ queryset=ContentType.objects.all()
+ )
+ assigned_object = serializers.SerializerMethodField(read_only=True)
+ kind = ChoiceField(
+ choices=JournalEntryKindChoices,
+ required=False
+ )
+
+ class Meta:
+ model = JournalEntry
+ fields = [
+ 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
+ 'created_by', 'kind', 'comments',
+ ]
+
+ def validate(self, data):
+
+ # Validate that the parent object exists
+ if 'assigned_object_type' in data and 'assigned_object_id' in data:
+ try:
+ data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
+ except ObjectDoesNotExist:
+ raise serializers.ValidationError(
+ f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
+ )
+
+ # Enforce model validation
+ super().validate(data)
+
+ return data
+
+ @swagger_serializer_method(serializer_or_field=serializers.DictField)
+ def get_assigned_object(self, instance):
+ serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
+ context = {'request': self.context['request']}
+ return serializer(instance.assigned_object, context=context).data
+
+
#
# Config contexts
#
@@ -166,12 +239,24 @@ class ConfigContextSerializer(ValidatedModelSerializer):
required=False,
many=True
)
+ site_groups = SerializedPKRelatedField(
+ queryset=SiteGroup.objects.all(),
+ serializer=NestedSiteGroupSerializer,
+ required=False,
+ many=True
+ )
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=NestedSiteSerializer,
required=False,
many=True
)
+ device_types = SerializedPKRelatedField(
+ queryset=DeviceType.objects.all(),
+ serializer=NestedDeviceTypeSerializer,
+ required=False,
+ many=True
+ )
roles = SerializedPKRelatedField(
queryset=DeviceRole.objects.all(),
serializer=NestedDeviceRoleSerializer,
@@ -218,8 +303,9 @@ class ConfigContextSerializer(ValidatedModelSerializer):
class Meta:
model = ConfigContext
fields = [
- 'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
- 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
+ 'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
+ 'data', 'created', 'last_updated',
]
@@ -227,7 +313,7 @@ class Meta:
# Job Results
#
-class JobResultSerializer(serializers.ModelSerializer):
+class JobResultSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
user = NestedUserSerializer(
read_only=True
@@ -240,7 +326,7 @@ class JobResultSerializer(serializers.ModelSerializer):
class Meta:
model = JobResult
fields = [
- 'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
+ 'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
]
@@ -318,7 +404,7 @@ class ScriptOutputSerializer(serializers.Serializer):
# Change logging
#
-class ObjectChangeSerializer(serializers.ModelSerializer):
+class ObjectChangeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
user = NestedUserSerializer(
read_only=True
@@ -337,8 +423,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
class Meta:
model = ObjectChange
fields = [
- 'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
- 'changed_object_id', 'changed_object', 'object_data',
+ 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
+ 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -365,13 +451,13 @@ def get_changed_object(self, obj):
# ContentTypes
#
-class ContentTypeSerializer(serializers.ModelSerializer):
+class ContentTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
display_name = serializers.SerializerMethodField()
class Meta:
model = ContentType
- fields = ['id', 'url', 'app_label', 'model', 'display_name']
+ fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name']
@swagger_serializer_method(serializer_or_field=serializers.CharField)
def get_display_name(self, obj):
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index da62b3d72fa..565f2cdc74f 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -5,9 +5,15 @@
router = OrderedDefaultRouter()
router.APIRootView = views.ExtrasRootView
+# Webhooks
+router.register('webhooks', views.WebhookViewSet)
+
# Custom fields
router.register('custom-fields', views.CustomFieldViewSet)
+# Custom links
+router.register('custom-links', views.CustomLinkViewSet)
+
# Export templates
router.register('export-templates', views.ExportTemplateViewSet)
@@ -17,6 +23,9 @@
# Image attachments
router.register('image-attachments', views.ImageAttachmentViewSet)
+# Journal entries
+router.register('journal-entries', views.JournalEntryViewSet)
+
# Config contexts
router.register('config-contexts', views.ConfigContextViewSet)
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 1067ac0d399..cee5146a672 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -11,9 +11,7 @@
from extras import filters
from extras.choices import JobResultStatusChoices
-from extras.models import (
- ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
-)
+from extras.models import *
from extras.models import CustomField
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
@@ -55,6 +53,17 @@ def get_queryset(self):
return queryset.annotate_config_context_data()
+#
+# Webhooks
+#
+
+class WebhookViewSet(ModelViewSet):
+ metadata_class = ContentTypeMetadata
+ queryset = Webhook.objects.all()
+ serializer_class = serializers.WebhookSerializer
+ filterset_class = filters.WebhookFilterSet
+
+
#
# Custom fields
#
@@ -84,6 +93,17 @@ def get_serializer_context(self):
return context
+#
+# Custom links
+#
+
+class CustomLinkViewSet(ModelViewSet):
+ metadata_class = ContentTypeMetadata
+ queryset = CustomLink.objects.all()
+ serializer_class = serializers.CustomLinkSerializer
+ filterset_class = filters.CustomLinkFilterSet
+
+
#
# Export templates
#
@@ -118,13 +138,24 @@ class ImageAttachmentViewSet(ModelViewSet):
filterset_class = filters.ImageAttachmentFilterSet
+#
+# Journal entries
+#
+
+class JournalEntryViewSet(ModelViewSet):
+ metadata_class = ContentTypeMetadata
+ queryset = JournalEntry.objects.all()
+ serializer_class = serializers.JournalEntrySerializer
+ filterset_class = filters.JournalEntryFilterSet
+
+
#
# Config contexts
#
class ConfigContextViewSet(ModelViewSet):
queryset = ConfigContext.objects.prefetch_related(
- 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
+ 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
)
serializer_class = serializers.ConfigContextSerializer
filterset_class = filters.ConfigContextFilterSet
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index 45f8ac31f83..33c70f70d55 100644
--- a/netbox/extras/choices.py
+++ b/netbox/extras/choices.py
@@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_DATE = 'date'
TYPE_URL = 'url'
TYPE_SELECT = 'select'
+ TYPE_MULTISELECT = 'multiselect'
CHOICES = (
(TYPE_TEXT, 'Text'),
@@ -21,6 +22,7 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_DATE, 'Date'),
(TYPE_URL, 'URL'),
(TYPE_SELECT, 'Selection'),
+ (TYPE_MULTISELECT, 'Multiple selection'),
)
@@ -85,6 +87,32 @@ class ObjectChangeActionChoices(ChoiceSet):
}
+#
+# Jounral entries
+#
+
+class JournalEntryKindChoices(ChoiceSet):
+
+ KIND_INFO = 'info'
+ KIND_SUCCESS = 'success'
+ KIND_WARNING = 'warning'
+ KIND_DANGER = 'danger'
+
+ CHOICES = (
+ (KIND_INFO, 'Info'),
+ (KIND_SUCCESS, 'Success'),
+ (KIND_WARNING, 'Warning'),
+ (KIND_DANGER, 'Danger'),
+ )
+
+ CSS_CLASSES = {
+ KIND_INFO: 'default',
+ KIND_SUCCESS: 'success',
+ KIND_WARNING: 'warning',
+ KIND_DANGER: 'danger',
+ }
+
+
#
# Log Levels for Reports and Scripts
#
diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py
index e3c313735e9..4b5c42eeb63 100644
--- a/netbox/extras/filters.py
+++ b/netbox/extras/filters.py
@@ -4,12 +4,12 @@
from django.db.models import Q
from django.forms import DateField, IntegerField, NullBooleanField
-from dcim.models import DeviceRole, Platform, Region, Site
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet, ContentTypeFilter
from virtualization.models import Cluster, ClusterGroup
from .choices import *
-from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
+from .models import *
__all__ = (
@@ -17,12 +17,15 @@
'ContentTypeFilterSet',
'CreatedUpdatedFilterSet',
'CustomFieldFilter',
+ 'CustomLinkFilterSet',
'CustomFieldModelFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
+ 'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'TagFilterSet',
+ 'WebhookFilterSet',
)
EXACT_FILTER_TYPES = (
@@ -33,6 +36,20 @@
)
+class WebhookFilterSet(BaseFilterSet):
+ content_types = ContentTypeFilter()
+ http_method = django_filters.MultipleChoiceFilter(
+ choices=WebhookHttpMethodChoices
+ )
+
+ class Meta:
+ model = Webhook
+ fields = [
+ 'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled',
+ 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
+ ]
+
+
class CustomFieldFilter(django_filters.Filter):
"""
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
@@ -73,12 +90,20 @@ def __init__(self, *args, **kwargs):
class CustomFieldFilterSet(django_filters.FilterSet):
+ content_types = ContentTypeFilter()
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
+class CustomLinkFilterSet(BaseFilterSet):
+
+ class Meta:
+ model = CustomLink
+ fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
+
+
class ExportTemplateFilterSet(BaseFilterSet):
class Meta:
@@ -94,6 +119,37 @@ class Meta:
fields = ['id', 'content_type_id', 'object_id', 'name']
+class JournalEntryFilterSet(BaseFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
+ created = django_filters.DateTimeFromToRangeFilter()
+ assigned_object_type = ContentTypeFilter()
+ created_by_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=User.objects.all(),
+ label='User (ID)',
+ )
+ created_by = django_filters.ModelMultipleChoiceFilter(
+ field_name='created_by__username',
+ queryset=User.objects.all(),
+ to_field_name='username',
+ label='User (name)',
+ )
+ kind = django_filters.MultipleChoiceFilter(
+ choices=JournalEntryKindChoices
+ )
+
+ class Meta:
+ model = JournalEntry
+ fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(comments__icontains=value)
+
+
class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -129,6 +185,17 @@ class ConfigContextFilterSet(BaseFilterSet):
to_field_name='slug',
label='Region (slug)',
)
+ site_group = django_filters.ModelMultipleChoiceFilter(
+ field_name='site_groups__slug',
+ queryset=SiteGroup.objects.all(),
+ to_field_name='slug',
+ label='Site group (slug)',
+ )
+ site_group_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='site_groups',
+ queryset=SiteGroup.objects.all(),
+ label='Site group',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='sites',
queryset=Site.objects.all(),
@@ -140,6 +207,11 @@ class ConfigContextFilterSet(BaseFilterSet):
to_field_name='slug',
label='Site (slug)',
)
+ device_type_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device_types',
+ queryset=DeviceType.objects.all(),
+ label='Device type',
+ )
role_id = django_filters.ModelMultipleChoiceFilter(
field_name='roles',
queryset=DeviceRole.objects.all(),
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index 932d07a4d3a..977ad9d6820 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -2,25 +2,51 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
-from dcim.models import DeviceRole, Platform, Region, Site
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
- ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
- StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
+ CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
+ BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
-from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
+from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
#
# Custom fields
#
-class CustomFieldModelForm(forms.ModelForm):
+class CustomFieldForm(forms.Form):
+ """
+ Extend Form to include custom field support.
+ """
+ model = None
+ def __init__(self, *args, **kwargs):
+ if self.model is None:
+ raise NotImplementedError("CustomFieldForm must specify a model class.")
+ self.custom_fields = []
+
+ super().__init__(*args, **kwargs)
+
+ # Append relevant custom fields to the form instance
+ obj_type = ContentType.objects.get_for_model(self.model)
+ for cf in CustomField.objects.filter(content_types=obj_type):
+ field_name = 'cf_{}'.format(cf.name)
+ self.fields[field_name] = cf.to_form_field()
+
+ # Annotate the field in the list of CustomField form fields
+ self.custom_fields.append(field_name)
+
+
+class CustomFieldModelForm(forms.ModelForm):
+ """
+ Extend ModelForm to include custom field support.
+ """
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
@@ -116,6 +142,9 @@ class Meta:
fields = [
'name', 'slug', 'color', 'description'
]
+ fieldsets = (
+ ('Tag', ('name', 'slug', 'color', 'description')),
+ )
class TagCSVForm(CSVModelForm):
@@ -149,7 +178,7 @@ class TagFilterForm(BootstrapMixin, forms.Form):
model = Tag
q = forms.CharField(
required=False,
- label='Search'
+ label=_('Search')
)
@@ -181,10 +210,18 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
queryset=Region.objects.all(),
required=False
)
+ site_groups = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False
+ )
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False
)
+ device_types = DynamicModelMultipleChoiceField(
+ queryset=DeviceType.objects.all(),
+ required=False
+ )
roles = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False
@@ -220,8 +257,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConfigContext
fields = (
- 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
- 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+ 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
+ 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
@@ -250,54 +287,69 @@ class Meta:
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
+ field_order = [
+ 'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id',
+ 'tenant_group_id', 'tenant_id',
+ ]
q = forms.CharField(
required=False,
- label='Search'
+ label=_('Search')
)
- region = DynamicModelMultipleChoiceField(
+ region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Regions')
+ )
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site groups')
)
- site = DynamicModelMultipleChoiceField(
+ site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Sites')
)
- role = DynamicModelMultipleChoiceField(
+ device_type_id = DynamicModelMultipleChoiceField(
+ queryset=DeviceType.objects.all(),
+ required=False,
+ label=_('Device types')
+ )
+ role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Roles')
)
- platform = DynamicModelMultipleChoiceField(
+ platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Platforms')
)
- cluster_group = DynamicModelMultipleChoiceField(
+ cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Cluster groups')
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
- label='Cluster'
+ label=_('Clusters')
)
- tenant_group = DynamicModelMultipleChoiceField(
+ tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Tenant groups')
)
- tenant = DynamicModelMultipleChoiceField(
+ tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
- to_field_name='slug',
- required=False
+ required=False,
+ label=_('Tenant')
)
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
- required=False
+ required=False,
+ label=_('Tags')
)
@@ -308,7 +360,7 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
- label='Has local config context data',
+ label=_('Has local config context data'),
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
@@ -328,6 +380,79 @@ class Meta:
]
+#
+# Journal entries
+#
+
+class JournalEntryForm(BootstrapMixin, forms.ModelForm):
+ comments = CommentField()
+
+ class Meta:
+ model = JournalEntry
+ fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
+ widgets = {
+ 'assigned_object_type': forms.HiddenInput,
+ 'assigned_object_id': forms.HiddenInput,
+ }
+
+
+class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=JournalEntry.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ kind = forms.ChoiceField(
+ choices=JournalEntryKindChoices,
+ required=False
+ )
+ comments = forms.CharField(
+ required=False,
+ widget=forms.Textarea()
+ )
+
+ class Meta:
+ nullable_fields = []
+
+
+class JournalEntryFilterForm(BootstrapMixin, forms.Form):
+ model = JournalEntry
+ q = forms.CharField(
+ required=False,
+ label=_('Search')
+ )
+ created_after = forms.DateTimeField(
+ required=False,
+ label=_('After'),
+ widget=DateTimePicker()
+ )
+ created_before = forms.DateTimeField(
+ required=False,
+ label=_('Before'),
+ widget=DateTimePicker()
+ )
+ created_by_id = DynamicModelMultipleChoiceField(
+ queryset=User.objects.all(),
+ required=False,
+ label=_('User'),
+ widget=APISelectMultiple(
+ api_url='/api/users/users/',
+ )
+ )
+ assigned_object_type_id = DynamicModelMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ required=False,
+ label=_('Object Type'),
+ widget=APISelectMultiple(
+ api_url='/api/extras/content-types/',
+ )
+ )
+ kind = forms.ChoiceField(
+ choices=add_blank_choice(JournalEntryKindChoices),
+ required=False,
+ widget=StaticSelect2()
+ )
+
+
#
# Change logging
#
@@ -336,16 +461,16 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
model = ObjectChange
q = forms.CharField(
required=False,
- label='Search'
+ label=_('Search')
)
time_after = forms.DateTimeField(
- label='After',
required=False,
+ label=_('After'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
- label='Before',
required=False,
+ label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
@@ -356,8 +481,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
- display_field='username',
- label='User',
+ label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
)
@@ -365,8 +489,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
- display_field='display_name',
- label='Object Type',
+ label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
diff --git a/netbox/extras/management/commands/webhook_receiver.py b/netbox/extras/management/commands/webhook_receiver.py
index b15dc9d27f5..147e4c26164 100644
--- a/netbox/extras/management/commands/webhook_receiver.py
+++ b/netbox/extras/management/commands/webhook_receiver.py
@@ -1,3 +1,4 @@
+import json
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -47,8 +48,10 @@ def do_ANY(self):
# Print the request body (if any)
content_length = self.headers.get('Content-Length')
if content_length is not None:
- body = self.rfile.read(int(content_length))
- print(body.decode('utf-8'))
+ body = self.rfile.read(int(content_length)).decode('utf-8')
+ if self.headers.get('Content-Type') == 'application/json':
+ body = json.loads(body)
+ print(json.dumps(body, indent=4))
else:
print('(No body)')
diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py
new file mode 100644
index 00000000000..c7304334518
--- /dev/null
+++ b/netbox/extras/migrations/0054_standardize_models.py
@@ -0,0 +1,61 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0053_rename_webhook_obj_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='configcontext',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='customfield',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='customlink',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='exporttemplate',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='imageattachment',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='jobresult',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='objectchange',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='tag',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='taggeditem',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='webhook',
+ name='id',
+ field=models.BigAutoField(primary_key=True, serialize=False),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0055_objectchange_data.py b/netbox/extras/migrations/0055_objectchange_data.py
new file mode 100644
index 00000000000..4dc33fc1c15
--- /dev/null
+++ b/netbox/extras/migrations/0055_objectchange_data.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2b1 on 2021-03-03 20:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0054_standardize_models'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='objectchange',
+ old_name='object_data',
+ new_name='postchange_data',
+ ),
+ migrations.AlterField(
+ model_name='objectchange',
+ name='postchange_data',
+ field=models.JSONField(blank=True, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name='objectchange',
+ name='prechange_data',
+ field=models.JSONField(blank=True, editable=False, null=True),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0056_extend_configcontext.py b/netbox/extras/migrations/0056_extend_configcontext.py
new file mode 100644
index 00000000000..9c7e2d700b3
--- /dev/null
+++ b/netbox/extras/migrations/0056_extend_configcontext.py
@@ -0,0 +1,22 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0130_sitegroup'),
+ ('extras', '0055_objectchange_data'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='configcontext',
+ name='site_groups',
+ field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'),
+ ),
+ migrations.AddField(
+ model_name='configcontext',
+ name='device_types',
+ field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_device_types_+', to='dcim.DeviceType'),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0057_customlink_rename_fields.py b/netbox/extras/migrations/0057_customlink_rename_fields.py
new file mode 100644
index 00000000000..6aba35d9f22
--- /dev/null
+++ b/netbox/extras/migrations/0057_customlink_rename_fields.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2b1 on 2021-03-09 01:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0056_extend_configcontext'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='customlink',
+ old_name='text',
+ new_name='link_text',
+ ),
+ migrations.RenameField(
+ model_name='customlink',
+ old_name='url',
+ new_name='link_url',
+ ),
+ migrations.AlterField(
+ model_name='customlink',
+ name='new_window',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py
new file mode 100644
index 00000000000..22abf965ca2
--- /dev/null
+++ b/netbox/extras/migrations/0058_journalentry.py
@@ -0,0 +1,32 @@
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('extras', '0057_customlink_rename_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='JournalEntry',
+ fields=[
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('assigned_object_id', models.PositiveIntegerField()),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('kind', models.CharField(default='info', max_length=30)),
+ ('comments', models.TextField()),
+ ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name_plural': 'journal entries',
+ 'ordering': ('-created',),
+ },
+ ),
+ ]
diff --git a/netbox/extras/migrations/0059_exporttemplate_as_attachment.py b/netbox/extras/migrations/0059_exporttemplate_as_attachment.py
new file mode 100644
index 00000000000..6e6ae0413ac
--- /dev/null
+++ b/netbox/extras/migrations/0059_exporttemplate_as_attachment.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0058_journalentry'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='as_attachment',
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index c6191bbd22e..84676453f3f 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -1,21 +1,18 @@
-from .change_logging import ChangeLoggedModel, ObjectChange
-from .customfields import CustomField, CustomFieldModel
-from .models import (
- ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
- Webhook,
-)
+from .change_logging import ObjectChange
+from .configcontexts import ConfigContext, ConfigContextModel
+from .customfields import CustomField
+from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
from .tags import Tag, TaggedItem
__all__ = (
- 'ChangeLoggedModel',
'ConfigContext',
'ConfigContextModel',
'CustomField',
- 'CustomFieldModel',
'CustomLink',
'ExportTemplate',
'ImageAttachment',
'JobResult',
+ 'JournalEntry',
'ObjectChange',
'Report',
'Script',
diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py
index 52ffd38f99a..fac86b6419a 100644
--- a/netbox/extras/models/change_logging.py
+++ b/netbox/extras/models/change_logging.py
@@ -4,48 +4,12 @@
from django.db import models
from django.urls import reverse
-from utilities.querysets import RestrictedQuerySet
-from utilities.utils import serialize_object
from extras.choices import *
+from netbox.models import BigIDModel
+from utilities.querysets import RestrictedQuerySet
-#
-# Change logging
-#
-
-class ChangeLoggedModel(models.Model):
- """
- An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
- null to facilitate adding these fields to existing instances via a database migration.
- """
- created = models.DateField(
- auto_now_add=True,
- blank=True,
- null=True
- )
- last_updated = models.DateTimeField(
- auto_now=True,
- blank=True,
- null=True
- )
-
- class Meta:
- abstract = True
-
- def to_objectchange(self, action):
- """
- Return a new ObjectChange representing a change made to this object. This will typically be called automatically
- by ChangeLoggingMiddleware.
- """
- return ObjectChange(
- changed_object=self,
- object_repr=str(self),
- action=action,
- object_data=serialize_object(self)
- )
-
-
-class ObjectChange(models.Model):
+class ObjectChange(BigIDModel):
"""
Record a change to an object and the user account associated with that change. A change record may optionally
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
@@ -103,15 +67,22 @@ class ObjectChange(models.Model):
max_length=200,
editable=False
)
- object_data = models.JSONField(
- editable=False
+ prechange_data = models.JSONField(
+ editable=False,
+ blank=True,
+ null=True
+ )
+ postchange_data = models.JSONField(
+ editable=False,
+ blank=True,
+ null=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
- 'related_object_type', 'related_object_id', 'object_repr', 'object_data',
+ 'related_object_type', 'related_object_id', 'object_repr', 'prechange_data', 'postchange_data',
]
class Meta:
@@ -150,7 +121,8 @@ def to_csv(self):
self.related_object_type,
self.related_object_id,
self.object_repr,
- self.object_data,
+ self.prechange_data,
+ self.postchange_data,
)
def get_action_class(self):
diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py
new file mode 100644
index 00000000000..8c142de8b74
--- /dev/null
+++ b/netbox/extras/models/configcontexts.py
@@ -0,0 +1,166 @@
+from collections import OrderedDict
+
+from django.core.validators import ValidationError
+from django.db import models
+from django.urls import reverse
+
+from extras.querysets import ConfigContextQuerySet
+from extras.utils import extras_features
+from netbox.models import ChangeLoggedModel
+from utilities.utils import deepmerge
+
+
+__all__ = (
+ 'ConfigContext',
+ 'ConfigContextModel',
+)
+
+
+#
+# Config contexts
+#
+
+@extras_features('webhooks')
+class ConfigContext(ChangeLoggedModel):
+ """
+ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
+ qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
+ will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ weight = models.PositiveSmallIntegerField(
+ default=1000
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ is_active = models.BooleanField(
+ default=True,
+ )
+ regions = models.ManyToManyField(
+ to='dcim.Region',
+ related_name='+',
+ blank=True
+ )
+ site_groups = models.ManyToManyField(
+ to='dcim.SiteGroup',
+ related_name='+',
+ blank=True
+ )
+ sites = models.ManyToManyField(
+ to='dcim.Site',
+ related_name='+',
+ blank=True
+ )
+ device_types = models.ManyToManyField(
+ to='dcim.DeviceType',
+ related_name='+',
+ blank=True
+ )
+ roles = models.ManyToManyField(
+ to='dcim.DeviceRole',
+ related_name='+',
+ blank=True
+ )
+ platforms = models.ManyToManyField(
+ to='dcim.Platform',
+ related_name='+',
+ blank=True
+ )
+ cluster_groups = models.ManyToManyField(
+ to='virtualization.ClusterGroup',
+ related_name='+',
+ blank=True
+ )
+ clusters = models.ManyToManyField(
+ to='virtualization.Cluster',
+ related_name='+',
+ blank=True
+ )
+ tenant_groups = models.ManyToManyField(
+ to='tenancy.TenantGroup',
+ related_name='+',
+ blank=True
+ )
+ tenants = models.ManyToManyField(
+ to='tenancy.Tenant',
+ related_name='+',
+ blank=True
+ )
+ tags = models.ManyToManyField(
+ to='extras.Tag',
+ related_name='+',
+ blank=True
+ )
+ data = models.JSONField()
+
+ objects = ConfigContextQuerySet.as_manager()
+
+ class Meta:
+ ordering = ['weight', 'name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('extras:configcontext', kwargs={'pk': self.pk})
+
+ def clean(self):
+ super().clean()
+
+ # Verify that JSON data is provided as an object
+ if type(self.data) is not dict:
+ raise ValidationError(
+ {'data': 'JSON data must be in object form. Example: {"foo": 123}'}
+ )
+
+
+class ConfigContextModel(models.Model):
+ """
+ A model which includes local configuration context data. This local data will override any inherited data from
+ ConfigContexts.
+ """
+ local_context_data = models.JSONField(
+ blank=True,
+ null=True,
+ )
+
+ class Meta:
+ abstract = True
+
+ def get_config_context(self):
+ """
+ Return the rendered configuration context for a device or VM.
+ """
+
+ # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
+ data = OrderedDict()
+
+ if not hasattr(self, 'config_context_data'):
+ # The annotation is not available, so we fall back to manually querying for the config context objects
+ config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
+ else:
+ # The attribute may exist, but the annotated value could be None if there is no config context data
+ config_context_data = self.config_context_data or []
+
+ for context in config_context_data:
+ data = deepmerge(data, context)
+
+ # If the object has local config context data defined, merge it last
+ if self.local_context_data:
+ data = deepmerge(data, self.local_context_data)
+
+ return data
+
+ def clean(self):
+ super().clean()
+
+ # Verify that JSON data is provided as an object
+ if self.local_context_data and type(self.local_context_data) is not dict:
+ raise ValidationError(
+ {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
+ )
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 4f37d4870d1..2360da73932 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -1,71 +1,23 @@
import re
-from collections import OrderedDict
from datetime import datetime, date
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
-from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.utils.safestring import mark_safe
from extras.choices import *
from extras.utils import FeatureQuery
-from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
+from netbox.models import BigIDModel
+from utilities.forms import (
+ CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice,
+)
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
-class CustomFieldModel(models.Model):
- """
- Abstract class for any model which may have custom fields associated with it.
- """
- custom_field_data = models.JSONField(
- encoder=DjangoJSONEncoder,
- blank=True,
- default=dict
- )
-
- class Meta:
- abstract = True
-
- @property
- def cf(self):
- """
- Convenience wrapper for custom field data.
- """
- return self.custom_field_data
-
- def get_custom_fields(self):
- """
- Return a dictionary of custom fields for a single object in the form {{{ obj.circuit.provider }}
+{{ obj.circuit.cid }}
+{{ form.term_side.value }}
+Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Circuits | ++ {{ circuits_table.rows|length }} + | +
Site | -- {% if termination.site.region %} - {{ termination.site.region }} / - {% endif %} - {{ termination.site }} - | -||
Termination | -
- {% if termination.cable %}
- {% if perms.dcim.delete_cable %}
-
-
- Disconnect
-
-
+ {% if termination.site %}
+ | ||
Site | ++ {% if termination.site.region %} + {{ termination.site.region }} / {% endif %} - {{ termination.cable }} - - - - {% with peer=termination.get_cable_peer %} - to - {% if peer.device %} - {{ peer.device }} - {% elif peer.circuit %} - {{ peer.circuit }} + {{ termination.site }} + | +||
Termination | +
+ {% if termination.mark_connected %}
+
+ Marked as connected
+ {% elif termination.cable %}
+ {% if perms.dcim.delete_cable %}
+
+
+ Disconnect
+
+
{% endif %}
- ({{ peer }})
- {% endwith %}
- {% else %}
- {% if perms.dcim.add_cable %}
-
-
-
-
-
-
+ {{ termination.cable }}
+
+
+
+ {% with peer=termination.get_cable_peer %}
+ to {{ peer.parent_object }}
+ / {% if peer.get_absolute_url %}{{ peer }}{% else %}{{ peer }}{% endif %}
+ {% endwith %}
+ {% else %}
+ {% if perms.dcim.add_cable %}
+
+
+
+
+
+
+ {% endif %}
+ Not defined
{% endif %}
- Not defined
- {% endif %}
- |
- ||
Provider Network | ++ {{ termination.provider_network }} + | +||
Speed | @@ -92,21 +99,6 @@ {% endif %} | ||
IP Addressing | -
- {% if termination.connected_endpoint %}
- {% for ip in termination.ip_addresses %}
- {% if not forloop.first %} {% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - — - {% endif %} - |
- ||
Cross-Connect | {{ termination.xconnect_id|placeholder }} | diff --git a/netbox/templates/circuits/inc/speed_widget.html b/netbox/templates/circuits/inc/speed_widget.html deleted file mode 100644 index 988418945af..00000000000 --- a/netbox/templates/circuits/inc/speed_widget.html +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 8778c3ac2ba..718d7f65e0a 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -1,65 +1,16 @@ -{% extends 'base.html' %} -{% load buttons %} +{% extends 'generic/object.html' %} {% load static %} -{% load custom_links %} {% load helpers %} {% load plugins %} -{% block title %}{{ object }}{% endblock %} - -{% block header %} - - - -||
Circuits | - {{ circuits_table.rows|length }} + {{ circuits_table.rows|length }} |
Provider | ++ {{ object.provider }} + | +
Name | +{{ object.name }} | +
Description | +{{ object.description }} | +
{{ termination_a.device.site.region }}
{{ termination_a.device.site.group }}
+Cable | diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b64b4aff2ce..96dfa5761ff 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +||
Type | -{{ object.get_type_display }} | +{{ object.get_type_display|placeholder }} | +
Speed | +{{ object.get_speed_display|placeholder }} | |
Description | @@ -34,6 +44,7 @@
Cable | diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 55be343acbb..6c5d8588ef5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -18,21 +18,41 @@
Site | +Region | {% if object.site.region %} - {{ object.site.region }} / + {% for region in object.site.region.get_ancestors %} + {{ region }} / + {% endfor %} + {{ object.site.region }} + {% else %} + None + {% endif %} + | +
Site | ++ {{ object.site }} + | +|
Location | ++ {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / + {% endfor %} + {{ object.location }} + {% else %} + None {% endif %} - {{ object.site }} | |
Rack | {% if object.rack %} - {% if object.rack.group %} - {{ object.rack.group }} / - {% endif %} {{ object.rack }} {% else %} None @@ -74,7 +94,7 @@ | |
Device Type | - {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) + {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) | |
Role | - {{ object.device_role }} + {{ object.device_role }} | |
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Color | ++ + | +
VM Role | ++ {% if object.vm_role %} + + {% else %} + + {% endif %} + | +
Devices | ++ {{ devices_table.rows|length }} + | +
Manufacturer | -{{ object.manufacturer }} | +{{ object.manufacturer }} |
Model Name | diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html deleted file mode 100644 index 5aba04b3988..00000000000 --- a/netbox/templates/dcim/devicetype_edit.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
Cable | diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html index 1962248e7bb..26a7e1cd320 100644 --- a/netbox/templates/dcim/inc/cabletermination.html +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -1,12 +1,12 @@- {% if termination.parent.provider %} + {% if termination.parent_object.provider %} - - {{ termination.parent.provider }} - {{ termination.parent }} + + {{ termination.parent_object.provider }} + {{ termination.parent_object }} {% else %} - {{ termination.parent }} + {{ termination.parent_object }} {% endif %} | diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index 3169d2ffc1b..d5b9f6112ab 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,6 +1,6 @@ {% if path.destination_id %} {% with endpoint=path.destination %} - | {{ endpoint.parent }} | +{{ endpoint.parent_object }} | {{ endpoint }} | {% endwith %} {% else %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 2c0f6e01f5a..9f85196feec 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -3,6 +3,21 @@ {% load plugins %} {% load render_table from django_tables2 %} +{% block breadcrumbs %} + {{ block.super }} +
Parent | ++ {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} + | +||||
LAG | - {% if object.lag%} + {% if object.lag %} {{ object.lag }} {% else %} None @@ -67,6 +92,7 @@ |
Cable | @@ -251,6 +281,11 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Site | +{{ object.site }} | +
Parent | ++ {% if object.parent %} + {{ object.parent }} + {% else %} + — + {% endif %} + | +
Racks | ++ {{ object.racks.count }} + | +
Devices | ++ {{ devices_table.rows|length }} + | +
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Device types | ++ {{ devicetypes_table.rows|length }} + | +
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Manufacturer | ++ {% if object.manufacturer %} + {{ object.manufacturer }} + {% else %} + None + {% endif %} + | +
NAPALM Driver | +{{ object.napalm_driver|placeholder }} | +
NAPALM Arguments | +{{ object.napalm_args }} |
+
Devices | ++ {{ devices_table.rows|length }} + | +
Cable | diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html deleted file mode 100644 index 0a6581444ce..00000000000 --- a/netbox/templates/dcim/powerfeed_edit.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
Cable | diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 179697b4f4b..0ddfa38851d 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -1,59 +1,15 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} -{% load static %} {% load plugins %} +{% load render_table from django_tables2 %} -{% block header %} - - -||
Rack Group | +Location |
- {% if object.rack_group %}
- {{ object.rack_group }}
+ {% if object.location %}
+ {{ object.location }}
{% else %}
None
{% endif %}
@@ -92,7 +48,37 @@ {% block title %}{{ object }}{% endblock %}
- {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %}
+
{% plugin_full_width_page object %}
-
- {% if form.custom_fields %}
- Power Panel
-
- {% render_field form.region %}
- {% render_field form.site %}
- {% render_field form.rack_group %}
- {% render_field form.name %}
- {% render_field form.tags %}
-
-
-
- {% endif %}
-{% endblock %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html
index 7356bafb922..1a7cc24e76c 100644
--- a/netbox/templates/dcim/powerport.html
+++ b/netbox/templates/dcim/powerport.html
@@ -2,6 +2,12 @@
{% load helpers %}
{% load plugins %}
+{% block breadcrumbs %}
+ {{ block.super }}
+ Custom Fields
-
- {% render_custom_fields form %}
-
-
@@ -42,6 +48,7 @@
|
Cable | diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6a00308f302..3bb0084a31b 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,74 +1,46 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} {% load static %} {% load plugins %} -{% block header %} - - -|||||||||
Group | +Location |
- {% if object.group %}
- {% for group in object.group.get_ancestors %}
- {{ group }} /
+ {% if object.location %}
+ {% for location in object.location.get_ancestors %}
+ {{ location }} /
{% endfor %}
- {{ object.group }}
+ {{ object.location }}
{% else %}
None
{% endif %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html
index 9ce242191e9..af94a71976a 100644
--- a/netbox/templates/dcim/rack_edit.html
+++ b/netbox/templates/dcim/rack_edit.html
@@ -6,12 +6,19 @@
Rack
{% render_field form.region %}
+ {% render_field form.site_group %}
{% render_field form.site %}
- {% render_field form.group %}
+ {% render_field form.location %}
{% render_field form.name %}
- {% render_field form.facility_id %}
{% render_field form.status %}
{% render_field form.role %}
+ {% render_field form.tags %}
+
+
+
+
{% endif %}
- Inventory Control
+
+ {% render_field form.facility_id %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
@@ -52,12 +59,6 @@
-
Tags
-
- {% render_field form.tags %}
-
- Comments
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html
index 5273b2a3907..06e14e7db65 100644
--- a/netbox/templates/dcim/rackreservation.html
+++ b/netbox/templates/dcim/rackreservation.html
@@ -1,56 +1,23 @@
-{% extends 'base.html' %}
+{% extends 'generic/object.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load static %}
{% load plugins %}
-{% block header %}
-
-
- {% block title %}{{ object }}{% endblock %}- {% include 'inc/created_updated.html' %} - - +{% block breadcrumbs %} +{% block title %}{{ object }}{% endblock %}{% if rack.site.region %} {{ rack.site.region }} / {% endif %} - {{ rack.site }} + {{ rack.site }} |
|||||||
Group |
{% if rack.group %}
- {{ rack.group }}
+ {{ rack.group }}
{% else %}
None
{% endif %}
diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html
deleted file mode 100644
index 1465dc02deb..00000000000
--- a/netbox/templates/dcim/rackreservation_edit.html
+++ /dev/null
@@ -1,33 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-
-
- Rack Reservation
-
- {% render_field form.region %}
- {% render_field form.site %}
- {% render_field form.rack_group %}
- {% render_field form.rack %}
- {% render_field form.units %}
- {% render_field form.user %}
- {% render_field form.description %}
- {% render_field form.tags %}
-
-
-
- {% if form.custom_fields %}
- Tenant Assignment
-
- {% render_field form.tenant_group %}
- {% render_field form.tenant %}
-
-
-
- {% endif %}
-{% endblock %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html
new file mode 100644
index 00000000000..89306b481ea
--- /dev/null
+++ b/netbox/templates/dcim/rackrole.html
@@ -0,0 +1,66 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+ Custom Fields
-
- {% render_custom_fields form %}
-
-
+
+
+
+
+
+ {% plugin_left_page object %}
+
+ Rack Role
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_right_page object %}
+
+
+
+{% endblock %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html
index 3198503973b..eb9452a0c18 100644
--- a/netbox/templates/dcim/rearport.html
+++ b/netbox/templates/dcim/rearport.html
@@ -2,6 +2,12 @@
{% load helpers %}
{% load plugins %}
+{% block breadcrumbs %}
+ {{ block.super }}
+
+
+
+
+ {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %}
+ {% plugin_full_width_page object %}
+
+ Racks
+
+ {% include 'inc/table.html' with table=racks_table %}
+ {% if perms.dcim.add_rack %}
+
+ {% endif %}
+
@@ -38,6 +44,7 @@
|
Cable | diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html new file mode 100644 index 00000000000..1e2d395ddc5 --- /dev/null +++ b/netbox/templates/dcim/region.html @@ -0,0 +1,86 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Parent | ++ {% if object.parent %} + {{ object.parent }} + {% else %} + — + {% endif %} + | +
Sites | ++ {{ sites_table.rows|length }} + | +
Location | +Racks | +Devices | ++ | |
---|---|---|---|---|
{{ rg }} | -{{ rg.rack_count }} | ++ {{ location }} + | ++ {{ location.rack_count }} | ++ {{ location.device_count }} + |
All racks | -{{ stats.rack_count }} | - -
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
Parent | ++ {% if object.parent %} + {{ object.parent }} + {% else %} + — + {% endif %} + | +
Sites | ++ {{ sites_table.rows|length }} + | +
Object | ++ {{ object.assigned_object }} + | +
Created | ++ {{ object.created }} + | +
Created By | ++ {{ object.created_by }} + | +
Kind | ++ {{ object.get_kind_display }} + | +
{% for k, v in object.prechange_data.items %}{% spaceless %}
+ {{ k }}: {{ v|render_json }}
+ {% endspaceless %}
+{% endfor %}
+ {% else %}
+ None
+ {% endif %}
+ {{ object.object_data|render_json }}+ {% if object.postchange_data %} +
{% for k, v in object.postchange_data.items %}{% spaceless %}
+ {{ k }}: {{ v|render_json }}
+ {% endspaceless %}
+{% endfor %}
+ {% else %}
+ None
+ {% endif %}
{{ report.description }}
{% endif %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 3f083951227..7a99d245dfd 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -15,7 +15,7 @@ -{{ script.Meta.description|render_markdown }}