Merge pull request 'Disk size SI units' (#302) from 287-disk-size-unit into main
Reviewed-on: #302
This commit is contained in:
commit
8b0c2a8d43
5 changed files with 102 additions and 38 deletions
|
|
@ -239,9 +239,9 @@ The Servala Team"""
|
||||||
service_offering = ServiceOffering.objects.get(
|
service_offering = ServiceOffering.objects.get(
|
||||||
osb_plan_id=plan_id, service=service
|
osb_plan_id=plan_id, service=service
|
||||||
)
|
)
|
||||||
except Service.DoesNotExist:
|
except Service.DoesNotExist: # pragma: no-cover
|
||||||
return self._error(f"Unknown service_id: {service_id}")
|
return self._error(f"Unknown service_id: {service_id}")
|
||||||
except ServiceOffering.DoesNotExist:
|
except ServiceOffering.DoesNotExist: # pragma: no-cover
|
||||||
return self._error(
|
return self._error(
|
||||||
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
|
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
|
||||||
)
|
)
|
||||||
|
|
@ -284,7 +284,7 @@ The Servala Team"""
|
||||||
|
|
||||||
if service_instance:
|
if service_instance:
|
||||||
organization = service_instance.organization
|
organization = service_instance.organization
|
||||||
except Exception:
|
except Exception: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
description_parts = [f"Action: {action}", f"Service: {service.name}"]
|
description_parts = [f"Action: {action}", f"Service: {service.name}"]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.forms.models import ModelForm, ModelFormMetaclass
|
from django.forms.models import ModelForm, ModelFormMetaclass
|
||||||
|
|
||||||
from servala.core.crd.utils import deslugify
|
from servala.core.crd.utils import deslugify
|
||||||
from servala.core.models import ControlPlaneCRD
|
from servala.core.models import ControlPlaneCRD
|
||||||
from servala.frontend.forms.widgets import DynamicArrayWidget
|
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
|
||||||
|
|
||||||
# Fields that must be present in every form
|
# Fields that must be present in every form
|
||||||
MANDATORY_FIELDS = ["name"]
|
MANDATORY_FIELDS = ["name"]
|
||||||
|
|
@ -25,6 +27,11 @@ DEFAULT_FIELD_CONFIGS = {
|
||||||
"help_text": "Domain names for accessing this service",
|
"help_text": "Domain names for accessing this service",
|
||||||
"required": False,
|
"required": False,
|
||||||
},
|
},
|
||||||
|
"spec.parameters.size.disk": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Disk size",
|
||||||
|
"addon_text": "Gi",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -335,6 +342,19 @@ class CustomFormMixin(FormGeneratorMixin):
|
||||||
if field_type == "number":
|
if field_type == "number":
|
||||||
min_val = field_config.get("min_value")
|
min_val = field_config.get("min_value")
|
||||||
max_val = field_config.get("max_value")
|
max_val = field_config.get("max_value")
|
||||||
|
unit = field_config.get("addon_text")
|
||||||
|
|
||||||
|
if unit:
|
||||||
|
field.widget = NumberInputWithAddon(addon_text=unit)
|
||||||
|
field.addon_text = unit
|
||||||
|
value = self.initial.get(field_name)
|
||||||
|
if value and isinstance(value, str) and value.endswith(unit):
|
||||||
|
numeric_value = value[: -len(unit)]
|
||||||
|
with suppress(ValueError):
|
||||||
|
if "." in numeric_value:
|
||||||
|
self.initial[field_name] = float(numeric_value)
|
||||||
|
else:
|
||||||
|
self.initial[field_name] = int(numeric_value)
|
||||||
|
|
||||||
validators = []
|
validators = []
|
||||||
if min_val is not None:
|
if min_val is not None:
|
||||||
|
|
@ -406,6 +426,11 @@ class CustomFormMixin(FormGeneratorMixin):
|
||||||
|
|
||||||
mapping = field_name
|
mapping = field_name
|
||||||
value = self.cleaned_data.get(field_name)
|
value = self.cleaned_data.get(field_name)
|
||||||
|
field = self.fields[field_name]
|
||||||
|
|
||||||
|
if addon_text := getattr(field, "addon_text", None):
|
||||||
|
value = f"{value}{addon_text}"
|
||||||
|
|
||||||
parts = mapping.split(".")
|
parts = mapping.split(".")
|
||||||
current = nested
|
current = nested
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.forms.widgets import NumberInput
|
||||||
|
|
||||||
|
|
||||||
class DynamicArrayWidget(forms.Widget):
|
class DynamicArrayWidget(forms.Widget):
|
||||||
|
|
@ -216,3 +217,21 @@ class DynamicArrayField(forms.JSONField):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
|
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NumberInputWithAddon(NumberInput):
|
||||||
|
"""
|
||||||
|
Widget for number input fields with a suffix add-on (e.g., "Gi", "MB").
|
||||||
|
Renders as a Bootstrap input-group with the suffix displayed as an add-on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "frontend/forms/number_input_with_addon.html"
|
||||||
|
|
||||||
|
def __init__(self, addon_text="", attrs=None):
|
||||||
|
super().__init__(attrs)
|
||||||
|
self.addon_text = addon_text
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
context = super().get_context(name, value, attrs)
|
||||||
|
context["widget"]["addon_text"] = self.addon_text
|
||||||
|
return context
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="{{ widget.type }}"
|
||||||
|
name="{{ widget.name }}"
|
||||||
|
{% if widget.value != None %}value="{{ widget.value }}"{% endif %}
|
||||||
|
{% if widget.attrs.id %}id="{{ widget.attrs.id }}"{% endif %}
|
||||||
|
{% for name, value in widget.attrs.items %} {% if value is not False and name != "id" %} {{ name }}{% if value is not True %}="{{ value }}"{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
class="form-control{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}" />
|
||||||
|
<span class="input-group-text">{{ widget.addon_text }}</span>
|
||||||
|
</div>
|
||||||
|
|
@ -10,28 +10,6 @@ from servala.core.forms import ServiceDefinitionAdminForm
|
||||||
from servala.core.models import ControlPlaneCRD
|
from servala.core.models import ControlPlaneCRD
|
||||||
|
|
||||||
|
|
||||||
def test_custom_model_form_class_is_none_when_no_form_config():
|
|
||||||
crd = Mock(spec=ControlPlaneCRD)
|
|
||||||
service_def = Mock()
|
|
||||||
service_def.form_config = None
|
|
||||||
crd.service_definition = service_def
|
|
||||||
crd.django_model = Mock()
|
|
||||||
|
|
||||||
if not (
|
|
||||||
crd.django_model
|
|
||||||
and crd.service_definition
|
|
||||||
and crd.service_definition.form_config
|
|
||||||
and crd.service_definition.form_config.get("fieldsets")
|
|
||||||
):
|
|
||||||
result = None
|
|
||||||
else:
|
|
||||||
result = generate_custom_form_class(
|
|
||||||
crd.service_definition.form_config, crd.django_model
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_model_form_class_returns_class_when_form_config_exists():
|
def test_custom_model_form_class_returns_class_when_form_config_exists():
|
||||||
|
|
||||||
crd = Mock(spec=ControlPlaneCRD)
|
crd = Mock(spec=ControlPlaneCRD)
|
||||||
|
|
@ -60,18 +38,9 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
|
||||||
app_label = "test"
|
app_label = "test"
|
||||||
|
|
||||||
crd.django_model = TestModel
|
crd.django_model = TestModel
|
||||||
|
result = generate_custom_form_class(
|
||||||
if not (
|
crd.service_definition.form_config, crd.django_model
|
||||||
crd.django_model
|
)
|
||||||
and crd.service_definition
|
|
||||||
and crd.service_definition.form_config
|
|
||||||
and crd.service_definition.form_config.get("fieldsets")
|
|
||||||
):
|
|
||||||
result = None
|
|
||||||
else:
|
|
||||||
result = generate_custom_form_class(
|
|
||||||
crd.service_definition.form_config, crd.django_model
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert hasattr(result, "form_config")
|
assert hasattr(result, "form_config")
|
||||||
|
|
@ -1084,3 +1053,43 @@ def test_empty_values_dont_override_default_configs():
|
||||||
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"]
|
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"]
|
||||||
|
|
||||||
assert name_field.required is False # Was overridden by explicit False
|
assert name_field.required is False # Was overridden by explicit False
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_field_with_addon_text_roundtrip():
|
||||||
|
class TestModel(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
disk_size = models.IntegerField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "test"
|
||||||
|
|
||||||
|
form_config = {
|
||||||
|
"fieldsets": [
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Name",
|
||||||
|
"controlplane_field_mapping": "name",
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"label": "Disk Size",
|
||||||
|
"controlplane_field_mapping": "disk_size",
|
||||||
|
"addon_text": "Gi",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
form_class = generate_custom_form_class(form_config, TestModel)
|
||||||
|
form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"})
|
||||||
|
|
||||||
|
assert form.initial["disk_size"] == 25
|
||||||
|
form = form_class(data={"name": "test-instance", "disk_size": "25"})
|
||||||
|
form.fields["context"].required = False
|
||||||
|
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
|
||||||
|
nested_data = form.get_nested_data()
|
||||||
|
assert nested_data["disk_size"] == "25Gi"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue