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..a2577d9 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 @@ -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.5 with: token: ${{ secrets.RENOVATE_TOKEN }} env: 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 diff --git a/pyproject.toml b/pyproject.toml index 51897da..3622227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,12 @@ name = "servala" version = "0.0.0" description = "Servala portal server and frontend" readme = "README.md" -requires-python = ">=3.14.0" +requires-python = ">=3.14.1" dependencies = [ "argon2-cffi>=25.1.0", "cryptography>=46.0.3", - "django==5.2.8", - "django-allauth>=65.13.0", + "django==5.2.9", + "django-allauth>=65.13.1", "django-auditlog>=3.3.0", "django-fernet-encrypted-fields>=0.3.1", "django-jsonform>=2.23.2", @@ -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.47.0", "urlman>=2.0.2", ] @@ -33,8 +33,8 @@ dev = [ "coverage>=7.12.0", "djlint>=1.36.4", "flake8>=7.3.0", - "flake8-bugbear>=25.10.21", - "flake8-pyproject>=1.2.3", + "flake8-bugbear>=25.11.29", + "flake8-pyproject>=1.2.4", "isort>=7.0.0", "pytest>=9.0.1", "pytest-cov>=7.0.0", 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/admin.py b/src/servala/core/admin.py index 29ddb2f..60fe147 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,11 +9,8 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm from servala.core.models import ( BillingEntity, CloudProvider, - ComputePlan, - ComputePlanAssignment, ControlPlane, ControlPlaneCRD, - OdooObjectCache, Organization, OrganizationInvitation, OrganizationMembership, @@ -272,19 +269,6 @@ 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): @@ -379,21 +363,15 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin): @admin.register(ServiceInstance) class ServiceInstanceAdmin(admin.ModelAdmin): - list_display = ( - "name", - "organization", - "context", - "compute_plan_assignment", - "created_by", - ) - list_filter = ("organization", "context", "compute_plan_assignment") + list_display = ("name", "organization", "context", "created_by") + list_filter = ("organization", "context") search_fields = ( "name", "organization__name", "context__service_offering__service__name", ) readonly_fields = ("name", "organization", "context") - autocomplete_fields = ("organization", "context", "compute_plan_assignment") + autocomplete_fields = ("organization", "context") def get_readonly_fields(self, request, obj=None): if obj: # If this is an edit (not a new instance) @@ -412,10 +390,6 @@ class ServiceInstanceAdmin(admin.ModelAdmin): ) }, ), - ( - _("Plan"), - {"fields": ("compute_plan_assignment",)}, - ), ) @@ -446,138 +420,3 @@ 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", - "unit", - "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", - "unit", - "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", - "unit", - "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/crd/forms.py b/src/servala/core/crd/forms.py index b88f2db..0f825ce 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -1,10 +1,12 @@ +from contextlib import suppress + from django import forms from django.core.validators import MaxValueValidator, MinValueValidator from django.forms.models import ModelForm, ModelFormMetaclass from servala.core.crd.utils import deslugify from servala.core.models import ControlPlaneCRD -from servala.frontend.forms.widgets import DynamicArrayWidget +from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon # Fields that must be present in every form MANDATORY_FIELDS = ["name"] @@ -25,6 +27,11 @@ DEFAULT_FIELD_CONFIGS = { "help_text": "Domain names for accessing this service", "required": False, }, + "spec.parameters.size.disk": { + "type": "number", + "label": "Disk size", + "addon_text": "Gi", + }, } @@ -69,17 +76,13 @@ 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): @@ -92,15 +95,6 @@ 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] @@ -348,6 +342,19 @@ class CustomFormMixin(FormGeneratorMixin): if field_type == "number": min_val = field_config.get("min_value") max_val = field_config.get("max_value") + unit = field_config.get("addon_text") + + if unit: + field.widget = NumberInputWithAddon(addon_text=unit) + field.addon_text = unit + value = self.initial.get(field_name) + if value and isinstance(value, str) and value.endswith(unit): + numeric_value = value[: -len(unit)] + with suppress(ValueError): + if "." in numeric_value: + self.initial[field_name] = float(numeric_value) + else: + self.initial[field_name] = int(numeric_value) validators = [] if min_val is not None: @@ -419,6 +426,11 @@ class CustomFormMixin(FormGeneratorMixin): mapping = field_name value = self.cleaned_data.get(field_name) + field = self.fields[field_name] + + if addon_text := getattr(field, "addon_text", None): + value = f"{value}{addon_text}" + parts = mapping.split(".") current = nested for part in parts[:-1]: diff --git a/src/servala/core/migrations/0016_computeplan_and_more.py b/src/servala/core/migrations/0016_computeplan_and_more.py deleted file mode 100644 index a64bf50..0000000 --- a/src/servala/core/migrations/0016_computeplan_and_more.py +++ /dev/null @@ -1,309 +0,0 @@ -# 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 deleted file mode 100644 index 38bfd46..0000000 --- a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py +++ /dev/null @@ -1,68 +0,0 @@ -# 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/__init__.py b/src/servala/core/models/__init__.py index 4cb8fd7..4c23f18 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,4 +1,3 @@ -from .odoo_cache import OdooObjectCache from .organization import ( BillingEntity, Organization, @@ -7,10 +6,6 @@ from .organization import ( OrganizationOrigin, OrganizationRole, ) -from .plan import ( - ComputePlan, - ComputePlanAssignment, -) from .service import ( CloudProvider, ControlPlane, @@ -26,11 +21,8 @@ from .user import User __all__ = [ "BillingEntity", "CloudProvider", - "ComputePlan", - "ComputePlanAssignment", "ControlPlane", "ControlPlaneCRD", - "OdooObjectCache", "Organization", "OrganizationInvitation", "OrganizationMembership", @@ -38,8 +30,8 @@ __all__ = [ "OrganizationRole", "Service", "ServiceCategory", - "ServiceDefinition", "ServiceInstance", + "ServiceDefinition", "ServiceOffering", "User", ] diff --git a/src/servala/core/models/odoo_cache.py b/src/servala/core/models/odoo_cache.py deleted file mode 100644 index 8f0bd3f..0000000 --- a/src/servala/core/models/odoo_cache.py +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 0e493af..0000000 --- a/src/servala/core/models/plan.py +++ /dev/null @@ -1,165 +0,0 @@ -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 _ - -from servala.core.models.mixins import ServalaModelMixin - - -class ComputePlan(ServalaModelMixin): - """ - Compute resource plans for service instances. - - Defines CPU and memory allocations. Pricing and service level are configured - per assignment to a ControlPlaneCRD. - """ - - 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"), - ) - - 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"), - ) - - class Meta: - verbose_name = _("Compute Plan") - verbose_name_plural = _("Compute Plans") - ordering = ["name"] - - def __str__(self): - return self.name - - def get_resource_summary(self): - return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" - - -class ComputePlanAssignment(ServalaModelMixin): - """ - Links compute plans to control plane CRDs with pricing and service level. - - 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. - """ - - SLA_CHOICES = [ - ("besteffort", _("Best Effort")), - ("guaranteed", _("Guaranteed Availability")), - ] - - compute_plan = models.ForeignKey( - ComputePlan, - on_delete=models.CASCADE, - related_name="assignments", - verbose_name=_("Compute plan"), - ) - control_plane_crd = models.ForeignKey( - "ControlPlaneCRD", - on_delete=models.CASCADE, - related_name="compute_plan_assignments", - verbose_name=_("Control plane CRD"), - ) - sla = models.CharField( - max_length=20, - choices=SLA_CHOICES, - verbose_name=_("SLA"), - help_text=_("Service Level Agreement"), - ) - odoo_product_id = models.CharField( - max_length=255, - verbose_name=_("Odoo product ID"), - help_text=_( - "Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')" - ), - ) - odoo_unit_id = models.CharField( - max_length=255, - verbose_name=_("Odoo unit ID"), - ) - price = models.DecimalField( - max_digits=10, - decimal_places=2, - validators=[MinValueValidator(Decimal("0.00"))], - verbose_name=_("Price"), - help_text=_("Price per unit"), - ) - - 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)], - verbose_name=_("Minimum service size"), - help_text=_( - "Minimum value for spec.parameters.instances " - "(Guaranteed Availability may require multiple instances)" - ), - ) - sort_order = models.PositiveIntegerField( - default=0, - verbose_name=_("Sort order"), - help_text=_("Order in which plans are displayed to users"), - ) - is_active = models.BooleanField( - default=True, - verbose_name=_("Is active"), - help_text=_("Whether this plan is available for this CRD"), - ) - - class Meta: - 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.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" - - def get_odoo_reporting_product_id(self): - # 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 3465d54..ab7b76f 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,29 +170,6 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) - storage_plan_odoo_product_id = models.CharField( - max_length=255, - null=True, - blank=True, - verbose_name=_("Storage plan Odoo product ID"), - help_text=_("Storage product ID in Odoo"), - ) - storage_plan_odoo_unit_id = models.CharField( - max_length=255, - null=True, - blank=True, - verbose_name=_("Storage plan Odoo unit ID"), - help_text=_("Unit of measure ID in Odoo"), - ) - 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") @@ -636,15 +613,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="service_instances", on_delete=models.PROTECT, ) - compute_plan_assignment = models.ForeignKey( - to="core.ComputePlanAssignment", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="instances", - verbose_name=_("Compute plan assignment"), - help_text=_("Compute plan with SLA for this instance"), - ) class Meta: verbose_name = _("Service instance") @@ -686,60 +654,6 @@ 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: @@ -794,15 +708,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_instance( - cls, - name, - organization, - context, - created_by, - spec_data, - compute_plan_assignment=None, - ): + def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: @@ -811,7 +717,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): organization=organization, created_by=created_by, context=context, - compute_plan_assignment=compute_plan_assignment, ) except IntegrityError: message = _( @@ -822,11 +727,6 @@ 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"] = {} @@ -844,13 +744,6 @@ 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 @@ -888,23 +781,12 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(organization.add_support_message(message)) return instance - def update_spec(self, spec_data, updated_by, compute_plan_assignment=None): + def update_spec(self, spec_data, updated_by): 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, @@ -914,14 +796,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=patch_body, ) self._clear_kubernetes_caches() - - 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() + self.save() # Updates updated_at timestamp except ApiException as e: if e.status == 404: message = _( diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 169d6ea..23325f3 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -4,7 +4,6 @@ from django.utils.translation import gettext_lazy as _ from servala.core.models import ( CloudProvider, - ComputePlanAssignment, ControlPlane, Service, ServiceCategory, @@ -57,34 +56,6 @@ 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( 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 @@ +