From 1cf1947539c45f8e22830bbc6705f880d87b6195 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 31 Oct 2025 11:49:04 +0100 Subject: [PATCH] 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"] + } + } + ] + } + } + } + } + } + } +}