Просмотр исходного кода

fix(api): Handle plugin models without REST serializers in GFK fields

Adds allow_missing_serializer=True to GFKSerializerField instances
across serializers for bookmarks, contacts, events, attachments,
journals, and notifications. Returns null for object fields when related
model lacks REST API serializer.
Includes regression tests using DummyNetBoxModel.

Fixes #23372
Martin Hauser 2 дней назад
Родитель
Сommit
9a9770995d

+ 2 - 1
netbox/extras/api/serializers_/attachments.py

@@ -16,7 +16,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.all()
     )
-    parent = GFKSerializerField(read_only=True)
+    # A plugin model can support image attachments without a REST API serializer
+    parent = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     image_width = serializers.IntegerField(read_only=True)
     image_height = serializers.IntegerField(read_only=True)
     image_size = serializers.IntegerField(read_only=True)

+ 2 - 1
netbox/extras/api/serializers_/bookmarks.py

@@ -14,7 +14,8 @@ class BookmarkSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.with_feature('bookmarks'),
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support bookmarks without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     user = UserSerializer(nested=True)
 
     class Meta:

+ 2 - 1
netbox/extras/api/serializers_/events.py

@@ -30,7 +30,8 @@ class EventRuleSerializer(OwnerMixin, NetBoxModelSerializer):
         required=False,
         allow_null=True,
     )
-    action_object = GFKSerializerField(read_only=True)
+    # A plugin action can target a model without a REST API serializer
+    action_object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     action_is_available = serializers.BooleanField(read_only=True)
 
     class Meta:

+ 2 - 1
netbox/extras/api/serializers_/journaling.py

@@ -18,7 +18,8 @@ class JournalEntrySerializer(NetBoxModelSerializer):
     assigned_object_type = ContentTypeField(
         queryset=ObjectType.objects.all()
     )
