Compare commits

..

9 commits

Author SHA1 Message Date
ad622ef14b Add tests for plans
All checks were successful
Tests / test (push) Successful in 28s
2025-12-02 16:28:54 +01:00
83f60711bb Show plans in forms 2025-12-02 16:26:32 +01:00
7d42820026 Show plans in detail view 2025-12-02 16:25:36 +01:00
2a63677539 Implement view logic 2025-12-02 16:25:16 +01:00
ef4f76b290 Implement form changes for plan integration 2025-12-02 16:11:51 +01:00
c5b2c58305 Add missing admin field 2025-12-02 16:09:48 +01:00
29661aa7cd Implement plan logic in create/update 2025-12-02 16:09:21 +01:00
2bbd643cf9 Add migrations and final model changes 2025-12-02 16:09:10 +01:00
5cee0194f5 Fix broken FQDN due to timing problems 2025-12-02 13:20:36 +01:00
15 changed files with 1265 additions and 194 deletions

View file

@ -459,6 +459,7 @@ class ComputePlanAssignmentInline(admin.TabularInline):
"odoo_product_id", "odoo_product_id",
"odoo_unit_id", "odoo_unit_id",
"price", "price",
"unit",
"minimum_service_size", "minimum_service_size",
"sort_order", "sort_order",
"is_active", "is_active",
@ -509,6 +510,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin):
"control_plane_crd", "control_plane_crd",
"sla", "sla",
"price", "price",
"unit",
"sort_order", "sort_order",
"is_active", "is_active",
) )
@ -545,6 +547,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin):
{ {
"fields": ( "fields": (
"price", "price",
"unit",
"minimum_service_size", "minimum_service_size",
) )
}, },

View file

