diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 5d5c34e..5681ae0 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -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) diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index 55b67d0..9742233 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -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( diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 7a6f390..280c780 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -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): """