diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 60fe147..29ddb2f 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,138 @@ 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 0f825ce..b022a5e 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -76,13 +76,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): @@ -95,6 +99,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/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/__init__.py b/src/servala/core/models/__init__.py index 4c23f18..4cb8fd7 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,10 @@ from .organization import ( OrganizationOrigin, OrganizationRole, ) +from .plan import ( + ComputePlan, + ComputePlanAssignment, +) from .service import ( CloudProvider, ControlPlane, @@ -21,8 +26,11 @@ from .user import User __all__ = [ "BillingEntity", "CloudProvider", + "ComputePlan", + "ComputePlanAssignment", "ControlPlane", "ControlPlaneCRD", + "OdooObjectCache", "Organization", "OrganizationInvitation", "OrganizationMembership", @@ -30,8 +38,8 @@ __all__ = [ "OrganizationRole", "Service", "ServiceCategory", - "ServiceInstance", "ServiceDefinition", + "ServiceInstance", "ServiceOffering", "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..0e493af --- /dev/null +++ b/src/servala/core/models/plan.py @@ -0,0 +1,165 @@ +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 ab7b76f..3465d54 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,6 +170,29 @@ 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") @@ -613,6 +636,15 @@ 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") @@ -654,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: @@ -708,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: @@ -717,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 = _( @@ -727,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"] = {} @@ -744,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 @@ -781,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, @@ -796,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 = _( 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( 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:"-" }} 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..5ca113a --- /dev/null +++ b/src/servala/frontend/templates/includes/plan_selection.html @@ -0,0 +1,126 @@ +{% load i18n static %} + +
+ {% if plan_form %} + +
+ {% for assignment in plan_form.fields.compute_plan_assignment.queryset %} + + {% endfor %} +
+
+ +
+ {% for assignment in plan_form.fields.compute_plan_assignment.queryset %} +
+
+
+
{{ assignment.compute_plan.name }}
+ + {{ assignment.get_sla_display }} + +
+
+
CHF {{ assignment.price }}
+
{% trans "per" %} {{ assignment.get_unit_display }}
+
+
+
+ {{ assignment.compute_plan.cpu_limits }} {% trans "vCPU" %} + + {{ assignment.compute_plan.memory_limits }} {% trans "RAM" %} +
+
+ {% endfor %} +
+
+
+
+
+
+
+ +
+
+
{% trans "Storage" %}
+ {% trans "Billed separately based on disk usage" %} +
+
+ {% if storage_plan %} +
+
CHF {{ storage_plan.price_per_gib }}
+
{% trans "per GiB / hour" %}
+
+ {% 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 %} 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) diff --git a/src/servala/static/css/plan-selection.css b/src/servala/static/css/plan-selection.css new file mode 100644 index 0000000..8ce099c --- /dev/null +++ b/src/servala/static/css/plan-selection.css @@ -0,0 +1,140 @@ +.plan-selection .plan-dropdown { + position: relative; +} + +.plan-selection .plan-dropdown-toggle { + cursor: pointer; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background: #fff; +} + +.plan-selection .plan-dropdown-toggle:hover { + border-color: #a1afdf; +} + +.plan-selection .plan-dropdown-toggle:focus { + color: #607080; + background-color: #fff; + border-color: #a1afdf; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25); +} + +.plan-selection .plan-dropdown-toggle[aria-expanded="true"] { + border-color: #a1afdf; + box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-color: transparent; +} + +.plan-selection .plan-dropdown-toggle .dropdown-arrow { + transition: transform 0.2s ease; +} + +.plan-selection .plan-dropdown-toggle[aria-expanded="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.plan-selection .plan-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + display: none; + margin-top: -1px; + background: #fff; + border: 1px solid #a1afdf; + border-top: none; + border-radius: 0 0 0.375rem 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + max-height: 400px; + overflow-y: auto; +} + +.plan-selection .plan-dropdown-menu.show { + display: block; +} + +.plan-selection .plan-option { + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.15s ease; +} + +.plan-selection .plan-option:last-child { + border-bottom: none; +} + +.plan-selection .plan-option:hover, +.plan-selection .plan-option.focused { + background-color: #f8f9fa; +} + +.plan-selection .plan-option.selected { + background-color: #f0f4ff; +} + +.plan-selection .plan-option.selected.focused { + background-color: #e0e8ff; +} + +.plan-selection .plan-content { + padding: 0.75rem 1rem; +} + +.plan-selection .plan-header h6 { + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.plan-selection .badge { + font-size: 0.75rem; + padding: 0.25em 0.5em; +} + +.plan-selection .price-display { + font-size: 1.25rem; + font-weight: 600; +} + +.plan-selection .plan-specs { + margin-top: 0.5rem; +} + +.plan-selection .form-check-input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.plan-selection .storage-card { + margin-top: 0.75rem; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background: #fff; +} + +.plan-selection .storage-card .plan-content { + background-color: #f8f9fa; + border-radius: 0.375rem; +} + +.plan-selection .storage-card .storage-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: #e9ecef; + border-radius: 0.25rem; + color: #6c757d; +} + +.plan-selection .storage-card .storage-icon .bi::before { + vertical-align: top; +} 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() }); diff --git a/src/servala/static/js/plan-selection.js b/src/servala/static/js/plan-selection.js new file mode 100644 index 0000000..4b4c014 --- /dev/null +++ b/src/servala/static/js/plan-selection.js @@ -0,0 +1,244 @@ +/** + * Plan Selection Dropdown + * A custom dropdown component for selecting compute plans with keyboard navigation. + */ +(function() { + function initPlanSelection(container) { + const planDropdownToggle = container.querySelector('#plan-dropdown-toggle'); + const planDropdownMenu = container.querySelector('#plan-dropdown-menu'); + const planOptions = Array.from(container.querySelectorAll('.plan-option')); + const radioInputName = container.dataset.radioName; + const radioInputs = container.querySelectorAll('input[name="' + radioInputName + '"]'); + let focusedIndex = -1; + + if (!planDropdownToggle || planOptions.length === 0) { + return; + } + + // Update the display in the toggle button based on selected plan + function updateSelectedDisplay(data) { + const nameEl = container.querySelector('#selected-plan-name'); + const priceEl = container.querySelector('#selected-plan-price'); + const unitEl = container.querySelector('#selected-plan-unit'); + const cpuEl = container.querySelector('#selected-plan-cpu'); + const memoryEl = container.querySelector('#selected-plan-memory'); + const slaBadge = container.querySelector('#selected-plan-sla'); + + if (nameEl) nameEl.textContent = data.planName; + if (priceEl) priceEl.textContent = 'CHF ' + data.planPrice; + if (unitEl) unitEl.textContent = data.planUnit; + if (cpuEl) cpuEl.textContent = data.cpuLimits; + if (memoryEl) memoryEl.textContent = data.memoryLimits; + + if (slaBadge) { + slaBadge.textContent = data.planSlaDisplay; + slaBadge.className = 'badge bg-' + (data.planSla === 'guaranteed' ? 'success' : 'secondary'); + } + } + + // Update CPU/memory fields in the form + function updateFormFields(data) { + const cpuLimit = document.querySelector('input[name="expert-spec.parameters.size.cpu"]'); + const memoryLimit = document.querySelector('input[name="expert-spec.parameters.size.memory"]'); + const cpuRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.cpu"]'); + const memoryRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.memory"]'); + + if (cpuLimit) cpuLimit.value = data.cpuLimits; + if (memoryLimit) memoryLimit.value = data.memoryLimits; + if (cpuRequest) cpuRequest.value = data.cpuRequests; + if (memoryRequest) memoryRequest.value = data.memoryRequests; + } + + // Select a plan by value + function selectPlan(value) { + // Update the hidden radio input + radioInputs.forEach(radio => { + radio.checked = (radio.value === value); + }); + + // Update visual selection + planOptions.forEach(option => { + option.classList.toggle('selected', option.dataset.value === value); + }); + + // Find selected option and update display + const selectedOption = container.querySelector('.plan-option[data-value="' + value + '"]'); + if (selectedOption) { + const data = { + planName: selectedOption.dataset.planName, + planSla: selectedOption.dataset.planSla, + planSlaDisplay: selectedOption.dataset.planSlaDisplay, + planPrice: selectedOption.dataset.planPrice, + planUnit: selectedOption.dataset.planUnit, + cpuLimits: selectedOption.dataset.cpuLimits, + memoryLimits: selectedOption.dataset.memoryLimits, + cpuRequests: selectedOption.dataset.cpuRequests, + memoryRequests: selectedOption.dataset.memoryRequests + }; + updateSelectedDisplay(data); + updateFormFields(data); + } + } + + // Update focused option visually + function updateFocusedOption(newIndex) { + planOptions.forEach(opt => opt.classList.remove('focused')); + if (newIndex >= 0 && newIndex < planOptions.length) { + focusedIndex = newIndex; + planOptions[focusedIndex].classList.add('focused'); + // Scroll into view if needed + planOptions[focusedIndex].scrollIntoView({ block: 'nearest' }); + } + } + + // Get index of currently selected option + function getSelectedIndex() { + return planOptions.findIndex(opt => opt.classList.contains('selected')); + } + + // Open dropdown + function openDropdown() { + planDropdownToggle.setAttribute('aria-expanded', 'true'); + planDropdownMenu.classList.add('show'); + // Set focus to selected option or first option + const selectedIdx = getSelectedIndex(); + updateFocusedOption(selectedIdx >= 0 ? selectedIdx : 0); + } + + // Close dropdown + function closeDropdown() { + planDropdownToggle.setAttribute('aria-expanded', 'false'); + planDropdownMenu.classList.remove('show'); + planOptions.forEach(opt => opt.classList.remove('focused')); + focusedIndex = -1; + } + + // Check if dropdown is open + function isOpen() { + return planDropdownToggle.getAttribute('aria-expanded') === 'true'; + } + + // Toggle dropdown visibility + function toggleDropdown() { + if (isOpen()) { + closeDropdown(); + } else { + openDropdown(); + } + } + + // Event listeners + planDropdownToggle.addEventListener('click', toggleDropdown); + planDropdownToggle.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (isOpen() && focusedIndex >= 0) { + selectPlan(planOptions[focusedIndex].dataset.value); + closeDropdown(); + } else { + toggleDropdown(); + } + } else if (e.key === 'Escape') { + closeDropdown(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isOpen()) { + openDropdown(); + } else { + const newIndex = focusedIndex < planOptions.length - 1 ? focusedIndex + 1 : 0; + updateFocusedOption(newIndex); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!isOpen()) { + openDropdown(); + } else { + const newIndex = focusedIndex > 0 ? focusedIndex - 1 : planOptions.length - 1; + updateFocusedOption(newIndex); + } + } + }); + + planOptions.forEach((option, index) => { + option.addEventListener('click', function() { + selectPlan(this.dataset.value); + closeDropdown(); + planDropdownToggle.focus(); + }); + option.addEventListener('mouseenter', function() { + updateFocusedOption(index); + }); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', function(e) { + if (!planDropdownToggle.contains(e.target) && !planDropdownMenu.contains(e.target)) { + closeDropdown(); + } + }); + + // Initialize with currently checked radio + const checkedRadio = container.querySelector('input[name="' + radioInputName + '"]:checked'); + if (checkedRadio) { + selectPlan(checkedRadio.value); + } + } + + function initStorageCostCalculator(container) { + const pricePerGiB = parseFloat(container.dataset.storagePricePerGib) || 0; + const perHourText = container.dataset.perHourText || 'per hour'; + + if (pricePerGiB === 0) { + return; + } + + function setup() { + const diskInput = document.getElementById('id_custom-spec.parameters.size.disk'); + if (diskInput && !diskInput.dataset.storageListenerAttached) { + diskInput.dataset.storageListenerAttached = 'true'; + diskInput.addEventListener('input', function() { + const sizeGiB = parseFloat(this.value) || 0; + const totalCost = (sizeGiB * pricePerGiB).toFixed(2); + const display = document.getElementById('storage-cost-display'); + if (display && sizeGiB > 0) { + display.innerHTML = ' ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = CHF ' + totalCost + ' ' + perHourText; + } else if (display) { + display.textContent = ''; + } + }); + + // Trigger initial calculation if disk field has a value + if (diskInput.value) { + diskInput.dispatchEvent(new Event('input')); + } + } + } + + // Try to setup immediately (in case form is already loaded) + setup(); + + // Also setup after HTMX swaps the form in + document.body.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form' || event.detail.target.id === 'control-plane-info') { + setup(); + } + }); + } + + // Initialize all plan selection components on the page + function init() { + document.querySelectorAll('.plan-selection').forEach(function(container) { + if (container.dataset.radioName) { + initPlanSelection(container); + initStorageCostCalculator(container); + } + }); + } + + // Run on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); 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"