WIP: Service Plans #308

Draft
rixx wants to merge 11 commits from 264-service-plans into main
17 changed files with 1722 additions and 158 deletions

View file

@ -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")

View file

@ -69,13 +69,17 @@ class CrdModelFormMixin(FormGeneratorMixin):
"spec.parameters.network.serviceType",
"spec.parameters.scheduling",
"spec.parameters.security",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
# Fields populated from compute plan
READONLY_FIELDS = [
"spec.parameters.size.cpu",
"spec.parameters.size.memory",
"spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
def __init__(self, *args, **kwargs):
@ -88,6 +92,15 @@ class CrdModelFormMixin(FormGeneratorMixin):
):
field.widget = forms.HiddenInput()
field.required = False
elif name in self.READONLY_FIELDS or any(
name.startswith(f) for f in self.READONLY_FIELDS
):
field.disabled = True
field.required = False
field.widget.attrs["readonly"] = "readonly"
field.widget.attrs["class"] = (
field.widget.attrs.get("class", "") + " form-control-plaintext"
)
def strip_title(self, field_name, label):
field = self.fields[field_name]

View file

@ -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),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
]

View file

@ -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"])

View file

@ -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
)

View file

@ -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 = _(

View file

@ -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(

View file

@ -51,6 +51,29 @@
<dd class="col-sm-8">
{{ instance.context.control_plane.name }}
</dd>
{% if compute_plan_assignment %}
<dt class="col-sm-4">{% translate "Compute Plan" %}</dt>
<dd class="col-sm-8">
{{ compute_plan_assignment.compute_plan.name }}
<span class="badge bg-{% if compute_plan_assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %} ms-1">
{{ compute_plan_assignment.get_sla_display }}
</span>
<div class="text-muted small mt-1">
<i class="bi bi-cpu"></i> {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU
<span class="mx-2"></span>
<i class="bi bi-memory"></i> {{ compute_plan_assignment.compute_plan.memory_limits }} RAM
<span class="mx-2"></span>
<strong>CHF {{ compute_plan_assignment.price }}</strong>/{{ compute_plan_assignment.get_unit_display }}
</div>
</dd>
{% endif %}
{% if storage_plan %}
<dt class="col-sm-4">{% translate "Storage Plan" %}</dt>
<dd class="col-sm-8">
<strong>CHF {{ storage_plan.price_per_gib }}</strong> per GiB
<div class="text-muted small">{% translate "Billed separately based on disk usage" %}</div>
</dd>
{% endif %}
<dt class="col-sm-4">{% translate "Created By" %}</dt>
<dd class="col-sm-8">
{{ instance.created_by|default:"-" }}

View file

@ -30,14 +30,42 @@
{% endpartialdef %}
{% block content %}
<section class="section">
<div class="card">
{% if not form and not custom_form %}
<div class="alert alert-warning" role="alert">
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
<form class="form form-vertical crd-form" method="post" novalidate>
{% csrf_token %}
{% if plan_form.errors or form.errors or custom_form.errors %}
<div class="row mt-3">
<div class="col-12">
{% include "frontend/forms/errors.html" with form=plan_form %}
{% if form %}
{% include "frontend/forms/errors.html" with form=form %}
{% endif %}
{% if custom_form %}
{% include "frontend/forms/errors.html" with form=custom_form %}
{% endif %}
</div>
</div>
{% else %}
<div id="service-form">{% partial service-form %}</div>
{% endif %}
</div>
<!-- Compute Plan Selection -->
{% if plan_form %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">{% translate "Compute Plan" %}</h5>
</div>
<div class="card-body">
{% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %}
</div>
</div>
{% endif %}
<!-- Service Form -->
<div class="card">
{% if not form and not custom_form %}
<div class="alert alert-warning" role="alert">
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
</div>
{% else %}
<div id="service-form">{% partial service-form %}</div>
{% endif %}
</div>
</form>
</section>
{% endblock content %}

View file

@ -124,12 +124,61 @@
</div>
</div>
{% endif %}
<!-- Service Form (unchanged) -->
<div class="row mt-3">
<div class="col-12">
<div id="service-form">{% partial service-form %}</div>
<form class="form form-vertical crd-form" method="post" novalidate>
{% csrf_token %}
{% if plan_form.errors or service_form.errors or custom_service_form.errors %}
<div class="row mt-3">
<div class="col-12">
{% 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 %}
</div>
</div>
{% endif %}
<!-- Compute Plan Selection -->
{% if context_object %}
{% if not has_available_plans %}
<div class="row mt-3">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>
<strong>{% translate "No Compute Plans Available" %}</strong>
<p class="mb-0">
{% translate "Service instances cannot be created for this offering because no billing plans are configured. Please contact support." %}
</p>
</div>
</div>
</div>
</div>
{% else %}
<div class="row mt-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% translate "Select Compute Plan" %}</h5>
</div>
<div class="card-body">
{% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %}
</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
<!-- Service Form -->
<div class="row mt-3">
<div class="col-12">
<fieldset {% if context_object and not has_available_plans %}disabled{% endif %}>
<div id="service-form">{% partial service-form %}</div>
</fieldset>
</div>
</div>
</div>
</form>
</section>
{% endblock content %}
{% block extra_js %}

View file

@ -0,0 +1,178 @@
{% load i18n %}
<style>
.plan-selection .plan-card {
margin-bottom: 0.75rem;
}
.plan-selection .plan-card .card {
cursor: pointer;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
margin-bottom: 0;
}
.plan-selection .plan-card .card-body {
padding: 0.75rem 1rem;
}
.plan-selection .plan-card input[type="radio"]:checked+label .card {
border-color: #a1afdf;
box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25);
}
.plan-selection .plan-card .card:hover {
border-color: #a1afdf;
}
.plan-selection .form-check-input {
position: absolute;
opacity: 0;
}
.plan-selection 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 .storage-info {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background-color: #f8f9fa;
border-left: 3px solid #6c757d;
}
</style>
<div class="plan-selection">
{% if plan_form %}
{% for assignment in plan_form.fields.compute_plan_assignment.queryset %}
<div class="form-check plan-card">
<input class="form-check-input"
type="radio"
name="{{ plan_form.compute_plan_assignment.html_name }}"
id="{{ plan_form.compute_plan_assignment.auto_id }}_{{ forloop.counter0 }}"
value="{{ assignment.pk }}"
{% if plan_form.compute_plan_assignment.value == assignment.pk|stringformat:"s" or plan_form.fields.compute_plan_assignment.initial == assignment or not plan_form.is_bound and forloop.first %}checked{% endif %}
data-memory-limits="{{ assignment.compute_plan.memory_limits }}"
data-memory-requests="{{ assignment.compute_plan.memory_requests }}"
data-cpu-limits="{{ assignment.compute_plan.cpu_limits }}"
data-cpu-requests="{{ assignment.compute_plan.cpu_requests }}"
required>
<label class="form-check-label w-100"
for="{{ plan_form.compute_plan_assignment.auto_id }}_{{ forloop.counter0 }}">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6>{{ assignment.compute_plan.name }}</h6>
<span class="badge bg-{% if assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %}">
{{ assignment.get_sla_display }}
</span>
</div>
<div class="text-end">
<div class="price-display">CHF {{ assignment.price }}</div>
<div class="text-muted small">{% trans "per" %} {{ assignment.get_unit_display }}</div>
</div>
</div>
<div class="mt-2 text-muted small">
<i class="bi bi-cpu"></i> {{ assignment.compute_plan.cpu_limits }} {% trans "vCPU" %}
<span class="mx-2"></span>
<i class="bi bi-memory"></i> {{ assignment.compute_plan.memory_limits }} {% trans "RAM" %}
</div>
</div>
</div>
</label>
</div>
{% endfor %}
<div class="storage-info">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong class="d-block mb-1">{% trans "Storage" %}</strong>
<span class="text-muted small">{% trans "Billed separately based on disk usage" %}</span>
</div>
{% if storage_plan %}
<div class="text-end">
<div class="fw-semibold">CHF {{ storage_plan.price_per_gib }}</div>
<div class="text-muted small">{% trans "per GiB" %}</div>
</div>
{% else %}
<div class="text-end text-muted small">{% trans "Included" %}</div>
{% endif %}
</div>
{% if storage_plan %}<div class="mt-2 text-muted small" id="storage-cost-display"></div>{% endif %}
</div>
{% else %}
<div class="alert alert-warning">{% trans "No compute plans available for this service offering." %}</div>
{% endif %}
</div>
<script>
// Update readonly CPU/memory fields when plan selection changes
document.querySelectorAll('input[name="{{ plan_form.compute_plan_assignment.html_name }}"]').forEach(radio => {
radio.addEventListener('change', function() {
if (this.checked) {
// Update CPU/memory fields in the form
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 = this.dataset.cpuLimits;
if (memoryLimit) memoryLimit.value = this.dataset.memoryLimits;
if (cpuRequest) cpuRequest.value = this.dataset.cpuRequests;
if (memoryRequest) memoryRequest.value = this.dataset.memoryRequests;
}
});
});
// Trigger initial update
const checkedRadio = document.querySelector('input[name="{{ plan_form.compute_plan_assignment.html_name }}"]:checked');
if (checkedRadio) {
checkedRadio.dispatchEvent(new Event('change'));
}
// Setup storage cost calculator
function setupStorageCostCalculator() {
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 pricePerGiB = {
{
storage_plan.price_per_gib |
default: 0
}
};
const totalCost = (sizeGiB * pricePerGiB).toFixed(2);
const display = document.getElementById('storage-cost-display');
if (display && sizeGiB > 0) {
display.innerHTML = '<i class="bi bi-calculator"></i> ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = <strong>CHF ' + totalCost + '</strong> {% trans "per hour" %}';
} 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)
setupStorageCostCalculator();
// 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') {
setupStorageCostCalculator();
}
});
</script>

