Compare commits
No commits in common. "ad622ef14b090d1fd325f89fcbf73d4b59d295c7" and "cce071397cc43ff57723359c8e8cb6f6df13c795" have entirely different histories.
ad622ef14b
...
cce071397c
15 changed files with 193 additions and 1264 deletions
|
|
@ -459,7 +459,6 @@ 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",
|
||||||
|
|
@ -510,7 +509,6 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin):
|
||||||
"control_plane_crd",
|
"control_plane_crd",
|
||||||
"sla",
|
"sla",
|
||||||
"price",
|
"price",
|
||||||
"unit",
|
|
||||||
"sort_order",
|
"sort_order",
|
||||||
"is_active",
|
"is_active",
|
||||||
)
|
)
|
||||||
|
|
@ -547,7 +545,6 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin):
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"price",
|
"price",
|
||||||
"unit",
|
|
||||||
"minimum_service_size",
|
"minimum_service_size",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -69,17 +69,13 @@ 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):
|
||||||
|
|
@ -92,15 +88,6 @@ 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]
|
||||||
|
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
# Generated by Django 5.2.8 on 2025-12-02 09:51
|
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
import rules.contrib.models
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("core", "0015_add_hide_expert_mode_to_service_definition"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ComputePlan",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created_at",
|
|
||||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"updated_at",
|
|
||||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
|
||||||
(
|
|
||||||
"description",
|
|
||||||
models.TextField(blank=True, verbose_name="Description"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"is_active",
|
|
||||||
models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text="Whether this plan is available for selection",
|
|
||||||
verbose_name="Is active",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"memory_requests",
|
|
||||||
models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
verbose_name="Memory requests",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"memory_limits",
|
|
||||||
models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
verbose_name="Memory limits",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cpu_requests",
|
|
||||||
models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
verbose_name="CPU requests",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cpu_limits",
|
|
||||||
models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
verbose_name="CPU limits",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Compute Plan",
|
|
||||||
"verbose_name_plural": "Compute Plans",
|
|
||||||
"ordering": ["name"],
|
|
||||||
},
|
|
||||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="controlplane",
|
|
||||||
name="storage_plan_odoo_product_id",
|
|
||||||
field=models.IntegerField(
|
|
||||||
blank=True,
|
|
||||||
help_text="ID of the storage product in Odoo",
|
|
||||||
null=True,
|
|
||||||
verbose_name="Storage plan Odoo product ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="controlplane",
|
|
||||||
name="storage_plan_odoo_unit_id",
|
|
||||||
field=models.IntegerField(
|
|
||||||
blank=True,
|
|
||||||
help_text="ID of the unit of measure in Odoo (uom.uom)",
|
|
||||||
null=True,
|
|
||||||
verbose_name="Storage plan Odoo unit ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="controlplane",
|
|
||||||
name="storage_plan_price_per_gib",
|
|
||||||
field=models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=2,
|
|
||||||
help_text="Price per GiB of storage",
|
|
||||||
max_digits=10,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Storage plan price per GiB",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ComputePlanAssignment",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created_at",
|
|
||||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"updated_at",
|
|
||||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"sla",
|
|
||||||
models.CharField(
|
|
||||||
choices=[
|
|
||||||
("besteffort", "Best Effort"),
|
|
||||||
("guaranteed", "Guaranteed Availability"),
|
|
||||||
],
|
|
||||||
help_text="Service Level Agreement",
|
|
||||||
max_length=20,
|
|
||||||
verbose_name="SLA",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"odoo_product_id",
|
|
||||||
models.IntegerField(
|
|
||||||
help_text="ID of the product in Odoo (product.product or product.template)",
|
|
||||||
verbose_name="Odoo product ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"odoo_unit_id",
|
|
||||||
models.IntegerField(
|
|
||||||
help_text="ID of the unit of measure in Odoo (uom.uom)",
|
|
||||||
verbose_name="Odoo unit ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"price",
|
|
||||||
models.DecimalField(
|
|
||||||
decimal_places=2,
|
|
||||||
help_text="Price per unit",
|
|
||||||
max_digits=10,
|
|
||||||
validators=[
|
|
||||||
django.core.validators.MinValueValidator(Decimal("0.00"))
|
|
||||||
],
|
|
||||||
verbose_name="Price",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"minimum_service_size",
|
|
||||||
models.PositiveIntegerField(
|
|
||||||
default=1,
|
|
||||||
help_text="Minimum value for spec.parameters.instances (Guaranteed Availability may require multiple instances)",
|
|
||||||
validators=[django.core.validators.MinValueValidator(1)],
|
|
||||||
verbose_name="Minimum service size",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"sort_order",
|
|
||||||
models.PositiveIntegerField(
|
|
||||||
default=0,
|
|
||||||
help_text="Order in which plans are displayed to users",
|
|
||||||
verbose_name="Sort order",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"is_active",
|
|
||||||
models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text="Whether this plan is available for this CRD",
|
|
||||||
verbose_name="Is active",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"compute_plan",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="assignments",
|
|
||||||
to="core.computeplan",
|
|
||||||
verbose_name="Compute plan",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"control_plane_crd",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="compute_plan_assignments",
|
|
||||||
to="core.controlplanecrd",
|
|
||||||
verbose_name="Control plane CRD",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Compute Plan Assignment",
|
|
||||||
"verbose_name_plural": "Compute Plan Assignments",
|
|
||||||
"ordering": ["sort_order", "compute_plan__name", "sla"],
|
|
||||||
"unique_together": {("compute_plan", "control_plane_crd", "sla")},
|
|
||||||
},
|
|
||||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="serviceinstance",
|
|
||||||
name="compute_plan_assignment",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
help_text="Compute plan with SLA for this instance",
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="instances",
|
|
||||||
to="core.computeplanassignment",
|
|
||||||
verbose_name="Compute plan assignment",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="OdooObjectCache",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created_at",
|
|
||||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"updated_at",
|
|
||||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"odoo_model",
|
|
||||||
models.CharField(
|
|
||||||
help_text="Odoo model name: 'product.product', 'product.template', 'uom.uom', etc.",
|
|
||||||
max_length=100,
|
|
||||||
verbose_name="Odoo model",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"odoo_id",
|
|
||||||
models.PositiveIntegerField(
|
|
||||||
help_text="ID in the Odoo model", verbose_name="Odoo ID"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"data",
|
|
||||||
models.JSONField(
|
|
||||||
help_text="Cached Odoo data including price, reporting_product_id, etc.",
|
|
||||||
verbose_name="Cached data",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"expires_at",
|
|
||||||
models.DateTimeField(
|
|
||||||
blank=True,
|
|
||||||
help_text="When cache should be refreshed (null = never expires)",
|
|
||||||
null=True,
|
|
||||||
verbose_name="Expires at",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Odoo Object Cache",
|
|
||||||
"verbose_name_plural": "Odoo Object Caches",
|
|
||||||
"indexes": [
|
|
||||||
models.Index(
|
|
||||||
fields=["odoo_model", "odoo_id"],
|
|
||||||
name="core_odooob_odoo_mo_51e258_idx",
|
|
||||||
),
|
|
||||||
models.Index(
|
|
||||||
fields=["expires_at"], name="core_odooob_expires_8fc00b_idx"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"unique_together": {("odoo_model", "odoo_id")},
|
|
||||||
},
|
|
||||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# Generated by Django 5.2.8 on 2025-12-02 10:35
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("core", "0016_computeplan_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="computeplanassignment",
|
|
||||||
name="unit",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[
|
|
||||||
("hour", "Hour"),
|
|
||||||
("day", "Day"),
|
|
||||||
("month", "Month (30 days)"),
|
|
||||||
("year", "Year"),
|
|
||||||
],
|
|
||||||
default="hour",
|
|
||||||
help_text="Unit for the price (e.g., price per hour)",
|
|
||||||
max_length=10,
|
|
||||||
verbose_name="Billing unit",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="computeplanassignment",
|
|
||||||
name="odoo_product_id",
|
|
||||||
field=models.CharField(
|
|
||||||
help_text="Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')",
|
|
||||||
max_length=255,
|
|
||||||
verbose_name="Odoo product ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="computeplanassignment",
|
|
||||||
name="odoo_unit_id",
|
|
||||||
field=models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
verbose_name="Odoo unit ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="controlplane",
|
|
||||||
name="storage_plan_odoo_product_id",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Storage product ID in Odoo",
|
|
||||||
max_length=255,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Storage plan Odoo product ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="controlplane",
|
|
||||||
name="storage_plan_odoo_unit_id",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Unit of measure ID in Odoo",
|
|
||||||
max_length=255,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Storage plan Odoo unit ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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 _
|
||||||
|
|
@ -30,21 +29,26 @@ 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:
|
||||||
|
|
@ -56,6 +60,12 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -85,23 +95,26 @@ 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(
|
|
||||||
max_length=255,
|
# Odoo product reference
|
||||||
|
odoo_product_id = models.IntegerField(
|
||||||
verbose_name=_("Odoo product ID"),
|
verbose_name=_("Odoo product ID"),
|
||||||
help_text=_(
|
help_text=_("ID of the product in Odoo (product.product or product.template)"),
|
||||||
"Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
odoo_unit_id = models.CharField(
|
odoo_unit_id = models.IntegerField(
|
||||||
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,
|
||||||
|
|
@ -110,20 +123,7 @@ class ComputePlanAssignment(ServalaModelMixin):
|
||||||
help_text=_("Price per unit"),
|
help_text=_("Price per unit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
BILLING_UNIT_CHOICES = [
|
# Service constraints
|
||||||
("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,11 +133,15 @@ 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"),
|
||||||
|
|
@ -154,12 +158,15 @@ 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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -170,19 +170,18 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
storage_plan_odoo_product_id = models.CharField(
|
# Storage plan configuration (hardcoded per control plane)
|
||||||
max_length=255,
|
storage_plan_odoo_product_id = models.IntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name=_("Storage plan Odoo product ID"),
|
verbose_name=_("Storage plan Odoo product ID"),
|
||||||
help_text=_("Storage product ID in Odoo"),
|
help_text=_("ID of the storage product in Odoo"),
|
||||||
)
|
)
|
||||||
storage_plan_odoo_unit_id = models.CharField(
|
storage_plan_odoo_unit_id = models.IntegerField(
|
||||||
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=_("Unit of measure ID in Odoo"),
|
help_text=_("ID of the unit of measure in Odoo (uom.uom)"),
|
||||||
)
|
)
|
||||||
storage_plan_price_per_gib = models.DecimalField(
|
storage_plan_price_per_gib = models.DecimalField(
|
||||||
max_digits=10,
|
max_digits=10,
|
||||||
|
|
@ -686,60 +685,6 @@ 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:
|
||||||
|
|
@ -794,15 +739,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_instance(
|
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||||
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:
|
||||||
|
|
@ -811,7 +748,6 @@ 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 = _(
|
||||||
|
|
@ -822,11 +758,6 @@ 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"] = {}
|
||||||
|
|
||||||
|
|
@ -844,13 +775,6 @@ 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
|
||||||
|
|
@ -888,23 +812,12 @@ 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, compute_plan_assignment=None):
|
def update_spec(self, spec_data, updated_by):
|
||||||
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,
|
||||||
|
|
@ -914,14 +827,7 @@ 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 = _(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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,
|
||||||
|
|
@ -57,34 +56,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -51,29 +51,6 @@
|
||||||
<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:"-" }}
|
||||||
|
|
|
||||||
|
|
@ -30,33 +30,6 @@
|
||||||
{% 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">
|
||||||
|
|
@ -66,6 +39,5 @@
|
||||||
<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 %}
|
||||||
|
|
|
||||||
|
|
@ -124,61 +124,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="form form-vertical crd-form" method="post" novalidate>
|
<!-- Service Form (unchanged) -->
|
||||||
{% 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 %}
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
{% 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>
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load get_field %}
|
{% load get_field %}
|
||||||
{% load static %}
|
{% 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" %}
|
{% include "frontend/forms/errors.html" %}
|
||||||
{% if form and expert_form and not hide_expert_mode %}
|
{% if form and expert_form and not hide_expert_mode %}
|
||||||
<div class="mb-3 text-end">
|
<div class="mb-3 text-end">
|
||||||
|
|
@ -138,6 +142,7 @@
|
||||||
{% 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>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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,
|
||||||
|
|
@ -153,13 +152,6 @@ 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": {
|
||||||
|
|
@ -213,7 +205,6 @@ 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:
|
||||||
|
|
@ -231,17 +222,6 @@ 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):
|
||||||
|
|
@ -252,9 +232,6 @@ 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:
|
||||||
|
|
@ -268,11 +245,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
)
|
)
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if form.is_valid() and self.plan_form.is_valid():
|
if 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,
|
||||||
|
|
@ -280,22 +253,16 @@ 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(
|
||||||
_("Error creating instance: {error}.").format(error=str(e))
|
_(f"Error creating instance: {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)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -365,18 +332,6 @@ 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):
|
||||||
|
|
@ -520,17 +475,6 @@ 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
|
||||||
|
|
@ -545,7 +489,7 @@ class ServiceInstanceUpdateView(
|
||||||
else:
|
else:
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
|
|
||||||
if form.is_valid() and self.plan_form.is_valid():
|
if form.is_valid():
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
|
@ -562,29 +506,14 @@ 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:
|
||||||
# Use the form with errors if passed, otherwise create new
|
context["custom_form"] = self.get_custom_form()
|
||||||
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):
|
||||||
|
|
@ -604,17 +533,7 @@ 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)
|
||||||
|
|
||||||
compute_plan_assignment = None
|
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
||||||
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(
|
||||||
|
|
@ -627,7 +546,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(
|
||||||
_("Error updating instance: {error}.").format(error=str(e))
|
_(f"Error updating instance: {str(e)}.")
|
||||||
)
|
)
|
||||||
form.add_error(None, error_message)
|
form.add_error(None, error_message)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,7 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
let isArrayField = true;
|
let isArrayField = true;
|
||||||
|
|
||||||
if (fqdnFieldContainer) {
|
if (fqdnFieldContainer) {
|
||||||
fqdnField = fqdnFieldContainer.querySelector("input.array-item-input")
|
let 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;
|
||||||
|
|
@ -58,14 +53,10 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runFqdnInit = () => {
|
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")});
|
||||||
|
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()
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
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"
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue