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(
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}")
except ServiceOffering.DoesNotExist:
except ServiceOffering.DoesNotExist: # pragma: no-cover
return self._error(
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
)
@ -284,7 +284,7 @@ The Servala Team"""
if service_instance:
organization = service_instance.organization
except Exception:
except Exception: # pragma: no cover
pass
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.core.validators import MaxValueValidator, MinValueValidator
from django.forms.models import ModelForm, ModelFormMetaclass
from servala.core.crd.utils import deslugify
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
MANDATORY_FIELDS = ["name"]
@ -25,6 +27,11 @@ DEFAULT_FIELD_CONFIGS = {
"help_text": "Domain names for accessing this service",
"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":
min_val = field_config.get("min_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 = []
if min_val is not None:
@ -406,6 +426,11 @@ class CustomFormMixin(FormGeneratorMixin):
mapping = 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(".")
current = nested
for part in parts[:-1]:

View file

@ -2,6 +2,7 @@ import json
from django import forms
from django.core.exceptions import ValidationError
from django.forms.widgets import NumberInput
class DynamicArrayWidget(forms.Widget):
@ -216,3 +217,21 @@ class DynamicArrayField(forms.JSONField):
raise ValidationError(
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
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():
crd = Mock(spec=ControlPlaneCRD)
@ -60,18 +38,9 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
app_label = "test"
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(
crd.service_definition.form_config, crd.django_model
)
result = generate_custom_form_class(
crd.service_definition.form_config, crd.django_model
)
assert result is not None
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.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"