@ -69,13 +69,17 @@ class CrdModelFormMixin(FormGeneratorMixin):
"spec.parameters.network.serviceType", "spec.parameters.network.serviceType",
"spec.parameters.scheduling", "spec.parameters.scheduling",
"spec.parameters.security", "spec.parameters.security",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
# Fields populated from compute plan
READONLY_FIELDS = [
"spec.parameters.size.cpu", "spec.parameters.size.cpu",
"spec.parameters.size.memory", "spec.parameters.size.memory",
"spec.parameters.size.requests.cpu", "spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory", "spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -88,6 +92,15 @@ class CrdModelFormMixin(FormGeneratorMixin):
): ):
field.widget = forms.HiddenInput() field.widget = forms.HiddenInput()
field.required = False 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): def strip_title(self, field_name, label):
field = self.fields[field_name] 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,5 +1,6 @@
from decimal import Decimal from decimal import Decimal
from auditlog.registry import auditlog
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -29,26 +30,21 @@ class ComputePlan(ServalaModelMixin):
help_text=_("Whether this plan is available for selection"), help_text=_("Whether this plan is available for selection"),
) )
# Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m")
memory_requests = models.CharField( memory_requests = models.CharField(
max_length=20, max_length=20,
verbose_name=_("Memory requests"), verbose_name=_("Memory requests"),
help_text=_("e.g., '2Gi', '512Mi'"),
) )
memory_limits = models.CharField( memory_limits = models.CharField(
max_length=20, max_length=20,
verbose_name=_("Memory limits"), verbose_name=_("Memory limits"),
help_text=_("e.g., '4Gi', '1Gi'"),
) )
cpu_requests = models.CharField( cpu_requests = models.CharField(
max_length=20, max_length=20,
verbose_name=_("CPU requests"), verbose_name=_("CPU requests"),
help_text=_("e.g., '500m', '1', '2'"),
) )
cpu_limits = models.CharField( cpu_limits = models.CharField(
max_length=20, max_length=20,
verbose_name=_("CPU limits"), verbose_name=_("CPU limits"),
help_text=_("e.g., '2000m', '2', '4'"),
) )
class Meta: class Meta:
@ -60,12 +56,6 @@ class ComputePlan(ServalaModelMixin):
return self.name return self.name
def get_resource_summary(self): def get_resource_summary(self):
"""
Get a human-readable summary of resources.
Returns:
String like "2 vCPU, 4Gi RAM"
"""
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
@ -95,26 +85,23 @@ class ComputePlanAssignment(ServalaModelMixin):
related_name="compute_plan_assignments", related_name="compute_plan_assignments",
verbose_name=_("Control plane CRD"), verbose_name=_("Control plane CRD"),
) )
# Service Level Agreement
sla = models.CharField( sla = models.CharField(
max_length=20, max_length=20,
choices=SLA_CHOICES, choices=SLA_CHOICES,
verbose_name=_("SLA"), verbose_name=_("SLA"),
help_text=_("Service Level Agreement"), help_text=_("Service Level Agreement"),
) )
odoo_product_id = models.CharField(
# Odoo product reference max_length=255,
odoo_product_id = models.IntegerField(
verbose_name=_("Odoo product ID"), verbose_name=_("Odoo product ID"),
help_text=_("ID of the product in Odoo (product.product or product.template)"), help_text=_(
"Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')"
),
) )
odoo_unit_id = models.IntegerField( odoo_unit_id = models.CharField(
max_length=255,
verbose_name=_("Odoo unit ID"), verbose_name=_("Odoo unit ID"),
help_text=_("ID of the unit of measure in Odoo (uom.uom)"),
) )
# Pricing
price = models.DecimalField( price = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=2, decimal_places=2,
@ -123,7 +110,20 @@ class ComputePlanAssignment(ServalaModelMixin):
help_text=_("Price per unit"), help_text=_("Price per unit"),
) )
# Service constraints BILLING_UNIT_CHOICES = [
("hour", _("Hour")),
("day", _("Day")),
("month", _("Month (30 days / 720 hours)")),
("year", _("Year")),
]
unit = models.CharField(
max_length=10,
choices=BILLING_UNIT_CHOICES,
default="hour",
verbose_name=_("Billing unit"),
help_text=_("Unit for the price (e.g., price per hour)"),
)
minimum_service_size = models.PositiveIntegerField( minimum_service_size = models.PositiveIntegerField(
default=1, default=1,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
@ -133,15 +133,11 @@ class ComputePlanAssignment(ServalaModelMixin):
"(Guaranteed Availability may require multiple instances)" "(Guaranteed Availability may require multiple instances)"
), ),
) )
# Display ordering in UI
sort_order = models.PositiveIntegerField( sort_order = models.PositiveIntegerField(
default=0, default=0,
verbose_name=_("Sort order"), verbose_name=_("Sort order"),
help_text=_("Order in which plans are displayed to users"), help_text=_("Order in which plans are displayed to users"),
) )
# Allow per-assignment activation
is_active = models.BooleanField( is_active = models.BooleanField(
default=True, default=True,
verbose_name=_("Is active"), verbose_name=_("Is active"),
@ -158,15 +154,12 @@ class ComputePlanAssignment(ServalaModelMixin):
return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}"
def get_odoo_reporting_product_id(self): def get_odoo_reporting_product_id(self):
"""
Get the reporting product ID for this plan.
In the future, this will query Odoo based on invoicing policy.
For now, returns the product ID directly.
Returns:
The Odoo product ID to use for billing
"""
# TODO: Implement Odoo cache lookup when OdooObjectCache is integrated # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated
# For now, just return the product ID # For now, just return the product ID
return self.odoo_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,18 +170,19 @@ class ControlPlane(ServalaModelMixin, models.Model):
), ),
) )
# Storage plan configuration (hardcoded per control plane) storage_plan_odoo_product_id = models.CharField(
storage_plan_odoo_product_id = models.IntegerField( max_length=255,
null=True, null=True,
blank=True, blank=True,
verbose_name=_("Storage plan Odoo product ID"), verbose_name=_("Storage plan Odoo product ID"),
help_text=_("ID of the storage product in Odoo"), help_text=_("Storage product ID in Odoo"),
) )
storage_plan_odoo_unit_id = models.IntegerField( storage_plan_odoo_unit_id = models.CharField(
max_length=255,
null=True, null=True,
blank=True, blank=True,
verbose_name=_("Storage plan Odoo unit ID"), verbose_name=_("Storage plan Odoo unit ID"),
help_text=_("ID of the unit of measure in Odoo (uom.uom)"), help_text=_("Unit of measure ID in Odoo"),
) )
storage_plan_price_per_gib = models.DecimalField( storage_plan_price_per_gib = models.DecimalField(
max_digits=10, max_digits=10,
@ -685,6 +686,60 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(spec_data) spec_data = prune_empty_data(spec_data)
return 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 @classmethod
def _format_kubernetes_error(cls, error_message): def _format_kubernetes_error(cls, error_message):
if not error_message: if not error_message:
@ -739,7 +794,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@classmethod @classmethod
@transaction.atomic @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 # Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization) context.control_plane.get_or_create_namespace(organization)
try: try:
@ -748,6 +811,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
organization=organization, organization=organization,
created_by=created_by, created_by=created_by,
context=context, context=context,
compute_plan_assignment=compute_plan_assignment,
) )
except IntegrityError: except IntegrityError:
message = _( message = _(
@ -758,6 +822,11 @@ class ServiceInstance(ServalaModelMixin, models.Model):
try: try:
spec_data = cls._prepare_spec_data(spec_data) 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: if "writeConnectionSecretToRef" not in spec_data:
spec_data["writeConnectionSecretToRef"] = {} spec_data["writeConnectionSecretToRef"] = {}
@ -775,6 +844,13 @@ class ServiceInstance(ServalaModelMixin, models.Model):
}, },
"spec": spec_data, "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: if label := context.control_plane.required_label:
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label} create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
api_instance = context.control_plane.custom_objects_api api_instance = context.control_plane.custom_objects_api
@ -812,12 +888,23 @@ class ServiceInstance(ServalaModelMixin, models.Model):
raise ValidationError(organization.add_support_message(message)) raise ValidationError(organization.add_support_message(message))
return instance return instance
def update_spec(self, spec_data, updated_by): def update_spec(self, spec_data, updated_by, compute_plan_assignment=None):
try: try:
spec_data = self._prepare_spec_data(spec_data) 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 api_instance = self.context.control_plane.custom_objects_api
patch_body = {"spec": spec_data} 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( api_instance.patch_namespaced_custom_object(
group=self.context.group, group=self.context.group,
version=self.context.version, version=self.context.version,
@ -827,7 +914,14 @@ class ServiceInstance(ServalaModelMixin, models.Model):
body=patch_body, body=patch_body,
) )
self._clear_kubernetes_caches() 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: except ApiException as e:
if e.status == 404: if e.status == 404:
message = _( message = _(

View file

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from servala.core.models import ( from servala.core.models import (
CloudProvider, CloudProvider,
ComputePlanAssignment,
ControlPlane, ControlPlane,
Service, Service,
ServiceCategory, ServiceCategory,
@ -56,6 +57,34 @@ class ControlPlaneSelectForm(forms.Form):
self.fields["control_plane"].initial = planes.first() 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): class ServiceInstanceFilterForm(forms.Form):
name = forms.CharField(required=False, label=_("Name")) name = forms.CharField(required=False, label=_("Name"))
service = forms.ModelChoiceField( service = forms.ModelChoiceField(

View file

@ -51,6 +51,29 @@
<dd class="col-sm-8"> <dd class="col-sm-8">
{{ instance.context.control_plane.name }} {{ instance.context.control_plane.name }}
</dd> </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> <dt class="col-sm-4">{% translate "Created By" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{{ instance.created_by|default:"-" }} {{ instance.created_by|default:"-" }}

View file

@ -30,6 +30,33 @@
{% endpartialdef %} {% endpartialdef %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<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>
{% endif %}
<!-- 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"> <div class="card">
{% if not form and not custom_form %} {% if not form and not custom_form %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
@ -39,5 +66,6 @@
<div id="service-form">{% partial service-form %}</div> <div id="service-form">{% partial service-form %}</div>
{% endif %} {% endif %}
</div> </div>
</form>
</section> </section>
{% endblock content %} {% endblock content %}

View file

@ -124,12 +124,61 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Service Form (unchanged) --> <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="row mt-3">
<div class="col-12"> <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> <div id="service-form">{% partial service-form %}</div>
</fieldset>
</div> </div>
</div> </div>
</form>
</section> </section>
{% endblock content %} {% endblock content %}
{% block extra_js %} {% 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,20 +1,16 @@
{% load i18n %} {% load i18n %}
{% load get_field %} {% load get_field %}
{% load static %} {% load static %}
<form class="form form-vertical crd-form" {% include "frontend/forms/errors.html" %}
method="post" {% if form and expert_form and not hide_expert_mode %}
{% 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"> <div class="mb-3 text-end">
<a href="#" <a href="#"
class="text-muted small" class="text-muted small"
id="expert-mode-toggle" id="expert-mode-toggle"
style="text-decoration: none">{% translate "Show Expert Mode" %}</a> style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
</div> </div>
{% endif %} {% endif %}
<div id="custom-form-container" <div id="custom-form-container"
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}"> class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
{% if form and form.context %}{{ form.context }}{% endif %} {% if form and form.context %}{{ form.context }}{% endif %}
{% if form and form.get_fieldsets|length == 1 %} {% if form and form.get_fieldsets|length == 1 %}
@ -80,8 +76,8 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if expert_form and not hide_expert_mode %} {% if expert_form and not hide_expert_mode %}
<div id="expert-form-container" <div id="expert-form-container"
class="expert-crd-form" class="expert-crd-form"
style="{% if form %}display:none{% endif %}"> style="{% if form %}display:none{% endif %}">
@ -128,21 +124,20 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if form %} {% if form %}
<input type="hidden" <input type="hidden"
name="active_form" name="active_form"
id="active-form-input" id="active-form-input"
value="custom"> value="custom">
{% endif %} {% endif %}
<div class="col-sm-12 d-flex justify-content-end"> <div class="col-sm-12 d-flex justify-content-end">
{# browser form validation fails when there are fields missing/invalid that are hidden #} {# browser form validation fails when there are fields missing/invalid that are hidden #}
<input class="btn btn-primary me-1 mb-1" <input class="btn btn-primary me-1 mb-1"
type="submit" type="submit"
{% if form and expert_form %}formnovalidate{% endif %} {% if form and expert_form %}formnovalidate{% endif %}
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" /> value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
</div> </div>
</form>
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script> <script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
{% if form and not hide_expert_mode %} {% if form and not hide_expert_mode %}
<script defer src="{% static 'js/expert-mode.js' %}"></script> <script defer src="{% static 'js/expert-mode.js' %}"></script>

View file

@ -14,6 +14,7 @@ from servala.core.models import (
ServiceOffering, ServiceOffering,
) )
from servala.frontend.forms.service import ( from servala.frontend.forms.service import (
ComputePlanSelectionForm,
ControlPlaneSelectForm, ControlPlaneSelectForm,
ServiceFilterForm, ServiceFilterForm,
ServiceInstanceDeleteForm, ServiceInstanceDeleteForm,
@ -152,6 +153,13 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
control_plane=self.selected_plane, service_offering=self.object control_plane=self.selected_plane, service_offering=self.object
).first() ).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): def get_instance_form_kwargs(self, ignore_data=False):
return { return {
"initial": { "initial": {
@ -205,6 +213,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["select_form"] = self.select_form context["select_form"] = self.select_form
context["has_control_planes"] = self.planes.exists() context["has_control_planes"] = self.planes.exists()
context["selected_plane"] = self.selected_plane context["selected_plane"] = self.selected_plane
context["context_object"] = self.context_object
context["hide_expert_mode"] = self.hide_expert_mode context["hide_expert_mode"] = self.hide_expert_mode
if self.request.method == "POST": if self.request.method == "POST":
if self.is_custom_form: if self.is_custom_form:
@ -222,6 +231,17 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
if self.selected_plane and self.selected_plane.wildcard_dns: if self.selected_plane and self.selected_plane.wildcard_dns:
context["wildcard_dns"] = self.selected_plane.wildcard_dns context["wildcard_dns"] = self.selected_plane.wildcard_dns
context["organization_namespace"] = self.request.organization.namespace 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 return context
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -232,6 +252,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["form_error"] = True context["form_error"] = True
return self.render_to_response(context) return self.render_to_response(context)
if not self.plan_form.is_valid():
return self.render_to_response(context)
if self.is_custom_form: if self.is_custom_form:
form = self.get_custom_instance_form() form = self.get_custom_instance_form()
else: else:
@ -245,7 +268,11 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
) )
return self.render_to_response(context) 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: try:
service_instance = ServiceInstance.create_instance( service_instance = ServiceInstance.create_instance(
organization=self.request.organization, organization=self.request.organization,
@ -253,16 +280,22 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context=self.context_object, context=self.context_object,
created_by=request.user, created_by=request.user,
spec_data=form.get_nested_data().get("spec"), spec_data=form.get_nested_data().get("spec"),
compute_plan_assignment=compute_plan_assignment,
) )
return redirect(service_instance.urls.base) return redirect(service_instance.urls.base)
except ValidationError as e: except ValidationError as e:
form.add_error(None, e.message or str(e)) form.add_error(None, e.message or str(e))
except Exception as e: except Exception as e:
error_message = self.organization.add_support_message( 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) 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) return self.render_to_response(context)
@ -332,6 +365,18 @@ class ServiceInstanceDetailView(
context["has_delete_permission"] = self.request.user.has_perm( context["has_delete_permission"] = self.request.user.has_perm(
ServiceInstance.get_perm("delete"), self.object 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 return context
def get_nested_spec(self): def get_nested_spec(self):
@ -475,6 +520,17 @@ class ServiceInstanceUpdateView(
kwargs.pop("data", None) kwargs.pop("data", None)
return cls(**kwargs) 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 @property
def is_custom_form(self): def is_custom_form(self):
# Note: "custom form" = user-friendly, subset of fields # Note: "custom form" = user-friendly, subset of fields
@ -489,7 +545,7 @@ class ServiceInstanceUpdateView(
else: else:
form = self.get_form() 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_valid(form)
return self.form_invalid(form) return self.form_invalid(form)
@ -506,14 +562,29 @@ class ServiceInstanceUpdateView(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["hide_expert_mode"] = self.hide_expert_mode 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.request.method == "POST":
if self.is_custom_form: 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) context["form"] = self.get_form(ignore_data=True)
else: 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) context["custom_form"] = self.get_custom_form(ignore_data=True)
else: else:
context["custom_form"] = self.get_custom_form() 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 return context
def _deep_merge(self, base, update): def _deep_merge(self, base, update):
@ -533,7 +604,17 @@ class ServiceInstanceUpdateView(
current_spec = dict(self.object.spec) if self.object.spec else {} current_spec = dict(self.object.spec) if self.object.spec else {}
spec_data = self._deep_merge(current_spec, spec_data) 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( messages.success(
self.request, self.request,
_("Service instance '{name}' updated successfully.").format( _("Service instance '{name}' updated successfully.").format(
@ -546,7 +627,7 @@ class ServiceInstanceUpdateView(
return self.form_invalid(form) return self.form_invalid(form)
except Exception as e: except Exception as e:
error_message = self.organization.add_support_message( 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) form.add_error(None, error_message)
return self.form_invalid(form) return self.form_invalid(form)

View file

@ -9,7 +9,12 @@ const initializeFqdnGeneration = (prefix) => {
let isArrayField = true; let isArrayField = true;
if (fqdnFieldContainer) { 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 { } else {
fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`); fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`);
isArrayField = false; isArrayField = false;
@ -53,10 +58,14 @@ const initializeFqdnGeneration = (prefix) => {
} }
} }
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); const runFqdnInit = () => {
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form') {
initializeFqdnGeneration("custom"); initializeFqdnGeneration("custom");
initializeFqdnGeneration("expert"); 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"