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_unit_id",
"price",
"unit",
"minimum_service_size",
"sort_order",
"is_active",
@ -509,6 +510,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin):
"control_plane_crd",
"sla",
"price",
"unit",
"sort_order",
"is_active",
)
@ -545,6 +547,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin):
{
"fields": (
"price",
"unit",
"minimum_service_size",
)
},

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,5 +1,6 @@
from decimal import Decimal
from auditlog.registry import auditlog
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -29,26 +30,21 @@ class ComputePlan(ServalaModelMixin):
help_text=_("Whether this plan is available for selection"),
)
# Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m")
memory_requests = models.CharField(
max_length=20,
verbose_name=_("Memory requests"),
help_text=_("e.g., '2Gi', '512Mi'"),
)
memory_limits = models.CharField(
max_length=20,
verbose_name=_("Memory limits"),
help_text=_("e.g., '4Gi', '1Gi'"),
)
cpu_requests = models.CharField(
max_length=20,
verbose_name=_("CPU requests"),
help_text=_("e.g., '500m', '1', '2'"),
)
cpu_limits = models.CharField(
max_length=20,
verbose_name=_("CPU limits"),
help_text=_("e.g., '2000m', '2', '4'"),
)
class Meta:
@ -60,12 +56,6 @@ class ComputePlan(ServalaModelMixin):
return self.name
def get_resource_summary(self):
"""
Get a human-readable summary of resources.
Returns:
String like "2 vCPU, 4Gi RAM"
"""
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
@ -95,26 +85,23 @@ class ComputePlanAssignment(ServalaModelMixin):
related_name="compute_plan_assignments",
verbose_name=_("Control plane CRD"),
)
# Service Level Agreement
sla = models.CharField(
max_length=20,
choices=SLA_CHOICES,
verbose_name=_("SLA"),
help_text=_("Service Level Agreement"),
)
# Odoo product reference
odoo_product_id = models.IntegerField(
odoo_product_id = models.CharField(
max_length=255,
verbose_name=_("Odoo product ID"),
help_text=_("ID of the product in Odoo (product.product or product.template)"),
help_text=_(
"Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')"
),
)
odoo_unit_id = models.IntegerField(
odoo_unit_id = models.CharField(
max_length=255,
verbose_name=_("Odoo unit ID"),
help_text=_("ID of the unit of measure in Odoo (uom.uom)"),
)
# Pricing
price = models.DecimalField(
max_digits=10,
decimal_places=2,
@ -123,7 +110,20 @@ class ComputePlanAssignment(ServalaModelMixin):
help_text=_("Price per unit"),
)
# Service constraints
BILLING_UNIT_CHOICES = [
("hour", _("Hour")),
("day", _("Day")),
("month", _("Month (30 days / 720 hours)")),
("year", _("Year")),
]
unit = models.CharField(
max_length=10,
choices=BILLING_UNIT_CHOICES,
default="hour",
verbose_name=_("Billing unit"),
help_text=_("Unit for the price (e.g., price per hour)"),
)
minimum_service_size = models.PositiveIntegerField(
default=1,
validators=[MinValueValidator(1)],
@ -133,15 +133,11 @@ class ComputePlanAssignment(ServalaModelMixin):
"(Guaranteed Availability may require multiple instances)"
),
)
# Display ordering in UI
sort_order = models.PositiveIntegerField(
default=0,
verbose_name=_("Sort order"),
help_text=_("Order in which plans are displayed to users"),
)
# Allow per-assignment activation
is_active = models.BooleanField(
default=True,
verbose_name=_("Is active"),
@ -158,15 +154,12 @@ class ComputePlanAssignment(ServalaModelMixin):
return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}"
def get_odoo_reporting_product_id(self):
"""
Get the reporting product ID for this plan.
In the future, this will query Odoo based on invoicing policy.
For now, returns the product ID directly.
Returns:
The Odoo product ID to use for billing
"""
# TODO: Implement Odoo cache lookup when OdooObjectCache is integrated
# For now, just return the product ID
return self.odoo_product_id
auditlog.register(ComputePlan, exclude_fields=["updated_at"], serialize_data=True)
auditlog.register(
ComputePlanAssignment, exclude_fields=["updated_at"], serialize_data=True
)

View file

@ -170,18 +170,19 @@ class ControlPlane(ServalaModelMixin, models.Model):
),
)
# Storage plan configuration (hardcoded per control plane)
storage_plan_odoo_product_id = models.IntegerField(
storage_plan_odoo_product_id = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name=_("Storage plan Odoo product ID"),
help_text=_("ID of the storage product in Odoo"),
help_text=_("Storage product ID in Odoo"),
)
storage_plan_odoo_unit_id = models.IntegerField(
storage_plan_odoo_unit_id = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name=_("Storage plan Odoo unit ID"),
help_text=_("ID of the unit of measure in Odoo (uom.uom)"),
help_text=_("Unit of measure ID in Odoo"),
)
storage_plan_price_per_gib = models.DecimalField(
max_digits=10,
@ -685,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:
@ -739,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:
@ -748,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 = _(
@ -758,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"] = {}
@ -775,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
@ -812,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,
@ -827,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,6 +30,33 @@
{% endpartialdef %}
{% block content %}
<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">
{% if not form and not custom_form %}
<div class="alert alert-warning" role="alert">
@ -39,5 +66,6 @@
<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) -->
<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>
</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,20 +1,16 @@
{% 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 %}
{% 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"
{% 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 %}
@ -80,8 +76,8 @@
{% endfor %}
</div>
{% endif %}
</div>
{% if expert_form and not hide_expert_mode %}
</div>
{% if expert_form and not hide_expert_mode %}
<div id="expert-form-container"
class="expert-crd-form"
style="{% if form %}display:none{% endif %}">
@ -128,21 +124,20 @@
{% endfor %}
</div>
</div>
{% endif %}
{% if 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">
{% 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>
</form>
</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') {
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"