diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py
index d718fe0..9310bd4 100644
--- a/src/servala/core/admin.py
+++ b/src/servala/core/admin.py
@@ -1,7 +1,7 @@
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
-from servala.core.forms import ControlPlaneAdminForm
+from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
from servala.core.models import (
BillingEntity,
CloudProvider,
@@ -12,7 +12,9 @@ from servala.core.models import (
Plan,
Service,
ServiceCategory,
+ ServiceDefinition,
ServiceOffering,
+ ServiceOfferingControlPlane,
User,
)
@@ -101,6 +103,7 @@ class ServiceAdmin(admin.ModelAdmin):
list_filter = ("category",)
search_fields = ("name", "description")
autocomplete_fields = ("category",)
+ prepopulated_fields = {"slug": ["name"]}
@admin.register(CloudProvider)
@@ -121,7 +124,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = (
(
None,
- {"fields": ("name", "description", "cloud_provider")},
+ {"fields": ("name", "description", "cloud_provider", "service_definition")},
),
(
_("API Credentials"),
@@ -170,11 +173,54 @@ class PlanAdmin(admin.ModelAdmin):
autocomplete_fields = ("service_offering",)
+@admin.register(ServiceDefinition)
+class ServiceDefinitionAdmin(admin.ModelAdmin):
+ form = ServiceDefinitionAdminForm
+ list_display = ("name", "service")
+ list_filter = ("service",)
+ search_fields = ("name", "description")
+ autocomplete_fields = ("service",)
+
+ fieldsets = (
+ (
+ None,
+ {"fields": ("name", "description", "service")},
+ ),
+ (
+ _("API Definition"),
+ {
+ "fields": ("api_group", "api_version", "api_kind"),
+ "description": _("API definition for the Kubernetes Custom Resource"),
+ },
+ ),
+ )
+
+ def get_exclude(self, request, obj=None):
+ # Exclude the original api_definition field as we're using our custom fields
+ 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_plane")
+ list_filter = ("service", "provider")
search_fields = ("description",)
autocomplete_fields = ("service", "provider")
- filter_horizontal = ("control_plane",)
- inlines = (PlanInline,)
+ inlines = (
+ ServiceOfferingControlPlaneInline,
+ PlanInline,
+ )
diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py
index d7740ae..1fece41 100644
--- a/src/servala/core/forms.py
+++ b/src/servala/core/forms.py
@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
-from servala.core.models import ControlPlane
+from servala.core.models import ControlPlane, ServiceDefinition
class ControlPlaneAdminForm(forms.ModelForm):
@@ -70,3 +70,65 @@ class ControlPlaneAdminForm(forms.ModelForm):
def save(self, *args, **kwargs):
self.instance.api_credentials = self.cleaned_data["api_credentials"]
return super().save(*args, **kwargs)
+
+
+class ServiceDefinitionAdminForm(forms.ModelForm):
+ api_group = forms.CharField(
+ required=False,
+ help_text=_("API Group"),
+ )
+ api_version = forms.CharField(
+ required=False,
+ help_text=_("API Version"),
+ )
+ api_kind = forms.CharField(
+ required=False,
+ help_text=_("API Kind"),
+ )
+
+ class Meta:
+ model = ServiceDefinition
+ fields = "__all__"
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # If we have existing api_definition, populate the individual fields
+ if self.instance.pk and self.instance.api_definition:
+ api_def = self.instance.api_definition
+ self.fields["api_group"].initial = api_def.get("group", "")
+ self.fields["api_version"].initial = api_def.get("version", "")
+ self.fields["api_kind"].initial = api_def.get("kind", "")
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ api_group = cleaned_data.get("api_group")
+ api_version = cleaned_data.get("api_version")
+ api_kind = cleaned_data.get("api_kind")
+
+ if api_group and api_version and api_kind:
+ cleaned_data["api_definition"] = {
+ "group": api_group,
+ "version": api_version,
+ "kind": api_kind,
+ }
+ else:
+ if not (api_group or api_version or api_kind):
+ cleaned_data["api_definition"] = {}
+ else:
+ # Some fields are filled but not all – validation will fail at model level.
+ api_def = {}
+ if api_group:
+ api_def["group"] = api_group
+ if api_version:
+ api_def["version"] = api_version
+ if api_kind:
+ api_def["kind"] = api_kind
+ cleaned_data["api_definition"] = api_def
+
+ return cleaned_data
+
+ def save(self, *args, **kwargs):
+ self.instance.api_definition = self.cleaned_data["api_definition"]
+ return super().save(*args, **kwargs)
diff --git a/src/servala/core/migrations/0006_service_slug.py b/src/servala/core/migrations/0006_service_slug.py
new file mode 100644
index 0000000..f15e796
--- /dev/null
+++ b/src/servala/core/migrations/0006_service_slug.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.2b1 on 2025-03-24 14:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0005_remove_controlplane_k8s_api_endpoint"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="service",
+ name="slug",
+ field=models.SlugField(
+ default="slug", max_length=100, unique=True, verbose_name="URL slug"
+ ),
+ preserve_default=False,
+ ),
+ ]
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/models/__init__.py b/src/servala/core/models/__init__.py
index f637b57..6d5b24d 100644
--- a/src/servala/core/models/__init__.py
+++ b/src/servala/core/models/__init__.py
@@ -11,7 +11,9 @@ from .service import (
Plan,
Service,
ServiceCategory,
+ ServiceDefinition,
ServiceOffering,
+ ServiceOfferingControlPlane,
)
from .user import User
@@ -26,6 +28,8 @@ __all__ = [
"Plan",
"Service",
"ServiceCategory",
+ "ServiceDefinition",
"ServiceOffering",
+ "ServiceOfferingControlPlane",
"User",
]
diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py
index be6737e..e842c36 100644
--- a/src/servala/core/models/organization.py
+++ b/src/servala/core/models/organization.py
@@ -38,6 +38,7 @@ class Organization(ServalaModelMixin, models.Model):
class urls(urlman.Urls):
base = "/org/{self.slug}/"
details = "{base}details/"
+ services = "{base}services/"
@cached_property
def slug(self):
diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py
index 78815da..f1dc20b 100644
--- a/src/servala/core/models/service.py
+++ b/src/servala/core/models/service.py
@@ -44,6 +44,7 @@ class Service(models.Model):
"""
name = models.CharField(max_length=100, verbose_name=_("Name"))
+ slug = models.SlugField(max_length=100, verbose_name=_("URL slug"), unique=True)
category = models.ForeignKey(
to="ServiceCategory",
on_delete=models.PROTECT,
@@ -67,18 +68,13 @@ class Service(models.Model):
return self.name
-def validate_api_credentials(value):
- """
- Validates that api_credentials either contains all required fields or is empty.
- """
- # If empty dict, that's valid
- if not value:
- return
-
- # Check for required fields
- required_fields = ("certificate-authority-data", "server", "token")
- missing_fields = required_fields - set(value)
+def validate_dict(data, required_fields=None, allow_empty=True):
+ if not data:
+ if allow_empty:
+ return
+ raise ValidationError(_("Data may not be empty!"))
+ missing_fields = required_fields - set(data)
if missing_fields:
raise ValidationError(
_("Missing required fields in API credentials: %(fields)s"),
@@ -86,6 +82,11 @@ def validate_api_credentials(value):
)
+def validate_api_credentials(value):
+ required_fields = ("certificate-authority-data", "server", "token")
+ return validate_dict(value, required_fields)
+
+
class ControlPlane(models.Model):
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
@@ -228,6 +229,88 @@ class Plan(models.Model):
return self.name
+def validate_api_definition(value):
+ required_fields = ("group", "version", "kind")
+ return validate_dict(value, required_fields)
+
+
+class ServiceDefinition(models.Model):
+ """
+ Configuration/service implementation: contains information on which
+ CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane.
+
+ Is required in order to query the OpenAPI spec for dynamic form generation.
+ """
+
+ name = models.CharField(max_length=100, verbose_name=_("Name"))
+ description = models.TextField(blank=True, verbose_name=_("Description"))
+ api_definition = models.JSONField(
+ verbose_name=_("API Definition"),
+ help_text=_("Contains group, version, and kind information"),
+ null=True,
+ blank=True,
+ )
+ service = models.ForeignKey(
+ to="Service",
+ on_delete=models.CASCADE,
+ related_name="service_definitions",
+ 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")
+
+ def __str__(self):
+ 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".
@@ -245,8 +328,9 @@ class ServiceOffering(models.Model):
related_name="offerings",
verbose_name=_("Provider"),
)
- control_plane = models.ManyToManyField(
+ control_planes = models.ManyToManyField(
to="ControlPlane",
+ through="ServiceOfferingControlPlane",
related_name="offerings",
verbose_name=_("Control planes"),
)
@@ -255,3 +339,8 @@ class ServiceOffering(models.Model):
class Meta:
verbose_name = _("Service offering")
verbose_name_plural = _("Service offerings")
+
+ def __str__(self):
+ return _("{service_name} at {provider_name}").format(
+ service_name=self.service.name, provider_name=self.provider.name
+ )
diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py
new file mode 100644
index 0000000..d7ea60f
--- /dev/null
+++ b/src/servala/frontend/forms/service.py
@@ -0,0 +1,22 @@
+from django import forms
+
+from servala.core.models import CloudProvider, ServiceCategory
+
+
+class ServiceFilterForm(forms.Form):
+ category = forms.ModelChoiceField(
+ queryset=ServiceCategory.objects.all(), required=False
+ )
+ cloud_provider = forms.ModelChoiceField(
+ queryset=CloudProvider.objects.all(), required=False
+ )
+ q = forms.CharField(required=False)
+
+ def filter_queryset(self, queryset):
+ if category := self.cleaned_data.get("category"):
+ queryset = queryset.filter(category=category)
+ if cloud_provider := self.cleaned_data.get("cloud_provider"):
+ queryset = queryset.filter(
+ offerings__control_planes__cloud_provider=cloud_provider
+ )
+ return queryset
diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html
new file mode 100644
index 0000000..c3962eb
--- /dev/null
+++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html
@@ -0,0 +1,65 @@
+{% extends "frontend/base.html" %}
+{% load i18n %}
+{% load static %}
+{% block html_title %}
+ {% block page_title %}
+ {{ service.name }}
+ {% endblock page_title %}
+{% endblock html_title %}
+{% block content %}
+ {{ service.description|default:"No description available." }} {{ offering.description }} {{ offering.provider.description }} {% translate "No offerings found." %} {% translate "Please choose your zone." %}
+ {% blocktranslate trimmed with zone=offering.control_planes.all.first.name %}
+ Your zone will be {{ zone }}.
+ {% endblocktranslate %}
+ {{ service.description }} {% translate "No services found." %}
+ {% endif %}
+
{{ service.name }}
+ {{ service.category }}
+
+ {% endif %}
+
{{ offering.provider.name }}
+
+ {% endif %}
+
{{ offering }}
+ {{ offering.service.category }}
+
+ {% endif %}
+
{{ service.name }}
+ {{ service.category }}
+