Merge pull request 'Dynamic service ordering forms' (#27) from 14-dynamic-service-ordering-forms into main
Reviewed-on: http://servala-2nkgm.app.codey.ch/servala/servala-portal/pulls/27
This commit is contained in:
commit
d3eb9c55f7
10 changed files with 308 additions and 35 deletions
|
@ -30,6 +30,7 @@ Then use ``uv`` to install the project and run its commands while you’re devel
|
||||||
```bash
|
```bash
|
||||||
uv sync --dev
|
uv sync --dev
|
||||||
uv run --env-file=.env src/manage.py migrate
|
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
|
uv run --env-file=.env src/manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ export XDG_CONFIG_HOME="/app/config"
|
||||||
|
|
||||||
echo "Applying database migrations"
|
echo "Applying database migrations"
|
||||||
uv run src/manage.py migrate
|
uv run src/manage.py migrate
|
||||||
|
uv run src/manage.py createcachetable
|
||||||
|
|
||||||
echo "Starting Caddy"
|
echo "Starting Caddy"
|
||||||
exec caddy run --config /app/config/caddy/Caddyfile --adapter caddyfile 2>&1 &
|
exec caddy run --config /app/config/caddy/Caddyfile --adapter caddyfile 2>&1 &
|
||||||
|
|
127
src/servala/core/crd.py
Normal file
127
src/servala/core/crd.py
Normal 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)
|
|
@ -1,6 +1,8 @@
|
||||||
import kubernetes
|
import kubernetes
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from encrypted_fields.fields import EncryptedJSONField
|
from encrypted_fields.fields import EncryptedJSONField
|
||||||
from kubernetes import config
|
from kubernetes import config
|
||||||
|
@ -310,6 +312,58 @@ class ServiceOfferingControlPlane(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
|
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):
|
class ServiceOffering(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from servala.core.models import CloudProvider, ServiceCategory
|
from servala.core.models import CloudProvider, ControlPlane, ServiceCategory
|
||||||
|
|
||||||
|
|
||||||
class ServiceFilterForm(forms.Form):
|
class ServiceFilterForm(forms.Form):
|
||||||
|
@ -20,3 +20,11 @@ class ServiceFilterForm(forms.Form):
|
||||||
offerings__control_planes__cloud_provider=cloud_provider
|
offerings__control_planes__cloud_provider=cloud_provider
|
||||||
)
|
)
|
||||||
return queryset
|
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
|
||||||
|
|
|
@ -1,11 +1,28 @@
|
||||||
{% extends "frontend/base.html" %}
|
{% extends "frontend/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load partials %}
|
||||||
{% block html_title %}
|
{% block html_title %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
{{ offering }}
|
{{ offering }}
|
||||||
{% endblock page_title %}
|
{% endblock page_title %}
|
||||||
{% endblock html_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 %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
@ -23,18 +40,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
{% if not has_control_planes %}
|
||||||
{% if offering.control_planes.all.count > 1 %}
|
<p>{% translate "We currently cannot offer this service, sorry!" %}</p>
|
||||||
<p>{% translate "Please choose your zone." %}</p>
|
{% else %}
|
||||||
{% else %}
|
<form hx-trigger="change"
|
||||||
<p>
|
hx-get="{{ request.path }}?fragment=service-form"
|
||||||
{% blocktranslate trimmed with zone=offering.control_planes.all.first.name %}
|
hx-target="#service-form">
|
||||||
Your zone will be <strong>{{ zone }}</strong>.
|
{{ select_form }}
|
||||||
{% endblocktranslate %}
|
</form>
|
||||||
</p>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="service-form">{% partial service-form %}</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
@ -5,13 +5,30 @@ from rules.contrib.views import AutoPermissionRequiredMixin, PermissionRequiredM
|
||||||
from servala.core.models import Organization
|
from servala.core.models import Organization
|
||||||
|
|
||||||
|
|
||||||
class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
|
class HtmxViewMixin:
|
||||||
fragments = []
|
fragments = []
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_htmx(self):
|
def is_htmx(self):
|
||||||
return self.request.headers.get("HX-Request")
|
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
|
@property
|
||||||
def permission_type(self):
|
def permission_type(self):
|
||||||
if self.request.method == "POST" or getattr(
|
if self.request.method == "POST" or getattr(
|
||||||
|
@ -31,20 +48,6 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
|
||||||
result["has_change_permission"] = self.has_change_permission()
|
result["has_change_permission"] = self.has_change_permission()
|
||||||
return result
|
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):
|
def get_form_kwargs(self):
|
||||||
result = super().get_form_kwargs()
|
result = super().get_form_kwargs()
|
||||||
if self.is_htmx:
|
if self.is_htmx:
|
||||||
|
@ -82,8 +85,8 @@ class OrganizationViewMixin(PermissionRequiredMixin):
|
||||||
def get_permission_object(self):
|
def get_permission_object(self):
|
||||||
return self.organization
|
return self.organization
|
||||||
|
|
||||||
|
def has_organization_permission(self):
|
||||||
|
return self.request.user.has_perm("core.view_organization", self.organization)
|
||||||
|
|
||||||
def has_permission(self):
|
def has_permission(self):
|
||||||
return (
|
return self.has_organization_permission() and super().has_permission()
|
||||||
self.request.user.has_perm("core.view_organization", self.organization)
|
|
||||||
and super().has_permission()
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
from servala.core.models import Service, ServiceOffering
|
from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane
|
||||||
from servala.frontend.forms.service import ServiceFilterForm
|
from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm
|
||||||
from servala.frontend.views.mixins import OrganizationViewMixin
|
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
||||||
|
|
||||||
|
|
||||||
class ServiceListView(OrganizationViewMixin, ListView):
|
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"
|
template_name = "frontend/organizations/service_offering_detail.html"
|
||||||
context_object_name = "offering"
|
context_object_name = "offering"
|
||||||
model = ServiceOffering
|
model = ServiceOffering
|
||||||
permission_type = "view"
|
permission_type = "view"
|
||||||
|
fragments = ("service-form",)
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
return self.has_organization_permission()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return ServiceOffering.objects.all().select_related(
|
return ServiceOffering.objects.all().select_related(
|
||||||
"service", "service__category", "provider"
|
"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
|
||||||
|
|
|
@ -132,6 +132,13 @@ STATIC_URL = "static/" # CSS, JavaScript, etc.
|
||||||
STATIC_ROOT = BASE_DIR / "static.dist"
|
STATIC_ROOT = BASE_DIR / "static.dist"
|
||||||
MEDIA_URL = "media/" # User uploads, e.g. images
|
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
|
# Additional locations of static files
|
||||||
STATICFILES_FINDERS = (
|
STATICFILES_FINDERS = (
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
|
|
19
src/servala/static/mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js
vendored
Normal file
19
src/servala/static/mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue