WIP: Service Plans #308
17 changed files with 1722 additions and 158 deletions
|
|
@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
|
|||
from servala.core.models import (
|
||||
BillingEntity,
|
||||
CloudProvider,
|
||||
ComputePlan,
|
||||
ComputePlanAssignment,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
OdooObjectCache,
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
|
|
@ -269,6 +272,19 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
|||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Storage Plan"),
|
||||
{
|
||||
"fields": (
|
||||
"storage_plan_odoo_product_id",
|
||||
"storage_plan_odoo_unit_id",
|
||||
"storage_plan_price_per_gib",
|
||||
),
|
||||
"description": _(
|
||||
"Storage plan configuration for this control plane (hardcoded per control plane)."
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def get_exclude(self, request, obj=None):
|
||||
|
|
@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(ServiceInstance)
|
||||
class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "organization", "context", "created_by")
|
||||
list_filter = ("organization", "context")
|
||||
list_display = (
|
||||
"name",
|
||||
"organization",
|
||||
"context",
|
||||
"compute_plan_assignment",
|
||||
"created_by",
|
||||
)
|
||||
list_filter = ("organization", "context", "compute_plan_assignment")
|
||||
search_fields = (
|
||||
"name",
|
||||
"organization__name",
|
||||
"context__service_offering__service__name",
|
||||
)
|
||||
readonly_fields = ("name", "organization", "context")
|
||||
autocomplete_fields = ("organization", "context")
|
||||
autocomplete_fields = ("organization", "context", "compute_plan_assignment")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj: # If this is an edit (not a new instance)
|
||||
|
|
@ -390,6 +412,10 @@ class ServiceInstanceAdmin(admin.ModelAdmin):
|
|||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Plan"),
|
||||
{"fields": ("compute_plan_assignment",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -420,3 +446,138 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
|||
schema=external_links_schema
|
||||
)
|
||||
return form
|
||||
|
||||
|
||||
class ComputePlanAssignmentInline(admin.TabularInline):
|
||||
model = ComputePlanAssignment
|
||||
extra = 1
|
||||
autocomplete_fields = ("control_plane_crd",)
|
||||
fields = (
|
||||
"compute_plan",
|
||||
"control_plane_crd",
|
||||
"sla",
|
||||
"odoo_product_id",
|
||||
"odoo_unit_id",
|
||||
"price",
|
||||
"unit",
|
||||
"minimum_service_size",
|
||||
"sort_order",
|
||||
"is_active",
|
||||
)
|
||||
readonly_fields = ()
|
||||
|
||||
|
||||
@admin.register(ComputePlan)
|
||||
class ComputePlanAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"is_active",
|
||||
"memory_limits",
|
||||
"cpu_limits",
|
||||
)
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "description")
|
||||
inlines = (ComputePlanAssignmentInline,)
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"description",
|
||||
"is_active",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Resources"),
|
||||
{
|
||||
"fields": (
|
||||
"memory_requests",
|
||||
"memory_limits",
|
||||
"cpu_requests",
|
||||
"cpu_limits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ComputePlanAssignment)
|
||||
class ComputePlanAssignmentAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"compute_plan",
|
||||
"control_plane_crd",
|
||||
"sla",
|
||||
"price",
|
||||
"unit",
|
||||
"sort_order",
|
||||
"is_active",
|
||||
)
|
||||
list_filter = ("is_active", "sla", "control_plane_crd")
|
||||
search_fields = (
|
||||
"compute_plan__name",
|
||||
"control_plane_crd__service_offering__service__name",
|
||||
)
|
||||
autocomplete_fields = ("compute_plan", "control_plane_crd")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"compute_plan",
|
||||
"control_plane_crd",
|
||||
"sla",
|
||||
"is_active",
|
||||
"sort_order",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Odoo Integration"),
|
||||
{
|
||||
"fields": (
|
||||
"odoo_product_id",
|
||||
"odoo_unit_id",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Pricing & Constraints"),
|
||||
{
|
||||
"fields": (
|
||||
"price",
|
||||
"unit",
|
||||
"minimum_service_size",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(OdooObjectCache)
|
||||
class OdooObjectCacheAdmin(admin.ModelAdmin):
|
||||
list_display = ("odoo_model", "odoo_id", "updated_at", "expires_at", "is_expired")
|
||||
list_filter = ("odoo_model", "updated_at", "expires_at")
|
||||
search_fields = ("odoo_model", "odoo_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
actions = ["refresh_caches"]
|
||||
|
||||
def is_expired(self, obj):
|
||||
return obj.is_expired()
|
||||
|
||||
is_expired.boolean = True
|
||||
is_expired.short_description = _("Expired")
|
||||
|
||||
def refresh_caches(self, request, queryset):
|
||||
"""Admin action to refresh selected Odoo caches."""
|
||||
refreshed_count = 0
|
||||
for cache_obj in queryset:
|
||||
cache_obj.fetch_and_update()
|
||||
refreshed_count += 1
|
||||
messages.success(
|
||||
request,
|
||||
_(f"Successfully refreshed {refreshed_count} cache(s)."),
|
||||
)
|
||||
|
||||
refresh_caches.short_description = _("Refresh caches")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
309
src/servala/core/migrations/0016_computeplan_and_more.py
Normal file
309
src/servala/core/migrations/0016_computeplan_and_more.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from .odoo_cache import OdooObjectCache
|
||||
from .organization import (
|
||||
BillingEntity,
|
||||
Organization,
|
||||
|
|
@ -6,6 +7,10 @@ from .organization import (
|
|||
OrganizationOrigin,
|
||||
OrganizationRole,
|
||||
)
|
||||
from .plan import (
|
||||
ComputePlan,
|
||||
ComputePlanAssignment,
|
||||
)
|
||||
from .service import (
|
||||
CloudProvider,
|
||||
ControlPlane,
|
||||
|
|
@ -21,8 +26,11 @@ from .user import User
|
|||
__all__ = [
|
||||
"BillingEntity",
|
||||
"CloudProvider",
|
||||
"ComputePlan",
|
||||
"ComputePlanAssignment",
|
||||
"ControlPlane",
|
||||
"ControlPlaneCRD",
|
||||
"OdooObjectCache",
|
||||
"Organization",
|
||||
"OrganizationInvitation",
|
||||
"OrganizationMembership",
|
||||
|
|
@ -30,8 +38,8 @@ __all__ = [
|
|||
"OrganizationRole",
|
||||
"Service",
|
||||
"ServiceCategory",
|
||||
"ServiceInstance",
|
||||
"ServiceDefinition",
|
||||
"ServiceInstance",
|
||||
"ServiceOffering",
|
||||
"User",
|
||||
]
|
||||
|
|
|
|||
124
src/servala/core/models/odoo_cache.py
Normal file
124
src/servala/core/models/odoo_cache.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
|
||||
|
||||
class OdooObjectCache(ServalaModelMixin):
|
||||
"""
|
||||
Generic cache for Odoo API responses.
|
||||
|
||||
Caches data from various Odoo models (product.product, product.template, uom.uom, etc.)
|
||||
to reduce API calls and improve performance.
|
||||
"""
|
||||
|
||||
odoo_model = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name=_("Odoo model"),
|
||||
help_text=_(
|
||||
"Odoo model name: 'product.product', 'product.template', 'uom.uom', etc."
|
||||
),
|
||||
)
|
||||
odoo_id = models.PositiveIntegerField(
|
||||
verbose_name=_("Odoo ID"),
|
||||
help_text=_("ID in the Odoo model"),
|
||||
)
|
||||
data = models.JSONField(
|
||||
verbose_name=_("Cached data"),
|
||||
help_text=_("Cached Odoo data including price, reporting_product_id, etc."),
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Expires at"),
|
||||
help_text=_("When cache should be refreshed (null = never expires)"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Odoo Object Cache")
|
||||
verbose_name_plural = _("Odoo Object Caches")
|
||||
unique_together = [["odoo_model", "odoo_id"]]
|
||||
indexes = [
|
||||
models.Index(fields=["odoo_model", "odoo_id"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.odoo_model}({self.odoo_id})"
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if cache needs refresh."""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
from django.utils import timezone
|
||||
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@classmethod
|
||||
def get_or_fetch(cls, odoo_model, odoo_id, ttl_hours=24):
|
||||
"""
|
||||
Get cached data or fetch from Odoo if expired/missing.
|
||||
|
||||
Args:
|
||||
odoo_model: Odoo model name (e.g., 'product.product')
|
||||
odoo_id: ID in the Odoo model
|
||||
ttl_hours: Time-to-live in hours for the cache
|
||||
|
||||
Returns:
|
||||
OdooObjectCache instance with fresh data
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
try:
|
||||
cache_obj = cls.objects.get(odoo_model=odoo_model, odoo_id=odoo_id)
|
||||
if not cache_obj.is_expired():
|
||||
return cache_obj
|
||||
# Cache exists but expired, refresh it
|
||||
cache_obj.fetch_and_update(ttl_hours=ttl_hours)
|
||||
return cache_obj
|
||||
except cls.DoesNotExist:
|
||||
# Create new cache entry
|
||||
cache_obj = cls.objects.create(
|
||||
odoo_model=odoo_model,
|
||||
odoo_id=odoo_id,
|
||||
data={},
|
||||
expires_at=(
|
||||
timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None
|
||||
),
|
||||
)
|
||||
cache_obj.fetch_and_update(ttl_hours=ttl_hours)
|
||||
return cache_obj
|
||||
|
||||
def fetch_and_update(self, ttl_hours=24):
|
||||
"""
|
||||
Fetch latest data from Odoo and update cache.
|
||||
|
||||
Args:
|
||||
ttl_hours: Time-to-live in hours for the cache
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from servala.core.odoo import CLIENT
|
||||
|
||||
# Fetch data from Odoo
|
||||
results = CLIENT.search_read(
|
||||
self.odoo_model,
|
||||
[[("id", "=", self.odoo_id)]],
|
||||
fields=None, # Fetch all fields
|
||||
)
|
||||
|
||||
if results:
|
||||
self.data = results[0]
|
||||
self.expires_at = (
|
||||
timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None
|
||||
)
|
||||
self.save(update_fields=["data", "expires_at", "updated_at"])
|
||||
else:
|
||||
# Object not found in Odoo, mark as expired immediately
|
||||
self.data = {}
|
||||
self.expires_at = timezone.now()
|
||||
self.save(update_fields=["data", "expires_at", "updated_at"])
|
||||
165
src/servala/core/models/plan.py
Normal file
165
src/servala/core/models/plan.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
from decimal import Decimal
|
||||
|
||||
from auditlog.registry import auditlog
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
|
||||
|
||||
class ComputePlan(ServalaModelMixin):
|
||||
"""
|
||||
Compute resource plans for service instances.
|
||||
|
||||
Defines CPU and memory allocations. Pricing and service level are configured
|
||||
per assignment to a ControlPlaneCRD.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name=_("Description"),
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Is active"),
|
||||
help_text=_("Whether this plan is available for selection"),
|
||||
)
|
||||
|
||||
memory_requests = models.CharField(
|
||||
max_length=20,
|
||||
verbose_name=_("Memory requests"),
|
||||
)
|
||||
memory_limits = models.CharField(
|
||||
max_length=20,
|
||||
verbose_name=_("Memory limits"),
|
||||
)
|
||||
cpu_requests = models.CharField(
|
||||
max_length=20,
|
||||
verbose_name=_("CPU requests"),
|
||||
)
|
||||
cpu_limits = models.CharField(
|
||||
max_length=20,
|
||||
verbose_name=_("CPU limits"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Compute Plan")
|
||||
verbose_name_plural = _("Compute Plans")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_resource_summary(self):
|
||||
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
|
||||
|
||||
|
||||
class ComputePlanAssignment(ServalaModelMixin):
|
||||
"""
|
||||
Links compute plans to control plane CRDs with pricing and service level.
|
||||
|
||||
A product in Odoo represents a service with a specific compute plan, control plane,
|
||||
and SLA. This model stores that correlation. The same compute plan can be assigned
|
||||
multiple times to the same CRD with different SLAs and pricing.
|
||||
"""
|
||||
|
||||
SLA_CHOICES = [
|
||||
("besteffort", _("Best Effort")),
|
||||
("guaranteed", _("Guaranteed Availability")),
|
||||
]
|
||||
|
||||
compute_plan = models.ForeignKey(
|
||||
ComputePlan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="assignments",
|
||||
verbose_name=_("Compute plan"),
|
||||
)
|
||||
control_plane_crd = models.ForeignKey(
|
||||
"ControlPlaneCRD",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="compute_plan_assignments",
|
||||
verbose_name=_("Control plane CRD"),
|
||||
)
|
||||
sla = models.CharField(
|
||||
max_length=20,
|
||||
choices=SLA_CHOICES,
|
||||
verbose_name=_("SLA"),
|
||||
help_text=_("Service Level Agreement"),
|
||||
)
|
||||
odoo_product_id = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Odoo product ID"),
|
||||
help_text=_(
|
||||
"Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')"
|
||||
),
|
||||
)
|
||||
odoo_unit_id = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Odoo unit ID"),
|
||||
)
|
||||
price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal("0.00"))],
|
||||
verbose_name=_("Price"),
|
||||
help_text=_("Price per unit"),
|
||||
)
|
||||
|
||||
BILLING_UNIT_CHOICES = [
|
||||
("hour", _("Hour")),
|
||||
("day", _("Day")),
|
||||
("month", _("Month (30 days / 720 hours)")),
|
||||
("year", _("Year")),
|
||||
]
|
||||
unit = models.CharField(
|
||||
max_length=10,
|
||||
choices=BILLING_UNIT_CHOICES,
|
||||
default="hour",
|
||||
verbose_name=_("Billing unit"),
|
||||
help_text=_("Unit for the price (e.g., price per hour)"),
|
||||
)
|
||||
|
||||
minimum_service_size = models.PositiveIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1)],
|
||||
verbose_name=_("Minimum service size"),
|
||||
help_text=_(
|
||||
"Minimum value for spec.parameters.instances "
|
||||
"(Guaranteed Availability may require multiple instances)"
|
||||
),
|
||||
)
|
||||
sort_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Sort order"),
|
||||
help_text=_("Order in which plans are displayed to users"),
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Is active"),
|
||||
help_text=_("Whether this plan is available for this CRD"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Compute Plan Assignment")
|
||||
verbose_name_plural = _("Compute Plan Assignments")
|
||||
unique_together = [["compute_plan", "control_plane_crd", "sla"]]
|
||||
ordering = ["sort_order", "compute_plan__name", "sla"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}"
|
||||
|
||||
def get_odoo_reporting_product_id(self):
|
||||
# TODO: Implement Odoo cache lookup when OdooObjectCache is integrated
|
||||
# For now, just return the product ID
|
||||
return self.odoo_product_id
|
||||
|
||||
|
||||
auditlog.register(ComputePlan, exclude_fields=["updated_at"], serialize_data=True)
|
||||
auditlog.register(
|
||||
ComputePlanAssignment, exclude_fields=["updated_at"], serialize_data=True
|
||||
)
|
||||
|
|
@ -170,6 +170,29 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
|||
),
|
||||
)
|
||||
|
||||
storage_plan_odoo_product_id = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Storage plan Odoo product ID"),
|
||||
help_text=_("Storage product ID in Odoo"),
|
||||
)
|
||||
storage_plan_odoo_unit_id = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Storage plan Odoo unit ID"),
|
||||
help_text=_("Unit of measure ID in Odoo"),
|
||||
)
|
||||
storage_plan_price_per_gib = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Storage plan price per GiB"),
|
||||
help_text=_("Price per GiB of storage"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Control plane")
|
||||
verbose_name_plural = _("Control planes")
|
||||
|
|
@ -613,6 +636,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
related_name="service_instances",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
compute_plan_assignment = models.ForeignKey(
|
||||
to="core.ComputePlanAssignment",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="instances",
|
||||
verbose_name=_("Compute plan assignment"),
|
||||
help_text=_("Compute plan with SLA for this instance"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Service instance")
|
||||
|
|
@ -654,6 +686,60 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
spec_data = prune_empty_data(spec_data)
|
||||
return spec_data
|
||||
|
||||
@staticmethod
|
||||
def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
|
||||
"""
|
||||
Apply compute plan resource allocations and SLA to spec.
|
||||
"""
|
||||
if not compute_plan_assignment:
|
||||
return spec_data
|
||||
|
||||
compute_plan = compute_plan_assignment.compute_plan
|
||||
|
||||
if "parameters" not in spec_data:
|
||||
spec_data["parameters"] = {}
|
||||
if "size" not in spec_data["parameters"]:
|
||||
spec_data["parameters"]["size"] = {}
|
||||
if "requests" not in spec_data["parameters"]["size"]:
|
||||
spec_data["parameters"]["size"]["requests"] = {}
|
||||
if "service" not in spec_data["parameters"]:
|
||||
spec_data["parameters"]["service"] = {}
|
||||
|
||||
spec_data["parameters"]["size"]["memory"] = compute_plan.memory_limits
|
||||
spec_data["parameters"]["size"]["cpu"] = compute_plan.cpu_limits
|
||||
spec_data["parameters"]["size"]["requests"][
|
||||
"memory"
|
||||
] = compute_plan.memory_requests
|
||||
spec_data["parameters"]["size"]["requests"]["cpu"] = compute_plan.cpu_requests
|
||||
spec_data["parameters"]["service"]["serviceLevel"] = compute_plan_assignment.sla
|
||||
return spec_data
|
||||
|
||||
@staticmethod
|
||||
def _build_billing_annotations(compute_plan_assignment, control_plane):
|
||||
"""
|
||||
Build Kubernetes annotations for billing integration.
|
||||
"""
|
||||
annotations = {}
|
||||
|
||||
if compute_plan_assignment:
|
||||
annotations["servala.com/erp_product_id_resource"] = str(
|
||||
compute_plan_assignment.odoo_product_id
|
||||
)
|
||||
annotations["servala.com/erp_unit_id_resource"] = str(
|
||||
compute_plan_assignment.odoo_unit_id
|
||||
)
|
||||
|
||||
if control_plane.storage_plan_odoo_product_id:
|
||||
annotations["servala.com/erp_product_id_storage"] = str(
|
||||
control_plane.storage_plan_odoo_product_id
|
||||
)
|
||||
if control_plane.storage_plan_odoo_unit_id:
|
||||
annotations["servala.com/erp_unit_id_storage"] = str(
|
||||
control_plane.storage_plan_odoo_unit_id
|
||||
)
|
||||
|
||||
return annotations
|
||||
|
||||
@classmethod
|
||||
def _format_kubernetes_error(cls, error_message):
|
||||
if not error_message:
|
||||
|
|
@ -708,7 +794,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||
def create_instance(
|
||||
cls,
|
||||
name,
|
||||
organization,
|
||||
context,
|
||||
created_by,
|
||||
spec_data,
|
||||
compute_plan_assignment=None,
|
||||
):
|
||||
# Ensure the namespace exists
|
||||
context.control_plane.get_or_create_namespace(organization)
|
||||
try:
|
||||
|
|
@ -717,6 +811,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
organization=organization,
|
||||
created_by=created_by,
|
||||
context=context,
|
||||
compute_plan_assignment=compute_plan_assignment,
|
||||
)
|
||||
except IntegrityError:
|
||||
message = _(
|
||||
|
|
@ -727,6 +822,11 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
try:
|
||||
spec_data = cls._prepare_spec_data(spec_data)
|
||||
|
||||
if compute_plan_assignment:
|
||||
spec_data = cls._apply_compute_plan_to_spec(
|
||||
spec_data, compute_plan_assignment
|
||||
)
|
||||
|
||||
if "writeConnectionSecretToRef" not in spec_data:
|
||||
spec_data["writeConnectionSecretToRef"] = {}
|
||||
|
||||
|
|
@ -744,6 +844,13 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
},
|
||||
"spec": spec_data,
|
||||
}
|
||||
|
||||
annotations = cls._build_billing_annotations(
|
||||
compute_plan_assignment, context.control_plane
|
||||
)
|
||||
if annotations:
|
||||
create_data["metadata"]["annotations"] = annotations
|
||||
|
||||
if label := context.control_plane.required_label:
|
||||
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
||||
api_instance = context.control_plane.custom_objects_api
|
||||
|
|
@ -781,12 +888,23 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
raise ValidationError(organization.add_support_message(message))
|
||||
return instance
|
||||
|
||||
def update_spec(self, spec_data, updated_by):
|
||||
def update_spec(self, spec_data, updated_by, compute_plan_assignment=None):
|
||||
try:
|
||||
spec_data = self._prepare_spec_data(spec_data)
|
||||
|
||||
plan_to_use = compute_plan_assignment or self.compute_plan_assignment
|
||||
if plan_to_use:
|
||||
spec_data = self._apply_compute_plan_to_spec(spec_data, plan_to_use)
|
||||
|
||||
api_instance = self.context.control_plane.custom_objects_api
|
||||
patch_body = {"spec": spec_data}
|
||||
|
||||
annotations = self._build_billing_annotations(
|
||||
plan_to_use, self.context.control_plane
|
||||
)
|
||||
if annotations:
|
||||
patch_body["metadata"] = {"annotations": annotations}
|
||||
|
||||
api_instance.patch_namespaced_custom_object(
|
||||
group=self.context.group,
|
||||
version=self.context.version,
|
||||
|
|
@ -796,7 +914,14 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
body=patch_body,
|
||||
)
|
||||
self._clear_kubernetes_caches()
|
||||
self.save() # Updates updated_at timestamp
|
||||
|
||||
if (
|
||||
compute_plan_assignment
|
||||
and compute_plan_assignment != self.compute_plan_assignment
|
||||
):
|
||||
self.compute_plan_assignment = compute_plan_assignment
|
||||
# Saving to update updated_at timestamp even if nothing was visibly changed
|
||||
self.save()
|
||||
except ApiException as e:
|
||||
if e.status == 404:
|
||||
message = _(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:"-" }}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
178
src/servala/frontend/templates/includes/plan_selection.html
Normal file
178
src/servala/frontend/templates/includes/plan_selection.html
Normal 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>
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
{% load i18n %}
|
||||
{% load get_field %}
|
||||
{% load static %}
|
||||
<form class="form form-vertical crd-form"
|
||||
method="post"
|
||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||
{% csrf_token %}
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
{% if form and expert_form and not hide_expert_mode %}
|
||||
<div class="mb-3 text-end">
|
||||
|
|
@ -142,7 +138,6 @@
|
|||
{% if form and expert_form %}formnovalidate{% endif %}
|
||||
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
|
|
|||
199
src/tests/test_compute_plans.py
Normal file
199
src/tests/test_compute_plans.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue