From af64d5468f89f28177a5dbbefc96875e62e67cf5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 16:08:27 +0100 Subject: [PATCH 1/4] Implement CRD retrieval --- src/servala/core/models/service.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f1dc20b..b659b2d 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -310,6 +310,15 @@ class ServiceOfferingControlPlane(models.Model): def __str__(self): return f"{self.service_offering} on {self.control_plane} with {self.service_definition}" + def get_resource_definition(self): + client = self.control_plane.get_kubernetes_client() + api_instance = kubernetes.client.ApiextensionsV1Api(client) + kind = self.service_definition.api_definition["kind"].lower() + group = self.service_definition.api_definition["group"] + name = f"{kind}.{group}" + crd = api_instance.read_custom_resource_definition(name) + return crd + class ServiceOffering(models.Model): """ From 234ff8e1d657f5d4773c326a8df3ebf5a34e9c19 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 17:13:43 +0100 Subject: [PATCH 2/4] Build dynamic model and modelform generation --- src/servala/core/crd.py | 92 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/servala/core/crd.py diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py new file mode 100644 index 0000000..5a3803c --- /dev/null +++ b/src/servala/core/crd.py @@ -0,0 +1,92 @@ +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +from django.db import models +from django.forms.models import ModelForm, ModelFormMetaclass + + +def generate_django_model(crd, group, version, kind): + """ + Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. + """ + schema = crd.spec.versions[0].schema.open_apiv3_schema + properties = schema.properties + required_fields = schema.required + model_fields = {"__module__": "crd_models"} + + defaults = {"apiVersion": f"{group}/{version}", "kind": kind} + + for prop_name, prop_schema in properties.items(): + is_required = prop_name in required_fields + field = get_django_field( + prop_schema, is_required, default=defaults.get(prop_name) + ) + model_fields[prop_name] = field + + meta_class = type("Meta", (), {"app_label": "crd_models"}) + model_fields["Meta"] = meta_class + + # create the model class + model_name = crd.spec.names.kind + model_class = type(model_name, (models.Model,), model_fields) + return model_class + + +def get_django_field(prop_schema, is_required=False, default=None): + field_type = prop_schema.type or "string" + format = prop_schema.format + + kwargs = { + "blank": not is_required, + "null": not is_required, + "help_text": prop_schema.description or "", + "validators": [], + "default": default, + # TODO: verbose_name? + } + + if prop_schema.minimum: + kwargs["validators"].append(MinValueValidator(prop_schema.minimum)) + if prop_schema.maximum: + kwargs["validators"].append(MaxValueValidator(prop_schema.maximum)) + + if field_type == "string": + if format == "date-time": + return models.DateTimeField(**kwargs) + elif format == "date": + return models.DateField(**kwargs) + else: + max_length = prop_schema.max_length or 255 + if prop_schema.pattern: + kwargs["validators"].append(RegexValidator(regex=prop_schema.pattern)) + return models.CharField(max_length=max_length, **kwargs) + elif field_type == "integer": + return models.IntegerField(**kwargs) + elif field_type == "number": + return models.FloatField(**kwargs) + elif field_type == "boolean": + return models.BooleanField(**kwargs) + elif field_type == "object": + kwargs["help_text"] += " (JSON object)" + return models.JSONField(**kwargs) + elif field_type == "array": + kwargs["help_text"] += " (JSON array)" + return models.JSONField(**kwargs) + return models.CharField(max_length=255, **kwargs) + + +class CrdModelFormMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["apiVersion"].disabled = True + + +def generate_model_form_class(model): + meta_attrs = { + "model": model, + "fields": "__all__", + } + fields = { + "Meta": type("Meta", (object,), meta_attrs), + "__module__": "crd_models", + } + class_name = f"{model.__name__}ModelForm" + return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) From ee8fba07efe3774376c6d2af84900312c8c27f84 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 18:30:08 +0100 Subject: [PATCH 3/4] Use serialized data for schema --- src/servala/core/crd.py | 95 ++++++++++++++++++++---------- src/servala/core/models/service.py | 30 +++++++++- 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 5a3803c..bf22bcc 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -1,52 +1,80 @@ +import re + from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import models from django.forms.models import ModelForm, ModelFormMetaclass +from django.utils.translation import gettext_lazy as _ -def generate_django_model(crd, group, version, kind): +def generate_django_model(schema, group, version, kind): """ Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. """ - schema = crd.spec.versions[0].schema.open_apiv3_schema - properties = schema.properties - required_fields = schema.required + spec = schema["properties"].get("spec") or {} + # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} + model_fields = {"__module__": "crd_models"} - - defaults = {"apiVersion": f"{group}/{version}", "kind": kind} - - for prop_name, prop_schema in properties.items(): - is_required = prop_name in required_fields - field = get_django_field( - prop_schema, is_required, default=defaults.get(prop_name) - ) - model_fields[prop_name] = field + model_fields.update(build_object_fields(spec, "spec")) meta_class = type("Meta", (), {"app_label": "crd_models"}) model_fields["Meta"] = meta_class # create the model class - model_name = crd.spec.names.kind + model_name = kind model_class = type(model_name, (models.Model,), model_fields) return model_class -def get_django_field(prop_schema, is_required=False, default=None): - field_type = prop_schema.type or "string" - format = prop_schema.format +def build_object_fields(schema, name, verbose_name_prefix=None): + required_fields = schema.get("required", []) + properties = schema.get("properties", {}) + fields = {} + + for field_name, field_schema in properties.items(): + is_required = field_name in required_fields + full_name = f"{name}.{field_name}" + result = get_django_field( + field_schema, + is_required, + field_name, + full_name, + verbose_name_prefix=verbose_name_prefix, + ) + if isinstance(result, dict): + fields.update(result) + else: + fields[full_name] = result + return fields + + +def deslugify(title): + if "_" in title: + title.replace("_", " ") + return title.title() + return re.sub(r"(? Date: Tue, 25 Mar 2025 18:30:31 +0100 Subject: [PATCH 4/4] Show service ordering form --- src/servala/frontend/forms/service.py | 10 ++++- .../service_offering_detail.html | 33 ++++++++++----- src/servala/frontend/views/mixins.py | 41 ++++++++++--------- src/servala/frontend/views/service.py | 38 +++++++++++++++-- 4 files changed, 87 insertions(+), 35 deletions(-) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index d7ea60f..0afb1b5 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -1,6 +1,6 @@ from django import forms -from servala.core.models import CloudProvider, ServiceCategory +from servala.core.models import CloudProvider, ControlPlane, ServiceCategory class ServiceFilterForm(forms.Form): @@ -20,3 +20,11 @@ class ServiceFilterForm(forms.Form): offerings__control_planes__cloud_provider=cloud_provider ) return queryset + + +class ControlPlaneSelectForm(forms.Form): + control_plane = forms.ModelChoiceField(queryset=ControlPlane.objects.none()) + + def __init__(self, *args, planes=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields["control_plane"].queryset = planes diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 1ed9a12..bd6a575 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -1,11 +1,23 @@ {% extends "frontend/base.html" %} {% load i18n %} {% load static %} +{% load partials %} {% block html_title %} {% block page_title %} {{ offering }} {% endblock page_title %} {% endblock html_title %} +{% partialdef service-form %} +{% if service_form %} + {% if form_error %} +
+ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %} +
+ {% else %} + {% include "includes/form.html" with form=service_form %} + {% endif %} +{% endif %} +{% endpartialdef %} {% block content %}
@@ -23,17 +35,16 @@
-
- {% 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 %} -
+ {% if offering.control_planes.all.count == 0 %} +

{% translate "We currently cannot offer this service, sorry!" %}

+ {% else %} +
+ {{ select_form }} +
+
{% partial service-form %}
+ {% endif %}
diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index f304e42..fd5a494 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -5,13 +5,30 @@ from rules.contrib.views import AutoPermissionRequiredMixin, PermissionRequiredM from servala.core.models import Organization -class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): +class HtmxViewMixin: fragments = [] @cached_property def is_htmx(self): return self.request.headers.get("HX-Request") + def _get_fragment(self): + if self.request.method == "POST": + fragment = self.request.POST.get("fragment") + else: + fragment = self.request.GET.get("fragment") + if fragment and fragment in self.fragments: + return fragment + + def get_template_names(self): + template_names = super().get_template_names() + if self.is_htmx and (fragment := self._get_fragment()): + return [f"{template_names[0]}#{fragment}"] + return template_names + + +class HtmxUpdateView(AutoPermissionRequiredMixin, HtmxViewMixin, UpdateView): + @property def permission_type(self): if self.request.method == "POST" or getattr( @@ -31,20 +48,6 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): result["has_change_permission"] = self.has_change_permission() return result - def _get_fragment(self): - if self.request.method == "POST": - fragment = self.request.POST.get("fragment") - else: - fragment = self.request.GET.get("fragment") - if fragment and fragment in self.fragments: - return fragment - - def get_template_names(self): - template_names = super().get_template_names() - if self.is_htmx and (fragment := self._get_fragment()): - return [f"{template_names[0]}#{fragment}"] - return template_names - def get_form_kwargs(self): result = super().get_form_kwargs() if self.is_htmx: @@ -82,8 +85,8 @@ class OrganizationViewMixin(PermissionRequiredMixin): def get_permission_object(self): return self.organization + def has_organization_permission(self): + return self.request.user.has_perm("core.view_organization", self.organization) + def has_permission(self): - return ( - self.request.user.has_perm("core.view_organization", self.organization) - and super().has_permission() - ) + return self.has_organization_permission() and super().has_permission() diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index f5c33f0..faead0f 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,9 +1,9 @@ from django.utils.functional import cached_property from django.views.generic import DetailView, ListView -from servala.core.models import Service, ServiceOffering -from servala.frontend.forms.service import ServiceFilterForm -from servala.frontend.views.mixins import OrganizationViewMixin +from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane +from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm +from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin class ServiceListView(OrganizationViewMixin, ListView): @@ -44,13 +44,43 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): ) -class ServiceOfferingDetailView(OrganizationViewMixin, DetailView): +class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView): template_name = "frontend/organizations/service_offering_detail.html" context_object_name = "offering" model = ServiceOffering permission_type = "view" + fragments = ("service-form",) + + def has_permission(self): + return self.has_organization_permission() def get_queryset(self): return ServiceOffering.objects.all().select_related( "service", "service__category", "provider" ) + + @cached_property + def planes(self): + return self.object.control_planes.all() + + @cached_property + def select_form(self): + data = None + if "control_plane" in self.request.GET: + data = self.request.GET + return ControlPlaneSelectForm(data=data, planes=self.planes) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["select_form"] = self.select_form + if "control_plane" in self.request.GET: + if self.select_form.is_valid(): + so_cp = ServiceOfferingControlPlane.objects.filter( + control_plane=self.select_form.cleaned_data["control_plane"], + service_offering=self.object, + ).first() + if not so_cp: + context["form_error"] = True + else: + context["service_form"] = so_cp.model_form_class() + return context