Compare commits
3 commits
ece60ad3b1
...
985e4b47c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 985e4b47c0 | |||
| 6182b36daf | |||
| 1ed261d4b2 |
4 changed files with 345 additions and 11 deletions
|
|
@ -161,6 +161,9 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
|
|
||||||
form_config = cleaned_data.get("form_config")
|
form_config = cleaned_data.get("form_config")
|
||||||
if form_config:
|
if form_config:
|
||||||
|
form_config = self._normalize_form_config_types(form_config)
|
||||||
|
cleaned_data["form_config"] = form_config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jsonschema.validate(
|
jsonschema.validate(
|
||||||
instance=form_config, schema=self.form_config_schema
|
instance=form_config, schema=self.form_config_schema
|
||||||
|
|
@ -182,6 +185,42 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
def _normalize_form_config_types(self, form_config):
|
||||||
|
"""
|
||||||
|
Normalize form_config by converting string representations of numbers
|
||||||
|
to actual integers/floats. The JSON form widget sends all values
|
||||||
|
as strings, but the schema expects proper types.
|
||||||
|
"""
|
||||||
|
if not isinstance(form_config, dict):
|
||||||
|
return form_config
|
||||||
|
|
||||||
|
integer_fields = ["max_length", "rows", "min_values", "max_values"]
|
||||||
|
number_fields = ["min_value", "max_value"]
|
||||||
|
|
||||||
|
for fieldset in form_config.get("fieldsets", []):
|
||||||
|
for field in fieldset.get("fields", []):
|
||||||
|
for field_name in integer_fields:
|
||||||
|
if field_name in field and field[field_name] is not None:
|
||||||
|
value = field[field_name]
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
field[field_name] = int(value) if value else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
for field_name in number_fields:
|
||||||
|
if field_name in field and field[field_name] is not None:
|
||||||
|
value = field[field_name]
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
field[field_name] = (
|
||||||
|
int(value) if "." not in value else float(value)
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return form_config
|
||||||
|
|
||||||
def _validate_field_mappings(self, form_config, cleaned_data):
|
def _validate_field_mappings(self, form_config, cleaned_data):
|
||||||
if not self.instance.pk:
|
if not self.instance.pk:
|
||||||
return
|
return
|
||||||
|
|
@ -239,6 +278,32 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
if not mapping:
|
if not mapping:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
field_name = field.get("label", mapping)
|
||||||
|
custom_choices = field.get("choices", [])
|
||||||
|
|
||||||
|
# Single-element choices [value] are transformed to [value, value]
|
||||||
|
for i, choice in enumerate(custom_choices):
|
||||||
|
if not isinstance(choice, (list, tuple)):
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"Field '{}': Choice at index {} must be a list or tuple, "
|
||||||
|
"but got: {}"
|
||||||
|
).format(field_name, i, repr(choice))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
choice_len = len(choice)
|
||||||
|
if choice_len == 1:
|
||||||
|
custom_choices[i] = [choice[0], choice[0]]
|
||||||
|
elif choice_len == 0 or choice_len > 2:
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"Field '{}': Choice at index {} must have 1 or 2 elements "
|
||||||
|
"(got {}): {}"
|
||||||
|
).format(field_name, i, choice_len, repr(choice))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
field_schema = self._get_field_schema(spec_schema, mapping, prefix)
|
field_schema = self._get_field_schema(spec_schema, mapping, prefix)
|
||||||
if not field_schema:
|
if not field_schema:
|
||||||
return
|
return
|
||||||
|
|
@ -247,7 +312,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
if not control_plane_choices:
|
if not control_plane_choices:
|
||||||
return
|
return
|
||||||
|
|
||||||
custom_choices = field.get("choices", [])
|
|
||||||
custom_choice_values = [choice[0] for choice in custom_choices]
|
custom_choice_values = [choice[0] for choice in custom_choices]
|
||||||
|
|
||||||
invalid_choices = [
|
invalid_choices = [
|
||||||
|
|
@ -257,7 +321,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
]
|
]
|
||||||
|
|
||||||
if invalid_choices:
|
if invalid_choices:
|
||||||
field_name = field.get("label", mapping)
|
|
||||||
errors.append(
|
errors.append(
|
||||||
_(
|
_(
|
||||||
"Field '{}' has invalid choice values: {}. "
|
"Field '{}' has invalid choice values: {}. "
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include "frontend/forms/errors.html" %}
|
{% include "frontend/forms/errors.html" %}
|
||||||
{% if form %}
|
{% if form %}
|
||||||
<div class="mb-3">
|
<div class="mb-3 text-end">
|
||||||
<button type="button"
|
<a href="#"
|
||||||
class="btn btn-sm btn-outline-secondary ml-auto d-block"
|
class="text-muted small"
|
||||||
id="expert-mode-toggle">
|
id="expert-mode-toggle"
|
||||||
<i class="bi bi-code-square"></i> {% translate "Show Expert Mode" %}
|
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="custom-form-container"
|
<div id="custom-form-container"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleButton.addEventListener('click', function() {
|
toggleButton.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
isExpertMode = !isExpertMode;
|
isExpertMode = !isExpertMode;
|
||||||
|
|
||||||
const activeFormInput = document.getElementById('active-form-input');
|
const activeFormInput = document.getElementById('active-form-input');
|
||||||
|
|
@ -23,12 +24,12 @@
|
||||||
if (isExpertMode) {
|
if (isExpertMode) {
|
||||||
customFormContainer.style.display = 'none';
|
customFormContainer.style.display = 'none';
|
||||||
expertFormContainer.style.display = 'block';
|
expertFormContainer.style.display = 'block';
|
||||||
toggleButton.innerHTML = '<i class="bi bi-code-square-fill"></i> Show Simplified Form';
|
toggleButton.textContent = 'Show Simplified Form';
|
||||||
if (activeFormInput) activeFormInput.value = 'expert';
|
if (activeFormInput) activeFormInput.value = 'expert';
|
||||||
} else {
|
} else {
|
||||||
customFormContainer.style.display = 'block';
|
customFormContainer.style.display = 'block';
|
||||||
expertFormContainer.style.display = 'none';
|
expertFormContainer.style.display = 'none';
|
||||||
toggleButton.innerHTML = '<i class="bi bi-code-square"></i> Show Expert Mode';
|
toggleButton.textContent = 'Show Expert Mode';
|
||||||
if (activeFormInput) activeFormInput.value = 'custom';
|
if (activeFormInput) activeFormInput.value = 'custom';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -633,3 +633,274 @@ def test_default_value_not_override_existing_instance():
|
||||||
|
|
||||||
assert form.initial["name"] == "existing-name"
|
assert form.initial["name"] == "existing-name"
|
||||||
assert form.initial["port"] == 3000
|
assert form.initial["port"] == 3000
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_config_coerces_string_numbers_to_integers():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
schema = form.form_config_schema
|
||||||
|
|
||||||
|
config_with_string_numbers = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Service Name",
|
||||||
|
"controlplane_field_mapping": "spec.serviceName",
|
||||||
|
"max_length": "64", # String instead of integer
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Description",
|
||||||
|
"controlplane_field_mapping": "spec.description",
|
||||||
|
"rows": "5", # String instead of integer
|
||||||
|
"max_length": "500", # String instead of integer
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"label": "Port",
|
||||||
|
"controlplane_field_mapping": "spec.port",
|
||||||
|
"min_value": "1", # String instead of integer
|
||||||
|
"max_value": "65535", # String instead of integer
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"label": "Tags",
|
||||||
|
"controlplane_field_mapping": "spec.tags",
|
||||||
|
"min_values": "0", # String instead of integer
|
||||||
|
"max_values": "10", # String instead of integer
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_config = form._normalize_form_config_types(config_with_string_numbers)
|
||||||
|
fields = normalized_config["fieldsets"][0]["fields"]
|
||||||
|
|
||||||
|
assert fields[0]["max_length"] == 64
|
||||||
|
assert isinstance(fields[0]["max_length"], int)
|
||||||
|
|
||||||
|
assert fields[1]["rows"] == 5
|
||||||
|
assert isinstance(fields[1]["rows"], int)
|
||||||
|
assert fields[1]["max_length"] == 500
|
||||||
|
assert isinstance(fields[1]["max_length"], int)
|
||||||
|
|
||||||
|
assert fields[2]["min_value"] == 1
|
||||||
|
assert isinstance(fields[2]["min_value"], int)
|
||||||
|
assert fields[2]["max_value"] == 65535
|
||||||
|
assert isinstance(fields[2]["max_value"], int)
|
||||||
|
|
||||||
|
assert fields[3]["min_values"] == 0
|
||||||
|
assert isinstance(fields[3]["min_values"], int)
|
||||||
|
assert fields[3]["max_values"] == 10
|
||||||
|
assert isinstance(fields[3]["max_values"], int)
|
||||||
|
|
||||||
|
jsonschema.validate(instance=normalized_config, schema=schema)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_config_handles_float_numbers():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
|
||||||
|
config_with_floats = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"label": "Price",
|
||||||
|
"controlplane_field_mapping": "spec.price",
|
||||||
|
"min_value": "0.01", # String float
|
||||||
|
"max_value": "999.99", # String float
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_config = form._normalize_form_config_types(config_with_floats)
|
||||||
|
field = normalized_config["fieldsets"][0]["fields"][0]
|
||||||
|
|
||||||
|
assert field["min_value"] == 0.01
|
||||||
|
assert isinstance(field["min_value"], float)
|
||||||
|
assert field["max_value"] == 999.99
|
||||||
|
assert isinstance(field["max_value"], float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_config_handles_empty_string_as_none():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
|
||||||
|
config_with_empty_strings = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Name",
|
||||||
|
"controlplane_field_mapping": "name",
|
||||||
|
"max_length": "", # Empty string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_config = form._normalize_form_config_types(config_with_empty_strings)
|
||||||
|
field = normalized_config["fieldsets"][0]["fields"][0]
|
||||||
|
assert field["max_length"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_element_choices_are_normalized():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
mock_crd = Mock()
|
||||||
|
mock_crd.resource_schema = {
|
||||||
|
"properties": {
|
||||||
|
"spec": {
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["6.2", "7.0", "7.2"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_with_single_choices = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Name",
|
||||||
|
"controlplane_field_mapping": "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"label": "Version",
|
||||||
|
"controlplane_field_mapping": "spec.version",
|
||||||
|
"choices": [["6.2"]], # Single element - should be transformed
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
spec_schema = mock_crd.resource_schema["properties"]["spec"]
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for field in config_with_single_choices["fieldsets"][0]["fields"]:
|
||||||
|
if field.get("type") == "choice":
|
||||||
|
form._validate_choice_field(
|
||||||
|
field, field["controlplane_field_mapping"], spec_schema, "spec", errors
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(errors) == 0, f"Expected no errors but got: {errors}"
|
||||||
|
version_field = config_with_single_choices["fieldsets"][0]["fields"][1]
|
||||||
|
assert version_field["choices"] == [["6.2", "6.2"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_element_choices_work_correctly():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
mock_crd = Mock()
|
||||||
|
mock_crd.resource_schema = {
|
||||||
|
"properties": {
|
||||||
|
"spec": {
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["6.2", "7.0"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_with_proper_choices = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"label": "Version",
|
||||||
|
"controlplane_field_mapping": "spec.version",
|
||||||
|
"choices": [["6.2", "Version 6.2"], ["7.0", "Version 7.0"]],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
spec_schema = mock_crd.resource_schema["properties"]["spec"]
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for field in config_with_proper_choices["fieldsets"][0]["fields"]:
|
||||||
|
if field.get("type") == "choice":
|
||||||
|
form._validate_choice_field(
|
||||||
|
field, field["controlplane_field_mapping"], spec_schema, "spec", errors
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(errors) == 0, f"Expected no errors but got: {errors}"
|
||||||
|
version_field = config_with_proper_choices["fieldsets"][0]["fields"][0]
|
||||||
|
assert version_field["choices"] == [["6.2", "Version 6.2"], ["7.0", "Version 7.0"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_choices_fail_validation():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
config_with_empty_choice = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"label": "Version",
|
||||||
|
"controlplane_field_mapping": "spec.version",
|
||||||
|
"choices": [[]], # Empty choice - invalid
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for field in config_with_empty_choice["fieldsets"][0]["fields"]:
|
||||||
|
if field.get("type") == "choice":
|
||||||
|
form._validate_choice_field(
|
||||||
|
field, field["controlplane_field_mapping"], {}, "spec", errors
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(errors) > 0
|
||||||
|
assert "must have 1 or 2 elements" in str(errors[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_plus_element_choices_fail_validation():
|
||||||
|
form = ServiceDefinitionAdminForm()
|
||||||
|
config_with_long_choice = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"label": "Version",
|
||||||
|
"controlplane_field_mapping": "spec.version",
|
||||||
|
"choices": [
|
||||||
|
["6.2", "Version 6.2", "Extra"]
|
||||||
|
], # 3 elements - invalid
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for field in config_with_long_choice["fieldsets"][0]["fields"]:
|
||||||
|
if field.get("type") == "choice":
|
||||||
|
form._validate_choice_field(
|
||||||
|
field, field["controlplane_field_mapping"], {}, "spec", errors
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(errors) > 0
|
||||||
|
assert "must have 1 or 2 elements" in str(errors[0])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue