diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 29ddb2f..f7cf161 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -459,7 +459,6 @@ class ComputePlanAssignmentInline(admin.TabularInline): "odoo_product_id", "odoo_unit_id", "price", - "unit", "minimum_service_size", "sort_order", "is_active", @@ -510,7 +509,6 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin): "control_plane_crd", "sla", "price", - "unit", "sort_order", "is_active", ) @@ -547,7 +545,6 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin): { "fields": ( "price", - "unit", "minimum_service_size", ) }, diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index b88f2db..659684e 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -69,17 +69,13 @@ 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): @@ -92,15 +88,6 @@ 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] diff --git a/src/servala/core/migrations/0016_computeplan_and_more.py b/src/servala/core/migrations/0016_computeplan_and_more.py deleted file mode 100644 index a64bf50..0000000 --- a/src/servala/core/migrations/0016_computeplan_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py b/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py deleted file mode 100644 index 38bfd46..0000000 --- a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py +++ /dev/null @@ -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", - ), - ), - ] diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py index 0e493af..6fc50e4 100644 --- a/src/servala/core/models/plan.py +++ b/src/servala/core/models/plan.py @@ -1,6 +1,5 @@ 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 _ @@ -30,21 +29,26 @@ class ComputePlan(ServalaModelMixin): help_text=_("Whether this plan is available for selection"), ) + # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") memory_requests = models.CharField( max_length=20, verbose_name=_("Memory requests"), + help_text=_("e.g., '2Gi', '512Mi'"), ) memory_limits = models.CharField( max_length=20, verbose_name=_("Memory limits"), + help_text=_("e.g., '4Gi', '1Gi'"), ) cpu_requests = models.CharField( max_length=20, verbose_name=_("CPU requests"), + help_text=_("e.g., '500m', '1', '2'"), ) cpu_limits = models.CharField( max_length=20, verbose_name=_("CPU limits"), + help_text=_("e.g., '2000m', '2', '4'"), ) class Meta: @@ -56,6 +60,12 @@ class ComputePlan(ServalaModelMixin): return self.name def get_resource_summary(self): + """ + Get a human-readable summary of resources. + + Returns: + String like "2 vCPU, 4Gi RAM" + """ return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" @@ -85,23 +95,26 @@ class ComputePlanAssignment(ServalaModelMixin): related_name="compute_plan_assignments", verbose_name=_("Control plane CRD"), ) + + # Service Level Agreement sla = models.CharField( max_length=20, choices=SLA_CHOICES, verbose_name=_("SLA"), help_text=_("Service Level Agreement"), ) - odoo_product_id = models.CharField( - max_length=255, + + # Odoo product reference + odoo_product_id = models.IntegerField( verbose_name=_("Odoo product ID"), - help_text=_( - "Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')" - ), + help_text=_("ID of the product in Odoo (product.product or product.template)"), ) - odoo_unit_id = models.CharField( - max_length=255, + odoo_unit_id = models.IntegerField( verbose_name=_("Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), ) + + # Pricing price = models.DecimalField( max_digits=10, decimal_places=2, @@ -110,20 +123,7 @@ class ComputePlanAssignment(ServalaModelMixin): 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)"), - ) - + # Service constraints minimum_service_size = models.PositiveIntegerField( default=1, validators=[MinValueValidator(1)], @@ -133,11 +133,15 @@ class ComputePlanAssignment(ServalaModelMixin): "(Guaranteed Availability may require multiple instances)" ), ) + + # Display ordering in UI sort_order = models.PositiveIntegerField( default=0, verbose_name=_("Sort order"), help_text=_("Order in which plans are displayed to users"), ) + + # Allow per-assignment activation is_active = models.BooleanField( default=True, verbose_name=_("Is active"), @@ -154,12 +158,15 @@ class ComputePlanAssignment(ServalaModelMixin): return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" def get_odoo_reporting_product_id(self): + """ + Get the reporting product ID for this plan. + + In the future, this will query Odoo based on invoicing policy. + For now, returns the product ID directly. + + Returns: + The Odoo product ID to use for billing + """ # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated # For now, just return the product ID return self.odoo_product_id - - -auditlog.register(ComputePlan, exclude_fields=["updated_at"], serialize_data=True) -auditlog.register( - ComputePlanAssignment, exclude_fields=["updated_at"], serialize_data=True -) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 3465d54..d4612c7 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,19 +170,18 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) - storage_plan_odoo_product_id = models.CharField( - max_length=255, + # Storage plan configuration (hardcoded per control plane) + storage_plan_odoo_product_id = models.IntegerField( null=True, blank=True, 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( - max_length=255, + storage_plan_odoo_unit_id = models.IntegerField( null=True, blank=True, 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( max_digits=10, @@ -686,60 +685,6 @@ 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: @@ -794,15 +739,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_instance( - cls, - name, - organization, - context, - created_by, - spec_data, - compute_plan_assignment=None, - ): + def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: @@ -811,7 +748,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): organization=organization, created_by=created_by, context=context, - compute_plan_assignment=compute_plan_assignment, ) except IntegrityError: message = _( @@ -822,11 +758,6 @@ 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"] = {} @@ -844,13 +775,6 @@ 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 @@ -888,23 +812,12 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(organization.add_support_message(message)) return instance - def update_spec(self, spec_data, updated_by, compute_plan_assignment=None): + def update_spec(self, spec_data, updated_by): 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, @@ -914,14 +827,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=patch_body, ) self._clear_kubernetes_caches() - - 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() + self.save() # Updates updated_at timestamp except ApiException as e: if e.status == 404: message = _( diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 169d6ea..23325f3 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -4,7 +4,6 @@ from django.utils.translation import gettext_lazy as _ from servala.core.models import ( CloudProvider, - ComputePlanAssignment, ControlPlane, Service, ServiceCategory, @@ -57,34 +56,6 @@ 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( diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 5c72de6..948a2df 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -51,29 +51,6 @@
{{ instance.context.control_plane.name }}
- {% if compute_plan_assignment %} -
{% translate "Compute Plan" %}
-
- {{ compute_plan_assignment.compute_plan.name }} - - {{ compute_plan_assignment.get_sla_display }} - -
- {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU - - {{ compute_plan_assignment.compute_plan.memory_limits }} RAM - - CHF {{ compute_plan_assignment.price }}/{{ compute_plan_assignment.get_unit_display }} -
-
- {% endif %} - {% if storage_plan %} -
{% translate "Storage Plan" %}
-
- CHF {{ storage_plan.price_per_gib }} per GiB -
{% translate "Billed separately based on disk usage" %}
-
- {% endif %}
{% translate "Created By" %}
{{ instance.created_by|default:"-" }} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html index 021be3c..17b9a51 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -30,42 +30,14 @@ {% endpartialdef %} {% block content %}
-
- {% csrf_token %} - {% if plan_form.errors or form.errors or custom_form.errors %} -
-
- {% 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 %} -
+
+ {% if not form and not custom_form %} + + {% else %} +
{% partial service-form %}
{% endif %} - - {% if plan_form %} -
-
-
{% translate "Compute Plan" %}
-
-
- {% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %} -
-
- {% endif %} - -
- {% if not form and not custom_form %} - - {% else %} -
{% partial service-form %}
- {% endif %} -
- +
{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 39b69a8..927c6e3 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -124,61 +124,12 @@ {% endif %} -
- {% csrf_token %} - {% if plan_form.errors or service_form.errors or custom_service_form.errors %} -
-
- {% 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 %} -
-
- {% endif %} - - {% if context_object %} - {% if not has_available_plans %} -
-
- -
-
- {% else %} -
-
-
-
-
{% translate "Select Compute Plan" %}
-
-
- {% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %} -
-
-
-
- {% endif %} - {% endif %} - -
-
-
-
{% partial service-form %}
-
-
+ +
+
+
{% partial service-form %}
- +
{% endblock content %} {% block extra_js %} diff --git a/src/servala/frontend/templates/includes/plan_selection.html b/src/servala/frontend/templates/includes/plan_selection.html deleted file mode 100644 index 045aeb3..0000000 --- a/src/servala/frontend/templates/includes/plan_selection.html +++ /dev/null @@ -1,178 +0,0 @@ -{% load i18n %} - -
- {% if plan_form %} - {% for assignment in plan_form.fields.compute_plan_assignment.queryset %} -
- - -
- {% endfor %} -
-
-
- {% trans "Storage" %} - {% trans "Billed separately based on disk usage" %} -
- {% if storage_plan %} -
-
CHF {{ storage_plan.price_per_gib }}
-
{% trans "per GiB" %}
-
- {% else %} -
{% trans "Included" %}
- {% endif %} -
- {% if storage_plan %}
{% endif %} -
- {% else %} -
{% trans "No compute plans available for this service offering." %}
- {% endif %} -
- diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index f54b1ae..5f289b7 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,64 +1,26 @@ {% load i18n %} {% load get_field %} {% load static %} -{% include "frontend/forms/errors.html" %} -{% if form and expert_form and not hide_expert_mode %} - -{% endif %} -
- {% if form and form.context %}{{ form.context }}{% endif %} - {% if form and form.get_fieldsets|length == 1 %} - {# Single fieldset - render without tabs #} - {% for fieldset in form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} - {% elif form %} - {# Multiple fieldsets or auto-generated form - render with tabs #} - -
- {% for fieldset in form.get_fieldsets %} -
+
{% for field in fieldset.fields %} {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} {% endfor %} @@ -74,70 +36,113 @@ {% endfor %}
{% endfor %} + {% elif form %} + {# Multiple fieldsets or auto-generated form - render with tabs #} + +
+ {% for fieldset in form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endif %} +
+ {% if expert_form and not hide_expert_mode %} +
+ {% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %} + +
+ {% for fieldset in expert_form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
{% endif %} -
-{% if expert_form and not hide_expert_mode %} -
- {% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %} - -
- {% for fieldset in expert_form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} -
+ {% if form %} + + {% endif %} +
+ {# browser form validation fails when there are fields missing/invalid that are hidden #} +
-{% endif %} -{% if form %} - -{% endif %} -
- {# browser form validation fails when there are fields missing/invalid that are hidden #} - -
+ {% if form and not hide_expert_mode %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index b9f7d56..c26194d 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -14,7 +14,6 @@ from servala.core.models import ( ServiceOffering, ) from servala.frontend.forms.service import ( - ComputePlanSelectionForm, ControlPlaneSelectForm, ServiceFilterForm, ServiceInstanceDeleteForm, @@ -153,13 +152,6 @@ 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": { @@ -213,7 +205,6 @@ 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: @@ -231,17 +222,6 @@ 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): @@ -252,9 +232,6 @@ 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: @@ -268,11 +245,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) return self.render_to_response(context) - if form.is_valid() and self.plan_form.is_valid(): - compute_plan_assignment = self.plan_form.cleaned_data[ - "compute_plan_assignment" - ] - + if form.is_valid(): try: service_instance = ServiceInstance.create_instance( organization=self.request.organization, @@ -280,22 +253,16 @@ 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( - _("Error creating instance: {error}.").format(error=str(e)) + _(f"Error creating instance: {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) @@ -365,18 +332,6 @@ 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): @@ -520,17 +475,6 @@ 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 @@ -545,7 +489,7 @@ class ServiceInstanceUpdateView( else: 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_invalid(form) @@ -562,29 +506,14 @@ 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: - # Use the form with errors if passed, otherwise create new - context["custom_form"] = form_from_kwargs or self.get_custom_form() + context["custom_form"] = 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): @@ -604,17 +533,7 @@ class ServiceInstanceUpdateView( current_spec = dict(self.object.spec) if self.object.spec else {} spec_data = self._deep_merge(current_spec, spec_data) - 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, - ) + self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) messages.success( self.request, _("Service instance '{name}' updated successfully.").format( @@ -627,7 +546,7 @@ class ServiceInstanceUpdateView( return self.form_invalid(form) except Exception as e: 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) return self.form_invalid(form) diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index 43805bf..0996bda 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -9,12 +9,7 @@ const initializeFqdnGeneration = (prefix) => { let isArrayField = true; if (fqdnFieldContainer) { - 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 - } + let fqdnField = fqdnFieldContainer.querySelector('input.array-item-input'); } else { fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`); isArrayField = false; @@ -58,14 +53,10 @@ const initializeFqdnGeneration = (prefix) => { } } -const runFqdnInit = () => { - initializeFqdnGeneration("custom"); - initializeFqdnGeneration("expert"); -} - -document.addEventListener('DOMContentLoaded', () => { - runFqdnInit() -}); +document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); document.body.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'service-form') runFqdnInit() + if (event.detail.target.id === 'service-form') { + initializeFqdnGeneration("custom"); + initializeFqdnGeneration("expert"); + } }); diff --git a/src/tests/test_compute_plans.py b/src/tests/test_compute_plans.py deleted file mode 100644 index 0317229..0000000 --- a/src/tests/test_compute_plans.py +++ /dev/null @@ -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"