From 5e327892f0511bd7438e0d4f8427f1a4f4d15165 Mon Sep 17 00:00:00 2001
From: Frederico Sequeira <frederico.sequeira@ext.ec.europa.eu>
Date: Thu, 16 Jan 2025 11:17:47 +0000
Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=85=20Add=20API=20test=20for=20provid?=
 =?UTF-8?q?er=20type=20extra=20config?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 netbox_sys_plugin/api/serializers.py          |  10 +
 ...traconfig_assigned_object_type_and_more.py |  25 ++
 netbox_sys_plugin/models/provider.py          |   2 +-
 .../provider_type_extra_config/__init__.py    |   0
 .../test_provider_type_extra_config_api.py    | 233 ++++++++++++++++++
 5 files changed, 269 insertions(+), 1 deletion(-)
 create mode 100644 netbox_sys_plugin/migrations/0003_alter_providertypeextraconfig_assigned_object_type_and_more.py
 create mode 100644 netbox_sys_plugin/tests/provider_type_extra_config/__init__.py
 create mode 100644 netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_api.py

diff --git a/netbox_sys_plugin/api/serializers.py b/netbox_sys_plugin/api/serializers.py
index 2dfb905..84ed14d 100644
--- a/netbox_sys_plugin/api/serializers.py
+++ b/netbox_sys_plugin/api/serializers.py
@@ -123,11 +123,21 @@ class WebhookSettingsSerializer(NetBoxModelSerializer):
 class ProviderTypeExtraConfigSerializer(NetBoxModelSerializer):
     id = serializers.IntegerField(read_only=True)
     extra_config_structure = serializers.JSONField()
+    assigned_object_type = serializers.PrimaryKeyRelatedField(
+        queryset=ContentType.objects.all(),
+        write_only=True,
+        )
+    display = serializers.CharField(source="__str__",read_only=True)
 
     class Meta:
         model = ProviderTypeExtraConfig
         fields = '__all__'
 
+    def create(self, validated_data):
+        assigned_object_type = validated_data.pop("assigned_object_type")
+        validated_data["assigned_object_type"] = assigned_object_type
+        return super().create(validated_data)
+
 class VmAssignedExtraConfigSerializer(NetBoxModelSerializer):
     id = serializers.IntegerField(read_only=True)
     virtual_machine = VirtualMachineSerializer(source='assigned_object', read_only=True)
diff --git a/netbox_sys_plugin/migrations/0003_alter_providertypeextraconfig_assigned_object_type_and_more.py b/netbox_sys_plugin/migrations/0003_alter_providertypeextraconfig_assigned_object_type_and_more.py
new file mode 100644
index 0000000..f684ab6
--- /dev/null
+++ b/netbox_sys_plugin/migrations/0003_alter_providertypeextraconfig_assigned_object_type_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.16 on 2025-01-16 11:16
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('netbox_sys_plugin', '0002_alter_providercredentials_assigned_object_type'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='providertypeextraconfig',
+            name='assigned_object_type',
+            field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(('app_label', 'virtualization'), ('model', 'clustertype'))), null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
+        ),
+        migrations.AlterField(
+            model_name='virtualmachinetype',
+            name='assigned_object_type',
+            field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(('app_label', 'virtualization'), ('model', 'clustertype'))), null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
+        ),
+    ]
diff --git a/netbox_sys_plugin/models/provider.py b/netbox_sys_plugin/models/provider.py
index 12a7871..e6770e0 100644
--- a/netbox_sys_plugin/models/provider.py
+++ b/netbox_sys_plugin/models/provider.py
@@ -9,7 +9,7 @@ from netbox.models import NetBoxModel
 from ..validators import validate_extra_config_structure
 
 CLUSTER_ASSIGNMENT_MODELS = models.Q(models.Q(app_label="virtualization", model="cluster"))
