Merge pull request 'Dynamic service ordering forms' (#27) from 14-dynamic-service-ordering-forms into main
All checks were successful
Tests / test (push) Successful in 23s
Build and Deploy Staging / build (push) Successful in 50s
Build and Deploy Staging / deploy (push) Successful in 8s

Reviewed-on: http://servala-2nkgm.app.codey.ch/servala/servala-portal/pulls/27
This commit is contained in:
Tobias Kunze 2025-03-26 14:43:15 +00:00
commit d3eb9c55f7
10 changed files with 308 additions and 35 deletions

View file

@ -30,6 +30,7 @@ Then use ``uv`` to install the project and run its commands while youre devel
```bash
uv sync --dev
uv run --env-file=.env src/manage.py migrate
uv run --env-file=.env src/manage.py createcachetable
uv run --env-file=.env src/manage.py runserver
```

View file

@ -8,6 +8,7 @@ export XDG_CONFIG_HOME="/app/config"
echo "Applying database migrations"
uv run src/manage.py migrate
uv run src/manage.py createcachetable
echo "Starting Caddy"
exec caddy run --config /app/config/caddy/Caddyfile --adapter caddyfile 2>&1 &

127
src/servala/core/crd.py Normal file
View file

@ -0,0 +1,127 @@
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(schema, group, version, kind):
"""
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
"""
spec = schema["properties"].get("spec") or {}
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
model_fields = {"__module__": "crd_models"}
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 = kind
model_class = type(model_name, (models.Model,), model_fields)
return model_class
def build_object_fields(schema, name, verbose_name_prefix=None):
required_fields = schema.get("required") or []
properties = schema.get("properties") or {}
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"(?<!^)(?=[A-Z])", " ", title).capitalize()
def get_django_field(
field_schema, is_required, field_name, full_name, verbose_name_prefix=None
):
field_type = field_schema.get("type") or "string"
format = field_schema.get("format")
verbose_name_prefix = verbose_name_prefix or ""
verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip()
kwargs = {
"blank": not is_required,
"null": not is_required,
"help_text": field_schema.get("description"),
"validators": [],
"verbose_name": verbose_name,
"default": field_schema.get("default"),
}
if minimum := field_schema.get("minimum"):
kwargs["validators"].append(MinValueValidator(minimum))
if maximum := field_schema.get("maximum"):
kwargs["validators"].append(MaxValueValidator(maximum))
if field_type == "string":
if format == "date-time":
return models.DateTimeField(**kwargs)
elif format == "date":
return models.DateField(**kwargs)
else:
max_length = field_schema.get("max_length") or 255
if pattern := field_schema.get("pattern"):
kwargs["validators"].append(RegexValidator(regex=pattern))
if choices := field_schema.get("enum"):
kwargs["choices"] = ((choice, choice) for choice in choices)
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":
return build_object_fields(
field_schema, full_name, verbose_name_prefix=f"{verbose_name}:"
)
elif field_type == "array":
# TODO: handle items / validate items, build multi-select input
# if field_schema.get("items") and (choices := field_schema["items"].get("enum")):
# choices = [c, c for c in choices]
kwargs["help_text"] = _("JSON field (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
# self.fields["kind"].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)

View file

@ -1,6 +1,8 @@
import kubernetes
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField
from kubernetes import config
@ -310,6 +312,58 @@ class ServiceOfferingControlPlane(models.Model):
def __str__(self):
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
@cached_property
def resource_definition(self):
kind = self.service_definition.api_definition["kind"]
group = self.service_definition.api_definition["group"]
version = self.service_definition.api_definition["version"]
client = self.control_plane.get_kubernetes_client()
extensions_api = kubernetes.client.ApiextensionsV1Api(client)
crds = extensions_api.list_custom_resource_definition()
matching_crd = None
for crd in crds.items:
if matching_crd:
break
if crd.spec.group == group:
for served_version in crd.spec.versions:
if served_version.name == version and served_version.served:
if crd.spec.names.kind == kind:
matching_crd = crd
break
return matching_crd
@cached_property
def resource_schema(self):
cache_key = f"servala:crd:schema:{self.pk}"
if result := cache.get(cache_key):
return result
version = self.service_definition.api_definition["version"]
for v in self.resource_definition.spec.versions:
if v.name == version:
result = v.schema.open_apiv3_schema.to_dict()
timeout_seconds = 60 * 60 * 24
cache.set(cache_key, result, timeout=timeout_seconds)
return result
@cached_property
def django_model(self):
from servala.core.crd import generate_django_model
kwargs = {
key: value
for key, value in self.service_definition.api_definition.items()
if key in ("group", "version", "kind")
}
return generate_django_model(self.resource_schema, **kwargs)
@cached_property
def model_form_class(self):
from servala.core.crd import generate_model_form_class
return generate_model_form_class(self.django_model)
class ServiceOffering(models.Model):
"""

View file

@ -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

View file

@ -1,11 +1,28 @@
{% 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 %}
<div class="card">
<div class="card-header d-flex align-items-center"></div>
<div class="card-body">
{% if form_error %}
<div class="alert alert-danger">
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/form.html" with form=service_form %}
{% endif %}
</div>
</div>
{% endif %}
{% endpartialdef %}
{% block content %}
<section class="section">
<div class="card">
@ -23,18 +40,17 @@
</div>
</div>
<div class="card-body">
<div class="row">
{% if offering.control_planes.all.count > 1 %}
<p>{% translate "Please choose your zone." %}</p>
{% else %}
<p>
{% blocktranslate trimmed with zone=offering.control_planes.all.first.name %}
Your zone will be <strong>{{ zone }}</strong>.
{% endblocktranslate %}
</p>
{% endif %}
</div>
{% if not has_control_planes %}
<p>{% translate "We currently cannot offer this service, sorry!" %}</p>
{% else %}
<form hx-trigger="change"
hx-get="{{ request.path }}?fragment=service-form"
hx-target="#service-form">
{{ select_form }}
</form>
{% endif %}
</div>
</div>
<div id="service-form">{% partial service-form %}</div>
</section>
{% endblock content %}

View file

@ -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()

View file

@ -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,50 @@ 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
context["has_control_planes"] = self.planes.exists()
if "control_plane" in self.request.GET:
if self.select_form.is_valid():
context["selected_plane"] = self.select_form.cleaned_data[
"control_plane"
]
try:
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
except Exception:
context["form_error"] = True
else:
context["service_form"] = so_cp.model_form_class()
return context

View file

@ -132,6 +132,13 @@ STATIC_URL = "static/" # CSS, JavaScript, etc.
STATIC_ROOT = BASE_DIR / "static.dist"
MEDIA_URL = "media/" # User uploads, e.g. images
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "servala_cache",
}
}
# Additional locations of static files
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",

File diff suppressed because one or more lines are too long