Merge pull request 'Disk size SI units' (#302) from 287-disk-size-unit into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 44s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 7s

Reviewed-on: #302
This commit is contained in:
Tobias Brunner 2025-11-20 14:59:18 +00:00
commit 8b0c2a8d43
5 changed files with 102 additions and 38 deletions

View file

@ -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}"]

View file

@ -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]:

View file

@ -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

View file

@ -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>

View file

@ -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,15 +38,6 @@ 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
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( result = generate_custom_form_class(
crd.service_definition.form_config, crd.django_model crd.service_definition.form_config, crd.django_model
) )
@ -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"