-CLUSTER_TYPE_ASSIGNMENT_MODELS = models.Q(models.Q(app_label="virtualization", model="ClusterType"))
+CLUSTER_TYPE_ASSIGNMENT_MODELS = models.Q(models.Q(app_label="virtualization", model="clustertype"))
 
 #Provider Credentials
 class ProviderCredentials(NetBoxModel):
diff --git a/netbox_sys_plugin/tests/provider_type_extra_config/__init__.py b/netbox_sys_plugin/tests/provider_type_extra_config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_api.py b/netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_api.py
new file mode 100644
index 0000000..fb3071f
--- /dev/null
+++ b/netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_api.py
@@ -0,0 +1,233 @@
+"""SYS Plugin Provider Type Extra Config API Test Case Class"""
+
+from users.models import ObjectPermission
+from django.contrib.contenttypes.models import ContentType
+from rest_framework import status
+from virtualization.models import ClusterType
+from netbox_sys_plugin.models import ProviderTypeExtraConfig
+from ..base import BaseAPITestCase
+
+class ProviderTypeExtraConfigApiTestCase(BaseAPITestCase):
+    """Test suite for ProviderTypeExtraConfig API"""
+    model = ProviderTypeExtraConfig
+    brief_fields = ["extra_config_name","extra_config_structure","extra_config_description", "assigned_object_id", "assigned_object_type"]
+
+
+    @classmethod
+    def setUpTestData(cls):
+
+        """Set up test data for ProviderTypeExtraConfig API"""
+        cls.ct_ct = ContentType.objects.get_for_model(ClusterType)
+
+        # Create a ClusterType
+        cls.cluster_type = ClusterType.objects.create(name="Test ClusterType", slug="CT")
+
+        # Create a ClusterType
+        cls.cluster_type2 = ClusterType.objects.create(name="Test ClusterType 2", slug="CT2")
+
+        # Create a ClusterType
+        cls.cluster_type3 = ClusterType.objects.create(name="Test ClusterType 3", slug="CT3")
+
+        # Create a ClusterType
+        cls.cluster_type4 = ClusterType.objects.create(name="Test ClusterType 4", slug="CT4")
+
+        # Create a ClusterType
+        cls.cluster_type5 = ClusterType.objects.create(name="Test ClusterType 5", slug="CT5")
+
+        # Create a ClusterType
+        cls.cluster_type6 = ClusterType.objects.create(name="Test ClusterType 6", slug="CT6")
+
+        # Create a ClusterType
+        cls.cluster_type7 = ClusterType.objects.create(name="Test ClusterType 7", slug="CT8")
+
+        # Create a ClusterType
+        cls.cluster_type8 = ClusterType.objects.create(name="Test ClusterType 8", slug="CT7")
+
+        # Create Extra Configs entries linked to the Cluster Types
+        ProviderTypeExtraConfig.objects.create(
+            extra_config_name="TEST_1",
+            extra_config_structure={'test_extra1': [{'id': {'required': 'true','type': 'String'}}]},
+            extra_config_description="Test Extra1",
+            assigned_object=cls.cluster_type7
+        )
+        ProviderTypeExtraConfig.objects.create(
+            extra_config_name="TEST_2",
+            extra_config_structure={'test_extra2': [{'id': {'required': 'true','type': 'String'}}]},
+            extra_config_description="Test Extra2",
+            assigned_object=cls.cluster_type8
+        )
+
+        # Data for valid creation
+        cls.valid_create_data = [
+            {
+                "extra_config_name": "Test Config",
+                "extra_config_structure": {'test_valid': [{'id': {'required': 'true','type': 'String'}}]},
+                "extra_config_description": "Test structure",
+                "assigned_object_type": cls.ct_ct.id,
+                "assigned_object_id": cls.cluster_type.pk,
+            }
+        ]
+
+        # Data for invalid creation
+        cls.invalid_create_data = [
+            {
+                "extra_config_name": "Config Invalid 1",
+                "extra_config_structure": {'test_invalid': {'id': {'required': 'true'}}}, #No dict root key
+                "extra_config_description": "Test structure",
+                "assigned_object_id": cls.cluster_type2.pk,
+                "assigned_object_type": cls.ct_ct.id,
+            },
+            {
+                "extra_config_name": "Config Invalid 2",
+                "extra_config_structure": {'test_valid': [{'id': {'type': 'String'}}]}, #No required field
+                "extra_config_description": "Test structure",
+                "assigned_object_id": cls.cluster_type3.pk,
+                "assigned_object_type": cls.ct_ct.id,
+
+            },
+            {
+                "extra_config_name": "Config Invalid 4",
+                "extra_config_structure": {'test_valid': [{'id': {'required': 'true','type': 'String'}}]},
+                "extra_config_description": "Test structure",
+                "assigned_object_id": cls.cluster_type5.pk,
+                "assigned_object_type": None #No assignment field
+            },
+        ]
+
+        # Data for checking  unique key
+        cls.valid_check_unique_data = [
+            {
+                "extra_config_name": "Test Config uniq",
+                "extra_config_structure": {'test_uniq': [{'id': {'required': 'true','type': 'String'}}]},
+                "extra_config_description": "Test structure",
+                "assigned_object_type": cls.ct_ct.pk,
+                "assigned_object_id": cls.cluster_type6.id, # Same Virtual Machine
+            },
+            {
+                "extra_config_name": "Test Config uniq2",
+                "extra_config_structure": {'test_uniq2': [{'id': {'required': 'true','type': 'String'}}]},
+                "extra_config_description": "Test structure",
+                "assigned_object_type": cls.ct_ct.pk,
+                "assigned_object_id": cls.cluster_type6.id, # Same Virtual Machine
+            }
+        ]
+
+    def test_create_valid_extra_config(self):
+        """Test creating a valid extra config"""
+        obj_perm = ObjectPermission(
+            name="Create Extra Config Permission",
+            actions=["add", "view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        form_data = self.valid_create_data[0]
+        response = self.client.post(self._get_list_url(), form_data, format="json", **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data["extra_config_name"], form_data["extra_config_name"])
+        self.assertEqual(response.data["extra_config_structure"], form_data["extra_config_structure"])
+        self.assertEqual(response.data["extra_config_description"], form_data["extra_config_description"])
+
+    def test_extra_config_unique_key(self):
+        """Test extra config unique key"""
+        obj_perm = ObjectPermission(
+            name="Invalid Extra Config Permission",
+            actions=["add", "view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        for form_data in self.valid_check_unique_data:
+            response = self.client.post(self._get_list_url(), form_data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('The fields assigned_object_id must make a unique set.',str(response.data))
+
+    def test_create_invalid_extra_config(self):
+        """Test creating invalid extra config"""
+        obj_perm = ObjectPermission(
+            name="Invalid Extra Config Permission",
+            actions=["add", "view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        form_data = self.invalid_create_data[0]
+        response = self.client.post(self._get_list_url(), form_data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('the value of the root key must be a list',str(response.data))
+
+        form_data = self.invalid_create_data[1]
+        response = self.client.post(self._get_list_url(), form_data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('must contain `type` and `required` keys',str(response.data))
+
+        form_data = self.invalid_create_data[2]
+        response = self.client.post(self._get_list_url(), form_data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('This field may not be null.',str(response.data))
+
+    def test_update_extra_config(self):
+        """Test updating an existing extra config"""
+        extra_config = ProviderTypeExtraConfig.objects.first()
+        obj_perm = ObjectPermission(
+            name="Update Extra Config Permission",
+            actions=["change", "view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        update_data = {"extra_config_name": "Update Data"}
+        response = self.client.patch(self._get_detail_url(extra_config), update_data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data["extra_config_name"], update_data["extra_config_name"])
+
+    def test_get_all_extra_config(self):
+        """Test fetching all extra config"""
+        obj_perm = ObjectPermission(
+            name="Create Extra Config Permission",
+            actions=["view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        response = self.client.get(self._get_list_url(), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertGreaterEqual(len(response.data), 2)
+
+    def test_get_single_extra_config(self):
+        """Test fetching a single extra config"""
+        obj_perm = ObjectPermission(
+            name="View Extra Config Permission",
+            actions=["view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        extra_config = ProviderTypeExtraConfig.objects.first()
+        response = self.client.get(self._get_detail_url(extra_config), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data["extra_config_name"], extra_config.extra_config_name)
+        self.assertEqual(response.data["extra_config_structure"], extra_config.extra_config_structure)
+        self.assertEqual(response.data["extra_config_description"], extra_config.extra_config_description)
+
+    def test_delete_extra_config(self):
+        """Test deleting a extra config"""
+        extra_config = ProviderTypeExtraConfig.objects.first()
+        obj_perm = ObjectPermission(
+            name="Delete Extra Config Permission",
+            actions=["delete"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(ProviderTypeExtraConfig))
+
+        response = self.client.delete(self._get_detail_url(extra_config), **self.header)
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertFalse(ProviderTypeExtraConfig.objects.filter(id=extra_config.id).exists())
-- 
GitLab


From ea4a36d32203b12d3754ccb0c232435153812f1c Mon Sep 17 00:00:00 2001
From: Frederico Sequeira <frederico.sequeira@ext.ec.europa.eu>
Date: Thu, 16 Jan 2025 17:37:24 +0000
Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20Add=20view=20testing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../test_provider_type_extra_config_view.py   | 168 ++++++++++++++++++
 1 file changed, 168 insertions(+)
 create mode 100644 netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_view.py

diff --git a/netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_view.py b/netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_view.py
new file mode 100644
index 0000000..243f050
--- /dev/null
+++ b/netbox_sys_plugin/tests/provider_type_extra_config/test_provider_type_extra_config_view.py
@@ -0,0 +1,168 @@
+"""VM Maintenance Views Test Case Class"""
+
+from django.contrib.contenttypes.models import ContentType
+from users.models import ObjectPermission
+from virtualization.models import ClusterType
+from netbox_sys_plugin.models import ProviderTypeExtraConfig
+from netbox_sys_plugin.forms import ProviderTypeExtraConfigForm
+from .. base import BaseModelViewTestCase
+
+
+
+class ProviderTypeExtraConfigFormTestCase(
+    BaseModelViewTestCase,
+):
+    """Provider Type Extra Config Test Case Class"""
+
+    model = ProviderTypeExtraConfig
+    form = ProviderTypeExtraConfigForm
+
+    def test_create_valid_extra_config(self):
+        """Create a valid Extra Config"""
+
+        cluster_type1 = ClusterType.objects.create(name="Test ClusterType3", slug="ClusterType3")
+
+        form = ProviderTypeExtraConfigForm(data= {
+            "extra_config_name": "test_config_1",
+            "extra_config_structure": {'test_extra1': [{'id': {'required': 'true','type': 'String'}}]},
+            "extra_config_description": "Test Config 1",
+            "cluster_type": cluster_type1.pk
+        })
+        self.assertTrue(form.is_valid())
+        # Setup object permissions for the test user
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add', 'change']
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)  # pylint: disable=no-member
+        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # pylint: disable=no-member
+
+    def test_create_cluster_type_with_two_assigment(self):
+        """Test the assignment of 2 extra configs to the same Cluster Type """
+        # pylint: disable=W0201
+        self.clt_content_type = ContentType.objects.get_for_model(ClusterType)
+        # pylint: disable=W0201
+        self.cluster_type = ClusterType.objects.create(name="Test ClusterType3", slug="ClusterType3")
+        # pylint: disable=W0201
+        self.extra_config1 = ProviderTypeExtraConfig.objects.create(
+            extra_config_name='test_config_3',
+            extra_config_structure={"test_extra3": [{"id": {"required": "true","type": "String"}}]},
+            extra_config_description='Test Config 3',
+
+            assigned_object_type=self.clt_content_type,
+            assigned_object_id=self.cluster_type.pk
+            )
+
+        form = ProviderTypeExtraConfigForm(data= {
+            "extra_config_name": "test_config_2",
+            "extra_config_structure": {"test_extra3": [{"id": {"required": "true","type": "String"}}]},
+            "extra_config_description": "Test Config 2",
+            "cluster_type": self.cluster_type.pk,
+        })
+
+        self.assertFalse(form.is_valid())
+        # Setup object permissions for the test user
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add', 'change']
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)  # pylint: disable=no-member
+        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # pylint: disable=no-member
+
+        self.assertIn(
+            "A Provider Type can only have one extra configuration",
+            form.errors.get("__all__",[])
+        )
+
+    def test_invalid_extra_config_format(self):
+        """
+        Test invalid maintenance window invalid format.
+        """
+
+        # Create VLAN and VLANGroup for testing
+        # pylint: disable=W0201
+        self.vm_content_type = ContentType.objects.get_for_model(ClusterType)
+        # pylint: disable=W0201
+        self.cluster_type = ClusterType.objects.create(name="Test ClusterType3", slug="ClusterType3")
+
+        # Set up valid form data
+        self.invalid_valid_form_data = {
+            "extra_config_name": "test_config_format1",
+            "extra_config_structure": {'test_extra1': [{'id': {'required': 'true','type': 'String'}}]},
+            "extra_config_description": "Test Config format1",
+            "cluster_type": self.cluster_type.pk
+        }
+        #Invalid JSON
+        invalid_form_data = self.invalid_valid_form_data.copy()
+        invalid_form_data["extra_config_structure"] = "Invalid"  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "Enter a valid JSON.",
+            form.errors.get("extra_config_structure", []),
+        )
+        #List Validation
+        invalid_form_data["extra_config_structure"] = [{'test_structure': [{'field1': {'type': 'string', 'required': True}}]}]  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "The structure must be a dictionary.",
+            form.errors.get("extra_config_structure", []),
+        )
+        #Missing property Type
+        invalid_form_data["extra_config_structure"] = {'structure1': [], 'structure2': []}  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "The structure must contain exactly one key.",
+            form.errors.get("extra_config_structure", []),
+        )
+        #Missing property Required
+        invalid_form_data["extra_config_structure"] = {'test_structure': {'field1': {'type': 'string', 'required': True}}}  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "For test_structure the value of the root key must be a list.",
+            form.errors.get("extra_config_structure", []),
+        )
+
+        #Dict Validation
+        invalid_form_data["extra_config_structure"] = {'test_structure': ['field1']}  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "Each field in the list must be a dictionary.",
+            form.errors.get("extra_config_structure", []),
+        )
+        #Only one key
+        invalid_form_data["extra_config_structure"] = {'test_structure': [{'field1': 'string'}]}  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "The field `field1` must be a dictionary.",
+            form.errors.get("extra_config_structure", []),
+        )
+        #Missing property Type
+        invalid_form_data["extra_config_structure"] = {'test_structure': [{'field1': {'required': True}}]}  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "The field `field1` must contain `type` and `required` keys.",
+            form.errors.get("extra_config_structure", []),
+        )
+
+        #Missing property Required
+        invalid_form_data["extra_config_structure"] = {'test_structure': [{'field1': {'type': 'string'}}]}  # Invalid format
+        form = self.form(data=invalid_form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            "The field `field1` must contain `type` and `required` keys.",
+            form.errors.get("extra_config_structure", []),
+        )
+
+    def tearDown(self) -> None:# pylint: disable=invalid-name
+        """Method called immediately after the test method has been called and the result recorded."""
+        ProviderTypeExtraConfig.objects.all().delete()
+        super().tearDown()
-- 
GitLab