diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index f9c5e97..ea2f806 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -11,8 +11,8 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.12"] - netbox-version: ["v4.5.5"] + python-version: ["3.13"] + netbox-version: ["v4.5.10", "v4.6.2"] services: redis: image: redis diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index 994fbce..5d5da3f 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -15,7 +15,7 @@ jobs: max-parallel: 4 matrix: python-version: ["3.13"] - netbox-version: ["v4.5.10", "v4.6.1"] + netbox-version: ["v4.5.10", "v4.6.2"] services: redis: image: redis diff --git a/netbox_docker_plugin/__init__.py b/netbox_docker_plugin/__init__.py index 337c7f0..411b174 100644 --- a/netbox_docker_plugin/__init__.py +++ b/netbox_docker_plugin/__init__.py @@ -11,7 +11,7 @@ class NetBoxDockerConfig(PluginConfig): name = "netbox_docker_plugin" verbose_name = " NetBox Docker Plugin" description = "Manage Docker" - version = "5.2.0" + version = "5.3.0" base_url = "docker" min_version = "4.5.0" author = "Vincent Simonin , David Delassus " diff --git a/netbox_docker_plugin/api/serializers.py b/netbox_docker_plugin/api/serializers.py index 403bded..0cada78 100644 --- a/netbox_docker_plugin/api/serializers.py +++ b/netbox_docker_plugin/api/serializers.py @@ -6,6 +6,7 @@ from utilities.query import dict_to_filter_params from users.models import Token from virtualization.api.serializers import VirtualMachineSerializer +from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from ..models.host import Host from ..models.image import Image @@ -240,6 +241,8 @@ class ImageSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name="plugins-api:netbox_docker_plugin-api:image-detail" ) + tenant_group = TenantGroupSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) host = NestedHostSerializer() containers = NestedContainerSerializer(many=True, read_only=True) registry = NestedRegistrySerializer() @@ -252,6 +255,8 @@ class Meta: "id", "url", "display", + "tenant_group", + "tenant", "host", "name", "version", diff --git a/netbox_docker_plugin/filtersets.py b/netbox_docker_plugin/filtersets.py index 8674cd2..c2b2ffc 100644 --- a/netbox_docker_plugin/filtersets.py +++ b/netbox_docker_plugin/filtersets.py @@ -2,6 +2,7 @@ from django_filters import filters, ModelMultipleChoiceFilter from django.db.models import Q +from tenancy.models import Tenant, TenantGroup from netbox.filtersets import NetBoxModelFilterSet, BaseFilterSet from .models.host import Host from .models.image import Image @@ -77,6 +78,16 @@ class ImageFilterSet(NetBoxModelFilterSet): """Image filterset definition class""" name = filters.CharFilter(lookup_expr="icontains") + tenant_group_id = ModelMultipleChoiceFilter( + field_name="tenant_group_id", + queryset=TenantGroup.objects.all(), + label="Tenant Group (ID)", + ) + tenant_id = ModelMultipleChoiceFilter( + field_name="tenant_id", + queryset=Tenant.objects.all(), + label="Tenant (ID)", + ) host_id = ModelMultipleChoiceFilter( field_name="host_id", queryset=Host.objects.all(), diff --git a/netbox_docker_plugin/forms/image.py b/netbox_docker_plugin/forms/image.py index 36360fb..594bb03 100644 --- a/netbox_docker_plugin/forms/image.py +++ b/netbox_docker_plugin/forms/image.py @@ -7,6 +7,7 @@ DynamicModelMultipleChoiceField, DynamicModelChoiceField, ) +from tenancy.models import Tenant, TenantGroup from netbox.forms import ( NetBoxModelForm, NetBoxModelImportForm, @@ -21,6 +22,13 @@ class ImageForm(NetBoxModelForm): """Image form definition class""" + tenant_group = DynamicModelChoiceField( + label="Tenant Group", queryset=TenantGroup.objects.all(), required=False + ) + tenant = DynamicModelChoiceField( + label="Tenant", queryset=Tenant.objects.all(), required=False, + query_params={"group_id": "$tenant_group"}, + ) host = DynamicModelChoiceField( label="Host", queryset=Host.objects.all(), required=True ) @@ -31,11 +39,18 @@ class ImageForm(NetBoxModelForm): query_params={"host_id": "$host"}, ) + fieldsets = ( + FieldSet("host", "registry", "name", "version", "tags", name="General"), + FieldSet("tenant_group", "tenant", name="Tenancy"), + ) + class Meta: """Image form definition Meta class""" model = Image fields = ( + "tenant_group", + "tenant", "host", "registry", "name", @@ -44,6 +59,8 @@ class Meta: ) labels = { "name": "Name", + "tenant_group": "Tenant Group", + "tenant": "Tenant", "host": "Host", "registry": "Registry", "version": "Version", @@ -54,6 +71,16 @@ class ImageFilterForm(NetBoxModelFilterSetForm): """Image filter form definition class""" model = Image + tenant_group_id = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + label="Tenant Group", + ) + tenant_id = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + label="Tenant", + ) name = forms.CharField(label="Name", max_length=256, min_length=1, required=False) version = forms.CharField( label="Version", max_length=256, min_length=1, required=False @@ -97,9 +124,20 @@ class Meta: class ImageBulkEditForm(NetBoxModelBulkEditForm): """Image bulk edit form definition class""" + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + ) version = forms.CharField( required=False, ) model = Image - fieldsets = (FieldSet("version", name="General"),) + fieldsets = ( + FieldSet("version", name="General"), + FieldSet("tenant_group", "tenant", name="Tenancy"), + ) diff --git a/netbox_docker_plugin/migrations/1045_image_tenant_image_tenant_group.py b/netbox_docker_plugin/migrations/1045_image_tenant_image_tenant_group.py new file mode 100644 index 0000000..e6e6021 --- /dev/null +++ b/netbox_docker_plugin/migrations/1045_image_tenant_image_tenant_group.py @@ -0,0 +1,38 @@ +# pylint: disable=C0103 +"""Migration file""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration file""" + + dependencies = [ + ("netbox_docker_plugin", "1044_host_virtual_machine"), + ] + + operations = [ + migrations.AddField( + model_name="image", + name="tenant", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="images", + to="tenancy.tenant", + ), + ), + migrations.AddField( + model_name="image", + name="tenant_group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="images", + to="tenancy.tenantgroup", + ), + ), + ] diff --git a/netbox_docker_plugin/models/image.py b/netbox_docker_plugin/models/image.py index 5a3a050..a50c098 100644 --- a/netbox_docker_plugin/models/image.py +++ b/netbox_docker_plugin/models/image.py @@ -8,6 +8,7 @@ MinValueValidator, MaxValueValidator, ) +from tenancy.models import Tenant, TenantGroup from netbox.models import NetBoxModel from .host import Host from .registry import Registry @@ -16,6 +17,20 @@ class Image(NetBoxModel): """Image definition class""" + tenant_group = models.ForeignKey( + TenantGroup, + on_delete=models.SET_NULL, + related_name="images", + blank=True, + null=True, + ) + tenant = models.ForeignKey( + Tenant, + on_delete=models.SET_NULL, + related_name="images", + blank=True, + null=True, + ) host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="images") registry = models.ForeignKey( Registry, diff --git a/netbox_docker_plugin/tables.py b/netbox_docker_plugin/tables.py index 161cd0f..de6cecd 100644 --- a/netbox_docker_plugin/tables.py +++ b/netbox_docker_plugin/tables.py @@ -141,6 +141,8 @@ class Meta(NetBoxTable.Meta): class ImageTable(NetBoxTable): """Image Table definition class""" + tenant_group = tables.Column(linkify=True) + tenant = tables.Column(linkify=True) host = tables.Column(linkify=True) registry = tables.Column(linkify=True) name = tables.Column(linkify=True) @@ -162,6 +164,8 @@ class Meta(NetBoxTable.Meta): fields = ( "pk", "id", + "tenant_group", + "tenant", "host", "name", "version", @@ -172,6 +176,8 @@ class Meta(NetBoxTable.Meta): "tags", ) default_columns = ( + "tenant_group", + "tenant", "host", "name", "version", diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/image.html b/netbox_docker_plugin/templates/netbox_docker_plugin/image.html index 65e5b26..05d78d4 100644 --- a/netbox_docker_plugin/templates/netbox_docker_plugin/image.html +++ b/netbox_docker_plugin/templates/netbox_docker_plugin/image.html @@ -103,6 +103,31 @@

Image

+
+

Tenancy

+ + + + + + + + + +
Tenant Group + {% if object.tenant_group %} + {{ object.tenant_group }} + {% else %} + {{ None|placeholder }} + {% endif %} +
Tenant + {% if object.tenant %} + {{ object.tenant }} + {% else %} + {{ None|placeholder }} + {% endif %} +
+
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% plugin_left_page object %} diff --git a/netbox_docker_plugin/tests/image/test_image_api.py b/netbox_docker_plugin/tests/image/test_image_api.py index 83ad140..304c95e 100644 --- a/netbox_docker_plugin/tests/image/test_image_api.py +++ b/netbox_docker_plugin/tests/image/test_image_api.py @@ -4,6 +4,7 @@ from django.urls import reverse from core.models import ObjectType from rest_framework import status +from tenancy.models import Tenant, TenantGroup from users.models import ObjectPermission from utilities.testing import APIViewTestCases from netbox_docker_plugin.models.host import Host @@ -41,8 +42,17 @@ def setUpTestData(cls) -> None: registry = Registry.objects.filter(name="dockerhub")[0] + tenant_group = TenantGroup(name="Group 1", slug="group-1") + tenant_group.save() + tenant = Tenant.objects.create( + name="Tenant 1", slug="tenant-1", group=tenant_group + ) + Image.objects.create(host=host1, name="image1", registry=registry) - Image.objects.create(host=host1, name="image2", registry=registry) + Image.objects.create( + host=host1, name="image2", registry=registry, + tenant=tenant, tenant_group=tenant_group, + ) Image.objects.create( host=host2, name="image3", @@ -60,6 +70,8 @@ def setUpTestData(cls) -> None: "host": host1.pk, "name": "image5", "registry": registry.pk, + "tenant": tenant.pk, + "tenant_group": tenant_group.pk, }, { "host": host2.pk, diff --git a/netbox_docker_plugin/tests/image/test_image_filtersets.py b/netbox_docker_plugin/tests/image/test_image_filtersets.py new file mode 100644 index 0000000..787622e --- /dev/null +++ b/netbox_docker_plugin/tests/image/test_image_filtersets.py @@ -0,0 +1,89 @@ +"""Image filterset tests for tenant and tenant_group filters.""" + +from django.test import TestCase + +from tenancy.models import Tenant, TenantGroup + +from netbox_docker_plugin.filtersets import ImageFilterSet +from netbox_docker_plugin.models.host import Host +from netbox_docker_plugin.models.image import Image +from netbox_docker_plugin.models.registry import Registry + + +class ImageFilterSetTenancyTestCase(TestCase): + """Test ImageFilterSet filtering by tenant and tenant_group.""" + + queryset = Image.objects.all() + filterset = ImageFilterSet + + @classmethod + def setUpTestData(cls): + host = Host.objects.create(endpoint="http://localhost:8080", name="host1") + registry = Registry.objects.filter(name="dockerhub")[0] + + tenant_groups = ( + TenantGroup(name="Tenant Group 1", slug="tenant-group-1"), + TenantGroup(name="Tenant Group 2", slug="tenant-group-2"), + TenantGroup(name="Tenant Group 3", slug="tenant-group-3"), + ) + for tg in tenant_groups: + tg.save() + + tenants = ( + Tenant(name="Tenant 1", slug="tenant-1", group=tenant_groups[0]), + Tenant(name="Tenant 2", slug="tenant-2", group=tenant_groups[1]), + Tenant(name="Tenant 3", slug="tenant-3", group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + + unassigned_group = TenantGroup(name="Unassigned Group", slug="unassigned-group") + unassigned_group.save() + cls.unassigned_tenant = Tenant.objects.create( + name="Unassigned Tenant", slug="unassigned-tenant" + ) + + Image.objects.create( + name="image1", host=host, registry=registry, + tenant=tenants[0], tenant_group=tenant_groups[0], + ) + Image.objects.create( + name="image2", host=host, registry=registry, + tenant=tenants[1], tenant_group=tenant_groups[1], + ) + Image.objects.create( + name="image3", host=host, registry=registry, + tenant=tenants[2], tenant_group=tenant_groups[2], + ) + + def test_filter_by_tenant_id(self): + """Filter images by one or more tenant IDs.""" + tenants = Tenant.objects.all()[:2] + params = {"tenant_id": [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_filter_by_single_tenant_id(self): + """Filter images by a single tenant ID returns exactly one result.""" + tenant = Tenant.objects.get(slug="tenant-1") + params = {"tenant_id": [tenant.pk]} + qs = self.filterset(params, self.queryset).qs + self.assertEqual(qs.count(), 1) + self.assertEqual(qs.first().name, "image1") + + def test_filter_by_tenant_group_id(self): + """Filter images by one or more tenant group IDs.""" + groups = TenantGroup.objects.all()[:2] + params = {"tenant_group_id": [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_filter_by_single_tenant_group_id(self): + """Filter images by a single tenant group ID returns exactly one result.""" + group = TenantGroup.objects.get(slug="tenant-group-2") + params = {"tenant_group_id": [group.pk]} + qs = self.filterset(params, self.queryset).qs + self.assertEqual(qs.count(), 1) + self.assertEqual(qs.first().name, "image2") + + def test_filter_no_match(self): + """Filtering by a tenant with no images assigned returns an empty queryset.""" + params = {"tenant_id": [self.unassigned_tenant.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) diff --git a/netbox_docker_plugin/tests/image/test_image_tenancy.py b/netbox_docker_plugin/tests/image/test_image_tenancy.py new file mode 100644 index 0000000..7b9ad02 --- /dev/null +++ b/netbox_docker_plugin/tests/image/test_image_tenancy.py @@ -0,0 +1,121 @@ +"""Image ↔ Tenant / TenantGroup relationship tests.""" + +from django.test import TestCase + +from tenancy.models import Tenant, TenantGroup + +from netbox_docker_plugin.models.host import Host +from netbox_docker_plugin.models.image import Image +from netbox_docker_plugin.models.registry import Registry + + +class ImageTenancyTestCase(TestCase): + """Test the optional FK links between Image, Tenant and TenantGroup.""" + + @classmethod + def setUpTestData(cls): + cls.host = Host.objects.create(endpoint="http://localhost:8080", name="host1") + cls.registry = Registry.objects.filter(name="dockerhub")[0] + cls.tenant_group = TenantGroup(name="Group 1", slug="group-1") + cls.tenant_group.save() + cls.tenant = Tenant.objects.create( + name="Tenant 1", slug="tenant-1", group=cls.tenant_group + ) + + def test_image_without_tenancy(self): + """An Image with no tenant or group is valid.""" + image = Image.objects.create( + name="img-no-tenant", host=self.host, registry=self.registry + ) + self.assertIsNone(image.tenant) + self.assertIsNone(image.tenant_group) + + def test_image_with_tenant(self): + """An Image can be linked to a Tenant.""" + image = Image.objects.create( + name="img-tenant", + host=self.host, + registry=self.registry, + tenant=self.tenant, + ) + image.refresh_from_db() + self.assertEqual(image.tenant, self.tenant) + + def test_image_with_tenant_group(self): + """An Image can be linked to a TenantGroup.""" + image = Image.objects.create( + name="img-group", + host=self.host, + registry=self.registry, + tenant_group=self.tenant_group, + ) + image.refresh_from_db() + self.assertEqual(image.tenant_group, self.tenant_group) + + def test_image_with_tenant_and_tenant_group(self): + """An Image can be linked to both a Tenant and a TenantGroup simultaneously.""" + image = Image.objects.create( + name="img-both", + host=self.host, + registry=self.registry, + tenant=self.tenant, + tenant_group=self.tenant_group, + ) + image.refresh_from_db() + self.assertEqual(image.tenant, self.tenant) + self.assertEqual(image.tenant_group, self.tenant_group) + + def test_set_null_on_tenant_delete(self): + """Deleting a Tenant sets the Image.tenant FK to NULL.""" + tenant = Tenant.objects.create(name="Tenant del", slug="tenant-del") + image = Image.objects.create( + name="img-tenant-del", + host=self.host, + registry=self.registry, + tenant=tenant, + ) + tenant.delete() + image.refresh_from_db() + self.assertIsNone(image.tenant) + + def test_set_null_on_tenant_group_delete(self): + """Deleting a TenantGroup sets the Image.tenant_group FK to NULL.""" + group = TenantGroup(name="Group del", slug="group-del") + group.save() + image = Image.objects.create( + name="img-group-del", + host=self.host, + registry=self.registry, + tenant_group=group, + ) + group.delete() + image.refresh_from_db() + self.assertIsNone(image.tenant_group) + + def test_unlink_tenant(self): + """Setting tenant to None removes the link without deleting the Tenant.""" + image = Image.objects.create( + name="img-unlink-tenant", + host=self.host, + registry=self.registry, + tenant=self.tenant, + ) + image.tenant = None + image.save() + image.refresh_from_db() + self.assertIsNone(image.tenant) + self.assertTrue(Tenant.objects.filter(pk=self.tenant.pk).exists()) + + def test_unlink_tenant_group(self): + """Setting tenant_group to None removes the link without deleting the TenantGroup.""" + image = Image.objects.create( + name="img-unlink-group", + host=self.host, + registry=self.registry, + tenant_group=self.tenant_group, + ) + image.tenant_group = None + image.save() + image.refresh_from_db() + self.assertIsNone(image.tenant_group) + self.assertTrue(TenantGroup.objects.filter(pk=self.tenant_group.pk).exists()) diff --git a/netbox_docker_plugin/tests/image/test_image_views.py b/netbox_docker_plugin/tests/image/test_image_views.py index e39864f..46a70c0 100644 --- a/netbox_docker_plugin/tests/image/test_image_views.py +++ b/netbox_docker_plugin/tests/image/test_image_views.py @@ -1,5 +1,6 @@ """Image Views Test Case""" +from tenancy.models import Tenant, TenantGroup from utilities.testing import ViewTestCases from netbox_docker_plugin.tests.base import BaseModelViewTestCase from netbox_docker_plugin.models.host import Host @@ -22,6 +23,12 @@ def setUpTestData(cls): registry = Registry.objects.filter(name="dockerhub")[0] + tenant_group = TenantGroup(name="Group 1", slug="group-1") + tenant_group.save() + tenant = Tenant.objects.create( + name="Tenant 1", slug="tenant-1", group=tenant_group + ) + image1 = Image.objects.create(name="image1", host=host1, registry=registry) image2 = Image.objects.create(name="image2", host=host2, registry=registry) image3 = Image.objects.create(name="image3", host=host3, registry=registry) @@ -29,9 +36,10 @@ def setUpTestData(cls): cls.form_data = { "name": "image1", "version": "v1.2.3", - "provider": "github", "host": host1.pk, "registry": registry.pk, + "tenant_group": tenant_group.pk, + "tenant": tenant.pk, } cls.csv_data = ( @@ -41,7 +49,10 @@ def setUpTestData(cls): f"image6,v1.2.3,{registry.pk},{host3.pk}", ) - cls.bulk_edit_data = {"version": "v1.0.0"} + cls.bulk_edit_data = { + "version": "v1.0.0", + "tenant": tenant.pk, + } cls.csv_update_data = ( "id,version,registry", diff --git a/netbox_docker_plugin/tests/query_counts.json b/netbox_docker_plugin/tests/query_counts.json new file mode 100644 index 0000000..d0d2349 --- /dev/null +++ b/netbox_docker_plugin/tests/query_counts.json @@ -0,0 +1,14 @@ +{ + "container:api_list_objects": 24, + "container:list_objects_with_permission": 22, + "host:api_list_objects": 18, + "host:list_objects_with_permission": 19, + "image:api_list_objects": 18, + "image:list_objects_with_permission": 21, + "network:api_list_objects": 15, + "network:list_objects_with_permission": 20, + "registry:api_list_objects": 14, + "registry:list_objects_with_permission": 20, + "volume:api_list_objects": 15, + "volume:list_objects_with_permission": 20 +} diff --git a/pyproject.toml b/pyproject.toml index a084941..e5b0548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-docker-plugin" -version = "5.2.0" +version = "5.3.0" authors = [ { name="Vincent Simonin", email="vincent@saashup.com" }, { name="David Delassus", email="david.jose.delassus@gmail.com" }