diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index fb64a2b..87da376 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,6 +1,3 @@ -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 @@ -316,9 +313,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): ( _("Form Configuration"), { - "fields": ("form_config",), + "fields": ("advanced_fields",), "description": _( - "Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec." + "Configure which fields should be hidden behind an 'Advanced' toggle in the form" ), }, ), @@ -326,13 +323,19 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - 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 + # 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 ) return form diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index e414168..5d5c34e 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -6,8 +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, ControlPlaneCRD -from servala.frontend.forms.widgets import DynamicArrayField, DynamicArrayWidget +from servala.core.models import ServiceInstance class CRDModel(models.Model): @@ -23,11 +22,17 @@ 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 @@ -37,7 +42,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", "context"): + for field_name in ("name", "organization", "context"): model_fields[field_name] = duplicate_field(field_name, ServiceInstance) # All other fields are generated from the schema, except for the @@ -245,6 +250,8 @@ 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, @@ -284,31 +291,7 @@ def unnest_data(data): return result -class FormGeneratorMixin: - IS_CUSTOM_FORM = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if "context" in self.fields: - self.fields["context"].widget = forms.HiddenInput() - 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: - 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): +class CrdModelFormMixin: HIDDEN_FIELDS = [ "spec.compositeDeletePolicy", "spec.compositionRef", @@ -334,6 +317,9 @@ class CrdModelFormMixin(FormGeneratorMixin): 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 @@ -341,11 +327,22 @@ class CrdModelFormMixin(FormGeneratorMixin): 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 = [] @@ -527,136 +524,3 @@ 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) - - 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 == "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 = ["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 = { - "context": forms.ModelChoiceField( - queryset=ControlPlaneCRD.objects.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 9742233..034d1c9 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -1,7 +1,3 @@ -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 @@ -128,10 +124,6 @@ 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() @@ -159,25 +151,6 @@ 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/migrations/0012_remove_advanced_fields.py b/src/servala/core/migrations/0012_remove_advanced_fields.py deleted file mode 100644 index d60d4cc..0000000 --- a/src/servala/core/migrations/0012_remove_advanced_fields.py +++ /dev/null @@ -1,32 +0,0 @@ -# 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 deleted file mode 100644 index bd35891..0000000 --- a/src/servala/core/migrations/0013_add_form_config.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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 1535703..d03ef6b 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -360,16 +360,6 @@ 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, @@ -512,22 +502,6 @@ 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): """ @@ -891,6 +865,7 @@ 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/core/schemas/form_config_schema.json b/src/servala/core/schemas/form_config_schema.json deleted file mode 100644 index 1049ed8..0000000 --- a/src/servala/core/schemas/form_config_schema.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "$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 - }, - "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", - "items": { - "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"] - } - } - } - } - } - } - } - } - } -} diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 00c23b0..9d61825 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 @@
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %} + {% include "includes/tabbed_fieldset_form.html" with form=form %} {% endif %} 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 53e32d2..3305328 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 or custom_service_form %} +{% if service_form %}