diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py
index 9310bd4..d718fe0 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, ServiceDefinitionAdminForm
+from servala.core.forms import ControlPlaneAdminForm
from servala.core.models import (
BillingEntity,
CloudProvider,
@@ -12,9 +12,7 @@ from servala.core.models import (
Plan,
Service,
ServiceCategory,
- ServiceDefinition,
ServiceOffering,
- ServiceOfferingControlPlane,
User,
)
@@ -103,7 +101,6 @@ class ServiceAdmin(admin.ModelAdmin):
list_filter = ("category",)
search_fields = ("name", "description")
autocomplete_fields = ("category",)
- prepopulated_fields = {"slug": ["name"]}
@admin.register(CloudProvider)
@@ -124,7 +121,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = (
(
None,
- {"fields": ("name", "description", "cloud_provider", "service_definition")},
+ {"fields": ("name", "description", "cloud_provider")},
),
(
_("API Credentials"),
@@ -173,54 +170,11 @@ 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")
+ list_filter = ("service", "provider", "control_plane")
search_fields = ("description",)
autocomplete_fields = ("service", "provider")
- inlines = (
- ServiceOfferingControlPlaneInline,
- PlanInline,
- )
+ filter_horizontal = ("control_plane",)
+ inlines = (PlanInline,)
diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py
index 1fece41..d7740ae 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, ServiceDefinition
+from servala.core.models import ControlPlane
class ControlPlaneAdminForm(forms.ModelForm):
@@ -70,65 +70,3 @@ 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
deleted file mode 100644
index f15e796..0000000
--- a/src/servala/core/migrations/0006_service_slug.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# 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
deleted file mode 100644
index be0fac6..0000000
--- a/src/servala/core/migrations/0007_service_definition.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# 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 6d5b24d..f637b57 100644
--- a/src/servala/core/models/__init__.py
+++ b/src/servala/core/models/__init__.py
@@ -11,9 +11,7 @@ from .service import (
Plan,
Service,
ServiceCategory,
- ServiceDefinition,
ServiceOffering,
- ServiceOfferingControlPlane,
)
from .user import User
@@ -28,8 +26,6 @@ __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 e842c36..be6737e 100644
--- a/src/servala/core/models/organization.py
+++ b/src/servala/core/models/organization.py
@@ -38,7 +38,6 @@ 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 f1dc20b..78815da 100644
--- a/src/servala/core/models/service.py
+++ b/src/servala/core/models/service.py
@@ -44,7 +44,6 @@ 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,
@@ -68,13 +67,18 @@ class Service(models.Model):
return self.name
-def validate_dict(data, required_fields=None, allow_empty=True):
- if not data:
- if allow_empty:
- return
- raise ValidationError(_("Data may not be empty!"))
+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)
- missing_fields = required_fields - set(data)
if missing_fields:
raise ValidationError(
_("Missing required fields in API credentials: %(fields)s"),
@@ -82,11 +86,6 @@ def validate_dict(data, required_fields=None, allow_empty=True):
)
-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"))
@@ -229,88 +228,6 @@ 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".
@@ -328,9 +245,8 @@ class ServiceOffering(models.Model):
related_name="offerings",
verbose_name=_("Provider"),
)
- control_planes = models.ManyToManyField(
+ control_plane = models.ManyToManyField(
to="ControlPlane",
- through="ServiceOfferingControlPlane",
related_name="offerings",
verbose_name=_("Control planes"),
)
@@ -339,8 +255,3 @@ 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
deleted file mode 100644
index d7ea60f..0000000
--- a/src/servala/frontend/forms/service.py
+++ /dev/null
@@ -1,22 +0,0 @@
-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
deleted file mode 100644
index c3962eb..0000000
--- a/src/servala/frontend/templates/frontend/organizations/service_detail.html
+++ /dev/null
@@ -1,65 +0,0 @@
-{% 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 }}
-