From 1cf1947539c45f8e22830bbc6705f880d87b6195 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 31 Oct 2025 11:49:04 +0100 Subject: [PATCH 01/10] Provide form building schema --- src/servala/core/forms.py | 21 +++ src/servala/core/models/service.py | 10 ++ .../core/schemas/form_config_schema.json | 132 ++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/servala/core/schemas/form_config_schema.json diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index 034d1c9..55b67d0 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -1,3 +1,7 @@ +import json +from pathlib import Path + +import jsonschema from django import forms from django.utils.translation import gettext_lazy as _ from django_jsonform.widgets import JSONFormWidget @@ -124,6 +128,10 @@ class ServiceDefinitionAdminForm(forms.ModelForm): self.fields["api_version"].initial = api_def.get("version", "") self.fields["api_kind"].initial = api_def.get("kind", "") + schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json" + with open(schema_path) as f: + self.form_config_schema = json.load(f) + def clean(self): cleaned_data = super().clean() @@ -151,6 +159,19 @@ class ServiceDefinitionAdminForm(forms.ModelForm): api_def["kind"] = api_kind cleaned_data["api_definition"] = api_def + form_config = cleaned_data.get("form_config") + if form_config: + try: + 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)} + ) + except jsonschema.SchemaError as e: + raise forms.ValidationError( + {"form_config": _("Schema error: {}").format(e.message)} + ) + return cleaned_data def save(self, *args, **kwargs): diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index d03ef6b..470d928 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -360,6 +360,16 @@ class ServiceDefinition(ServalaModelMixin, models.Model): null=True, blank=True, ) + form_config = models.JSONField( + verbose_name=_("Form Configuration"), + help_text=_( + "Optional custom form configuration. When provided, this configuration will be used " + "to render the service form instead of auto-generating it from the OpenAPI spec. " + "Format: {\"fieldsets\": [{\"title\": \"Section\", \"fields\": [{...}]}]}" + ), + null=True, + blank=True, + ) service = models.ForeignKey( to="Service", on_delete=models.CASCADE, diff --git a/src/servala/core/schemas/form_config_schema.json b/src/servala/core/schemas/form_config_schema.json new file mode 100644 index 0000000..33955d8 --- /dev/null +++ b/src/servala/core/schemas/form_config_schema.json @@ -0,0 +1,132 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Service Definition Form Configuration Schema", + "description": "Schema for custom form configuration in ServiceDefinition", + "type": "object", + "required": ["fieldsets"], + "properties": { + "fieldsets": { + "type": "array", + "description": "Array of fieldset objects defining form sections", + "minItems": 1, + "items": { + "type": "object", + "required": ["fields"], + "properties": { + "title": { + "type": "string", + "description": "Optional title for the fieldset/tab" + }, + "fields": { + "type": "array", + "description": "Array of field definitions in this fieldset", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "type", "label", "controlplane_field_mapping"], + "properties": { + "name": { + "type": "string", + "description": "Unique field name/identifier", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "type": { + "type": "string", + "description": "Field type", + "enum": ["text", "email", "textarea", "number", "choice", "checkbox", "array"] + }, + "label": { + "type": "string", + "description": "Human-readable field label" + }, + "help_text": { + "type": "string", + "description": "Optional help text displayed below the field" + }, + "required": { + "type": "boolean", + "description": "Whether the field is required", + "default": false + }, + "default": { + "description": "Default value for the field" + }, + "controlplane_field_mapping": { + "type": "string", + "description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')" + }, + "max_length": { + "type": "integer", + "description": "Maximum length for text/textarea fields", + "minimum": 1 + }, + "rows": { + "type": "integer", + "description": "Number of rows for textarea fields", + "minimum": 1 + }, + "min_value": { + "type": "number", + "description": "Minimum value for number fields" + }, + "max_value": { + "type": "number", + "description": "Maximum value for number fields" + }, + "choices": { + "type": "array", + "description": "Array of [value, label] pairs for choice fields", + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + {"type": "string"}, + {"type": "string"} + ] + } + }, + "min_values": { + "type": "integer", + "description": "Minimum number of values for array fields", + "minimum": 0 + }, + "max_values": { + "type": "integer", + "description": "Maximum number of values for array fields", + "minimum": 1 + }, + "validators": { + "type": "array", + "description": "Array of validator names (for future use)", + "items": { + "type": "string", + "enum": ["email", "fqdn", "url", "ipv4", "ipv6"] + } + }, + "generators": { + "type": "array", + "description": "Array of generator function names (for future use)", + "items": { + "type": "string", + "enum": ["suggest_fqdn_from_name"] + } + } + }, + "allOf": [ + { + "if": { + "properties": {"type": {"const": "choice"}} + }, + "then": { + "required": ["choices"] + } + } + ] + } + } + } + } + } + } +} From 357e39b54369f6250725a59ee83a056a4cbd7910 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 08:57:28 +0100 Subject: [PATCH 02/10] Remove expert fields, add form config --- src/servala/core/admin.py | 27 +++++++--------- .../migrations/0012_remove_advanced_fields.py | 32 +++++++++++++++++++ .../core/migrations/0013_add_form_config.py | 23 +++++++++++++ src/servala/core/models/service.py | 2 +- .../core/schemas/form_config_schema.json | 24 +++----------- src/servala/static/css/servala.css | 16 +++++----- 6 files changed, 80 insertions(+), 44 deletions(-) create mode 100644 src/servala/core/migrations/0012_remove_advanced_fields.py create mode 100644 src/servala/core/migrations/0013_add_form_config.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 87da376..fb64a2b 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + from django.contrib import admin, messages from django.utils.translation import gettext_lazy as _ from django_jsonform.widgets import JSONFormWidget @@ -313,9 +316,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): ( _("Form Configuration"), { - "fields": ("advanced_fields",), + "fields": ("form_config",), "description": _( - "Configure which fields should be hidden behind an 'Advanced' toggle in the form" + "Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec." ), }, ), @@ -323,19 +326,13 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - # JSON schema for advanced_fields field - advanced_fields_schema = { - "type": "array", - "title": "Advanced Fields", - "items": { - "type": "string", - "title": "Field Name", - "description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)", - }, - } - if "advanced_fields" in form.base_fields: - form.base_fields["advanced_fields"].widget = JSONFormWidget( - schema=advanced_fields_schema + schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json" + with open(schema_path) as f: + form_config_schema = json.load(f) + + if "form_config" in form.base_fields: + form.base_fields["form_config"].widget = JSONFormWidget( + schema=form_config_schema ) return form diff --git a/src/servala/core/migrations/0012_remove_advanced_fields.py b/src/servala/core/migrations/0012_remove_advanced_fields.py new file mode 100644 index 0000000..d60d4cc --- /dev/null +++ b/src/servala/core/migrations/0012_remove_advanced_fields.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.7 on 2025-10-31 10:40 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0011_alter_organizationorigin_billing_entity"), + ] + + operations = [ + migrations.RemoveField( + model_name="servicedefinition", + name="advanced_fields", + ), + migrations.AlterField( + model_name="organization", + name="name", + field=models.CharField( + max_length=32, + validators=[ + django.core.validators.RegexValidator( + message="Organization name can only contain letters, numbers, and spaces.", + regex="^[A-Za-z0-9\\s]+$", + ) + ], + verbose_name="Name", + ), + ), + ] diff --git a/src/servala/core/migrations/0013_add_form_config.py b/src/servala/core/migrations/0013_add_form_config.py new file mode 100644 index 0000000..bd35891 --- /dev/null +++ b/src/servala/core/migrations/0013_add_form_config.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-10-31 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_remove_advanced_fields"), + ] + + operations = [ + migrations.AddField( + model_name="servicedefinition", + name="form_config", + field=models.JSONField( + blank=True, + help_text='Optional custom form configuration. When provided, this configuration will be used to render the service form instead of auto-generating it from the OpenAPI spec. Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}', + null=True, + verbose_name="Form Configuration", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 470d928..7a6f390 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -365,7 +365,7 @@ class ServiceDefinition(ServalaModelMixin, models.Model): help_text=_( "Optional custom form configuration. When provided, this configuration will be used " "to render the service form instead of auto-generating it from the OpenAPI spec. " - "Format: {\"fieldsets\": [{\"title\": \"Section\", \"fields\": [{...}]}]}" + 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}' ), null=True, blank=True, diff --git a/src/servala/core/schemas/form_config_schema.json b/src/servala/core/schemas/form_config_schema.json index 33955d8..1049ed8 100644 --- a/src/servala/core/schemas/form_config_schema.json +++ b/src/servala/core/schemas/form_config_schema.json @@ -48,9 +48,6 @@ "description": "Whether the field is required", "default": false }, - "default": { - "description": "Default value for the field" - }, "controlplane_field_mapping": { "type": "string", "description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')" @@ -78,12 +75,9 @@ "description": "Array of [value, label] pairs for choice fields", "items": { "type": "array", - "minItems": 2, - "maxItems": 2, - "items": [ - {"type": "string"}, - {"type": "string"} - ] + "items": { + "type": "string" + } } }, "min_values": { @@ -112,17 +106,7 @@ "enum": ["suggest_fqdn_from_name"] } } - }, - "allOf": [ - { - "if": { - "properties": {"type": {"const": "choice"}} - }, - "then": { - "required": ["choices"] - } - } - ] + } } } } diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index 067720b..cc69a4f 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -237,42 +237,42 @@ a.btn-keycloak { flex-grow: 1; } -/* CRD Form mandatory field styling */ -.crd-form .form-group.mandatory .form-label { +/* Expert CRD Form mandatory field styling */ +.expert-crd-form .form-group.mandatory .form-label { font-weight: bold; position: relative; } -.crd-form .form-group.mandatory .form-label::after { +.expert-crd-form .form-group.mandatory .form-label::after { content: " *"; color: #dc3545; font-weight: bold; } -.crd-form .form-group.mandatory { +.expert-crd-form .form-group.mandatory { border-left: 3px solid #dc3545; padding-left: 10px; background-color: rgba(220, 53, 69, 0.05); border-radius: 3px; } -.crd-form .nav-tabs .nav-link .mandatory-indicator { +.expert-crd-form .nav-tabs .nav-link .mandatory-indicator { color: #dc3545; font-weight: bold; font-size: 1.1em; margin-left: 4px; } -html[data-bs-theme="dark"] .crd-form .form-group.mandatory { +html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory { background-color: rgba(220, 53, 69, 0.1); border-left-color: #ff6b6b; } -html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after { +html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after { color: #ff6b6b; } -html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { +html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator { color: #ff6b6b; } From 0045e532ee6027801936fc7ba4f737ee171ea63b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 08:58:35 +0100 Subject: [PATCH 03/10] Generate custom service form from config --- src/servala/core/crd.py | 186 +++++++++++++++++++++++++---- src/servala/core/forms.py | 10 +- src/servala/core/models/service.py | 16 +++ 3 files changed, 187 insertions(+), 25 deletions(-) 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): """ From cedcab85c4a0c5a0b8503b9ca1e25759c5374ab9 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 09:01:00 +0100 Subject: [PATCH 04/10] Use custom forms in instance update --- .../service_instance_update.html | 2 +- .../includes/tabbed_fieldset_form.html | 161 +++++++++++++----- src/servala/frontend/views/service.py | 66 ++++++- src/servala/static/js/expert-mode.js | 48 ++++++ 4 files changed, 237 insertions(+), 40 deletions(-) create mode 100644 src/servala/static/js/expert-mode.js diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html index 51a9213..74259e6 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -22,7 +22,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %} {% else %} - {% include "includes/tabbed_fieldset_form.html" with form=form %} + {% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %} {% endif %} diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index c34c41a..744df5f 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -6,48 +6,130 @@ {% if form_action %}action="{{ form_action }}"{% endif %}> {% csrf_token %} {% include "frontend/forms/errors.html" %} - -
- {% for fieldset in form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
+ {% if form %} +
+ +
+ {% endif %} +
+ {% if form and form.get_fieldsets|length == 1 %} + {# Single fieldset - render without tabs #} + {% for fieldset in form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} + {% elif form %} + {# Multiple fieldsets or auto-generated form - render with tabs #} + +
+ {% for fieldset in form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %}
- {% endfor %} + {% endif %}
+ {% if form and expert_form %} + + {% endif %} + {% if form %} + + {% endif %}
+{% if form %} + +{% endif %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 689f381..52b0c71 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -393,11 +393,75 @@ class ServiceInstanceUpdateView( def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["instance"] = self.object.spec_object + kwargs["prefix"] = "expert" return kwargs + def get_form(self, *args, ignore_data=False, **kwargs): + if not ignore_data: + return super().get_form(*args, **kwargs) + cls = self.get_form_class() + kwargs = self.get_form_kwargs() + if ignore_data: + kwargs.pop("data", None) + return cls(**kwargs) + + def get_custom_form(self, ignore_data=False): + cls = self.object.context.custom_model_form_class + if not cls: + return + kwargs = self.get_form_kwargs() + kwargs["prefix"] = "custom" + if ignore_data: + kwargs.pop("data", None) + return cls(**kwargs) + + @property + def is_custom_form(self): + # Note: "custom form" = user-friendly, subset of fields + # vs "expert form" = auto-generated (all technical fields) + return self.request.POST.get("active_form", "expert") == "custom" + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + if self.is_custom_form: + form = self.get_custom_form() + else: + form = self.get_form() + + if form.is_valid(): + return self.form_valid(form) + return self.form_invalid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.method == "POST": + if self.is_custom_form: + context["custom_form"] = self.get_custom_form() + context["form"] = self.get_form(ignore_data=True) + else: + context["custom_form"] = self.get_custom_form(ignore_data=True) + else: + context["custom_form"] = self.get_custom_form() + return context + + def _deep_merge(self, base, update): + for key, value in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._deep_merge(base[key], value) + else: + base[key] = value + return base + def form_valid(self, form): try: - spec_data = form.get_nested_data().get("spec") + form_data = form.get_nested_data() + spec_data = form_data.get("spec") + + if self.is_custom_form: + current_spec = dict(self.object.spec) if self.object.spec else {} + spec_data = self._deep_merge(current_spec, spec_data) + self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) messages.success( self.request, diff --git a/src/servala/static/js/expert-mode.js b/src/servala/static/js/expert-mode.js new file mode 100644 index 0000000..d83bb69 --- /dev/null +++ b/src/servala/static/js/expert-mode.js @@ -0,0 +1,48 @@ +(function() { + 'use strict'; + + let isExpertMode = false; + + function initExpertMode() { + const toggleButton = document.getElementById('expert-mode-toggle'); + if (!toggleButton) return; + + const customFormContainer = document.getElementById('custom-form-container'); + const expertFormContainer = document.getElementById('expert-form-container'); + + if (!customFormContainer || !expertFormContainer) { + console.warn('Expert mode containers not found'); + return; + } + + toggleButton.addEventListener('click', function() { + isExpertMode = !isExpertMode; + + const activeFormInput = document.getElementById('active-form-input'); + + if (isExpertMode) { + customFormContainer.style.display = 'none'; + expertFormContainer.style.display = 'block'; + toggleButton.innerHTML = ' Show Simplified Form'; + if (activeFormInput) activeFormInput.value = 'expert'; + } else { + customFormContainer.style.display = 'block'; + expertFormContainer.style.display = 'none'; + toggleButton.innerHTML = ' Show Expert Mode'; + if (activeFormInput) activeFormInput.value = 'custom'; + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initExpertMode); + } else { + initExpertMode(); + } + + document.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form' || event.detail.target.classList.contains('crd-form')) { + initExpertMode(); + } + }); +})(); From 9e7330e24d9da4a5f3407ad8d25ce89b7fbbe229 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 09:01:32 +0100 Subject: [PATCH 05/10] Fix form display details --- src/servala/frontend/templates/frontend/forms/field.html | 2 +- src/servala/frontend/templatetags/get_field.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/templates/frontend/forms/field.html b/src/servala/frontend/templates/frontend/forms/field.html index 3e0a30b..b09f812 100644 --- a/src/servala/frontend/templates/frontend/forms/field.html +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -14,7 +14,7 @@ {% endif %} {% if field.use_fieldset %}{% endif %} {% for text in field.errors %}
{{ text }}
{% endfor %} - {% if field.help_text %} + {% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %} {{ field.help_text|safe }} {% endif %} diff --git a/src/servala/frontend/templatetags/get_field.py b/src/servala/frontend/templatetags/get_field.py index 3214beb..2141178 100644 --- a/src/servala/frontend/templatetags/get_field.py +++ b/src/servala/frontend/templatetags/get_field.py @@ -1,3 +1,5 @@ +from contextlib import suppress + from django import template register = template.Library() @@ -5,4 +7,5 @@ register = template.Library() @register.filter def get_field(form, field_name): - return form[field_name] + with suppress(KeyError): + return form[field_name] From 652e0798f4a1e3c4a7589328dcdd4e60d4fe51e7 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 09:23:59 +0100 Subject: [PATCH 06/10] Make sure FQDN generation works with custom form --- src/servala/static/js/fqdn.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index a92071d..2e20f6d 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -1,7 +1,7 @@ -const initializeFqdnGeneration = () => { - const nameField = document.querySelector('input#id_name'); - const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input'); +const initializeFqdnGeneration = (prefix) => { + const nameField = document.querySelector(`input#id_${prefix}-name`); + const fqdnField = document.getElementById(`${prefix}-spec.parameters.service.fqdn_container`).querySelector('input.array-item-input'); if (nameField && fqdnField) { const generateFqdn = (instanceName) => { @@ -38,9 +38,10 @@ const initializeFqdnGeneration = () => { } } -document.addEventListener('DOMContentLoaded', initializeFqdnGeneration); +document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); document.body.addEventListener('htmx:afterSwap', function(event) { if (event.detail.target.id === 'service-form') { - initializeFqdnGeneration(); + initializeFqdnGeneration("custom"); + initializeFqdnGeneration("expert"); } }); From 63039171c16f801a582cb11144c71f87797ea571 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 10:14:52 +0100 Subject: [PATCH 07/10] Fix FQDN generation --- src/servala/static/js/fqdn.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index 2e20f6d..178c586 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -9,28 +9,23 @@ const initializeFqdnGeneration = (prefix) => { return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; } - const newNameField = nameField.cloneNode(true); - nameField.parentNode.replaceChild(newNameField, nameField); - const newFqdnField = fqdnField.cloneNode(true); - fqdnField.parentNode.replaceChild(newFqdnField, fqdnField); - - newNameField.addEventListener('input', function() { - if (!newFqdnField.dataset.manuallyEdited) { - newFqdnField.value = generateFqdn(this.value); - const container = newFqdnField.closest('.dynamic-array-widget'); + nameField.addEventListener('input', function() { + if (!fqdnField.dataset.manuallyEdited) { + fqdnField.value = generateFqdn(this.value); + const container = fqdnField.closest('.dynamic-array-widget'); if (container && window.updateHiddenInput) { window.updateHiddenInput(container); } } }); - newFqdnField.addEventListener('input', function() { + fqdnField.addEventListener('input', function() { this.dataset.manuallyEdited = 'true'; }); - if (newNameField.value && !newFqdnField.value) { - newFqdnField.value = generateFqdn(newNameField.value); - const container = newFqdnField.closest('.dynamic-array-widget'); + if (nameField.value && !fqdnField.value) { + fqdnField.value = generateFqdn(nameField.value); + const container = fqdnField.closest('.dynamic-array-widget'); if (container && window.updateHiddenInput) { window.updateHiddenInput(container); } From 2931315b969ccabd55e6315b04b9e2f096cfa36d Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 10:15:23 +0100 Subject: [PATCH 08/10] Remove org field from generated form --- src/servala/core/crd.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 5681ae0..aebb99f 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -6,7 +6,7 @@ from django.db import models from django.forms.models import ModelForm, ModelFormMetaclass from django.utils.translation import gettext_lazy as _ -from servala.core.models import ServiceInstance +from servala.core.models import ServiceInstance, ControlPlaneCRD from servala.frontend.forms.widgets import DynamicArrayField, DynamicArrayWidget @@ -37,7 +37,7 @@ def generate_django_model(schema, group, version, kind): """ # We always need these three fields to know our own name and our full namespace model_fields = {"__module__": "crd_models"} - for field_name in ("name", "organization", "context"): + for field_name in ("name", "context"): model_fields[field_name] = duplicate_field(field_name, ServiceInstance) # All other fields are generated from the schema, except for the @@ -290,9 +290,10 @@ class FormGeneratorMixin: 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 "context" in self.fields: + self.fields["context"].widget = forms.HiddenInput() + if "context" in self.initial: + self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=self.initial["context"].pk) if self.instance and hasattr(self.instance, "name") and self.instance.name: if "name" in self.fields: @@ -546,7 +547,6 @@ class CustomFormMixin(FormGeneratorMixin): 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", []): @@ -605,7 +605,7 @@ class CustomFormMixin(FormGeneratorMixin): def get_nested_data(self): nested = {} for field_name in self.fields.keys(): - if field_name in ("organization", "context"): + if field_name == "context": value = self.cleaned_data.get(field_name) if value is not None: nested[field_name] = value @@ -629,7 +629,7 @@ def generate_custom_form_class(form_config, model): """ Generate a custom (user-friendly) form class from form_config JSON. """ - field_list = ["organization", "context", "name"] + field_list = ["context", "name"] for fieldset in form_config.get("fieldsets", []): for field_config in fieldset.get("fields", []): @@ -638,13 +638,8 @@ def generate_custom_form_class(form_config, model): field_list.append(field_name) fields = { - "organization": forms.ModelChoiceField( - queryset=None, - required=True, - widget=forms.HiddenInput(), - ), "context": forms.ModelChoiceField( - queryset=None, + queryset=ControlPlaneCRD.objects.none(), required=True, widget=forms.HiddenInput(), ), From 7f99c78084dc352888dc588fbc7ef61b79f0df2b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 5 Nov 2025 10:18:46 +0100 Subject: [PATCH 09/10] Fix array widget container ID --- .../frontend/templates/frontend/forms/dynamic_array.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 9d61825..00c23b0 100644 --- a/src/servala/frontend/templates/frontend/forms/dynamic_array.html +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -1,5 +1,5 @@
Date: Wed, 5 Nov 2025 10:19:56 +0100 Subject: [PATCH 10/10] Implement instance creation from custom form --- src/servala/core/crd.py | 5 +- src/servala/core/models/service.py | 1 - .../service_offering_detail.html | 4 +- .../includes/tabbed_fieldset_form.html | 12 ++-- src/servala/frontend/views/service.py | 61 +++++++++++-------- 5 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index aebb99f..e414168 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -292,8 +292,9 @@ class FormGeneratorMixin: if "context" in self.fields: self.fields["context"].widget = forms.HiddenInput() - if "context" in self.initial: - self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=self.initial["context"].pk) + if crd := self.initial.get("context"): + crd = getattr(crd, "pk", crd) # can be int or object + self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) if self.instance and hasattr(self.instance, "name") and self.instance.name: if "name" in self.fields: diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 280c780..1535703 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -891,7 +891,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): return return self.context.django_model( name=self.name, - organization=self.organization, context=self.context, spec=self.spec, # We pass -1 as ID in order to make it clear that a) this object exists (remotely), diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 3305328..53e32d2 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -17,7 +17,7 @@ {% endif %} {% endpartialdef %} {% partialdef service-form %} -{% if service_form %} +{% if service_form or custom_service_form %}
@@ -26,7 +26,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=service_form %} + {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %} {% endif %}
diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 744df5f..41f1af2 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -16,6 +16,7 @@
{% endif %}
+ {% if form and form.context %}{{ form.context }}{% endif %} {% if form and form.get_fieldsets|length == 1 %} {# Single fieldset - render without tabs #} {% for fieldset in form.get_fieldsets %} @@ -131,13 +132,10 @@ {% endif %}
- +
{% if form %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 52b0c71..2c6923e 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -123,7 +123,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView def context_object(self): if self.request.method == "POST": return ControlPlaneCRD.objects.filter( - pk=self.request.POST.get("context"), + pk=self.request.POST.get("expert-context", self.request.POST.get("custom-context")), # Make sure we don’t use a malicious ID control_plane__in=self.planes, ).first() @@ -131,37 +131,49 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView control_plane=self.selected_plane, service_offering=self.object ).first() - def get_instance_form(self): - if not self.context_object or not self.context_object.model_form_class: - return None - initial = { + def get_instance_form_kwargs(self, ignore_data=False): + return {"initial": { "organization": self.request.organization, "context": self.context_object, - } + }, "prefix": "expert", "data": self.request.POST if (self.request.method == "POST" and not ignore_data) else None + } - # Pre-populate FQDN field if it exists and control plane has wildcard DNS - form_class = self.context_object.model_form_class - if ( - "spec.parameters.service.fqdn" in form_class.base_fields - and self.context_object.control_plane.wildcard_dns - ): - # Generate initial FQDN: instancename-namespace.wildcard_dns - # We'll set a placeholder that JavaScript will replace dynamically - initial["spec.parameters.service.fqdn"] = "" + def get_instance_form(self, ignore_data=False): + if not self.context_object or not self.context_object.model_form_class: + return - return form_class( - data=self.request.POST if self.request.method == "POST" else None, - initial=initial, - ) + return self.context_object.model_form_class(**self.get_instance_form_kwargs(ignore_data=ignore_data)) + + def get_custom_instance_form(self, ignore_data=False): + if not self.context_object or not self.context_object.custom_model_form_class: + return + kwargs = self.get_instance_form_kwargs(ignore_data=ignore_data) + kwargs["prefix"] = "custom" + return self.context_object.custom_model_form_class(**kwargs) + + @property + def is_custom_form(self): + # Note: "custom form" = user-friendly, subset of fields + # vs "expert form" = auto-generated (all technical fields) + return self.request.POST.get("active_form", "expert") == "custom" 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() context["selected_plane"] = self.selected_plane - context["service_form"] = self.get_instance_form() - # Pass data for dynamic FQDN generation + if self.request.method == "POST": + if self.is_custom_form: + context["service_form"] = self.get_instance_form(ignore_data=True) + context["custom_service_form"] = self.get_custom_instance_form() + else: + context["service_form"] = self.get_instance_form() + context["custom_service_form"] = self.get_custom_instance_form(ignore_data=True) + else: + context["service_form"] = self.get_instance_form() + context["custom_service_form"] = self.get_custom_instance_form() + if self.selected_plane and self.selected_plane.wildcard_dns: context["wildcard_dns"] = self.selected_plane.wildcard_dns context["organization_namespace"] = self.request.organization.namespace @@ -175,7 +187,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["form_error"] = True return self.render_to_response(context) - form = self.get_instance_form() + if self.is_custom_form: + form = self.get_custom_instance_form() + else: + form = self.get_instance_form() if not form: # Should not happen if context_object is valid, but as a safeguard messages.error( self.request, @@ -203,8 +218,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) form.add_error(None, error_message) - # If the form is not valid or if the service creation failed, we render it again - context["service_form"] = form return self.render_to_response(context)