From a268625d808bb014f0fb1508dc546dd33bca3f4b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Nov 2025 11:56:10 +0100 Subject: [PATCH 01/22] Add number addon widget --- src/servala/frontend/forms/widgets.py | 19 +++++++++++++++++++ .../forms/number_input_with_addon.html | 11 +++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/servala/frontend/templates/frontend/forms/number_input_with_addon.html 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 }} +
From de6794046d77bf58a87154b0a726ecddd52d5fc5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Nov 2025 12:06:17 +0100 Subject: [PATCH 02/22] Implement unit fields for numeric fields --- src/servala/api/views.py | 6 +-- src/servala/core/crd/forms.py | 20 +++++++++ src/tests/test_form_config.py | 78 ++++++++++++++++++++--------------- 3 files changed, 67 insertions(+), 37 deletions(-) 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..6ef44e2 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -1,3 +1,5 @@ +from contextlib import suppress + from django import forms from django.core.validators import MaxValueValidator, MinValueValidator from django.forms.models import ModelForm, ModelFormMetaclass @@ -335,6 +337,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 +421,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/tests/test_form_config.py b/src/tests/test_form_config.py index 7188d61..5b33729 100644 --- a/src/tests/test_form_config.py +++ b/src/tests/test_form_config.py @@ -8,28 +8,7 @@ from servala.core.crd import generate_custom_form_class from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS 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 +from servala.frontend.forms.widgets import NumberInputWithAddon def test_custom_model_form_class_returns_class_when_form_config_exists(): @@ -60,18 +39,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 +1054,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" From 0a621f637284f2bf1b9b023718d6bbf294a1b52d Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Nov 2025 12:06:28 +0100 Subject: [PATCH 03/22] Add default unit for disk size field --- src/servala/core/crd/forms.py | 7 ++++++- src/tests/test_form_config.py | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index 6ef44e2..0f825ce 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -6,7 +6,7 @@ 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"] @@ -27,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", + }, } diff --git a/src/tests/test_form_config.py b/src/tests/test_form_config.py index 5b33729..c93f3fb 100644 --- a/src/tests/test_form_config.py +++ b/src/tests/test_form_config.py @@ -8,7 +8,6 @@ from servala.core.crd import generate_custom_form_class from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS from servala.core.forms import ServiceDefinitionAdminForm from servala.core.models import ControlPlaneCRD -from servala.frontend.forms.widgets import NumberInputWithAddon def test_custom_model_form_class_returns_class_when_form_config_exists(): From 1ca02bfb4d8b2914102cbf74093ed1cb04e857e9 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 21 Nov 2025 03:01:01 +0000 Subject: [PATCH 04/22] Update dependency django-allauth to >=65.13.1 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51897da..0b3a632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "argon2-cffi>=25.1.0", "cryptography>=46.0.3", "django==5.2.8", - "django-allauth>=65.13.0", + "django-allauth>=65.13.1", "django-auditlog>=3.3.0", "django-fernet-encrypted-fields>=0.3.1", "django-jsonform>=2.23.2", diff --git a/uv.lock b/uv.lock index 91e0cdb..6f83304 100644 --- a/uv.lock +++ b/uv.lock @@ -345,15 +345,15 @@ wheels = [ [[package]] name = "django-allauth" -version = "65.13.0" +version = "65.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/05/36b9de6d0109948717ee0fa8076d5b57396bc838d5239f5b44b7d4c29fb0/django_allauth-65.13.0.tar.gz", hash = "sha256:7d7b7e7ad603eb3864c142f051e2cce7be2f9a9c6945a51172ec83d48c6c843b", size = 1987616, upload-time = "2025-10-31T10:20:03.954Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/42a048ba1dedbb6b553f5376a6126b1c753c10c70d1edab8f94c560c8066/django_allauth-65.13.1.tar.gz", hash = "sha256:2af0d07812f8c1a8e3732feaabe6a9db5ecf3fad6b45b6a0f7fd825f656c5a15", size = 1983857, upload-time = "2025-11-20T16:34:40.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/17/f2fd703781aeeb6d314059408df77360f09625cc3ce85f264b104443108c/django_allauth-65.13.0-py3-none-any.whl", hash = "sha256:119c0cf1cc2e0d1a0fe2f13588f30951d64989256084de2d60f13ab9308f9fa0", size = 1787213, upload-time = "2025-10-31T10:20:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/d8/98/9d44ae1468abfdb521d651fb67f914165c7812dfdd97be16190c9b1cc246/django_allauth-65.13.1-py3-none-any.whl", hash = "sha256:2887294beedfd108b4b52ebd182e0ed373deaeb927fc5a22f77bbde3174704a6", size = 1787349, upload-time = "2025-11-20T16:34:37.354Z" }, ] [[package]] @@ -1134,7 +1134,7 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", specifier = "==5.2.8" }, - { name = "django-allauth", specifier = ">=65.13.0" }, + { name = "django-allauth", specifier = ">=65.13.1" }, { name = "django-auditlog", specifier = ">=3.3.0" }, { name = "django-fernet-encrypted-fields", specifier = ">=0.3.1" }, { name = "django-jsonform", specifier = ">=2.23.2" }, From ea313eca1d7722b309e3dca23eeb5b288a1a6678 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 21 Nov 2025 03:01:10 +0000 Subject: [PATCH 05/22] Update actions/checkout action to v6 --- .forgejo/workflows/build-deploy-prod.yaml | 4 ++-- .forgejo/workflows/build-deploy-staging.yaml | 4 ++-- .forgejo/workflows/docs.yaml | 4 ++-- .forgejo/workflows/renovate.yaml | 2 +- .forgejo/workflows/tests.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml index aa9315d..ddaeb1c 100644 --- a/.forgejo/workflows/build-deploy-prod.yaml +++ b/.forgejo/workflows/build-deploy-prod.yaml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -72,7 +72,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Determine image tag id: determine-tag diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml index 93c77b2..8f438cd 100644 --- a/.forgejo/workflows/build-deploy-staging.yaml +++ b/.forgejo/workflows/build-deploy-staging.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Deploy to OpenShift uses: docker://quay.io/appuio/oc:v4.19 diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml index 0b6c77c..b1e5fe5 100644 --- a/.forgejo/workflows/docs.yaml +++ b/.forgejo/workflows/docs.yaml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -49,7 +49,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Deploy to OpenShift uses: docker://quay.io/appuio/oc:v4.19 diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 6083841..82a9f0e 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -11,7 +11,7 @@ jobs: container: catthehacker/ubuntu:act-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml index e3900b3..b2cddd0 100644 --- a/.forgejo/workflows/tests.yaml +++ b/.forgejo/workflows/tests.yaml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 From 870acfa81b31138e09cf48d68baafe7cf79422c7 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 24 Nov 2025 03:00:48 +0000 Subject: [PATCH 06/22] Lock file maintenance --- uv.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 6f83304..1c2a7b7 100644 --- a/uv.lock +++ b/uv.lock @@ -47,11 +47,11 @@ wheels = [ [[package]] name = "asgiref" -version = "3.10.0" +version = "3.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" }, + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] [[package]] @@ -86,30 +86,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.40.74" +version = "1.41.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/37/0db5fc46548b347255310893f1a47971a1d8eb0dbc46dfb5ace8a1e7d45e/boto3-1.40.74.tar.gz", hash = "sha256:484e46bf394b03a7c31b34f90945ebe1390cb1e2ac61980d128a9079beac87d4", size = 111592, upload-time = "2025-11-14T20:29:10.991Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/81/2600e83ddd7cb1dac43d28fd39774434afcda0d85d730402192b1a9266a3/boto3-1.41.2.tar.gz", hash = "sha256:7054fbc61cadab383f40ea6d725013ba6c8f569641dddb14c0055e790280ad6c", size = 111593, upload-time = "2025-11-21T20:32:08.622Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/08/c52751748762901c0ca3c3019e3aa950010217f0fdf9940ebe68e6bb2f5a/boto3-1.40.74-py3-none-any.whl", hash = "sha256:41fc8844b37ae27b24bcabf8369769df246cc12c09453988d0696ad06d6aa9ef", size = 139360, upload-time = "2025-11-14T20:29:09.477Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/1ed7fdc3f124c1cf2df78e605588fa78a182410b832f5b71944a69436171/boto3-1.41.2-py3-none-any.whl", hash = "sha256:edcde82fdae4201aa690e3683f8e5b1a846cf1bbf79d03db4fa8a2f6f46dba9c", size = 139343, upload-time = "2025-11-21T20:32:07.147Z" }, ] [[package]] name = "botocore" -version = "1.40.74" +version = "1.41.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/dc/0412505f05286f282a75bb0c650e525ddcfaf3f6f1a05cd8e99d32a2db06/botocore-1.40.74.tar.gz", hash = "sha256:57de0b9ffeada06015b3c7e5186c77d0692b210d9e5efa294f3214df97e2f8ee", size = 14452479, upload-time = "2025-11-14T20:29:00.949Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/0b/6eb5dc752b240dd0b76d7e3257ae25b70683896d1789e7bfb78fba7c7c99/botocore-1.41.2.tar.gz", hash = "sha256:49a3e8f4c1a1759a687941fef8b36efd7bafcf63c1ef74aa75d6497eb4887c9c", size = 14660558, upload-time = "2025-11-21T20:31:58.785Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/a2/306dec16e3c84f3ca7aaead0084358c1c7fbe6501f6160844cbc93bc871e/botocore-1.40.74-py3-none-any.whl", hash = "sha256:f39f5763e35e75f0bd91212b7b36120b1536203e8003cd952ef527db79702b15", size = 14117911, upload-time = "2025-11-14T20:28:58.153Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/516ee2157c0686fbe48ca8b94dffc17a0c35040d4626761d74b1a43215c8/botocore-1.41.2-py3-none-any.whl", hash = "sha256:154052dfaa7292212f01c8fab822c76cd10a15a7e164e4c45e4634eb40214b90", size = 14324839, upload-time = "2025-11-21T20:31:56.236Z" }, ] [[package]] @@ -1059,14 +1059,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bb/940d6af975948c1cc18f44545ffb219d3c35d78ec972b42ae229e8e37e08/s3transfer-0.15.0.tar.gz", hash = "sha256:d36fac8d0e3603eff9b5bfa4282c7ce6feb0301a633566153cbd0b93d11d8379", size = 152185, upload-time = "2025-11-20T20:28:56.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/5ef25f52973aa12a19cf4e1375d00932d7fb354ffd310487ba7d44225c1a/s3transfer-0.15.0-py3-none-any.whl", hash = "sha256:6f8bf5caa31a0865c4081186689db1b2534cef721d104eb26101de4b9d6a5852", size = 85984, upload-time = "2025-11-20T20:28:55.046Z" }, ] [[package]] From 8e725932c4f682a6d4726a4d6e8fb4a1859267ea Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 25 Nov 2025 03:00:34 +0000 Subject: [PATCH 07/22] Update https://github.com/renovatebot/github-action action to v44.0.4 --- .forgejo/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 82a9f0e..3c643b1 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -19,7 +19,7 @@ jobs: node-version: "24" - name: Renovate - uses: https://github.com/renovatebot/github-action@v44.0.3 + uses: https://github.com/renovatebot/github-action@v44.0.4 with: token: ${{ secrets.RENOVATE_TOKEN }} env: From 0c09ae8eca4ee795a3e47e3098d92e60c6c1f7bb Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 25 Nov 2025 03:00:52 +0000 Subject: [PATCH 08/22] Update dependency sentry-sdk to >=2.46.0 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0b3a632..35bb517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "pyjwt>=2.10.1", "requests>=2.32.5", "rules>=3.5", - "sentry-sdk[django]>=2.45.0", + "sentry-sdk[django]>=2.46.0", "urlman>=2.0.2", ] diff --git a/uv.lock b/uv.lock index 6f83304..b5c5039 100644 --- a/uv.lock +++ b/uv.lock @@ -1071,15 +1071,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.45.0" +version = "2.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/89/1561b3dc8e28bf7978d031893297e89be266f53650c87bb14a29406a9791/sentry_sdk-2.45.0.tar.gz", hash = "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271", size = 373631, upload-time = "2025-11-18T13:23:22.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/c140a5837649e2bf2ec758494fde1d9a016c76777eab64e75ef38d685bbb/sentry_sdk-2.46.0.tar.gz", hash = "sha256:91821a23460725734b7741523021601593f35731808afc0bb2ba46c27b8acd91", size = 374761, upload-time = "2025-11-24T09:34:13.932Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/c6/039121a0355bc1b5bcceef0dabf211b021fd435d0ee5c46393717bb1c09f/sentry_sdk-2.45.0-py2.py3-none-any.whl", hash = "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23", size = 404791, upload-time = "2025-11-18T13:23:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/ce7c502a366f4835b1f9c057753f6989a92d3c70cbadb168193f5fb7499b/sentry_sdk-2.46.0-py2.py3-none-any.whl", hash = "sha256:4eeeb60198074dff8d066ea153fa6f241fef1668c10900ea53a4200abc8da9b1", size = 406266, upload-time = "2025-11-24T09:34:12.114Z" }, ] [package.optional-dependencies] @@ -1148,7 +1148,7 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.10.1" }, { name = "requests", specifier = ">=2.32.5" }, { name = "rules", specifier = ">=3.5" }, - { name = "sentry-sdk", extras = ["django"], specifier = ">=2.45.0" }, + { name = "sentry-sdk", extras = ["django"], specifier = ">=2.46.0" }, { name = "urlman", specifier = ">=2.0.2" }, ] From 9a3734192ed4d5254f1951859de1457f63144fc4 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Nov 2025 13:30:00 +0100 Subject: [PATCH 09/22] Initial Plan model --- src/servala/core/models/__init__.py | 14 +- src/servala/core/models/odoo_cache.py | 124 +++++++++ src/servala/core/models/plan.py | 351 ++++++++++++++++++++++++++ src/servala/core/models/service.py | 18 ++ 4 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/models/odoo_cache.py create mode 100644 src/servala/core/models/plan.py diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 4c23f18..e28081f 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,3 +1,4 @@ +from .odoo_cache import OdooObjectCache from .organization import ( BillingEntity, Organization, @@ -6,6 +7,12 @@ from .organization import ( OrganizationOrigin, OrganizationRole, ) +from .plan import ( + ResourcePlan, + ResourcePlanAssignment, + StoragePlan, + StoragePlanAssignment, +) from .service import ( CloudProvider, ControlPlane, @@ -23,15 +30,20 @@ __all__ = [ "CloudProvider", "ControlPlane", "ControlPlaneCRD", + "OdooObjectCache", "Organization", "OrganizationInvitation", "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", + "ResourcePlan", + "ResourcePlanAssignment", "Service", "ServiceCategory", - "ServiceInstance", "ServiceDefinition", + "ServiceInstance", "ServiceOffering", + "StoragePlan", + "StoragePlanAssignment", "User", ] diff --git a/src/servala/core/models/odoo_cache.py b/src/servala/core/models/odoo_cache.py new file mode 100644 index 0000000..8f0bd3f --- /dev/null +++ b/src/servala/core/models/odoo_cache.py @@ -0,0 +1,124 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from servala.core.models.mixins import ServalaModelMixin + + +class OdooObjectCache(ServalaModelMixin): + """ + Generic cache for Odoo API responses. + + Caches data from various Odoo models (product.product, product.template, uom.uom, etc.) + to reduce API calls and improve performance. + """ + + odoo_model = models.CharField( + max_length=100, + verbose_name=_("Odoo model"), + help_text=_( + "Odoo model name: 'product.product', 'product.template', 'uom.uom', etc." + ), + ) + odoo_id = models.PositiveIntegerField( + verbose_name=_("Odoo ID"), + help_text=_("ID in the Odoo model"), + ) + data = models.JSONField( + verbose_name=_("Cached data"), + help_text=_("Cached Odoo data including price, reporting_product_id, etc."), + ) + expires_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Expires at"), + help_text=_("When cache should be refreshed (null = never expires)"), + ) + + class Meta: + verbose_name = _("Odoo Object Cache") + verbose_name_plural = _("Odoo Object Caches") + unique_together = [["odoo_model", "odoo_id"]] + indexes = [ + models.Index(fields=["odoo_model", "odoo_id"]), + models.Index(fields=["expires_at"]), + ] + + def __str__(self): + return f"{self.odoo_model}({self.odoo_id})" + + def is_expired(self): + """Check if cache needs refresh.""" + if self.expires_at is None: + return False + from django.utils import timezone + + return timezone.now() > self.expires_at + + @classmethod + def get_or_fetch(cls, odoo_model, odoo_id, ttl_hours=24): + """ + Get cached data or fetch from Odoo if expired/missing. + + Args: + odoo_model: Odoo model name (e.g., 'product.product') + odoo_id: ID in the Odoo model + ttl_hours: Time-to-live in hours for the cache + + Returns: + OdooObjectCache instance with fresh data + """ + from datetime import timedelta + + from django.utils import timezone + + try: + cache_obj = cls.objects.get(odoo_model=odoo_model, odoo_id=odoo_id) + if not cache_obj.is_expired(): + return cache_obj + # Cache exists but expired, refresh it + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + return cache_obj + except cls.DoesNotExist: + # Create new cache entry + cache_obj = cls.objects.create( + odoo_model=odoo_model, + odoo_id=odoo_id, + data={}, + expires_at=( + timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None + ), + ) + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + return cache_obj + + def fetch_and_update(self, ttl_hours=24): + """ + Fetch latest data from Odoo and update cache. + + Args: + ttl_hours: Time-to-live in hours for the cache + """ + from datetime import timedelta + + from django.utils import timezone + + from servala.core.odoo import CLIENT + + # Fetch data from Odoo + results = CLIENT.search_read( + self.odoo_model, + [[("id", "=", self.odoo_id)]], + fields=None, # Fetch all fields + ) + + if results: + self.data = results[0] + self.expires_at = ( + timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None + ) + self.save(update_fields=["data", "expires_at", "updated_at"]) + else: + # Object not found in Odoo, mark as expired immediately + self.data = {} + self.expires_at = timezone.now() + self.save(update_fields=["data", "expires_at", "updated_at"]) diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py new file mode 100644 index 0000000..df7b088 --- /dev/null +++ b/src/servala/core/models/plan.py @@ -0,0 +1,351 @@ +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from servala.core.models.mixins import ServalaModelMixin + + +class BasePlan(ServalaModelMixin): + """ + Abstract base class for all plan types (resource and storage plans). + + Plans define service configurations and are linked to Odoo products for billing. + """ + + ODOO_PRODUCT_TYPE_CHOICES = [ + ("product.product", _("Product Variant")), + ("product.template", _("Product Template")), + ] + + name = models.CharField( + max_length=100, + verbose_name=_("Name"), + ) + description = models.TextField( + blank=True, + verbose_name=_("Description"), + ) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for selection"), + ) + + # Odoo product reference + odoo_plan_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Odoo plan ID"), + help_text=_("ID in the Odoo product model"), + ) + odoo_plan_type = models.CharField( + max_length=50, + choices=ODOO_PRODUCT_TYPE_CHOICES, + null=True, + blank=True, + verbose_name=_("Odoo plan type"), + help_text=_("Type of Odoo product model"), + ) + + # Odoo unit reference (name is cached in OdooObjectCache) + odoo_unit_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + + class Meta: + abstract = True + + def __str__(self): + return self.name + + @cached_property + def odoo_product_cache(self): + """ + Fetch cached Odoo product data. + + Returns: + OdooObjectCache instance or None if not configured + """ + if not self.odoo_plan_id or not self.odoo_plan_type: + return None + + from servala.core.models.odoo_cache import OdooObjectCache + + return OdooObjectCache.get_or_fetch( + odoo_model=self.odoo_plan_type, + odoo_id=self.odoo_plan_id, + ttl_hours=24, + ) + + @cached_property + def odoo_unit_cache(self): + """ + Fetch cached Odoo unit data. + + Returns: + OdooObjectCache instance or None if not configured + """ + if not self.odoo_unit_id: + return None + + from servala.core.models.odoo_cache import OdooObjectCache + + return OdooObjectCache.get_or_fetch( + odoo_model="uom.uom", + odoo_id=self.odoo_unit_id, + ttl_hours=168, # 1 week - units change rarely + ) + + def get_cached_price(self): + """ + Get price from Odoo cache. + + Returns: + Price as float or None if not available + """ + cache = self.odoo_product_cache + if cache and cache.data: + return cache.data.get("list_price") or cache.data.get("standard_price") + return None + + def get_reporting_product_id(self): + """ + Get the reporting product ID based on invoicing policy. + + Returns: + Reporting product ID or None + """ + cache = self.odoo_product_cache + if not cache or not cache.data: + return None + + invoicing_policy = cache.data.get("invoice_policy") + if invoicing_policy == "delivery": + # Metered billing + return cache.data.get("metered_billing_id") + else: + # Order-based billing + return cache.data.get("product_instance_event_id") + + def get_odoo_unit_name(self): + """ + Get the unit name from Odoo cache. + + Returns: + Unit name string or None + """ + cache = self.odoo_unit_cache + if cache and cache.data: + return cache.data.get("name") + return None + + def refresh_odoo_caches(self, ttl_hours=24): + """ + Force refresh of all Odoo caches for this plan. + + Args: + ttl_hours: Time-to-live in hours for the cache + """ + # Clear cached_property caches + if "odoo_product_cache" in self.__dict__: + del self.__dict__["odoo_product_cache"] + if "odoo_unit_cache" in self.__dict__: + del self.__dict__["odoo_unit_cache"] + + # Refresh caches + if self.odoo_plan_id and self.odoo_plan_type: + from servala.core.models.odoo_cache import OdooObjectCache + + try: + cache_obj = OdooObjectCache.objects.get( + odoo_model=self.odoo_plan_type, + odoo_id=self.odoo_plan_id, + ) + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + except OdooObjectCache.DoesNotExist: + # Will be created on next access + pass + + if self.odoo_unit_id: + from servala.core.models.odoo_cache import OdooObjectCache + + try: + cache_obj = OdooObjectCache.objects.get( + odoo_model="uom.uom", + odoo_id=self.odoo_unit_id, + ) + cache_obj.fetch_and_update(ttl_hours=168) + except OdooObjectCache.DoesNotExist: + # Will be created on next access + pass + + +class ResourcePlan(BasePlan): + """ + Compute resource plans for service instances. + + Defines CPU, memory, and storage allocations along with service level. + """ + + # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") + memory_requests = models.CharField( + max_length=20, + verbose_name=_("Memory requests"), + help_text=_("e.g., '2Gi', '512Mi'"), + ) + memory_limits = models.CharField( + max_length=20, + verbose_name=_("Memory limits"), + help_text=_("e.g., '4Gi', '1Gi'"), + ) + cpu_requests = models.CharField( + max_length=20, + verbose_name=_("CPU requests"), + help_text=_("e.g., '500m', '1', '2'"), + ) + cpu_limits = models.CharField( + max_length=20, + verbose_name=_("CPU limits"), + help_text=_("e.g., '2000m', '2', '4'"), + ) + + # Storage proposal (for UI pre-fill) + proposed_storage_gib = models.PositiveIntegerField( + validators=[MinValueValidator(1)], + verbose_name=_("Proposed storage (GiB)"), + help_text=_("Suggested storage amount in GiB to propose to the user"), + ) + + # Service level + service_level = models.CharField( + max_length=50, + blank=True, + verbose_name=_("Service level"), + help_text=_("e.g., 'Standard', 'Premium', 'Enterprise'"), + ) + + class Meta: + verbose_name = _("Resource Plan") + verbose_name_plural = _("Resource Plans") + ordering = ["name"] + + def get_resource_summary(self): + """ + Get a human-readable summary of resources. + + Returns: + String like "2 vCPU, 4Gi RAM" + """ + return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" + + +class StoragePlan(BasePlan): + """ + Storage plans for service instances. + + Currently inherits all fields from BasePlan. Future fields could include + storage class, performance tier, IOPS limits, etc. + """ + + class Meta: + verbose_name = _("Storage Plan") + verbose_name_plural = _("Storage Plans") + ordering = ["name"] + + +class ResourcePlanAssignment(ServalaModelMixin): + """ + Links resource plans to control plane CRDs. + + Allows the same plan to be reused across multiple CRDs with per-assignment + configuration (sorting, activation, future: pricing overrides, limits, etc.) + """ + + resource_plan = models.ForeignKey( + ResourcePlan, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name=_("Resource plan"), + ) + control_plane_crd = models.ForeignKey( + "ControlPlaneCRD", + on_delete=models.CASCADE, + related_name="resource_plan_assignments", + verbose_name=_("Control plane CRD"), + ) + + # Display ordering in UI + sort_order = models.PositiveIntegerField( + default=0, + verbose_name=_("Sort order"), + help_text=_("Order in which plans are displayed to users"), + ) + + # Allow per-assignment activation + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for this CRD"), + ) + + # Future: organization limits, pricing overrides, etc. + + class Meta: + verbose_name = _("Resource Plan Assignment") + verbose_name_plural = _("Resource Plan Assignments") + unique_together = [["resource_plan", "control_plane_crd"]] + ordering = ["sort_order", "resource_plan__name"] + + def __str__(self): + return f"{self.resource_plan.name} → {self.control_plane_crd}" + + +class StoragePlanAssignment(ServalaModelMixin): + """ + Links storage plans to control plane CRDs. + + Allows the same plan to be reused across multiple CRDs with per-assignment + configuration (sorting, activation, future: pricing overrides, limits, etc.) + """ + + storage_plan = models.ForeignKey( + StoragePlan, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name=_("Storage plan"), + ) + control_plane_crd = models.ForeignKey( + "ControlPlaneCRD", + on_delete=models.CASCADE, + related_name="storage_plan_assignments", + verbose_name=_("Control plane CRD"), + ) + + # Display ordering in UI + sort_order = models.PositiveIntegerField( + default=0, + verbose_name=_("Sort order"), + help_text=_("Order in which plans are displayed to users"), + ) + + # Allow per-assignment activation + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for this CRD"), + ) + + # Future: organization limits, pricing overrides, etc. + + class Meta: + verbose_name = _("Storage Plan Assignment") + verbose_name_plural = _("Storage Plan Assignments") + unique_together = [["storage_plan", "control_plane_crd"]] + ordering = ["sort_order", "storage_plan__name"] + + def __str__(self): + return f"{self.storage_plan.name} → {self.control_plane_crd}" diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ab7b76f..5a01f79 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -613,6 +613,24 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="service_instances", on_delete=models.PROTECT, ) + resource_plan = models.ForeignKey( + to="core.ResourcePlan", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="instances", + verbose_name=_("Resource plan"), + help_text=_("Compute resource plan for this instance"), + ) + storage_plan = models.ForeignKey( + to="core.StoragePlan", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="instances", + verbose_name=_("Storage plan"), + help_text=_("Storage plan for this instance"), + ) class Meta: verbose_name = _("Service instance") From cce071397cc43ff57723359c8e8cb6f6df13c795 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Nov 2025 13:40:05 +0100 Subject: [PATCH 10/22] Adjustments to data model --- src/servala/core/admin.py | 164 +++++++++++++- src/servala/core/models/__init__.py | 12 +- src/servala/core/models/plan.py | 335 +++++++--------------------- src/servala/core/models/service.py | 39 ++-- 4 files changed, 269 insertions(+), 281 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 60fe147..f7cf161 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm from servala.core.models import ( BillingEntity, CloudProvider, + ComputePlan, + ComputePlanAssignment, ControlPlane, ControlPlaneCRD, + OdooObjectCache, Organization, OrganizationInvitation, OrganizationMembership, @@ -269,6 +272,19 @@ class ControlPlaneAdmin(admin.ModelAdmin): ), }, ), + ( + _("Storage Plan"), + { + "fields": ( + "storage_plan_odoo_product_id", + "storage_plan_odoo_unit_id", + "storage_plan_price_per_gib", + ), + "description": _( + "Storage plan configuration for this control plane (hardcoded per control plane)." + ), + }, + ), ) def get_exclude(self, request, obj=None): @@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin): @admin.register(ServiceInstance) class ServiceInstanceAdmin(admin.ModelAdmin): - list_display = ("name", "organization", "context", "created_by") - list_filter = ("organization", "context") + list_display = ( + "name", + "organization", + "context", + "compute_plan_assignment", + "created_by", + ) + list_filter = ("organization", "context", "compute_plan_assignment") search_fields = ( "name", "organization__name", "context__service_offering__service__name", ) readonly_fields = ("name", "organization", "context") - autocomplete_fields = ("organization", "context") + autocomplete_fields = ("organization", "context", "compute_plan_assignment") def get_readonly_fields(self, request, obj=None): if obj: # If this is an edit (not a new instance) @@ -390,6 +412,10 @@ class ServiceInstanceAdmin(admin.ModelAdmin): ) }, ), + ( + _("Plan"), + {"fields": ("compute_plan_assignment",)}, + ), ) @@ -420,3 +446,135 @@ class ServiceOfferingAdmin(admin.ModelAdmin): schema=external_links_schema ) return form + + +class ComputePlanAssignmentInline(admin.TabularInline): + model = ComputePlanAssignment + extra = 1 + autocomplete_fields = ("control_plane_crd",) + fields = ( + "compute_plan", + "control_plane_crd", + "sla", + "odoo_product_id", + "odoo_unit_id", + "price", + "minimum_service_size", + "sort_order", + "is_active", + ) + readonly_fields = () + + +@admin.register(ComputePlan) +class ComputePlanAdmin(admin.ModelAdmin): + list_display = ( + "name", + "is_active", + "memory_limits", + "cpu_limits", + ) + list_filter = ("is_active",) + search_fields = ("name", "description") + inlines = (ComputePlanAssignmentInline,) + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "description", + "is_active", + ) + }, + ), + ( + _("Resources"), + { + "fields": ( + "memory_requests", + "memory_limits", + "cpu_requests", + "cpu_limits", + ) + }, + ), + ) + + +@admin.register(ComputePlanAssignment) +class ComputePlanAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "compute_plan", + "control_plane_crd", + "sla", + "price", + "sort_order", + "is_active", + ) + list_filter = ("is_active", "sla", "control_plane_crd") + search_fields = ( + "compute_plan__name", + "control_plane_crd__service_offering__service__name", + ) + autocomplete_fields = ("compute_plan", "control_plane_crd") + fieldsets = ( + ( + None, + { + "fields": ( + "compute_plan", + "control_plane_crd", + "sla", + "is_active", + "sort_order", + ) + }, + ), + ( + _("Odoo Integration"), + { + "fields": ( + "odoo_product_id", + "odoo_unit_id", + ) + }, + ), + ( + _("Pricing & Constraints"), + { + "fields": ( + "price", + "minimum_service_size", + ) + }, + ), + ) + + +@admin.register(OdooObjectCache) +class OdooObjectCacheAdmin(admin.ModelAdmin): + list_display = ("odoo_model", "odoo_id", "updated_at", "expires_at", "is_expired") + list_filter = ("odoo_model", "updated_at", "expires_at") + search_fields = ("odoo_model", "odoo_id") + readonly_fields = ("created_at", "updated_at") + actions = ["refresh_caches"] + + def is_expired(self, obj): + return obj.is_expired() + + is_expired.boolean = True + is_expired.short_description = _("Expired") + + def refresh_caches(self, request, queryset): + """Admin action to refresh selected Odoo caches.""" + refreshed_count = 0 + for cache_obj in queryset: + cache_obj.fetch_and_update() + refreshed_count += 1 + messages.success( + request, + _(f"Successfully refreshed {refreshed_count} cache(s)."), + ) + + refresh_caches.short_description = _("Refresh caches") diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index e28081f..4cb8fd7 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -8,10 +8,8 @@ from .organization import ( OrganizationRole, ) from .plan import ( - ResourcePlan, - ResourcePlanAssignment, - StoragePlan, - StoragePlanAssignment, + ComputePlan, + ComputePlanAssignment, ) from .service import ( CloudProvider, @@ -28,6 +26,8 @@ from .user import User __all__ = [ "BillingEntity", "CloudProvider", + "ComputePlan", + "ComputePlanAssignment", "ControlPlane", "ControlPlaneCRD", "OdooObjectCache", @@ -36,14 +36,10 @@ __all__ = [ "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", - "ResourcePlan", - "ResourcePlanAssignment", "Service", "ServiceCategory", "ServiceDefinition", "ServiceInstance", "ServiceOffering", - "StoragePlan", - "StoragePlanAssignment", "User", ] diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py index df7b088..6fc50e4 100644 --- a/src/servala/core/models/plan.py +++ b/src/servala/core/models/plan.py @@ -1,23 +1,20 @@ +from decimal import Decimal + from django.core.validators import MinValueValidator from django.db import models -from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from servala.core.models.mixins import ServalaModelMixin -class BasePlan(ServalaModelMixin): +class ComputePlan(ServalaModelMixin): """ - Abstract base class for all plan types (resource and storage plans). + Compute resource plans for service instances. - Plans define service configurations and are linked to Odoo products for billing. + Defines CPU and memory allocations. Pricing and service level are configured + per assignment to a ControlPlaneCRD. """ - ODOO_PRODUCT_TYPE_CHOICES = [ - ("product.product", _("Product Variant")), - ("product.template", _("Product Template")), - ] - name = models.CharField( max_length=100, verbose_name=_("Name"), @@ -32,165 +29,6 @@ class BasePlan(ServalaModelMixin): help_text=_("Whether this plan is available for selection"), ) - # Odoo product reference - odoo_plan_id = models.IntegerField( - null=True, - blank=True, - verbose_name=_("Odoo plan ID"), - help_text=_("ID in the Odoo product model"), - ) - odoo_plan_type = models.CharField( - max_length=50, - choices=ODOO_PRODUCT_TYPE_CHOICES, - null=True, - blank=True, - verbose_name=_("Odoo plan type"), - help_text=_("Type of Odoo product model"), - ) - - # Odoo unit reference (name is cached in OdooObjectCache) - odoo_unit_id = models.IntegerField( - null=True, - blank=True, - verbose_name=_("Odoo unit ID"), - help_text=_("ID of the unit of measure in Odoo (uom.uom)"), - ) - - class Meta: - abstract = True - - def __str__(self): - return self.name - - @cached_property - def odoo_product_cache(self): - """ - Fetch cached Odoo product data. - - Returns: - OdooObjectCache instance or None if not configured - """ - if not self.odoo_plan_id or not self.odoo_plan_type: - return None - - from servala.core.models.odoo_cache import OdooObjectCache - - return OdooObjectCache.get_or_fetch( - odoo_model=self.odoo_plan_type, - odoo_id=self.odoo_plan_id, - ttl_hours=24, - ) - - @cached_property - def odoo_unit_cache(self): - """ - Fetch cached Odoo unit data. - - Returns: - OdooObjectCache instance or None if not configured - """ - if not self.odoo_unit_id: - return None - - from servala.core.models.odoo_cache import OdooObjectCache - - return OdooObjectCache.get_or_fetch( - odoo_model="uom.uom", - odoo_id=self.odoo_unit_id, - ttl_hours=168, # 1 week - units change rarely - ) - - def get_cached_price(self): - """ - Get price from Odoo cache. - - Returns: - Price as float or None if not available - """ - cache = self.odoo_product_cache - if cache and cache.data: - return cache.data.get("list_price") or cache.data.get("standard_price") - return None - - def get_reporting_product_id(self): - """ - Get the reporting product ID based on invoicing policy. - - Returns: - Reporting product ID or None - """ - cache = self.odoo_product_cache - if not cache or not cache.data: - return None - - invoicing_policy = cache.data.get("invoice_policy") - if invoicing_policy == "delivery": - # Metered billing - return cache.data.get("metered_billing_id") - else: - # Order-based billing - return cache.data.get("product_instance_event_id") - - def get_odoo_unit_name(self): - """ - Get the unit name from Odoo cache. - - Returns: - Unit name string or None - """ - cache = self.odoo_unit_cache - if cache and cache.data: - return cache.data.get("name") - return None - - def refresh_odoo_caches(self, ttl_hours=24): - """ - Force refresh of all Odoo caches for this plan. - - Args: - ttl_hours: Time-to-live in hours for the cache - """ - # Clear cached_property caches - if "odoo_product_cache" in self.__dict__: - del self.__dict__["odoo_product_cache"] - if "odoo_unit_cache" in self.__dict__: - del self.__dict__["odoo_unit_cache"] - - # Refresh caches - if self.odoo_plan_id and self.odoo_plan_type: - from servala.core.models.odoo_cache import OdooObjectCache - - try: - cache_obj = OdooObjectCache.objects.get( - odoo_model=self.odoo_plan_type, - odoo_id=self.odoo_plan_id, - ) - cache_obj.fetch_and_update(ttl_hours=ttl_hours) - except OdooObjectCache.DoesNotExist: - # Will be created on next access - pass - - if self.odoo_unit_id: - from servala.core.models.odoo_cache import OdooObjectCache - - try: - cache_obj = OdooObjectCache.objects.get( - odoo_model="uom.uom", - odoo_id=self.odoo_unit_id, - ) - cache_obj.fetch_and_update(ttl_hours=168) - except OdooObjectCache.DoesNotExist: - # Will be created on next access - pass - - -class ResourcePlan(BasePlan): - """ - Compute resource plans for service instances. - - Defines CPU, memory, and storage allocations along with service level. - """ - # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") memory_requests = models.CharField( max_length=20, @@ -213,26 +51,14 @@ class ResourcePlan(BasePlan): help_text=_("e.g., '2000m', '2', '4'"), ) - # Storage proposal (for UI pre-fill) - proposed_storage_gib = models.PositiveIntegerField( - validators=[MinValueValidator(1)], - verbose_name=_("Proposed storage (GiB)"), - help_text=_("Suggested storage amount in GiB to propose to the user"), - ) - - # Service level - service_level = models.CharField( - max_length=50, - blank=True, - verbose_name=_("Service level"), - help_text=_("e.g., 'Standard', 'Premium', 'Enterprise'"), - ) - class Meta: - verbose_name = _("Resource Plan") - verbose_name_plural = _("Resource Plans") + verbose_name = _("Compute Plan") + verbose_name_plural = _("Compute Plans") ordering = ["name"] + def __str__(self): + return self.name + def get_resource_summary(self): """ Get a human-readable summary of resources. @@ -243,41 +69,71 @@ class ResourcePlan(BasePlan): return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" -class StoragePlan(BasePlan): +class ComputePlanAssignment(ServalaModelMixin): """ - Storage plans for service instances. + Links compute plans to control plane CRDs with pricing and service level. - Currently inherits all fields from BasePlan. Future fields could include - storage class, performance tier, IOPS limits, etc. + A product in Odoo represents a service with a specific compute plan, control plane, + and SLA. This model stores that correlation. The same compute plan can be assigned + multiple times to the same CRD with different SLAs and pricing. """ - class Meta: - verbose_name = _("Storage Plan") - verbose_name_plural = _("Storage Plans") - ordering = ["name"] + SLA_CHOICES = [ + ("besteffort", _("Best Effort")), + ("guaranteed", _("Guaranteed Availability")), + ] - -class ResourcePlanAssignment(ServalaModelMixin): - """ - Links resource plans to control plane CRDs. - - Allows the same plan to be reused across multiple CRDs with per-assignment - configuration (sorting, activation, future: pricing overrides, limits, etc.) - """ - - resource_plan = models.ForeignKey( - ResourcePlan, + compute_plan = models.ForeignKey( + ComputePlan, on_delete=models.CASCADE, related_name="assignments", - verbose_name=_("Resource plan"), + verbose_name=_("Compute plan"), ) control_plane_crd = models.ForeignKey( "ControlPlaneCRD", on_delete=models.CASCADE, - related_name="resource_plan_assignments", + related_name="compute_plan_assignments", verbose_name=_("Control plane CRD"), ) + # Service Level Agreement + sla = models.CharField( + max_length=20, + choices=SLA_CHOICES, + verbose_name=_("SLA"), + help_text=_("Service Level Agreement"), + ) + + # Odoo product reference + odoo_product_id = models.IntegerField( + verbose_name=_("Odoo product ID"), + help_text=_("ID of the product in Odoo (product.product or product.template)"), + ) + odoo_unit_id = models.IntegerField( + verbose_name=_("Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + + # Pricing + price = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.00"))], + verbose_name=_("Price"), + help_text=_("Price per unit"), + ) + + # Service constraints + minimum_service_size = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_("Minimum service size"), + help_text=_( + "Minimum value for spec.parameters.instances " + "(Guaranteed Availability may require multiple instances)" + ), + ) + # Display ordering in UI sort_order = models.PositiveIntegerField( default=0, @@ -292,60 +148,25 @@ class ResourcePlanAssignment(ServalaModelMixin): help_text=_("Whether this plan is available for this CRD"), ) - # Future: organization limits, pricing overrides, etc. - class Meta: - verbose_name = _("Resource Plan Assignment") - verbose_name_plural = _("Resource Plan Assignments") - unique_together = [["resource_plan", "control_plane_crd"]] - ordering = ["sort_order", "resource_plan__name"] + verbose_name = _("Compute Plan Assignment") + verbose_name_plural = _("Compute Plan Assignments") + unique_together = [["compute_plan", "control_plane_crd", "sla"]] + ordering = ["sort_order", "compute_plan__name", "sla"] def __str__(self): - return f"{self.resource_plan.name} → {self.control_plane_crd}" + return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" + def get_odoo_reporting_product_id(self): + """ + Get the reporting product ID for this plan. -class StoragePlanAssignment(ServalaModelMixin): - """ - Links storage plans to control plane CRDs. + In the future, this will query Odoo based on invoicing policy. + For now, returns the product ID directly. - Allows the same plan to be reused across multiple CRDs with per-assignment - configuration (sorting, activation, future: pricing overrides, limits, etc.) - """ - - storage_plan = models.ForeignKey( - StoragePlan, - on_delete=models.CASCADE, - related_name="assignments", - verbose_name=_("Storage plan"), - ) - control_plane_crd = models.ForeignKey( - "ControlPlaneCRD", - on_delete=models.CASCADE, - related_name="storage_plan_assignments", - verbose_name=_("Control plane CRD"), - ) - - # Display ordering in UI - sort_order = models.PositiveIntegerField( - default=0, - verbose_name=_("Sort order"), - help_text=_("Order in which plans are displayed to users"), - ) - - # Allow per-assignment activation - is_active = models.BooleanField( - default=True, - verbose_name=_("Is active"), - help_text=_("Whether this plan is available for this CRD"), - ) - - # Future: organization limits, pricing overrides, etc. - - class Meta: - verbose_name = _("Storage Plan Assignment") - verbose_name_plural = _("Storage Plan Assignments") - unique_together = [["storage_plan", "control_plane_crd"]] - ordering = ["sort_order", "storage_plan__name"] - - def __str__(self): - return f"{self.storage_plan.name} → {self.control_plane_crd}" + Returns: + The Odoo product ID to use for billing + """ + # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated + # For now, just return the product ID + return self.odoo_product_id diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 5a01f79..d4612c7 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,6 +170,28 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) + # Storage plan configuration (hardcoded per control plane) + storage_plan_odoo_product_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Storage plan Odoo product ID"), + help_text=_("ID of the storage product in Odoo"), + ) + storage_plan_odoo_unit_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Storage plan Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + storage_plan_price_per_gib = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + verbose_name=_("Storage plan price per GiB"), + help_text=_("Price per GiB of storage"), + ) + class Meta: verbose_name = _("Control plane") verbose_name_plural = _("Control planes") @@ -613,23 +635,14 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="service_instances", on_delete=models.PROTECT, ) - resource_plan = models.ForeignKey( - to="core.ResourcePlan", + compute_plan_assignment = models.ForeignKey( + to="core.ComputePlanAssignment", on_delete=models.SET_NULL, null=True, blank=True, related_name="instances", - verbose_name=_("Resource plan"), - help_text=_("Compute resource plan for this instance"), - ) - storage_plan = models.ForeignKey( - to="core.StoragePlan", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="instances", - verbose_name=_("Storage plan"), - help_text=_("Storage plan for this instance"), + verbose_name=_("Compute plan assignment"), + help_text=_("Compute plan with SLA for this instance"), ) class Meta: From c60a69a305b5d06748ad6fe5edb34349ecb201c9 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 30 Nov 2025 03:01:16 +0000 Subject: [PATCH 11/22] Update dependency flake8-bugbear to >=25.11.29 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35bb517..4548d9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dev = [ "coverage>=7.12.0", "djlint>=1.36.4", "flake8>=7.3.0", - "flake8-bugbear>=25.10.21", + "flake8-bugbear>=25.11.29", "flake8-pyproject>=1.2.3", "isort>=7.0.0", "pytest>=9.0.1", diff --git a/uv.lock b/uv.lock index 7359b8a..c5c4af9 100644 --- a/uv.lock +++ b/uv.lock @@ -489,15 +489,15 @@ wheels = [ [[package]] name = "flake8-bugbear" -version = "25.10.21" +version = "25.11.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "flake8" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/54/0f6e431adbc67fd420540e386cb20b57e73e8aeb393f0ae2311e91b4548f/flake8_bugbear-25.10.21.tar.gz", hash = "sha256:2876afcaed8bfb3464cf33e3ec42cc3bec0a004165b84400dc3392b0547c2714", size = 83080, upload-time = "2025-10-22T01:27:03.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/2a996e2fca7810bd1b031901d65fc4292630895afcb946ebd00568bdc669/flake8_bugbear-25.11.29.tar.gz", hash = "sha256:b5d06710f3d26e595541ad303ad4d5cb52578bd4bccbb2c2c0b2c72e243dafc8", size = 84896, upload-time = "2025-11-29T20:51:57.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/0e/8ba976f7d477cad69cc7af08dc7b0163181a5e19a82fe721f954e369c067/flake8_bugbear-25.10.21-py3-none-any.whl", hash = "sha256:f1c5654f9d9d3e62e90da1f0335551fdbc565c51749713177dbcfb9edb105405", size = 37257, upload-time = "2025-10-22T01:27:02.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/42/c18f199780d99a6f6a64c4a36f4ad28a445d9e11968a6025b21d0c8b6802/flake8_bugbear-25.11.29-py3-none-any.whl", hash = "sha256:9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298", size = 37861, upload-time = "2025-11-29T20:51:56.439Z" }, ] [[package]] @@ -1159,7 +1159,7 @@ dev = [ { name = "coverage", specifier = ">=7.12.0" }, { name = "djlint", specifier = ">=1.36.4" }, { name = "flake8", specifier = ">=7.3.0" }, - { name = "flake8-bugbear", specifier = ">=25.10.21" }, + { name = "flake8-bugbear", specifier = ">=25.11.29" }, { name = "flake8-pyproject", specifier = ">=1.2.3" }, { name = "isort", specifier = ">=7.0.0" }, { name = "pytest", specifier = ">=9.0.1" }, From 0a47eedc9e980cdde39c40450a2eb6ad7bcbbb72 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Dec 2025 03:01:20 +0000 Subject: [PATCH 12/22] Lock file maintenance --- uv.lock | 96 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/uv.lock b/uv.lock index 7359b8a..40172f4 100644 --- a/uv.lock +++ b/uv.lock @@ -86,30 +86,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.41.2" +version = "1.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/81/2600e83ddd7cb1dac43d28fd39774434afcda0d85d730402192b1a9266a3/boto3-1.41.2.tar.gz", hash = "sha256:7054fbc61cadab383f40ea6d725013ba6c8f569641dddb14c0055e790280ad6c", size = 111593, upload-time = "2025-11-21T20:32:08.622Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/9b/eef5346ce3148bf4856318fe629e0fd7f6dd73ffd55ea08e316c967f8af0/boto3-1.42.0.tar.gz", hash = "sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084", size = 112854, upload-time = "2025-12-01T02:31:09.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/41/1ed7fdc3f124c1cf2df78e605588fa78a182410b832f5b71944a69436171/boto3-1.41.2-py3-none-any.whl", hash = "sha256:edcde82fdae4201aa690e3683f8e5b1a846cf1bbf79d03db4fa8a2f6f46dba9c", size = 139343, upload-time = "2025-11-21T20:32:07.147Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2c/6c6ee5667426aee6629106b9e51668449fb34ec077655da82bf4b15d8890/boto3-1.42.0-py3-none-any.whl", hash = "sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039", size = 140617, upload-time = "2025-12-01T02:31:07.238Z" }, ] [[package]] name = "botocore" -version = "1.41.2" +version = "1.41.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/0b/6eb5dc752b240dd0b76d7e3257ae25b70683896d1789e7bfb78fba7c7c99/botocore-1.41.2.tar.gz", hash = "sha256:49a3e8f4c1a1759a687941fef8b36efd7bafcf63c1ef74aa75d6497eb4887c9c", size = 14660558, upload-time = "2025-11-21T20:31:58.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/04/8e8ca38631eeb499a1099dcc2a081faaea399f9d46080720540ff54ec609/botocore-1.41.6.tar.gz", hash = "sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75", size = 14770281, upload-time = "2025-12-01T02:30:54.286Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/4d/516ee2157c0686fbe48ca8b94dffc17a0c35040d4626761d74b1a43215c8/botocore-1.41.2-py3-none-any.whl", hash = "sha256:154052dfaa7292212f01c8fab822c76cd10a15a7e164e4c45e4634eb40214b90", size = 14324839, upload-time = "2025-11-21T20:31:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d4/587a71c599997b0f7aa842ea71604348f5a7d239cfff338292904f236983/botocore-1.41.6-py3-none-any.whl", hash = "sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0", size = 14442076, upload-time = "2025-12-01T02:30:50.724Z" }, ] [[package]] @@ -489,26 +489,26 @@ wheels = [ [[package]] name = "flake8-bugbear" -version = "25.10.21" +version = "25.11.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "flake8" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/54/0f6e431adbc67fd420540e386cb20b57e73e8aeb393f0ae2311e91b4548f/flake8_bugbear-25.10.21.tar.gz", hash = "sha256:2876afcaed8bfb3464cf33e3ec42cc3bec0a004165b84400dc3392b0547c2714", size = 83080, upload-time = "2025-10-22T01:27:03.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/2a996e2fca7810bd1b031901d65fc4292630895afcb946ebd00568bdc669/flake8_bugbear-25.11.29.tar.gz", hash = "sha256:b5d06710f3d26e595541ad303ad4d5cb52578bd4bccbb2c2c0b2c72e243dafc8", size = 84896, upload-time = "2025-11-29T20:51:57.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/0e/8ba976f7d477cad69cc7af08dc7b0163181a5e19a82fe721f954e369c067/flake8_bugbear-25.10.21-py3-none-any.whl", hash = "sha256:f1c5654f9d9d3e62e90da1f0335551fdbc565c51749713177dbcfb9edb105405", size = 37257, upload-time = "2025-10-22T01:27:02.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/42/c18f199780d99a6f6a64c4a36f4ad28a445d9e11968a6025b21d0c8b6802/flake8_bugbear-25.11.29-py3-none-any.whl", hash = "sha256:9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298", size = 37861, upload-time = "2025-11-29T20:51:56.439Z" }, ] [[package]] name = "flake8-pyproject" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flake8" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/1d/635e86f9f3a96b7ea9e9f19b5efe17a987e765c39ca496e4a893bb999112/flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a", size = 4756, upload-time = "2023-03-21T20:51:38.911Z" }, + { url = "https://files.pythonhosted.org/packages/85/6a/cdee9ff7f2b7c6ddc219fd95b7c70c0a3d9f0367a506e9793eedfc72e337/flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77", size = 5694, upload-time = "2025-11-28T21:40:01.309Z" }, ] [[package]] @@ -1001,39 +1001,39 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.29.0" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, - { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, - { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, - { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, - { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, - { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, - { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, - { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, - { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, - { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, - { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, - { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -1059,14 +1059,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.15.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/bb/940d6af975948c1cc18f44545ffb219d3c35d78ec972b42ae229e8e37e08/s3transfer-0.15.0.tar.gz", hash = "sha256:d36fac8d0e3603eff9b5bfa4282c7ce6feb0301a633566153cbd0b93d11d8379", size = 152185, upload-time = "2025-11-20T20:28:56.327Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/e1/5ef25f52973aa12a19cf4e1375d00932d7fb354ffd310487ba7d44225c1a/s3transfer-0.15.0-py3-none-any.whl", hash = "sha256:6f8bf5caa31a0865c4081186689db1b2534cef721d104eb26101de4b9d6a5852", size = 85984, upload-time = "2025-11-20T20:28:55.046Z" }, + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] [[package]] @@ -1179,11 +1179,11 @@ wheels = [ [[package]] name = "sqlparse" -version = "0.5.3" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, + { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] [[package]] From 0aebbc42f0b515e94467af35e5552955764874ba Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 2 Dec 2025 03:01:04 +0000 Subject: [PATCH 13/22] Update https://github.com/renovatebot/github-action action to v44.0.5 --- .forgejo/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 3c643b1..a2577d9 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -19,7 +19,7 @@ jobs: node-version: "24" - name: Renovate - uses: https://github.com/renovatebot/github-action@v44.0.4 + uses: https://github.com/renovatebot/github-action@v44.0.5 with: token: ${{ secrets.RENOVATE_TOKEN }} env: From 5cee0194f5ac7259786554486b19768b97cdcefa Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 13:20:36 +0100 Subject: [PATCH 14/22] Fix broken FQDN due to timing problems --- src/servala/static/js/fqdn.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index 0996bda..43805bf 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -9,7 +9,12 @@ const initializeFqdnGeneration = (prefix) => { let isArrayField = true; if (fqdnFieldContainer) { - let fqdnField = fqdnFieldContainer.querySelector('input.array-item-input'); + fqdnField = fqdnFieldContainer.querySelector("input.array-item-input") + if (!fqdnField) { + // We retry, as there is a field meant to be here, but not rendered yet + setTimeout(() => {initializeFqdnGeneration(prefix)}, 200) + return + } } else { fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`); isArrayField = false; @@ -53,10 +58,14 @@ const initializeFqdnGeneration = (prefix) => { } } -document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); -document.body.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'service-form') { - initializeFqdnGeneration("custom"); - initializeFqdnGeneration("expert"); - } +const runFqdnInit = () => { + initializeFqdnGeneration("custom"); + initializeFqdnGeneration("expert"); +} + +document.addEventListener('DOMContentLoaded', () => { + runFqdnInit() +}); +document.body.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form') runFqdnInit() }); From 2bbd643cf90e72e236af5a9d755413d5f1059dc5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:09:10 +0100 Subject: [PATCH 15/22] Add migrations and final model changes --- .../migrations/0016_computeplan_and_more.py | 309 ++++++++++++++++++ ..._unit_and_convert_odoo_ids_to_charfield.py | 68 ++++ src/servala/core/models/plan.py | 63 ++-- src/servala/core/models/service.py | 11 +- 4 files changed, 411 insertions(+), 40 deletions(-) create mode 100644 src/servala/core/migrations/0016_computeplan_and_more.py create mode 100644 src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py diff --git a/src/servala/core/migrations/0016_computeplan_and_more.py b/src/servala/core/migrations/0016_computeplan_and_more.py new file mode 100644 index 0000000..a64bf50 --- /dev/null +++ b/src/servala/core/migrations/0016_computeplan_and_more.py @@ -0,0 +1,309 @@ +# Generated by Django 5.2.8 on 2025-12-02 09:51 + +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +import rules.contrib.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_add_hide_expert_mode_to_service_definition"), + ] + + operations = [ + migrations.CreateModel( + name="ComputePlan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ("name", models.CharField(max_length=100, verbose_name="Name")), + ( + "description", + models.TextField(blank=True, verbose_name="Description"), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this plan is available for selection", + verbose_name="Is active", + ), + ), + ( + "memory_requests", + models.CharField( + max_length=20, + verbose_name="Memory requests", + ), + ), + ( + "memory_limits", + models.CharField( + max_length=20, + verbose_name="Memory limits", + ), + ), + ( + "cpu_requests", + models.CharField( + max_length=20, + verbose_name="CPU requests", + ), + ), + ( + "cpu_limits", + models.CharField( + max_length=20, + verbose_name="CPU limits", + ), + ), + ], + options={ + "verbose_name": "Compute Plan", + "verbose_name_plural": "Compute Plans", + "ordering": ["name"], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name="controlplane", + name="storage_plan_odoo_product_id", + field=models.IntegerField( + blank=True, + help_text="ID of the storage product in Odoo", + null=True, + verbose_name="Storage plan Odoo product ID", + ), + ), + migrations.AddField( + model_name="controlplane", + name="storage_plan_odoo_unit_id", + field=models.IntegerField( + blank=True, + help_text="ID of the unit of measure in Odoo (uom.uom)", + null=True, + verbose_name="Storage plan Odoo unit ID", + ), + ), + migrations.AddField( + model_name="controlplane", + name="storage_plan_price_per_gib", + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text="Price per GiB of storage", + max_digits=10, + null=True, + verbose_name="Storage plan price per GiB", + ), + ), + migrations.CreateModel( + name="ComputePlanAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ( + "sla", + models.CharField( + choices=[ + ("besteffort", "Best Effort"), + ("guaranteed", "Guaranteed Availability"), + ], + help_text="Service Level Agreement", + max_length=20, + verbose_name="SLA", + ), + ), + ( + "odoo_product_id", + models.IntegerField( + help_text="ID of the product in Odoo (product.product or product.template)", + verbose_name="Odoo product ID", + ), + ), + ( + "odoo_unit_id", + models.IntegerField( + help_text="ID of the unit of measure in Odoo (uom.uom)", + verbose_name="Odoo unit ID", + ), + ), + ( + "price", + models.DecimalField( + decimal_places=2, + help_text="Price per unit", + max_digits=10, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.00")) + ], + verbose_name="Price", + ), + ), + ( + "minimum_service_size", + models.PositiveIntegerField( + default=1, + help_text="Minimum value for spec.parameters.instances (Guaranteed Availability may require multiple instances)", + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="Minimum service size", + ), + ), + ( + "sort_order", + models.PositiveIntegerField( + default=0, + help_text="Order in which plans are displayed to users", + verbose_name="Sort order", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this plan is available for this CRD", + verbose_name="Is active", + ), + ), + ( + "compute_plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignments", + to="core.computeplan", + verbose_name="Compute plan", + ), + ), + ( + "control_plane_crd", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compute_plan_assignments", + to="core.controlplanecrd", + verbose_name="Control plane CRD", + ), + ), + ], + options={ + "verbose_name": "Compute Plan Assignment", + "verbose_name_plural": "Compute Plan Assignments", + "ordering": ["sort_order", "compute_plan__name", "sla"], + "unique_together": {("compute_plan", "control_plane_crd", "sla")}, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name="serviceinstance", + name="compute_plan_assignment", + field=models.ForeignKey( + blank=True, + help_text="Compute plan with SLA for this instance", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="instances", + to="core.computeplanassignment", + verbose_name="Compute plan assignment", + ), + ), + migrations.CreateModel( + name="OdooObjectCache", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ( + "odoo_model", + models.CharField( + help_text="Odoo model name: 'product.product', 'product.template', 'uom.uom', etc.", + max_length=100, + verbose_name="Odoo model", + ), + ), + ( + "odoo_id", + models.PositiveIntegerField( + help_text="ID in the Odoo model", verbose_name="Odoo ID" + ), + ), + ( + "data", + models.JSONField( + help_text="Cached Odoo data including price, reporting_product_id, etc.", + verbose_name="Cached data", + ), + ), + ( + "expires_at", + models.DateTimeField( + blank=True, + help_text="When cache should be refreshed (null = never expires)", + null=True, + verbose_name="Expires at", + ), + ), + ], + options={ + "verbose_name": "Odoo Object Cache", + "verbose_name_plural": "Odoo Object Caches", + "indexes": [ + models.Index( + fields=["odoo_model", "odoo_id"], + name="core_odooob_odoo_mo_51e258_idx", + ), + models.Index( + fields=["expires_at"], name="core_odooob_expires_8fc00b_idx" + ), + ], + "unique_together": {("odoo_model", "odoo_id")}, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py b/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py new file mode 100644 index 0000000..38bfd46 --- /dev/null +++ b/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.8 on 2025-12-02 10:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0016_computeplan_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="computeplanassignment", + name="unit", + field=models.CharField( + choices=[ + ("hour", "Hour"), + ("day", "Day"), + ("month", "Month (30 days)"), + ("year", "Year"), + ], + default="hour", + help_text="Unit for the price (e.g., price per hour)", + max_length=10, + verbose_name="Billing unit", + ), + ), + migrations.AlterField( + model_name="computeplanassignment", + name="odoo_product_id", + field=models.CharField( + help_text="Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')", + max_length=255, + verbose_name="Odoo product ID", + ), + ), + migrations.AlterField( + model_name="computeplanassignment", + name="odoo_unit_id", + field=models.CharField( + max_length=255, + verbose_name="Odoo unit ID", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="storage_plan_odoo_product_id", + field=models.CharField( + blank=True, + help_text="Storage product ID in Odoo", + max_length=255, + null=True, + verbose_name="Storage plan Odoo product ID", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="storage_plan_odoo_unit_id", + field=models.CharField( + blank=True, + help_text="Unit of measure ID in Odoo", + max_length=255, + null=True, + verbose_name="Storage plan Odoo unit ID", + ), + ), + ] diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py index 6fc50e4..0e493af 100644 --- a/src/servala/core/models/plan.py +++ b/src/servala/core/models/plan.py @@ -1,5 +1,6 @@ from decimal import Decimal +from auditlog.registry import auditlog from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ @@ -29,26 +30,21 @@ class ComputePlan(ServalaModelMixin): help_text=_("Whether this plan is available for selection"), ) - # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") memory_requests = models.CharField( max_length=20, verbose_name=_("Memory requests"), - help_text=_("e.g., '2Gi', '512Mi'"), ) memory_limits = models.CharField( max_length=20, verbose_name=_("Memory limits"), - help_text=_("e.g., '4Gi', '1Gi'"), ) cpu_requests = models.CharField( max_length=20, verbose_name=_("CPU requests"), - help_text=_("e.g., '500m', '1', '2'"), ) cpu_limits = models.CharField( max_length=20, verbose_name=_("CPU limits"), - help_text=_("e.g., '2000m', '2', '4'"), ) class Meta: @@ -60,12 +56,6 @@ class ComputePlan(ServalaModelMixin): return self.name def get_resource_summary(self): - """ - Get a human-readable summary of resources. - - Returns: - String like "2 vCPU, 4Gi RAM" - """ return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" @@ -95,26 +85,23 @@ class ComputePlanAssignment(ServalaModelMixin): related_name="compute_plan_assignments", verbose_name=_("Control plane CRD"), ) - - # Service Level Agreement sla = models.CharField( max_length=20, choices=SLA_CHOICES, verbose_name=_("SLA"), help_text=_("Service Level Agreement"), ) - - # Odoo product reference - odoo_product_id = models.IntegerField( + odoo_product_id = models.CharField( + max_length=255, verbose_name=_("Odoo product ID"), - help_text=_("ID of the product in Odoo (product.product or product.template)"), + help_text=_( + "Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')" + ), ) - odoo_unit_id = models.IntegerField( + odoo_unit_id = models.CharField( + max_length=255, verbose_name=_("Odoo unit ID"), - help_text=_("ID of the unit of measure in Odoo (uom.uom)"), ) - - # Pricing price = models.DecimalField( max_digits=10, decimal_places=2, @@ -123,7 +110,20 @@ class ComputePlanAssignment(ServalaModelMixin): help_text=_("Price per unit"), ) - # Service constraints + BILLING_UNIT_CHOICES = [ + ("hour", _("Hour")), + ("day", _("Day")), + ("month", _("Month (30 days / 720 hours)")), + ("year", _("Year")), + ] + unit = models.CharField( + max_length=10, + choices=BILLING_UNIT_CHOICES, + default="hour", + verbose_name=_("Billing unit"), + help_text=_("Unit for the price (e.g., price per hour)"), + ) + minimum_service_size = models.PositiveIntegerField( default=1, validators=[MinValueValidator(1)], @@ -133,15 +133,11 @@ class ComputePlanAssignment(ServalaModelMixin): "(Guaranteed Availability may require multiple instances)" ), ) - - # Display ordering in UI sort_order = models.PositiveIntegerField( default=0, verbose_name=_("Sort order"), help_text=_("Order in which plans are displayed to users"), ) - - # Allow per-assignment activation is_active = models.BooleanField( default=True, verbose_name=_("Is active"), @@ -158,15 +154,12 @@ class ComputePlanAssignment(ServalaModelMixin): return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" def get_odoo_reporting_product_id(self): - """ - Get the reporting product ID for this plan. - - In the future, this will query Odoo based on invoicing policy. - For now, returns the product ID directly. - - Returns: - The Odoo product ID to use for billing - """ # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated # For now, just return the product ID return self.odoo_product_id + + +auditlog.register(ComputePlan, exclude_fields=["updated_at"], serialize_data=True) +auditlog.register( + ComputePlanAssignment, exclude_fields=["updated_at"], serialize_data=True +) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index d4612c7..67ec4f7 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,18 +170,19 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) - # Storage plan configuration (hardcoded per control plane) - storage_plan_odoo_product_id = models.IntegerField( + storage_plan_odoo_product_id = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_("Storage plan Odoo product ID"), - help_text=_("ID of the storage product in Odoo"), + help_text=_("Storage product ID in Odoo"), ) - storage_plan_odoo_unit_id = models.IntegerField( + storage_plan_odoo_unit_id = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_("Storage plan Odoo unit ID"), - help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + help_text=_("Unit of measure ID in Odoo"), ) storage_plan_price_per_gib = models.DecimalField( max_digits=10, From 29661aa7cd195f03a01c24ce080796519b80b9f6 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:09:21 +0100 Subject: [PATCH 16/22] Implement plan logic in create/update --- src/servala/core/models/service.py | 99 +++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 67ec4f7..3465d54 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -686,6 +686,60 @@ class ServiceInstance(ServalaModelMixin, models.Model): spec_data = prune_empty_data(spec_data) return spec_data + @staticmethod + def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): + """ + Apply compute plan resource allocations and SLA to spec. + """ + if not compute_plan_assignment: + return spec_data + + compute_plan = compute_plan_assignment.compute_plan + + if "parameters" not in spec_data: + spec_data["parameters"] = {} + if "size" not in spec_data["parameters"]: + spec_data["parameters"]["size"] = {} + if "requests" not in spec_data["parameters"]["size"]: + spec_data["parameters"]["size"]["requests"] = {} + if "service" not in spec_data["parameters"]: + spec_data["parameters"]["service"] = {} + + spec_data["parameters"]["size"]["memory"] = compute_plan.memory_limits + spec_data["parameters"]["size"]["cpu"] = compute_plan.cpu_limits + spec_data["parameters"]["size"]["requests"][ + "memory" + ] = compute_plan.memory_requests + spec_data["parameters"]["size"]["requests"]["cpu"] = compute_plan.cpu_requests + spec_data["parameters"]["service"]["serviceLevel"] = compute_plan_assignment.sla + return spec_data + + @staticmethod + def _build_billing_annotations(compute_plan_assignment, control_plane): + """ + Build Kubernetes annotations for billing integration. + """ + annotations = {} + + if compute_plan_assignment: + annotations["servala.com/erp_product_id_resource"] = str( + compute_plan_assignment.odoo_product_id + ) + annotations["servala.com/erp_unit_id_resource"] = str( + compute_plan_assignment.odoo_unit_id + ) + + if control_plane.storage_plan_odoo_product_id: + annotations["servala.com/erp_product_id_storage"] = str( + control_plane.storage_plan_odoo_product_id + ) + if control_plane.storage_plan_odoo_unit_id: + annotations["servala.com/erp_unit_id_storage"] = str( + control_plane.storage_plan_odoo_unit_id + ) + + return annotations + @classmethod def _format_kubernetes_error(cls, error_message): if not error_message: @@ -740,7 +794,15 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_instance(cls, name, organization, context, created_by, spec_data): + def create_instance( + cls, + name, + organization, + context, + created_by, + spec_data, + compute_plan_assignment=None, + ): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: @@ -749,6 +811,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): organization=organization, created_by=created_by, context=context, + compute_plan_assignment=compute_plan_assignment, ) except IntegrityError: message = _( @@ -759,6 +822,11 @@ class ServiceInstance(ServalaModelMixin, models.Model): try: spec_data = cls._prepare_spec_data(spec_data) + if compute_plan_assignment: + spec_data = cls._apply_compute_plan_to_spec( + spec_data, compute_plan_assignment + ) + if "writeConnectionSecretToRef" not in spec_data: spec_data["writeConnectionSecretToRef"] = {} @@ -776,6 +844,13 @@ class ServiceInstance(ServalaModelMixin, models.Model): }, "spec": spec_data, } + + annotations = cls._build_billing_annotations( + compute_plan_assignment, context.control_plane + ) + if annotations: + create_data["metadata"]["annotations"] = annotations + if label := context.control_plane.required_label: create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label} api_instance = context.control_plane.custom_objects_api @@ -813,12 +888,23 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(organization.add_support_message(message)) return instance - def update_spec(self, spec_data, updated_by): + def update_spec(self, spec_data, updated_by, compute_plan_assignment=None): try: spec_data = self._prepare_spec_data(spec_data) + + plan_to_use = compute_plan_assignment or self.compute_plan_assignment + if plan_to_use: + spec_data = self._apply_compute_plan_to_spec(spec_data, plan_to_use) + api_instance = self.context.control_plane.custom_objects_api patch_body = {"spec": spec_data} + annotations = self._build_billing_annotations( + plan_to_use, self.context.control_plane + ) + if annotations: + patch_body["metadata"] = {"annotations": annotations} + api_instance.patch_namespaced_custom_object( group=self.context.group, version=self.context.version, @@ -828,7 +914,14 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=patch_body, ) self._clear_kubernetes_caches() - self.save() # Updates updated_at timestamp + + if ( + compute_plan_assignment + and compute_plan_assignment != self.compute_plan_assignment + ): + self.compute_plan_assignment = compute_plan_assignment + # Saving to update updated_at timestamp even if nothing was visibly changed + self.save() except ApiException as e: if e.status == 404: message = _( From c5b2c583057898445e763f424d6150fe1a91995b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:09:48 +0100 Subject: [PATCH 17/22] Add missing admin field --- src/servala/core/admin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index f7cf161..29ddb2f 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -459,6 +459,7 @@ class ComputePlanAssignmentInline(admin.TabularInline): "odoo_product_id", "odoo_unit_id", "price", + "unit", "minimum_service_size", "sort_order", "is_active", @@ -509,6 +510,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin): "control_plane_crd", "sla", "price", + "unit", "sort_order", "is_active", ) @@ -545,6 +547,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin): { "fields": ( "price", + "unit", "minimum_service_size", ) }, From ef4f76b2907c4f4da81b9a9d4c95d5cd1eb38681 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:11:51 +0100 Subject: [PATCH 18/22] Implement form changes for plan integration --- src/servala/core/crd/forms.py | 19 +++++++++++++++--- src/servala/frontend/forms/service.py | 29 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index 659684e..b88f2db 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -69,13 +69,17 @@ class CrdModelFormMixin(FormGeneratorMixin): "spec.parameters.network.serviceType", "spec.parameters.scheduling", "spec.parameters.security", + "spec.publishConnectionDetailsTo", + "spec.resourceRef", + "spec.writeConnectionSecretToRef", + ] + + # Fields populated from compute plan + READONLY_FIELDS = [ "spec.parameters.size.cpu", "spec.parameters.size.memory", "spec.parameters.size.requests.cpu", "spec.parameters.size.requests.memory", - "spec.publishConnectionDetailsTo", - "spec.resourceRef", - "spec.writeConnectionSecretToRef", ] def __init__(self, *args, **kwargs): @@ -88,6 +92,15 @@ class CrdModelFormMixin(FormGeneratorMixin): ): field.widget = forms.HiddenInput() field.required = False + elif name in self.READONLY_FIELDS or any( + name.startswith(f) for f in self.READONLY_FIELDS + ): + field.disabled = True + field.required = False + field.widget.attrs["readonly"] = "readonly" + field.widget.attrs["class"] = ( + field.widget.attrs.get("class", "") + " form-control-plaintext" + ) def strip_title(self, field_name, label): field = self.fields[field_name] diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 23325f3..169d6ea 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from servala.core.models import ( CloudProvider, + ComputePlanAssignment, ControlPlane, Service, ServiceCategory, @@ -56,6 +57,34 @@ class ControlPlaneSelectForm(forms.Form): self.fields["control_plane"].initial = planes.first() +class ComputePlanSelectionForm(forms.Form): + compute_plan_assignment = forms.ModelChoiceField( + queryset=ComputePlanAssignment.objects.none(), + widget=forms.RadioSelect, + required=True, + label=_("Compute Plan"), + empty_label=None, + ) + + def __init__(self, *args, control_plane_crd=None, **kwargs): + super().__init__(*args, **kwargs) + if control_plane_crd: + self.fields["compute_plan_assignment"].queryset = ( + ComputePlanAssignment.objects.filter( + control_plane_crd=control_plane_crd, is_active=True + ) + .select_related("compute_plan") + .order_by("sort_order", "compute_plan__name", "sla") + ) + if ( + not self.is_bound + and self.fields["compute_plan_assignment"].queryset.exists() + ): + self.fields["compute_plan_assignment"].initial = self.fields[ + "compute_plan_assignment" + ].queryset.first() + + class ServiceInstanceFilterForm(forms.Form): name = forms.CharField(required=False, label=_("Name")) service = forms.ModelChoiceField( From 2a63677539898c5e389c0f9155f1838467ed06b0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:25:16 +0100 Subject: [PATCH 19/22] Implement view logic --- src/servala/frontend/views/service.py | 93 +++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index c26194d..b9f7d56 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -14,6 +14,7 @@ from servala.core.models import ( ServiceOffering, ) from servala.frontend.forms.service import ( + ComputePlanSelectionForm, ControlPlaneSelectForm, ServiceFilterForm, ServiceInstanceDeleteForm, @@ -152,6 +153,13 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView control_plane=self.selected_plane, service_offering=self.object ).first() + @cached_property + def plan_form(self): + data = self.request.POST if self.request.method == "POST" else None + return ComputePlanSelectionForm( + data=data, control_plane_crd=self.context_object, prefix="plans" + ) + def get_instance_form_kwargs(self, ignore_data=False): return { "initial": { @@ -205,6 +213,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["select_form"] = self.select_form context["has_control_planes"] = self.planes.exists() context["selected_plane"] = self.selected_plane + context["context_object"] = self.context_object context["hide_expert_mode"] = self.hide_expert_mode if self.request.method == "POST": if self.is_custom_form: @@ -222,6 +231,17 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView if self.selected_plane and self.selected_plane.wildcard_dns: context["wildcard_dns"] = self.selected_plane.wildcard_dns context["organization_namespace"] = self.request.organization.namespace + + if self.context_object: + context["plan_form"] = self.plan_form + context["has_available_plans"] = self.plan_form.fields[ + "compute_plan_assignment" + ].queryset.exists() + if self.context_object.control_plane.storage_plan_price_per_gib: + context["storage_plan"] = { + "price_per_gib": self.context_object.control_plane.storage_plan_price_per_gib, + } + return context def post(self, request, *args, **kwargs): @@ -232,6 +252,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["form_error"] = True return self.render_to_response(context) + if not self.plan_form.is_valid(): + return self.render_to_response(context) + if self.is_custom_form: form = self.get_custom_instance_form() else: @@ -245,7 +268,11 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) return self.render_to_response(context) - if form.is_valid(): + if form.is_valid() and self.plan_form.is_valid(): + compute_plan_assignment = self.plan_form.cleaned_data[ + "compute_plan_assignment" + ] + try: service_instance = ServiceInstance.create_instance( organization=self.request.organization, @@ -253,16 +280,22 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context=self.context_object, created_by=request.user, spec_data=form.get_nested_data().get("spec"), + compute_plan_assignment=compute_plan_assignment, ) return redirect(service_instance.urls.base) except ValidationError as e: form.add_error(None, e.message or str(e)) except Exception as e: error_message = self.organization.add_support_message( - _(f"Error creating instance: {str(e)}.") + _("Error creating instance: {error}.").format(error=str(e)) ) form.add_error(None, error_message) + if self.is_custom_form: + context["custom_service_form"] = form + else: + context["service_form"] = form + return self.render_to_response(context) @@ -332,6 +365,18 @@ class ServiceInstanceDetailView( context["has_delete_permission"] = self.request.user.has_perm( ServiceInstance.get_perm("delete"), self.object ) + + if self.object.compute_plan_assignment: + context["compute_plan_assignment"] = self.object.compute_plan_assignment + + if ( + self.object.context + and self.object.context.control_plane.storage_plan_price_per_gib + ): + context["storage_plan"] = { + "price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib, + } + return context def get_nested_spec(self): @@ -475,6 +520,17 @@ class ServiceInstanceUpdateView( kwargs.pop("data", None) return cls(**kwargs) + @cached_property + def plan_form(self): + data = self.request.POST if self.request.method == "POST" else None + initial = self.object.compute_plan_assignment if self.object else None + return ComputePlanSelectionForm( + data=data, + control_plane_crd=self.object.context if self.object else None, + prefix="plans", + initial={"compute_plan_assignment": initial} if initial else None, + ) + @property def is_custom_form(self): # Note: "custom form" = user-friendly, subset of fields @@ -489,7 +545,7 @@ class ServiceInstanceUpdateView( else: form = self.get_form() - if form.is_valid(): + if form.is_valid() and self.plan_form.is_valid(): return self.form_valid(form) return self.form_invalid(form) @@ -506,14 +562,29 @@ class ServiceInstanceUpdateView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["hide_expert_mode"] = self.hide_expert_mode + + # Check if a form was passed (e.g., from form_invalid) + form_from_kwargs = kwargs.get("form") + if self.request.method == "POST": if self.is_custom_form: - context["custom_form"] = self.get_custom_form() + # Use the form with errors if passed, otherwise create new + context["custom_form"] = form_from_kwargs or self.get_custom_form() context["form"] = self.get_form(ignore_data=True) else: + # Use the form with errors if passed, otherwise create new + context["form"] = form_from_kwargs or self.get_form() context["custom_form"] = self.get_custom_form(ignore_data=True) else: context["custom_form"] = self.get_custom_form() + + if self.object and self.object.context: + context["plan_form"] = self.plan_form + if self.object.context.control_plane.storage_plan_price_per_gib: + context["storage_plan"] = { + "price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib, + } + return context def _deep_merge(self, base, update): @@ -533,7 +604,17 @@ class ServiceInstanceUpdateView( current_spec = dict(self.object.spec) if self.object.spec else {} spec_data = self._deep_merge(current_spec, spec_data) - self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) + compute_plan_assignment = None + if self.plan_form.is_valid(): + compute_plan_assignment = self.plan_form.cleaned_data.get( + "compute_plan_assignment" + ) + + self.object.update_spec( + spec_data=spec_data, + updated_by=self.request.user, + compute_plan_assignment=compute_plan_assignment, + ) messages.success( self.request, _("Service instance '{name}' updated successfully.").format( @@ -546,7 +627,7 @@ class ServiceInstanceUpdateView( return self.form_invalid(form) except Exception as e: error_message = self.organization.add_support_message( - _(f"Error updating instance: {str(e)}.") + _("Error updating instance: {error}.").format(error=str(e)) ) form.add_error(None, error_message) return self.form_invalid(form) From 7d4282002695f4aa3878f46c2833697aa0c82522 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:25:36 +0100 Subject: [PATCH 20/22] Show plans in detail view --- .../service_instance_detail.html | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 948a2df..5c72de6 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -51,6 +51,29 @@
{{ instance.context.control_plane.name }}
+ {% if compute_plan_assignment %} +
{% translate "Compute Plan" %}
+
+ {{ compute_plan_assignment.compute_plan.name }} + + {{ compute_plan_assignment.get_sla_display }} + +
+ {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU + + {{ compute_plan_assignment.compute_plan.memory_limits }} RAM + + CHF {{ compute_plan_assignment.price }}/{{ compute_plan_assignment.get_unit_display }} +
+
+ {% endif %} + {% if storage_plan %} +
{% translate "Storage Plan" %}
+
+ CHF {{ storage_plan.price_per_gib }} per GiB +
{% translate "Billed separately based on disk usage" %}
+
+ {% endif %}
{% translate "Created By" %}
{{ instance.created_by|default:"-" }} From 83f60711bb387adfc940c1f70fae0c59279e8509 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:26:32 +0100 Subject: [PATCH 21/22] Show plans in forms --- .../service_instance_update.html | 42 ++- .../service_offering_detail.html | 59 ++++- .../templates/includes/plan_selection.html | 178 +++++++++++++ .../includes/tabbed_fieldset_form.html | 241 +++++++++--------- 4 files changed, 385 insertions(+), 135 deletions(-) create mode 100644 src/servala/frontend/templates/includes/plan_selection.html diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html index 17b9a51..021be3c 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -30,14 +30,42 @@ {% endpartialdef %} {% block content %}
-
- {% if not form and not custom_form %} - + + {% if plan_form %} +
+
+
{% translate "Compute Plan" %}
+
+
+ {% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %} +
+
+ {% endif %} + +
+ {% if not form and not custom_form %} + + {% else %} +
{% partial service-form %}
+ {% endif %} +
+
{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 927c6e3..39b69a8 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -124,12 +124,61 @@ {% endif %} - -
-
-
{% partial service-form %}
+
+ {% csrf_token %} + {% if plan_form.errors or service_form.errors or custom_service_form.errors %} +
+
+ {% include "frontend/forms/errors.html" with form=plan_form %} + {% if service_form %} + {% include "frontend/forms/errors.html" with form=service_form %} + {% endif %} + {% if custom_service_form %} + {% include "frontend/forms/errors.html" with form=custom_service_form %} + {% endif %} +
+
+ {% endif %} + + {% if context_object %} + {% if not has_available_plans %} +
+
+ +
+
+ {% else %} +
+
+
+
+
{% translate "Select Compute Plan" %}
+
+
+ {% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %} +
+
+
+
+ {% endif %} + {% endif %} + +
+
+
+
{% partial service-form %}
+
+
-
+ {% endblock content %} {% block extra_js %} diff --git a/src/servala/frontend/templates/includes/plan_selection.html b/src/servala/frontend/templates/includes/plan_selection.html new file mode 100644 index 0000000..045aeb3 --- /dev/null +++ b/src/servala/frontend/templates/includes/plan_selection.html @@ -0,0 +1,178 @@ +{% load i18n %} + +
+ {% if plan_form %} + {% for assignment in plan_form.fields.compute_plan_assignment.queryset %} +
+ + +
+ {% endfor %} +
+
+
+ {% trans "Storage" %} + {% trans "Billed separately based on disk usage" %} +
+ {% if storage_plan %} +
+
CHF {{ storage_plan.price_per_gib }}
+
{% trans "per GiB" %}
+
+ {% else %} +
{% trans "Included" %}
+ {% endif %} +
+ {% if storage_plan %}
{% endif %} +
+ {% else %} +
{% trans "No compute plans available for this service offering." %}
+ {% endif %} +
+ diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 5f289b7..f54b1ae 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,26 +1,64 @@ {% load i18n %} {% load get_field %} {% load static %} -
- {% csrf_token %} - {% include "frontend/forms/errors.html" %} - {% if form and expert_form and not hide_expert_mode %} - - {% endif %} -
- {% if form and form.context %}{{ form.context }}{% endif %} - {% if form and form.get_fieldsets|length == 1 %} - {# Single fieldset - render without tabs #} +{% include "frontend/forms/errors.html" %} +{% if form and expert_form and not hide_expert_mode %} + +{% endif %} +
+ {% if form and form.context %}{{ form.context }}{% endif %} + {% if form and form.get_fieldsets|length == 1 %} + {# Single fieldset - render without tabs #} + {% for fieldset in form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} + {% elif form %} + {# Multiple fieldsets or auto-generated form - render with tabs #} + +
+ {% for fieldset in form.get_fieldsets %} +
{% for field in fieldset.fields %} {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} {% endfor %} @@ -36,113 +74,70 @@ {% endfor %}
{% endfor %} - {% elif form %} - {# Multiple fieldsets or auto-generated form - render with tabs #} - -
- {% for fieldset in form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} -
- {% endif %} -
- {% if expert_form and not hide_expert_mode %} -
- {% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %} - -
- {% for fieldset in expert_form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} -
{% endif %} - {% if form %} - - {% endif %} -
- {# browser form validation fails when there are fields missing/invalid that are hidden #} - +
+{% if expert_form and not hide_expert_mode %} +
+ {% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %} + +
+ {% for fieldset in expert_form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
- +{% endif %} +{% if form %} + +{% endif %} +
+ {# browser form validation fails when there are fields missing/invalid that are hidden #} + +
{% if form and not hide_expert_mode %} From ad622ef14b090d1fd325f89fcbf73d4b59d295c7 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 2 Dec 2025 16:28:54 +0100 Subject: [PATCH 22/22] Add tests for plans --- src/tests/test_compute_plans.py | 199 ++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/tests/test_compute_plans.py diff --git a/src/tests/test_compute_plans.py b/src/tests/test_compute_plans.py new file mode 100644 index 0000000..0317229 --- /dev/null +++ b/src/tests/test_compute_plans.py @@ -0,0 +1,199 @@ +from unittest.mock import Mock + +import pytest + +from servala.core.models import ( + ComputePlan, + ComputePlanAssignment, + ServiceInstance, +) + + +@pytest.mark.django_db +def test_create_compute_plan(): + plan = ComputePlan.objects.create( + name="Small", + description="Small resource plan", + memory_requests="512Mi", + memory_limits="1Gi", + cpu_requests="100m", + cpu_limits="500m", + is_active=True, + ) + + assert plan.name == "Small" + assert plan.memory_requests == "512Mi" + assert plan.memory_limits == "1Gi" + assert plan.cpu_requests == "100m" + assert plan.cpu_limits == "500m" + assert plan.is_active is True + + +@pytest.mark.django_db +def test_compute_plan_str(): + plan = ComputePlan.objects.create( + name="Medium", + memory_requests="1Gi", + memory_limits="2Gi", + cpu_requests="500m", + cpu_limits="1000m", + ) + assert str(plan) == "Medium" + + +@pytest.mark.django_db +def test_get_resource_summary(): + plan = ComputePlan.objects.create( + name="Large", + memory_requests="2Gi", + memory_limits="4Gi", + cpu_requests="1000m", + cpu_limits="2000m", + ) + summary = plan.get_resource_summary() + assert summary == "2000m vCPU, 4Gi RAM" + + +def test_apply_compute_plan_to_spec(): + compute_plan = Mock() + compute_plan.memory_requests = "512Mi" + compute_plan.memory_limits = "1Gi" + compute_plan.cpu_requests = "100m" + compute_plan.cpu_limits = "500m" + + compute_plan_assignment = Mock() + compute_plan_assignment.compute_plan = compute_plan + compute_plan_assignment.sla = "besteffort" + + spec_data = {"parameters": {}} + + result = ServiceInstance._apply_compute_plan_to_spec( + spec_data, compute_plan_assignment + ) + + assert result["parameters"]["size"]["memory"] == "1Gi" + assert result["parameters"]["size"]["cpu"] == "500m" + assert result["parameters"]["size"]["requests"]["memory"] == "512Mi" + assert result["parameters"]["size"]["requests"]["cpu"] == "100m" + + assert result["parameters"]["service"]["serviceLevel"] == "besteffort" + + +def test_apply_compute_plan_preserves_existing_spec(): + compute_plan = Mock() + compute_plan.memory_requests = "512Mi" + compute_plan.memory_limits = "1Gi" + compute_plan.cpu_requests = "100m" + compute_plan.cpu_limits = "500m" + + compute_plan_assignment = Mock() + compute_plan_assignment.compute_plan = compute_plan + compute_plan_assignment.sla = "guaranteed" + + spec_data = { + "parameters": { + "custom_field": "custom_value", + "service": {"existingField": "value"}, + } + } + + result = ServiceInstance._apply_compute_plan_to_spec( + spec_data, compute_plan_assignment + ) + + assert result["parameters"]["custom_field"] == "custom_value" + assert result["parameters"]["service"]["existingField"] == "value" + + assert result["parameters"]["size"]["memory"] == "1Gi" + assert result["parameters"]["service"]["serviceLevel"] == "guaranteed" + + +def test_apply_compute_plan_with_none(): + spec_data = {"parameters": {}} + result = ServiceInstance._apply_compute_plan_to_spec(spec_data, None) + + assert result == spec_data + + +def test_build_billing_annotations_complete(): + compute_plan_assignment = Mock() + compute_plan_assignment.odoo_product_id = "test-product-123" + compute_plan_assignment.odoo_unit_id = "test-unit-hour" + + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = "storage-product-id" + control_plane.storage_plan_odoo_unit_id = "storage-unit-id" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment, control_plane + ) + + assert annotations["servala.com/erp_product_id_resource"] == "test-product-123" + assert annotations["servala.com/erp_unit_id_resource"] == "test-unit-hour" + + assert annotations["servala.com/erp_product_id_storage"] == "storage-product-id" + assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit-id" + + +def test_build_billing_annotations_no_compute_plan(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = "storage-product-id" + control_plane.storage_plan_odoo_unit_id = "storage-unit-id" + + annotations = ServiceInstance._build_billing_annotations(None, control_plane) + + assert "servala.com/erp_product_id_resource" not in annotations + assert "servala.com/erp_unit_id_resource" not in annotations + assert annotations["servala.com/erp_product_id_storage"] == "storage-product-id" + assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit-id" + + +def test_build_billing_annotations_no_storage_plan(): + compute_plan_assignment = Mock() + compute_plan_assignment.odoo_product_id = "product-id" + compute_plan_assignment.odoo_unit_id = "unit-id" + + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = None + control_plane.storage_plan_odoo_unit_id = None + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment, control_plane + ) + + assert annotations["servala.com/erp_product_id_resource"] == "product-id" + assert annotations["servala.com/erp_unit_id_resource"] == "unit-id" + assert "servala.com/erp_product_id_storage" not in annotations + assert "servala.com/erp_unit_id_storage" not in annotations + + +def test_build_billing_annotations_empty(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = None + control_plane.storage_plan_odoo_unit_id = None + + annotations = ServiceInstance._build_billing_annotations(None, control_plane) + + assert annotations == {} + + +@pytest.mark.django_db +def test_hour_unit(): + choices = dict(ComputePlanAssignment.BILLING_UNIT_CHOICES) + assert "hour" in choices + assert str(choices["hour"]) == "Hour" + + +@pytest.mark.django_db +def test_all_billing_units(): + choices = dict(ComputePlanAssignment.BILLING_UNIT_CHOICES) + + assert "hour" in choices + assert "day" in choices + assert "month" in choices + assert "year" in choices + + assert str(choices["hour"]) == "Hour" + assert str(choices["day"]) == "Day" + assert "Month" in str(choices["month"]) + assert str(choices["year"]) == "Year"