diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 665c291..9310bd4 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -14,6 +14,7 @@ from servala.core.models import ( ServiceCategory, ServiceDefinition, ServiceOffering, + ServiceOfferingControlPlane, User, ) @@ -176,7 +177,7 @@ class PlanAdmin(admin.ModelAdmin): class ServiceDefinitionAdmin(admin.ModelAdmin): form = ServiceDefinitionAdminForm list_display = ("name", "service") - list_filter = ("service", "control_planes") + list_filter = ("service",) search_fields = ("name", "description") autocomplete_fields = ("service",) @@ -199,11 +200,27 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): return ["api_definition"] +class ServiceOfferingControlPlaneInline(admin.TabularInline): + model = ServiceOfferingControlPlane + extra = 1 + autocomplete_fields = ("control_plane", "service_definition") + + +@admin.register(ServiceOfferingControlPlane) +class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin): + list_display = ("service_offering", "control_plane", "service_definition") + list_filter = ("service_offering", "control_plane", "service_definition") + search_fields = ("service_offering__service__name", "control_plane__name") + autocomplete_fields = ("service_offering", "control_plane", "service_definition") + + @admin.register(ServiceOffering) class ServiceOfferingAdmin(admin.ModelAdmin): list_display = ("id", "service", "provider") - list_filter = ("service", "provider", "control_planes") + list_filter = ("service", "provider") search_fields = ("description",) autocomplete_fields = ("service", "provider") - filter_horizontal = ("control_planes",) - inlines = (PlanInline,) + inlines = ( + ServiceOfferingControlPlaneInline, + PlanInline, + ) diff --git a/src/servala/core/migrations/0007_service_definition.py b/src/servala/core/migrations/0007_service_definition.py new file mode 100644 index 0000000..be0fac6 --- /dev/null +++ b/src/servala/core/migrations/0007_service_definition.py @@ -0,0 +1,115 @@ +# Generated by Django 5.2b1 on 2025-03-25 11:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0006_service_slug"), + ] + + operations = [ + migrations.RemoveField( + model_name="serviceoffering", + name="control_plane", + ), + migrations.CreateModel( + name="ServiceDefinition", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Name")), + ( + "description", + models.TextField(blank=True, verbose_name="Description"), + ), + ( + "api_definition", + models.JSONField( + blank=True, + help_text="Contains group, version, and kind information", + null=True, + verbose_name="API Definition", + ), + ), + ( + "service", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="service_definitions", + to="core.service", + verbose_name="Service", + ), + ), + ], + options={ + "verbose_name": "Service definition", + "verbose_name_plural": "Service definitions", + }, + ), + migrations.CreateModel( + name="ServiceOfferingControlPlane", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "control_plane", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="offering_connections", + to="core.controlplane", + verbose_name="Control plane", + ), + ), + ( + "service_definition", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="offering_control_planes", + to="core.servicedefinition", + verbose_name="Service definition", + ), + ), + ( + "service_offering", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="control_plane_connections", + to="core.serviceoffering", + verbose_name="Service offering", + ), + ), + ], + options={ + "verbose_name": "Service offering control plane connection", + "verbose_name_plural": "Service offering control planes connections", + "unique_together": {("service_offering", "control_plane")}, + }, + ), + migrations.AddField( + model_name="serviceoffering", + name="control_planes", + field=models.ManyToManyField( + related_name="offerings", + through="core.ServiceOfferingControlPlane", + to="core.controlplane", + verbose_name="Control planes", + ), + ), + ] diff --git a/src/servala/core/migrations/0007_service_definitions.py b/src/servala/core/migrations/0007_service_definitions.py deleted file mode 100644 index bdc11e1..0000000 --- a/src/servala/core/migrations/0007_service_definitions.py +++ /dev/null @@ -1,72 +0,0 @@ -# Generated by Django 5.2b1 on 2025-03-24 14:41 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0006_service_slug"), - ] - - operations = [ - migrations.RenameField( - model_name="serviceoffering", - old_name="control_plane", - new_name="control_planes", - ), - migrations.CreateModel( - name="ServiceDefinition", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, verbose_name="Name")), - ( - "description", - models.TextField(blank=True, verbose_name="Description"), - ), - ( - "api_definition", - models.JSONField( - blank=True, - help_text="Contains group, version, and kind information", - null=True, - verbose_name="API Definition", - ), - ), - ( - "service", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="service_definitions", - to="core.service", - verbose_name="Service", - ), - ), - ], - options={ - "verbose_name": "Service definition", - "verbose_name_plural": "Service definitions", - }, - ), - migrations.AddField( - model_name="controlplane", - name="service_definition", - field=models.ForeignKey( - default=1, - on_delete=django.db.models.deletion.PROTECT, - related_name="control_planes", - to="core.servicedefinition", - verbose_name="Service definition", - ), - preserve_default=False, - ), - ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index b4ddf21..6d5b24d 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -13,6 +13,7 @@ from .service import ( ServiceCategory, ServiceDefinition, ServiceOffering, + ServiceOfferingControlPlane, ) from .user import User @@ -29,5 +30,6 @@ __all__ = [ "ServiceCategory", "ServiceDefinition", "ServiceOffering", + "ServiceOfferingControlPlane", "User", ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 330e077..f1dc20b 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -103,12 +103,6 @@ class ControlPlane(models.Model): related_name="control_planes", verbose_name=_("Cloud provider"), ) - service_definition = models.ForeignKey( - to="ServiceDefinition", - related_name="control_planes", - verbose_name=_("Service definition"), - on_delete=models.PROTECT, - ) class Meta: verbose_name = _("Control plane") @@ -263,6 +257,18 @@ class ServiceDefinition(models.Model): verbose_name=_("Service"), ) + @property + def control_planes(self): + return ControlPlane.objects.filter( + offering_connections__service_definition=self + ).distinct() + + @property + def service_offerings(self): + return ServiceOffering.objects.filter( + control_plane_connections__service_definition=self + ).distinct() + class Meta: verbose_name = _("Service definition") verbose_name_plural = _("Service definitions") @@ -271,6 +277,40 @@ class ServiceDefinition(models.Model): return self.name +class ServiceOfferingControlPlane(models.Model): + """ + Each combination of ServiceOffering and ControlPlane can have a different + ServiceDefinition, which is here modeled as the "through" model. + """ + + service_offering = models.ForeignKey( + to="ServiceOffering", + on_delete=models.CASCADE, + related_name="control_plane_connections", + verbose_name=_("Service offering"), + ) + control_plane = models.ForeignKey( + to="ControlPlane", + on_delete=models.CASCADE, + related_name="offering_connections", + verbose_name=_("Control plane"), + ) + service_definition = models.ForeignKey( + to="ServiceDefinition", + on_delete=models.PROTECT, + related_name="offering_control_planes", + verbose_name=_("Service definition"), + ) + + class Meta: + verbose_name = _("Service offering control plane connection") + verbose_name_plural = _("Service offering control planes connections") + unique_together = [("service_offering", "control_plane")] + + def __str__(self): + return f"{self.service_offering} on {self.control_plane} with {self.service_definition}" + + class ServiceOffering(models.Model): """ A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP". @@ -290,6 +330,7 @@ class ServiceOffering(models.Model): ) control_planes = models.ManyToManyField( to="ControlPlane", + through="ServiceOfferingControlPlane", related_name="offerings", verbose_name=_("Control planes"), )