{{ 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):
@@ -158,7 +158,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',)
})
)
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index c8c4ba89e..5cb1fc276 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 @@ class CustomFieldsDataField(Field):
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 5635f401b..4acde31ab 100644
--- a/netbox/extras/api/nested_serializers.py
+++ b/netbox/extras/api/nested_serializers.py
@@ -2,24 +2,44 @@ from rest_framework import serializers
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 a85ca05b7..4a1b154d3 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -4,17 +4,16 @@ from drf_yasg.utils import swagger_serializer_method
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.api.nested_serializers import NestedClusterGroupSerializer,
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',
+ ]
#
@@ -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 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
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 ConfigContextSerializer(ValidatedModelSerializer):
# 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 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
# 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 da62b3d72..565f2cdc7 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -5,9 +5,15 @@ from . import views
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 @@ router.register('tags', views.TagViewSet)
# 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 38077c89a..cee5146a6 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -11,9 +11,7 @@ from rq import Worker
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
@@ -21,7 +19,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
-from utilities.utils import copy_safe_request, get_subquery
+from utilities.utils import copy_safe_request, count_related
from . import serializers
@@ -39,7 +37,6 @@ class ConfigContextQuerySetMixin:
Provides a get_queryset() method which deals with adding the config context
data annotation or not.
"""
-
def get_queryset(self):
"""
Build the proper queryset based on the request context
@@ -49,11 +46,22 @@ class ConfigContextQuerySetMixin:
Else, return the queryset annotated with config context data
"""
-
+ queryset = super().get_queryset()
request = self.get_serializer_context()['request']
- if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
- return self.queryset
- return self.queryset.annotate_config_context_data()
+ if self.brief or 'config_context' in request.query_params.get('exclude', []):
+ return queryset
+ 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
#
@@ -85,6 +93,17 @@ class CustomFieldModelViewSet(ModelViewSet):
return context
+#
+# Custom links
+#
+
+class CustomLinkViewSet(ModelViewSet):
+ metadata_class = ContentTypeMetadata
+ queryset = CustomLink.objects.all()
+ serializer_class = serializers.CustomLinkSerializer
+ filterset_class = filters.CustomLinkFilterSet
+
+
#
# Export templates
#
@@ -102,7 +121,7 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(
- tagged_items=get_subquery(TaggedItem, 'tag')
+ tagged_items=count_related(TaggedItem, 'tag')
)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet
@@ -119,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 45f8ac31f..33c70f70d 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 7b341f74d..495e03797 100644
--- a/netbox/extras/filters.py
+++ b/netbox/extras/filters.py
@@ -2,13 +2,14 @@ import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
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__ = (
@@ -16,12 +17,15 @@ __all__ = (
'ContentTypeFilterSet',
'CreatedUpdatedFilterSet',
'CustomFieldFilter',
+ 'CustomLinkFilterSet',
'CustomFieldModelFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
+ 'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'TagFilterSet',
+ 'WebhookFilterSet',
)
EXACT_FILTER_TYPES = (
@@ -32,30 +36,41 @@ EXACT_FILTER_TYPES = (
)
+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.
"""
def __init__(self, custom_field, *args, **kwargs):
self.custom_field = custom_field
+
+ if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
+ self.field_class = IntegerField
+ elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+ self.field_class = NullBooleanField
+ elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
+ self.field_class = DateField
+
super().__init__(*args, **kwargs)
- def filter(self, queryset, value):
+ self.field_name = f'custom_field_data__{self.field_name}'
- # Skip filter on empty value
- if value is None or not value.strip():
- return queryset
-
- # Apply the assigned filter logic (exact or loose)
- if (
- self.custom_field.type in EXACT_FILTER_TYPES or
- self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
- ):
- kwargs = {f'custom_field_data__{self.field_name}': value}
- else:
- kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
-
- return queryset.filter(**kwargs)
+ if custom_field.type not in EXACT_FILTER_TYPES:
+ if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
+ self.lookup_expr = 'icontains'
class CustomFieldModelFilterSet(django_filters.FilterSet):
@@ -81,6 +96,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
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:
@@ -96,6 +118,37 @@ class ImageAttachmentFilterSet(BaseFilterSet):
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',
@@ -131,6 +184,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(),
@@ -142,6 +206,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 eee54076b..4cf5023a3 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -2,25 +2,51 @@ from django import forms
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,
+ 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)
@@ -46,13 +72,13 @@ class CustomFieldModelForm(forms.ModelForm):
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
- def save(self, commit=True):
+ def clean(self):
# Save custom field data on instance
for cf_name in self.custom_fields:
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
- return super().save(commit)
+ return super().clean()
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
@@ -116,6 +142,9 @@ class TagForm(BootstrapMixin, forms.ModelForm):
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 ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
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 = DynamicModelMultipleChoiceField(
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site groups')
+ )
+ 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,78 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
]
+#
+# Journal entries
+#
+
+class JournalEntryForm(BootstrapMixin, forms.ModelForm):
+
+ 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 +460,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 +480,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 +488,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/renaturalize.py b/netbox/extras/management/commands/renaturalize.py
index cfd037910..8ad51966a 100644
--- a/netbox/extras/management/commands/renaturalize.py
+++ b/netbox/extras/management/commands/renaturalize.py
@@ -1,3 +1,4 @@
+from cacheops import invalidate_model
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
@@ -27,7 +28,7 @@ class Command(BaseCommand):
app_label, model_name = name.split('.')
except ValueError:
raise CommandError(
- "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
+ f"Invalid format: {name}. Models must be specified in the form app_label.ModelName."
)
try:
app_config = apps.get_app_config(app_label)
@@ -36,13 +37,13 @@ class Command(BaseCommand):
try:
model = app_config.get_model(model_name)
except LookupError:
- raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
+ raise CommandError(f"Unknown model: {app_label}.{model_name}")
fields = [
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
]
if not fields:
raise CommandError(
- "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
+ f"Invalid model: {app_label}.{model_name} does not employ natural ordering"
)
models.append(
(model, fields)
@@ -67,7 +68,7 @@ class Command(BaseCommand):
models = self._get_models(args)
if options['verbosity']:
- self.stdout.write("Renaturalizing {} models.".format(len(models)))
+ self.stdout.write(f"Renaturalizing {len(models)} models.")
for model, fields in models:
for field in fields:
@@ -78,7 +79,7 @@ class Command(BaseCommand):
# Print the model and field name
if options['verbosity']:
self.stdout.write(
- "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
+ f"{model._meta.label}.{field.target_field} ({field.name})... ",
ending='\n' if options['verbosity'] >= 2 else ''
)
self.stdout.flush()
@@ -89,23 +90,26 @@ class Command(BaseCommand):
naturalized_value = naturalize(value, max_length=field.max_length)
if options['verbosity'] >= 2:
- self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='')
+ self.stdout.write(f" {value} -> {naturalized_value}", ending='')
self.stdout.flush()
# Update each unique field value in bulk
changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
if options['verbosity'] >= 2:
- self.stdout.write(" ({})".format(changed))
+ self.stdout.write(f" ({changed})")
count += changed
# Print the total count of alterations for the field
if options['verbosity'] >= 2:
- self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
- count, model._meta.verbose_name_plural, queryset.count()
- )))
+ self.stdout.write(self.style.SUCCESS(
+ f"{count} {model._meta.verbose_name_plural} updated ({queryset.count()} unique values)"
+ ))
elif options['verbosity']:
self.stdout.write(self.style.SUCCESS(str(count)))
+ # Invalidate cached queries
+ invalidate_model(model)
+
if options['verbosity']:
self.stdout.write(self.style.SUCCESS("Done."))
diff --git a/netbox/extras/management/commands/webhook_receiver.py b/netbox/extras/management/commands/webhook_receiver.py
index b15dc9d27..147e4c261 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 @@ class WebhookHandler(BaseHTTPRequestHandler):
# 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/0051_migrate_customfields.py b/netbox/extras/migrations/0051_migrate_customfields.py
index 2c992c700..41b2febe7 100644
--- a/netbox/extras/migrations/0051_migrate_customfields.py
+++ b/netbox/extras/migrations/0051_migrate_customfields.py
@@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor):
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
try:
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
- except ValueError as e:
+ except Exception as e:
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
raise e
model.objects.filter(pk=cfv.obj_id).update(**cf_data)
diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py
new file mode 100644
index 000000000..c73043345
--- /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 000000000..4dc33fc1c
--- /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 000000000..9c7e2d700
--- /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 000000000..6aba35d9f
--- /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 000000000..14be2a50d
--- /dev/null
+++ b/netbox/extras/migrations/0058_journalentry.py
@@ -0,0 +1,31 @@
+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)),
+ ('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/models/__init__.py b/netbox/extras/models/__init__.py
index c6191bbd2..84676453f 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 52ffd38f9..fac86b641 100644
--- a/netbox/extras/models/change_logging.py
+++ b/netbox/extras/models/change_logging.py
@@ -4,48 +4,12 @@ from django.contrib.contenttypes.models import ContentType
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 @@ class ObjectChange(models.Model):
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 000000000..8c142de8b
--- /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 be06eea8a..8b97877a4 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -1,69 +1,23 @@
import re
-from collections import OrderedDict
-from datetime import datetime
+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 {