Compare commits
No commits in common. "aa77a10de21aa761582182cb0d58c4555c0822b3" and "880d10c5e57064ab18936cac1c4dd80e669e38fb" have entirely different histories.
aa77a10de2
...
880d10c5e5
17 changed files with 143 additions and 706 deletions
|
|
@ -1,6 +1,3 @@
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_jsonform.widgets import JSONFormWidget
|
from django_jsonform.widgets import JSONFormWidget
|
||||||
|
|
@ -316,9 +313,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
|
||||||
(
|
(
|
||||||
_("Form Configuration"),
|
_("Form Configuration"),
|
||||||
{
|
{
|
||||||
"fields": ("form_config",),
|
"fields": ("advanced_fields",),
|
||||||
"description": _(
|
"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):
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
form = super().get_form(request, obj, **kwargs)
|
form = super().get_form(request, obj, **kwargs)
|
||||||
schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
|
# JSON schema for advanced_fields field
|
||||||
with open(schema_path) as f:
|
advanced_fields_schema = {
|
||||||
form_config_schema = json.load(f)
|
"type": "array",
|
||||||
|
"title": "Advanced Fields",
|
||||||
if "form_config" in form.base_fields:
|
"items": {
|
||||||
form.base_fields["form_config"].widget = JSONFormWidget(
|
"type": "string",
|
||||||
schema=form_config_schema
|
"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
|
return form
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ from django.db import models
|
||||||
from django.forms.models import ModelForm, ModelFormMetaclass
|
from django.forms.models import ModelForm, ModelFormMetaclass
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from servala.core.models import ServiceInstance, ControlPlaneCRD
|
from servala.core.models import ServiceInstance
|
||||||
from servala.frontend.forms.widgets import DynamicArrayField, DynamicArrayWidget
|
|
||||||
|
|
||||||
|
|
||||||
class CRDModel(models.Model):
|
class CRDModel(models.Model):
|
||||||
|
|
@ -23,11 +22,17 @@ class CRDModel(models.Model):
|
||||||
|
|
||||||
|
|
||||||
def duplicate_field(field_name, model):
|
def duplicate_field(field_name, model):
|
||||||
|
# Get the field from the model
|
||||||
field = model._meta.get_field(field_name)
|
field = model._meta.get_field(field_name)
|
||||||
|
|
||||||
|
# Create a new field with the same attributes
|
||||||
new_field = type(field).__new__(type(field))
|
new_field = type(field).__new__(type(field))
|
||||||
new_field.__dict__.update(field.__dict__)
|
new_field.__dict__.update(field.__dict__)
|
||||||
|
|
||||||
|
# Ensure the field is not linked to the original model
|
||||||
new_field.model = None
|
new_field.model = None
|
||||||
new_field.auto_created = False
|
new_field.auto_created = False
|
||||||
|
|
||||||
return new_field
|
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
|
# We always need these three fields to know our own name and our full namespace
|
||||||
model_fields = {"__module__": "crd_models"}
|
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)
|
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
|
||||||
|
|
||||||
# All other fields are generated from the schema, except for the
|
# All other fields are generated from the schema, except for the
|
||||||
|
|
@ -245,6 +250,8 @@ def get_django_field(
|
||||||
)
|
)
|
||||||
elif field_type == "array":
|
elif field_type == "array":
|
||||||
kwargs["help_text"] = field_schema.get("description") or _("List of values")
|
kwargs["help_text"] = field_schema.get("description") or _("List of values")
|
||||||
|
from servala.frontend.forms.widgets import DynamicArrayField
|
||||||
|
|
||||||
field = models.JSONField(**kwargs)
|
field = models.JSONField(**kwargs)
|
||||||
formfield_kwargs = {
|
formfield_kwargs = {
|
||||||
"label": field.verbose_name,
|
"label": field.verbose_name,
|
||||||
|
|
@ -284,31 +291,7 @@ def unnest_data(data):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class FormGeneratorMixin:
|
class CrdModelFormMixin:
|
||||||
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):
|
|
||||||
HIDDEN_FIELDS = [
|
HIDDEN_FIELDS = [
|
||||||
"spec.compositeDeletePolicy",
|
"spec.compositeDeletePolicy",
|
||||||
"spec.compositionRef",
|
"spec.compositionRef",
|
||||||
|
|
@ -334,6 +317,9 @@ class CrdModelFormMixin(FormGeneratorMixin):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.schema = self._meta.model.SCHEMA
|
self.schema = self._meta.model.SCHEMA
|
||||||
|
|
||||||
|
for field in ("organization", "context"):
|
||||||
|
self.fields[field].widget = forms.HiddenInput()
|
||||||
|
|
||||||
for name, field in self.fields.items():
|
for name, field in self.fields.items():
|
||||||
if name in self.HIDDEN_FIELDS or any(
|
if name in self.HIDDEN_FIELDS or any(
|
||||||
name.startswith(f) for f in self.HIDDEN_FIELDS
|
name.startswith(f) for f in self.HIDDEN_FIELDS
|
||||||
|
|
@ -341,11 +327,22 @@ class CrdModelFormMixin(FormGeneratorMixin):
|
||||||
field.widget = forms.HiddenInput()
|
field.widget = forms.HiddenInput()
|
||||||
field.required = False
|
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):
|
def strip_title(self, field_name, label):
|
||||||
field = self.fields[field_name]
|
field = self.fields[field_name]
|
||||||
if field and field.label and (position := field.label.find(label)) != -1:
|
if field and field.label and (position := field.label.find(label)) != -1:
|
||||||
field.label = field.label[position + len(label) :]
|
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):
|
def get_fieldsets(self):
|
||||||
fieldsets = []
|
fieldsets = []
|
||||||
|
|
||||||
|
|
@ -527,136 +524,3 @@ def generate_model_form_class(model):
|
||||||
}
|
}
|
||||||
class_name = f"{model.__name__}ModelForm"
|
class_name = f"{model.__name__}ModelForm"
|
||||||
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import jsonschema
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_jsonform.widgets import JSONFormWidget
|
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_version"].initial = api_def.get("version", "")
|
||||||
self.fields["api_kind"].initial = api_def.get("kind", "")
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
|
@ -159,25 +151,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
api_def["kind"] = api_kind
|
api_def["kind"] = api_kind
|
||||||
cleaned_data["api_definition"] = api_def
|
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
|
return cleaned_data
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -360,16 +360,6 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
|
||||||
null=True,
|
null=True,
|
||||||
blank=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(
|
service = models.ForeignKey(
|
||||||
to="Service",
|
to="Service",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
|
@ -512,22 +502,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
return
|
return
|
||||||
return generate_model_form_class(self.django_model)
|
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):
|
class ServiceOffering(ServalaModelMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
@ -891,6 +865,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
return
|
return
|
||||||
return self.context.django_model(
|
return self.context.django_model(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
|
organization=self.organization,
|
||||||
context=self.context,
|
context=self.context,
|
||||||
spec=self.spec,
|
spec=self.spec,
|
||||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="dynamic-array-widget"
|
<div class="dynamic-array-widget"
|
||||||
id="{{ widget.name }}_container"
|
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
|
||||||
data-name="{{ widget.name }}"
|
data-name="{{ widget.name }}"
|
||||||
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
|
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if field.use_fieldset %}</fieldset>{% endif %}
|
{% if field.use_fieldset %}</fieldset>{% endif %}
|
||||||
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
|
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
|
||||||
{% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %}
|
{% if field.help_text %}
|
||||||
<small class="form-text text-muted"
|
<small class="form-text text-muted"
|
||||||
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
|
{% include "includes/tabbed_fieldset_form.html" with form=form %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endpartialdef %}
|
{% endpartialdef %}
|
||||||
{% partialdef service-form %}
|
{% partialdef service-form %}
|
||||||
{% if service_form or custom_service_form %}
|
{% if service_form %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex align-items-center"></div>
|
<div class="card-header d-flex align-items-center"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
|
{% include "includes/tabbed_fieldset_form.html" with form=service_form %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,38 +6,6 @@
|
||||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include "frontend/forms/errors.html" %}
|
{% include "frontend/forms/errors.html" %}
|
||||||
{% if form %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-sm btn-outline-secondary ml-auto d-block"
|
|
||||||
id="expert-mode-toggle">
|
|
||||||
<i class="bi bi-code-square"></i> {% translate "Show Expert Mode" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div id="custom-form-container" class="{% if form %}custom-crd-form{% else %}expert-crd-form{% 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 %}
|
|
||||||
<div class="my-2">
|
|
||||||
{% 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 %}
|
|
||||||
<div>
|
|
||||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
|
||||||
{% for field in subfieldset.fields %}
|
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% elif form %}
|
|
||||||
{# Multiple fieldsets or auto-generated form - render with tabs #}
|
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
{% for fieldset in form.get_fieldsets %}
|
{% for fieldset in form.get_fieldsets %}
|
||||||
{% if not fieldset.hidden %}
|
{% if not fieldset.hidden %}
|
||||||
|
|
@ -80,64 +48,13 @@
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if form and expert_form %}
|
|
||||||
<div id="expert-form-container" class="expert-crd-form" style="display:none;">
|
|
||||||
<ul class="nav nav-tabs" id="expertTab" role="tablist">
|
|
||||||
{% for fieldset in expert_form.get_fieldsets %}
|
|
||||||
{% if not fieldset.hidden %}
|
|
||||||
<li class="nav-item"
|
|
||||||
role="presentation">
|
|
||||||
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
|
||||||
id="expert-{{ fieldset.title|slugify }}-tab"
|
|
||||||
data-bs-toggle="tab"
|
|
||||||
data-bs-target="#expert-{{ fieldset.title|slugify }}"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-controls="expert-{{ fieldset.title|slugify }}"
|
|
||||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
|
||||||
{{ fieldset.title }}
|
|
||||||
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<div class="tab-content" id="expertTabContent">
|
|
||||||
{% for fieldset in expert_form.get_fieldsets %}
|
|
||||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
|
||||||
id="expert-{{ fieldset.title|slugify }}"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
|
|
||||||
{% for field in fieldset.fields %}
|
|
||||||
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for subfieldset in fieldset.fieldsets %}
|
|
||||||
{% if subfieldset.fields %}
|
|
||||||
<div>
|
|
||||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
|
||||||
{% for field in subfieldset.fields %}
|
|
||||||
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if form %}
|
|
||||||
<input type="hidden" name="active_form" id="active-form-input" value="custom">
|
|
||||||
{% endif %}
|
|
||||||
<div class="col-sm-12 d-flex justify-content-end">
|
<div class="col-sm-12 d-flex justify-content-end">
|
||||||
<input class="btn btn-primary me-1 mb-1" type="submit"
|
<button class="btn btn-primary me-1 mb-1" type="submit">
|
||||||
{% if form and expert_form %}formnovalidate {% endif %} {# browser form validation fails when there are fields missing/invalid that are hidden #}
|
{% if form_submit_label %}
|
||||||
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}"
|
{{ form_submit_label }}
|
||||||
>
|
{% else %}
|
||||||
|
{% translate "Save" %}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% if form %}
|
|
||||||
<script defer src="{% static 'js/expert-mode.js' %}"></script>
|
|
||||||
{% endif %}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
@ -7,5 +5,4 @@ register = template.Library()
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_field(form, field_name):
|
def get_field(form, field_name):
|
||||||
with suppress(KeyError):
|
|
||||||
return form[field_name]
|
return form[field_name]
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
def context_object(self):
|
def context_object(self):
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
return ControlPlaneCRD.objects.filter(
|
return ControlPlaneCRD.objects.filter(
|
||||||
pk=self.request.POST.get("expert-context", self.request.POST.get("custom-context")),
|
pk=self.request.POST.get("context"),
|
||||||
# Make sure we don’t use a malicious ID
|
# Make sure we don’t use a malicious ID
|
||||||
control_plane__in=self.planes,
|
control_plane__in=self.planes,
|
||||||
).first()
|
).first()
|
||||||
|
|
@ -131,49 +131,37 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
control_plane=self.selected_plane, service_offering=self.object
|
control_plane=self.selected_plane, service_offering=self.object
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
def get_instance_form(self):
|
||||||
|
if not self.context_object or not self.context_object.model_form_class:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_instance_form_kwargs(self, ignore_data=False):
|
initial = {
|
||||||
return {"initial": {
|
|
||||||
"organization": self.request.organization,
|
"organization": self.request.organization,
|
||||||
"context": self.context_object,
|
"context": self.context_object,
|
||||||
}, "prefix": "expert", "data": self.request.POST if (self.request.method == "POST" and not ignore_data) else None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_instance_form(self, ignore_data=False):
|
# Pre-populate FQDN field if it exists and control plane has wildcard DNS
|
||||||
if not self.context_object or not self.context_object.model_form_class:
|
form_class = self.context_object.model_form_class
|
||||||
return
|
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"] = ""
|
||||||
|
|
||||||
return self.context_object.model_form_class(**self.get_instance_form_kwargs(ignore_data=ignore_data))
|
return form_class(
|
||||||
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
def get_custom_instance_form(self, ignore_data=False):
|
initial=initial,
|
||||||
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["select_form"] = self.select_form
|
context["select_form"] = self.select_form
|
||||||
context["has_control_planes"] = self.planes.exists()
|
context["has_control_planes"] = self.planes.exists()
|
||||||
context["selected_plane"] = self.selected_plane
|
context["selected_plane"] = self.selected_plane
|
||||||
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["service_form"] = self.get_instance_form()
|
||||||
context["custom_service_form"] = self.get_custom_instance_form(ignore_data=True)
|
# Pass data for dynamic FQDN generation
|
||||||
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:
|
if self.selected_plane and self.selected_plane.wildcard_dns:
|
||||||
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
||||||
context["organization_namespace"] = self.request.organization.namespace
|
context["organization_namespace"] = self.request.organization.namespace
|
||||||
|
|
@ -187,9 +175,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
context["form_error"] = True
|
context["form_error"] = True
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if self.is_custom_form:
|
|
||||||
form = self.get_custom_instance_form()
|
|
||||||
else:
|
|
||||||
form = self.get_instance_form()
|
form = self.get_instance_form()
|
||||||
if not form: # Should not happen if context_object is valid, but as a safeguard
|
if not form: # Should not happen if context_object is valid, but as a safeguard
|
||||||
messages.error(
|
messages.error(
|
||||||
|
|
@ -218,6 +203,8 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
)
|
)
|
||||||
form.add_error(None, error_message)
|
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)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -406,75 +393,11 @@ class ServiceInstanceUpdateView(
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs["instance"] = self.object.spec_object
|
kwargs["instance"] = self.object.spec_object
|
||||||
kwargs["prefix"] = "expert"
|
|
||||||
return kwargs
|
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):
|
def form_valid(self, form):
|
||||||
try:
|
try:
|
||||||
form_data = form.get_nested_data()
|
spec_data = form.get_nested_data().get("spec")
|
||||||
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)
|
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
|
|
|
||||||
|
|
@ -237,42 +237,42 @@ a.btn-keycloak {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expert CRD Form mandatory field styling */
|
/* CRD Form mandatory field styling */
|
||||||
.expert-crd-form .form-group.mandatory .form-label {
|
.crd-form .form-group.mandatory .form-label {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-crd-form .form-group.mandatory .form-label::after {
|
.crd-form .form-group.mandatory .form-label::after {
|
||||||
content: " *";
|
content: " *";
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-crd-form .form-group.mandatory {
|
.crd-form .form-group.mandatory {
|
||||||
border-left: 3px solid #dc3545;
|
border-left: 3px solid #dc3545;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
background-color: rgba(220, 53, 69, 0.05);
|
background-color: rgba(220, 53, 69, 0.05);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
|
.crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory {
|
html[data-bs-theme="dark"] .crd-form .form-group.mandatory {
|
||||||
background-color: rgba(220, 53, 69, 0.1);
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
border-left-color: #ff6b6b;
|
border-left-color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after {
|
html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
|
html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
(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 = '<i class="bi bi-code-square-fill"></i> Show Simplified Form';
|
|
||||||
if (activeFormInput) activeFormInput.value = 'expert';
|
|
||||||
} else {
|
|
||||||
customFormContainer.style.display = 'block';
|
|
||||||
expertFormContainer.style.display = 'none';
|
|
||||||
toggleButton.innerHTML = '<i class="bi bi-code-square"></i> 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
const initializeFqdnGeneration = (prefix) => {
|
const initializeFqdnGeneration = () => {
|
||||||
const nameField = document.querySelector(`input#id_${prefix}-name`);
|
const nameField = document.querySelector('input#id_name');
|
||||||
const fqdnField = document.getElementById(`${prefix}-spec.parameters.service.fqdn_container`).querySelector('input.array-item-input');
|
const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input');
|
||||||
|
|
||||||
if (nameField && fqdnField) {
|
if (nameField && fqdnField) {
|
||||||
const generateFqdn = (instanceName) => {
|
const generateFqdn = (instanceName) => {
|
||||||
|
|
@ -9,23 +9,28 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
nameField.addEventListener('input', function() {
|
const newNameField = nameField.cloneNode(true);
|
||||||
if (!fqdnField.dataset.manuallyEdited) {
|
nameField.parentNode.replaceChild(newNameField, nameField);
|
||||||
fqdnField.value = generateFqdn(this.value);
|
const newFqdnField = fqdnField.cloneNode(true);
|
||||||
const container = fqdnField.closest('.dynamic-array-widget');
|
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');
|
||||||
if (container && window.updateHiddenInput) {
|
if (container && window.updateHiddenInput) {
|
||||||
window.updateHiddenInput(container);
|
window.updateHiddenInput(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fqdnField.addEventListener('input', function() {
|
newFqdnField.addEventListener('input', function() {
|
||||||
this.dataset.manuallyEdited = 'true';
|
this.dataset.manuallyEdited = 'true';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nameField.value && !fqdnField.value) {
|
if (newNameField.value && !newFqdnField.value) {
|
||||||
fqdnField.value = generateFqdn(nameField.value);
|
newFqdnField.value = generateFqdn(newNameField.value);
|
||||||
const container = fqdnField.closest('.dynamic-array-widget');
|
const container = newFqdnField.closest('.dynamic-array-widget');
|
||||||
if (container && window.updateHiddenInput) {
|
if (container && window.updateHiddenInput) {
|
||||||
window.updateHiddenInput(container);
|
window.updateHiddenInput(container);
|
||||||
}
|
}
|
||||||
|
|
@ -33,10 +38,9 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")});
|
document.addEventListener('DOMContentLoaded', initializeFqdnGeneration);
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
if (event.detail.target.id === 'service-form') {
|
if (event.detail.target.id === 'service-form') {
|
||||||
initializeFqdnGeneration("custom");
|
initializeFqdnGeneration();
|
||||||
initializeFqdnGeneration("expert");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue