diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 5fdb91a..015e091 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -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}"] diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index 659684e..0f825ce 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -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]: diff --git a/src/servala/frontend/forms/widgets.py b/src/servala/frontend/forms/widgets.py index d67030f..99b7a59 100644 --- a/src/servala/frontend/forms/widgets.py +++ b/src/servala/frontend/forms/widgets.py @@ -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 diff --git a/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html b/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html new file mode 100644 index 0000000..4fe3b54 --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html @@ -0,0 +1,11 @@ +
+ + {{ widget.addon_text }} +
diff --git a/src/tests/test_form_config.py b/src/tests/test_form_config.py index 7188d61..c93f3fb 100644 --- a/src/tests/test_form_config.py +++ b/src/tests/test_form_config.py @@ -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"