View file

@ -1,26 +1,64 @@
{% load i18n %}
{% load get_field %}
{% load static %}
<form class="form form-vertical crd-form"
method="post"
{% if form_action %}action="{{ form_action }}"{% endif %}>
{% csrf_token %}
{% include "frontend/forms/errors.html" %}
{% if form and expert_form and not hide_expert_mode %}
<div class="mb-3 text-end">
<a href="#"
class="text-muted small"
id="expert-mode-toggle"
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
</div>
{% endif %}
<div id="custom-form-container"
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% 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 %}
<div class="mb-3 text-end">
<a href="#"
class="text-muted small"
id="expert-mode-toggle"
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
</div>
{% endif %}
<div id="custom-form-container"
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% 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 %}
<div class="my-2">
{% 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 %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% elif form %}
{# Multiple fieldsets or auto-generated form - render with tabs #}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
<div class="my-2">
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#custom-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="custom-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="custom-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
@ -36,113 +74,70 @@
{% endfor %}
</div>
{% endfor %}
{% elif form %}
{# Multiple fieldsets or auto-generated form - render with tabs #}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#custom-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="custom-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="custom-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
{% 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 %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if expert_form and not hide_expert_mode %}
<div id="expert-form-container"
class="expert-crd-form"
style="{% if form %}display:none{% endif %}">
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
<ul class="nav nav-tabs" id="expertTab" role="tablist">
{% for fieldset in expert_form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="expert-{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#expert-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="expert-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="expertTabContent">
{% for fieldset in expert_form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="expert-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
{% 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 %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if form %}
<input type="hidden"
name="active_form"
id="active-form-input"
value="custom">
{% endif %}
<div class="col-sm-12 d-flex justify-content-end">
{# browser form validation fails when there are fields missing/invalid that are hidden #}
<input class="btn btn-primary me-1 mb-1"
type="submit"
{% if form and expert_form %}formnovalidate{% endif %}
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
</div>
{% if expert_form and not hide_expert_mode %}
<div id="expert-form-container"
class="expert-crd-form"
style="{% if form %}display:none{% endif %}">
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
<ul class="nav nav-tabs" id="expertTab" role="tablist">
{% for fieldset in expert_form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="expert-{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#expert-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="expert-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="expertTabContent">
{% for fieldset in expert_form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="expert-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
{% 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 %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</form>
{% endif %}
{% if form %}
<input type="hidden"
name="active_form"
id="active-form-input"
value="custom">
{% endif %}
<div class="col-sm-12 d-flex justify-content-end">
{# browser form validation fails when there are fields missing/invalid that are hidden #}
<input class="btn btn-primary me-1 mb-1"
type="submit"
{% if form and expert_form %}formnovalidate{% endif %}
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
</div>
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
{% if form and not hide_expert_mode %}
<script defer src="{% static 'js/expert-mode.js' %}"></script>

View file

@ -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)

View file

@ -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()
});

View file

@ -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"