Use serialized data for schema

This commit is contained in:
Tobias Kunze 2025-03-25 18:30:08 +01:00
parent 234ff8e1d6
commit ee8fba07ef
2 changed files with 93 additions and 32 deletions

View file

@ -1,52 +1,80 @@
import re
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models from django.db import models
from django.forms.models import ModelForm, ModelFormMetaclass 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. Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
""" """
schema = crd.spec.versions[0].schema.open_apiv3_schema spec = schema["properties"].get("spec") or {}
properties = schema.properties # defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
required_fields = schema.required
model_fields = {"__module__": "crd_models"} model_fields = {"__module__": "crd_models"}
model_fields.update(build_object_fields(spec, "spec"))
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"}) meta_class = type("Meta", (), {"app_label": "crd_models"})
model_fields["Meta"] = meta_class model_fields["Meta"] = meta_class
# create the model class # create the model class
model_name = crd.spec.names.kind model_name = kind
model_class = type(model_name, (models.Model,), model_fields) model_class = type(model_name, (models.Model,), model_fields)
return model_class return model_class
def get_django_field(prop_schema, is_required=False, default=None): def build_object_fields(schema, name, verbose_name_prefix=None):
field_type = prop_schema.type or "string" required_fields = schema.get("required", [])
format = prop_schema.format 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"(?<!^)(?=[A-Z])", "_", title)
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 = { kwargs = {
"blank": not is_required, "blank": not is_required,
"null": not is_required, "null": not is_required,
"help_text": prop_schema.description or "", "help_text": field_schema.get("description"),
"validators": [], "validators": [],
"default": default, "verbose_name": verbose_name,
# TODO: verbose_name? "default": field_schema.get("default"),
} }
if prop_schema.minimum: if minimum := field_schema.get("minimum"):
kwargs["validators"].append(MinValueValidator(prop_schema.minimum)) kwargs["validators"].append(MinValueValidator(minimum))
if prop_schema.maximum: if maximum := field_schema.get("maximum"):
kwargs["validators"].append(MaxValueValidator(prop_schema.maximum)) kwargs["validators"].append(MaxValueValidator(maximum))
if field_type == "string": if field_type == "string":
if format == "date-time": if format == "date-time":
@ -54,9 +82,9 @@ def get_django_field(prop_schema, is_required=False, default=None):
elif format == "date": elif format == "date":
return models.DateField(**kwargs) return models.DateField(**kwargs)
else: else:
max_length = prop_schema.max_length or 255 max_length = field_schema.get("max_length") or 255
if prop_schema.pattern: if pattern := field_schema.get("pattern"):
kwargs["validators"].append(RegexValidator(regex=prop_schema.pattern)) kwargs["validators"].append(RegexValidator(regex=pattern))
return models.CharField(max_length=max_length, **kwargs) return models.CharField(max_length=max_length, **kwargs)
elif field_type == "integer": elif field_type == "integer":
return models.IntegerField(**kwargs) return models.IntegerField(**kwargs)
@ -65,10 +93,14 @@ def get_django_field(prop_schema, is_required=False, default=None):
elif field_type == "boolean": elif field_type == "boolean":
return models.BooleanField(**kwargs) return models.BooleanField(**kwargs)
elif field_type == "object": elif field_type == "object":
kwargs["help_text"] += " (JSON object)" return build_object_fields(
return models.JSONField(**kwargs) field_schema, full_name, verbose_name_prefix=f"{verbose_name}:"
)
elif field_type == "array": elif field_type == "array":
kwargs["help_text"] += " (JSON 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.JSONField(**kwargs)
return models.CharField(max_length=255, **kwargs) return models.CharField(max_length=255, **kwargs)
@ -76,7 +108,8 @@ def get_django_field(prop_schema, is_required=False, default=None):
class CrdModelFormMixin: class CrdModelFormMixin:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["apiVersion"].disabled = True # self.fields["apiVersion"].disabled = True
# self.fields["kind"].disabled = True
def generate_model_form_class(model): def generate_model_form_class(model):

View file

@ -1,6 +1,7 @@
import kubernetes import kubernetes
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,7 +311,8 @@ 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}"
def get_resource_definition(self): @cached_property
def resource_definition(self):
client = self.control_plane.get_kubernetes_client() client = self.control_plane.get_kubernetes_client()
api_instance = kubernetes.client.ApiextensionsV1Api(client) api_instance = kubernetes.client.ApiextensionsV1Api(client)
kind = self.service_definition.api_definition["kind"].lower() kind = self.service_definition.api_definition["kind"].lower()
@ -319,6 +321,32 @@ class ServiceOfferingControlPlane(models.Model):
crd = api_instance.read_custom_resource_definition(name) crd = api_instance.read_custom_resource_definition(name)
return crd return crd
@cached_property
def resource_schema(self):
for version in self.resource_definition.spec.versions:
if version.schema and version.schema.open_apiv3_schema:
schema_dict = kubernetes.client.ApiClient().sanitize_for_serialization(
version.schema.open_apiv3_schema
)
return schema_dict
@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):
""" """