-    assigned_object = GFKSerializerField(read_only=True)
+    # A plugin model can support journaling without a REST API serializer
+    assigned_object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     created_by = serializers.PrimaryKeyRelatedField(
         allow_null=True,
         queryset=User.objects.all(),

+ 4 - 2
netbox/extras/api/serializers_/notifications.py

@@ -17,7 +17,8 @@ class NotificationSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.with_feature('notifications'),
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support notifications without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     user = UserSerializer(nested=True)
 
     class Meta:
@@ -56,7 +57,8 @@ class SubscriptionSerializer(ValidatedModelSerializer):
     object_type = ContentTypeField(
         queryset=ObjectType.objects.with_feature('notifications'),
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support notifications without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     user = UserSerializer(nested=True)
 
     class Meta:

+ 169 - 0
netbox/extras/tests/test_api.py

@@ -38,6 +38,7 @@ from extras.scripts import BooleanVar, IntegerVar, MultiObjectVar, ObjectVar, St
 from extras.scripts import Script as PythonClass
 from netbox.event_rules import EventRuleAction, register_event_rule_action
 from netbox.registry import registry
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from users.constants import TOKEN_PREFIX
 from users.models import Group, ObjectPermission, Token, User
 from utilities.tables import get_table_for_model
@@ -303,6 +304,40 @@ class EventRuleActionAPITestCase(APITestCase):
         )
         self.assertIn(user_ct, field.queryset)
 
+    @tag('regression')  # Ref: #23372
+    def test_action_object_without_serializer(self):
+        """A rule whose action targets an object without a REST API serializer has a null action object."""
+        class PluginObjectAction(EventRuleAction):
+            slug = 'test.api_plugin_object_action'
+            label = 'API Plugin Object Action'
+            object_model = DummyNetBoxModel
+
+        register_event_rule_action(PluginObjectAction)
+        self.addCleanup(registry['event_rule_actions'].pop, PluginObjectAction.slug, None)
+
+        self.add_permissions('extras.view_eventrule')
+        webhook = Webhook.objects.create(name='Webhook 1', payload_url='http://example.com/')
+        EventRule.objects.create(name='Webhook Rule', event_types=[OBJECT_CREATED], action_object=webhook)
+        dummy = DummyNetBoxModel.objects.create()
+        event_rule = EventRule(
+            name='Plugin Rule', event_types=[OBJECT_CREATED], action_type=PluginObjectAction.slug, action_object=dummy
+        )
+        event_rule.full_clean()
+        event_rule.save()
+
+        response = self.client.get(reverse('extras-api:eventrule-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(event_rule.pk)['action_object'])
+        for item in results.values():
+            self.assertEqual(item['action_object']['id'], item['action_object_id'])
+
+        response = self.client.get(reverse('extras-api:eventrule-detail', kwargs={'pk': event_rule.pk}), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['action_object'])
+        self.assertEqual(response.data['action_object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['action_object_id'], dummy.pk)
+
 
 class CustomFieldTestCase(APIViewTestCases.APIViewTestCase):
     model = CustomField
@@ -952,6 +987,28 @@ class BookmarkTestCase(
             },
         ]
 
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A bookmark of an object without a REST API serializer has a null object."""
+        self.add_permissions('extras.view_bookmark')
+        dummy = DummyNetBoxModel.objects.create()
+        bookmark = Bookmark(object=dummy, user=self.user)
+        bookmark.full_clean()
+        bookmark.save()
+
+        response = self.client.get(reverse('extras-api:bookmark-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(bookmark.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(reverse('extras-api:bookmark-detail', kwargs={'pk': bookmark.pk}), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class ExportTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
@@ -1427,6 +1484,37 @@ class ImageAttachmentTestCase(
         )
         ImageAttachment.objects.bulk_create(image_attachments)
 
+    @tag('regression')  # Ref: #23372
+    def test_parent_without_serializer(self):
+        """An image attachment whose parent has no REST API serializer has a null parent."""
+        self.add_permissions('extras.view_imageattachment')
+        dummy = DummyNetBoxModel.objects.create()
+        image_attachment = ImageAttachment(
+            parent=dummy,
+            name='Image Attachment 4',
+            image='http://example.com/image4.png',
+            image_height=100,
+            image_width=100,
+        )
+        # The placeholder image is not a stored file
+        image_attachment.full_clean(exclude=['image'])
+        image_attachment.save()
+
+        response = self.client.get(reverse('extras-api:imageattachment-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(image_attachment.pk)['parent'])
+        for item in results.values():
+            self.assertEqual(item['parent']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('extras-api:imageattachment-detail', kwargs={'pk': image_attachment.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['parent'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
     model = JournalEntry
@@ -1505,6 +1593,39 @@ class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
         created_by_after = entry.created_by_id
         self.assertEqual(created_by_before, created_by_after)
 
+    @tag('regression')  # Ref: #23372
+    def test_assigned_object_without_serializer(self):
+        """A journal entry on an object without a REST API serializer has a null assigned object."""
+        self.add_permissions('extras.add_journalentry', 'extras.view_journalentry', 'extras.delete_journalentry')
+        dummy = DummyNetBoxModel.objects.create()
+        data = {
+            'assigned_object_type': 'dummy_plugin.dummynetboxmodel',
+            'assigned_object_id': dummy.pk,
+            'comments': 'Seventh entry',
+        }
+
+        response = self.client.post(reverse('extras-api:journalentry-list'), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertIsNone(response.data['assigned_object'])
+        journal_entry_id = response.data['id']
+        detail_url = reverse('extras-api:journalentry-detail', kwargs={'pk': journal_entry_id})
+
+        response = self.client.get(reverse('extras-api:journalentry-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(journal_entry_id)['assigned_object'])
+        for item in results.values():
+            self.assertEqual(item['assigned_object']['id'], item['assigned_object_id'])
+
+        response = self.client.get(detail_url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['assigned_object'])
+        self.assertEqual(response.data['assigned_object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['assigned_object_id'], dummy.pk)
+
+        response = self.client.delete(detail_url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+
 
 class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
     model = ConfigContextProfile
@@ -2815,6 +2936,30 @@ class SubscriptionTestCase(APIViewTestCases.APIViewTestCase):
             'object_type': 'dcim.notamodel',
         }
 
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A subscription to an object without a REST API serializer has a null object."""
+        self.add_permissions('extras.view_subscription')
+        dummy = DummyNetBoxModel.objects.create()
+        subscription = Subscription(object=dummy, user=self.user)
+        subscription.full_clean()
+        subscription.save()
+
+        response = self.client.get(reverse('extras-api:subscription-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(subscription.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('extras-api:subscription-detail', kwargs={'pk': subscription.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class NotificationGroupTestCase(APIViewTestCases.APIViewTestCase):
     model = NotificationGroup
@@ -2965,6 +3110,30 @@ class NotificationTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A notification about an object without a REST API serializer has a null object."""
+        self.add_permissions('extras.view_notification')
+        dummy = DummyNetBoxModel.objects.create()
+        notification = Notification(object=dummy, object_repr=str(dummy), event_type=OBJECT_CREATED, user=self.user)
+        notification.full_clean()
+        notification.save()
+
+        response = self.client.get(reverse('extras-api:notification-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(notification.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('extras-api:notification-detail', kwargs={'pk': notification.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)
+
 
 class _InMemoryScriptStorage:
     """Stateful stand-in for the scripts storage backend; mirrors its allow_overwrite option."""

+ 2 - 1
netbox/netbox/tests/dummy_plugin/models.py

@@ -1,6 +1,7 @@
 from django.db import models
 
 from netbox.models import NetBoxModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.querysets import RestrictedQuerySet
 
 
@@ -32,5 +33,5 @@ class DummySiteAttachment(models.Model):
         ordering = ['name']
 
 
-class DummyNetBoxModel(NetBoxModel):
+class DummyNetBoxModel(ContactsMixin, ImageAttachmentsMixin, NetBoxModel):
     pass

+ 2 - 1
netbox/tenancy/api/serializers_/contacts.py

@@ -68,7 +68,8 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
     object_type = ContentTypeField(
         queryset=ContentType.objects.all()
     )
-    object = GFKSerializerField(read_only=True)
+    # A plugin model can support contacts without a REST API serializer
+    object = GFKSerializerField(read_only=True, allow_missing_serializer=True)
     contact = ContactSerializer(nested=True)
     role = ContactRoleSerializer(nested=True, required=False, allow_null=True)
     priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default=lambda: '')

+ 27 - 0
netbox/tenancy/tests/test_api.py

@@ -6,6 +6,7 @@ from django.urls import reverse
 from rest_framework import status
 
 from dcim.models import Site
+from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from tenancy.choices import *
 from tenancy.models import *
 from utilities.testing import APITestCase, APIViewTestCases, disable_logging
@@ -368,3 +369,29 @@ class ContactAssignmentTestCase(APIViewTestCases.APIViewTestCase):
                 'priority': ContactPriorityChoices.PRIORITY_TERTIARY,
             },
         ]
+
+    @tag('regression')  # Ref: #23372
+    def test_object_without_serializer(self):
+        """A contact assignment for an object without a REST API serializer has a null object."""
+        self.add_permissions('tenancy.view_contactassignment')
+        dummy = DummyNetBoxModel.objects.create()
+        contact_assignment = ContactAssignment(
+            object=dummy, contact=Contact.objects.first(), role=ContactRole.objects.first()
+        )
+        contact_assignment.full_clean()
+        contact_assignment.save()
+
+        response = self.client.get(reverse('tenancy-api:contactassignment-list'), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        results = {item['id']: item for item in response.data['results']}
+        self.assertIsNone(results.pop(contact_assignment.pk)['object'])
+        for item in results.values():
+            self.assertEqual(item['object']['id'], item['object_id'])
+
+        response = self.client.get(
+            reverse('tenancy-api:contactassignment-detail', kwargs={'pk': contact_assignment.pk}), **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIsNone(response.data['object'])
+        self.assertEqual(response.data['object_type'], 'dummy_plugin.dummynetboxmodel')
+        self.assertEqual(response.data['object_id'], dummy.pk)