Custom form configuration #268

Merged
tobru merged 34 commits from 165-form-configuration into main 2025-11-10 14:49:33 +00:00
3 changed files with 187 additions and 25 deletions
Showing only changes of commit 0045e532ee - Show all commits

View file

@ -7,6 +7,7 @@ from django.forms.models import ModelForm, ModelFormMetaclass
from django.utils.translation import gettext_lazy as _
from servala.core.models import ServiceInstance
from servala.frontend.forms.widgets import DynamicArrayField, DynamicArrayWidget
class CRDModel(models.Model):
@ -22,17 +23,11 @@ class CRDModel(models.Model):
def duplicate_field(field_name, model):
# Get the field from the model
field = model._meta.get_field(field_name)
# Create a new field with the same attributes
new_field = type(field).__new__(type(field))
new_field.__dict__.update(field.__dict__)
# Ensure the field is not linked to the original model
new_field.model = None
new_field.auto_created = False
return new_field
@ -250,8 +245,6 @@ def get_django_field(
)
elif field_type == "array":
kwargs["help_text"] = field_schema.get("description") or _("List of values")
from servala.frontend.forms.widgets import DynamicArrayField
field = models.JSONField(**kwargs)
formfield_kwargs = {
"label": field.verbose_name,
@ -291,7 +284,29 @@ def unnest_data(data):
return result
class CrdModelFormMixin:
class FormGeneratorMixin:
IS_CUSTOM_FORM = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in ("organization", "context"):
if field in self.fields:
self.fields[field].widget = forms.HiddenInput()
if self.instance and hasattr(self.instance, "name") and self.instance.name:
if "name" in self.fields:
self.fields["name"].disabled = True
self.fields["name"].widget = forms.HiddenInput()
def has_mandatory_fields(self, field_list):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
return True
return False
class CrdModelFormMixin(FormGeneratorMixin):
HIDDEN_FIELDS = [
"spec.compositeDeletePolicy",
"spec.compositionRef",
@ -317,9 +332,6 @@ class CrdModelFormMixin:
super().__init__(*args, **kwargs)
self.schema = self._meta.model.SCHEMA
for field in ("organization", "context"):
self.fields[field].widget = forms.HiddenInput()
for name, field in self.fields.items():
if name in self.HIDDEN_FIELDS or any(
name.startswith(f) for f in self.HIDDEN_FIELDS
@ -327,22 +339,11 @@ class CrdModelFormMixin:
field.widget = forms.HiddenInput()
field.required = False
if self.instance and self.instance.pk:
self.fields["name"].disabled = True
self.fields["name"].help_text = _("Name cannot be changed after creation.")
self.fields["name"].widget = forms.HiddenInput()
def strip_title(self, field_name, label):
field = self.fields[field_name]
if field and field.label and (position := field.label.find(label)) != -1:
field.label = field.label[position + len(label) :]
def has_mandatory_fields(self, field_list):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
return True
return False
def get_fieldsets(self):
fieldsets = []
@ -524,3 +525,142 @@ def generate_model_form_class(model):
}
class_name = f"{model.__name__}ModelForm"
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)
class CustomFormMixin(FormGeneratorMixin):
"""
Base for custom (user-friendly) forms generated from ServiceDefinition.form_config.
"""
IS_CUSTOM_FORM = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._apply_field_config()
if (
self.instance
and hasattr(self.instance, "name")
and self.instance.name
and "name" in self.fields
):
self.fields["name"].widget = forms.HiddenInput()
self.fields["name"].disabled = True
self.fields.pop("context", None)
self.fields.pop("organization", None)
def _apply_field_config(self):
for fieldset in self.form_config.get("fieldsets", []):
for field_config in fieldset.get("fields", []):
field_name = field_config.get("controlplane_field_mapping")
if field_name not in self.fields:
continue
field = self.fields[field_name]
field_type = field_config.get("type")
field.label = field_config.get("label", field_config["name"])
field.help_text = field_config.get("help_text", "")
field.required = field_config.get("required", False)
if field_type == "textarea":
field.widget = forms.Textarea(
attrs={"rows": field_config.get("rows", 4)}
)
elif field_type == "array":
field.widget = DynamicArrayWidget()
if field_type == "number":
min_val = field_config.get("min_value")
max_val = field_config.get("max_value")
validators = []
if min_val is not None:
validators.append(MinValueValidator(min_val))
if max_val is not None:
validators.append(MaxValueValidator(max_val))
if validators:
field.validators.extend(validators)
field.controlplane_field_mapping = field_name
def get_fieldsets(self):
fieldsets = []
for fieldset_config in self.form_config.get("fieldsets", []):
field_names = [
f["controlplane_field_mapping"]
for f in fieldset_config.get("fields", [])
]
fieldset = {
"title": fieldset_config.get("title", "General"),
"fields": field_names,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(field_names),
}
fieldsets.append(fieldset)
return fieldsets
def get_nested_data(self):
nested = {}
for field_name in self.fields.keys():
if field_name in ("organization", "context"):
value = self.cleaned_data.get(field_name)
if value is not None:
nested[field_name] = value
continue
mapping = field_name
value = self.cleaned_data.get(field_name)
parts = mapping.split(".")
current = nested
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return nested
def generate_custom_form_class(form_config, model):
"""
Generate a custom (user-friendly) form class from form_config JSON.
"""
field_list = ["organization", "context", "name"]
for fieldset in form_config.get("fieldsets", []):
for field_config in fieldset.get("fields", []):
field_name = field_config.get("controlplane_field_mapping")
if field_name:
field_list.append(field_name)
fields = {
"organization": forms.ModelChoiceField(
queryset=None,
required=True,
widget=forms.HiddenInput(),
),
"context": forms.ModelChoiceField(
queryset=None,
required=True,
widget=forms.HiddenInput(),
),
}
meta_attrs = {
"model": model,
"fields": field_list,
}
form_fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
"form_config": form_config,
**fields,
}
class_name = f"{model.__name__}CustomForm"
return ModelFormMetaclass(class_name, (CustomFormMixin, ModelForm), form_fields)

View file

@ -162,10 +162,16 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
form_config = cleaned_data.get("form_config")
if form_config:
try:
jsonschema.validate(instance=form_config, schema=self.form_config_schema)
jsonschema.validate(
instance=form_config, schema=self.form_config_schema
)
except jsonschema.ValidationError as e:
raise forms.ValidationError(
{"form_config": _("Invalid form configuration: {}").format(e.message)}
{
"form_config": _("Invalid form configuration: {}").format(
e.message
)
}
)
except jsonschema.SchemaError as e:
raise forms.ValidationError(

View file

@ -512,6 +512,22 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
return
return generate_model_form_class(self.django_model)
@cached_property
def custom_model_form_class(self):
from servala.core.crd import generate_custom_form_class
if not self.django_model:
return
if not (
self.service_definition
and self.service_definition.form_config
and self.service_definition.form_config.get("fieldsets")
):
return
return generate_custom_form_class(
self.service_definition.form_config, self.django_model
)
class ServiceOffering(ServalaModelMixin, models.Model):
"""