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 %} +
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ service.name }}

+ {{ service.category }} +
+
+
+
+

{{ service.description|default:"No description available." }}

+
+
+
+ {% for offering in service.offerings.all %} +
+
+ {% if offering.provider.logo %} + {{ offering.provider.name }} + {% endif %} +
+

{{ offering.provider.name }}

+
+
+
+ {% if offering.description %} +

{{ offering.description }}

+ {% elif offering.provider.description %} +

{{ offering.provider.description }}

+ {% endif %} +
+ +
+ {% empty %} +
+
+

{% translate "No offerings found." %}

+
+
+ {% endfor %} +
+{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html new file mode 100644 index 0000000..1ed9a12 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -0,0 +1,40 @@ +{% extends "frontend/base.html" %} +{% load i18n %} +{% load static %} +{% block html_title %} + {% block page_title %} + {{ offering }} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ offering }}

+ {{ offering.service.category }} +
+
+
+
+ {% if offering.control_planes.all.count > 1 %} +

{% translate "Please choose your zone." %}

+ {% else %} +

+ {% blocktranslate trimmed with zone=offering.control_planes.all.first.name %} + Your zone will be {{ zone }}. + {% endblocktranslate %} +

+ {% endif %} +
+
+
+
+{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html new file mode 100644 index 0000000..3286952 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -0,0 +1,51 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {% block page_title %} + {% translate "Services" %} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+
+
+
+
+ {{ filter_form }} +
+
+
+
+ {% for service in services %} +
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ service.name }}

+ {{ service.category }} +
+
+
+ {% if service.description %}

{{ service.description }}

{% endif %} +
+ +
+ {% empty %} +
+
+

{% translate "No services found." %}

+
+
+ {% endfor %} +
+ +{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index 2d6433a..db6eda4 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -73,7 +73,7 @@
-

{% translate "Profile" %}

+

{% translate "Profile" %}

@@ -106,7 +106,7 @@
-

{% translate "Account" %}

+

{% translate "Account" %}

diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 13d9d5c..e2a98a2 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -116,6 +116,12 @@ {% translate 'Details' %} + {% endif %}