From 22e527bcd9dff6c7fa8b2954ee8598c594a2116b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Thu, 19 Jun 2025 16:19:59 +0200 Subject: [PATCH 01/16] add addons to services --- hub/services/admin/pricing.py | 95 ++++- ..._article_image_vshnappcataddon_and_more.py | 195 ++++++++++ hub/services/models/pricing.py | 163 +++++++- .../templates/services/offering_detail.html | 350 ++++++++++++++++++ .../templates/services/pricelist.html | 164 +++++++- hub/services/views/offerings.py | 37 ++ hub/services/views/pricelist.py | 38 ++ hub/settings.py | 1 + 8 files changed, 1039 insertions(+), 4 deletions(-) create mode 100644 hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 6da4852..61f4836 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -25,6 +25,9 @@ from ..models import ( VSHNAppCatBaseFee, VSHNAppCatPrice, VSHNAppCatUnitRate, + VSHNAppCatAddon, + VSHNAppCatAddonBaseFee, + VSHNAppCatAddonUnitRate, ProgressiveDiscountModel, DiscountTier, ExternalPricePlans, @@ -297,6 +300,15 @@ class VSHNAppCatUnitRateInline(admin.TabularInline): fields = ("currency", "service_level", "amount") +class VSHNAppCatAddonInline(admin.TabularInline): + """Inline admin for VSHNAppCatAddon model within the VSHNAppCatPrice admin""" + + model = VSHNAppCatAddon + extra = 1 + fields = ("name", "addon_type", "mandatory", "active") + show_change_link = True + + class DiscountTierInline(admin.TabularInline): """Inline admin for DiscountTier model""" @@ -330,7 +342,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin): ) list_filter = ("variable_unit", "service", "discount_model") search_fields = ("service__name",) - inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline] + inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline, VSHNAppCatAddonInline] def admin_display_base_fees(self, obj): """Display base fees in admin list view""" @@ -542,3 +554,84 @@ class ExternalPricePlansAdmin(ImportExportModelAdmin): return f"{count} plan{'s' if count != 1 else ''}" display_compare_to_count.short_description = "Compare To" + + +class VSHNAppCatAddonBaseFeeInline(admin.TabularInline): + """Inline admin for VSHNAppCatAddonBaseFee model""" + + model = VSHNAppCatAddonBaseFee + extra = 1 + fields = ("currency", "amount") + + +class VSHNAppCatAddonUnitRateInline(admin.TabularInline): + """Inline admin for VSHNAppCatAddonUnitRate model""" + + model = VSHNAppCatAddonUnitRate + extra = 1 + fields = ("currency", "service_level", "amount") + + +class VSHNAppCatAddonInline(admin.TabularInline): + """Inline admin for VSHNAppCatAddon model within the VSHNAppCatPrice admin""" + + model = VSHNAppCatAddon + extra = 1 + fields = ("name", "addon_type", "mandatory", "active", "order") + show_change_link = True + + +@admin.register(VSHNAppCatAddon) +class VSHNAppCatAddonAdmin(admin.ModelAdmin): + """Admin configuration for VSHNAppCatAddon model""" + + list_display = ( + "name", + "vshn_appcat_price_config", + "addon_type", + "mandatory", + "active", + "display_pricing", + "order", + ) + list_filter = ( + "addon_type", + "mandatory", + "active", + "vshn_appcat_price_config__service", + ) + search_fields = ( + "name", + "description", + "commercial_description", + "vshn_appcat_price_config__service__name", + ) + ordering = ("vshn_appcat_price_config__service__name", "order", "name") + + # Different inlines based on addon type + inlines = [VSHNAppCatAddonBaseFeeInline, VSHNAppCatAddonUnitRateInline] + + def display_pricing(self, obj): + """Display pricing information based on addon type""" + if obj.addon_type == "BF": # Base Fee + fees = obj.base_fees.all() + if not fees: + return "No base fees set" + return format_html( + "
".join([f"{fee.amount} {fee.currency}" for fee in fees]) + ) + elif obj.addon_type == "UR": # Unit Rate + rates = obj.unit_rates.all() + if not rates: + return "No unit rates set" + return format_html( + "
".join( + [ + f"{rate.amount} {rate.currency} ({rate.get_service_level_display()})" + for rate in rates + ] + ) + ) + return "Unknown addon type" + + display_pricing.short_description = "Pricing" diff --git a/hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py b/hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py new file mode 100644 index 0000000..a020a94 --- /dev/null +++ b/hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py @@ -0,0 +1,195 @@ +# Generated by Django 5.2 on 2025-06-19 13:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0034_article"), + ] + + operations = [ + migrations.AlterField( + model_name="article", + name="image", + field=models.ImageField( + help_text="Title picture for the article", upload_to="article_images/" + ), + ), + migrations.CreateModel( + name="VSHNAppCatAddon", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(help_text="Name of the addon", max_length=100), + ), + ( + "description", + models.TextField( + blank=True, help_text="Technical description of the addon" + ), + ), + ( + "commercial_description", + models.TextField( + blank=True, + help_text="Commercial description displayed in the frontend", + ), + ), + ( + "addon_type", + models.CharField( + choices=[("BF", "Base Fee"), ("UR", "Unit Rate")], + help_text="Type of addon pricing (fixed fee or per-unit)", + max_length=2, + ), + ), + ( + "mandatory", + models.BooleanField( + default=False, help_text="Is this addon mandatory?" + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Is this addon active and available for selection?", + ), + ), + ( + "order", + models.IntegerField( + default=0, help_text="Display order in the frontend" + ), + ), + ("valid_from", models.DateTimeField(blank=True, null=True)), + ("valid_to", models.DateTimeField(blank=True, null=True)), + ( + "vshn_appcat_price_config", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="addons", + to="services.vshnappcatprice", + ), + ), + ], + options={ + "verbose_name": "Service Addon", + "ordering": ["order", "name"], + }, + ), + migrations.CreateModel( + name="VSHNAppCatAddonBaseFee", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "currency", + models.CharField( + choices=[ + ("CHF", "Swiss Franc"), + ("EUR", "Euro"), + ("USD", "US Dollar"), + ], + max_length=3, + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + help_text="Base fee in the specified currency, excl. VAT", + max_digits=10, + ), + ), + ( + "addon", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="base_fees", + to="services.vshnappcataddon", + ), + ), + ], + options={ + "verbose_name": "Addon Base Fee", + "ordering": ["currency"], + "unique_together": {("addon", "currency")}, + }, + ), + migrations.CreateModel( + name="VSHNAppCatAddonUnitRate", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "currency", + models.CharField( + choices=[ + ("CHF", "Swiss Franc"), + ("EUR", "Euro"), + ("USD", "US Dollar"), + ], + max_length=3, + ), + ), + ( + "service_level", + models.CharField( + choices=[ + ("BE", "Best Effort"), + ("GA", "Guaranteed Availability"), + ], + max_length=2, + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=4, + help_text="Price per unit in the specified currency and service level, excl. VAT", + max_digits=10, + ), + ), + ( + "addon", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="unit_rates", + to="services.vshnappcataddon", + ), + ), + ], + options={ + "verbose_name": "Addon Unit Rate", + "ordering": ["currency", "service_level"], + "unique_together": {("addon", "currency", "service_level")}, + }, + ), + ] diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py index 42b2778..0d22ef2 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import Q from .base import Currency, Term, Unit from .providers import CloudProvider @@ -339,7 +340,11 @@ class VSHNAppCatPrice(models.Model): return None def calculate_final_price( - self, currency_code: str, service_level: str, number_of_units: int + self, + currency_code: str, + service_level: str, + number_of_units: int, + addon_ids=None, ): base_fee = self.get_base_fee(currency_code) unit_rate = self.get_unit_rate(currency_code, service_level) @@ -359,7 +364,49 @@ class VSHNAppCatPrice(models.Model): else: total_price = base_fee + (unit_rate * number_of_units) - return total_price + # Add prices for mandatory addons and selected addons + addon_total = 0 + addon_breakdown = [] + + # Query all active addons related to this price config + addons_query = self.addons.filter(active=True) + + # Include mandatory addons and explicitly selected addons + if addon_ids: + addons = addons_query.filter(Q(mandatory=True) | Q(id__in=addon_ids)) + else: + addons = addons_query.filter(mandatory=True) + + for addon in addons: + addon_price = 0 + if addon.addon_type == VSHNAppCatAddon.AddonType.BASE_FEE: + addon_price_value = addon.get_price(currency_code) + if addon_price_value: + addon_price = addon_price_value + elif addon.addon_type == VSHNAppCatAddon.AddonType.UNIT_RATE: + addon_price_value = addon.get_price(currency_code, service_level) + if addon_price_value: + addon_price = addon_price_value * number_of_units + + addon_total += addon_price + addon_breakdown.append( + { + "id": addon.id, + "name": addon.name, + "description": addon.description, + "commercial_description": addon.commercial_description, + "mandatory": addon.mandatory, + "price": addon_price, + } + ) + + total_price += addon_total + + return { + "total_price": total_price, + "addon_total": addon_total, + "addon_breakdown": addon_breakdown, + } class VSHNAppCatUnitRate(models.Model): @@ -389,6 +436,118 @@ class VSHNAppCatUnitRate(models.Model): return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}" +class VSHNAppCatAddon(models.Model): + """ + Addon pricing model for VSHNAppCatPrice. Can be added to a service price configuration + to provide additional features or resources with their own pricing. + """ + + class AddonType(models.TextChoices): + BASE_FEE = "BF", "Base Fee" # Fixed amount regardless of units + UNIT_RATE = "UR", "Unit Rate" # Price per unit + + vshn_appcat_price_config = models.ForeignKey( + VSHNAppCatPrice, on_delete=models.CASCADE, related_name="addons" + ) + name = models.CharField(max_length=100, help_text="Name of the addon") + description = models.TextField( + blank=True, help_text="Technical description of the addon" + ) + commercial_description = models.TextField( + blank=True, help_text="Commercial description displayed in the frontend" + ) + addon_type = models.CharField( + max_length=2, + choices=AddonType.choices, + help_text="Type of addon pricing (fixed fee or per-unit)", + ) + mandatory = models.BooleanField(default=False, help_text="Is this addon mandatory?") + active = models.BooleanField( + default=True, help_text="Is this addon active and available for selection?" + ) + order = models.IntegerField(default=0, help_text="Display order in the frontend") + valid_from = models.DateTimeField(blank=True, null=True) + valid_to = models.DateTimeField(blank=True, null=True) + + class Meta: + verbose_name = "Service Addon" + ordering = ["order", "name"] + + def __str__(self): + return f"{self.vshn_appcat_price_config.service.name} - {self.name}" + + def get_price(self, currency_code: str, service_level: str = None): + """Get the price for this addon in the specified currency and service level""" + try: + if self.addon_type == self.AddonType.BASE_FEE: + return self.base_fees.get(currency=currency_code).amount + elif self.addon_type == self.AddonType.UNIT_RATE: + if not service_level: + raise ValueError("Service level is required for unit rate addons") + return self.unit_rates.get( + currency=currency_code, service_level=service_level + ).amount + except ( + VSHNAppCatAddonBaseFee.DoesNotExist, + VSHNAppCatAddonUnitRate.DoesNotExist, + ): + return None + + +class VSHNAppCatAddonBaseFee(models.Model): + """Base fee for an addon (fixed amount regardless of units)""" + + addon = models.ForeignKey( + VSHNAppCatAddon, on_delete=models.CASCADE, related_name="base_fees" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Base fee in the specified currency, excl. VAT", + ) + + class Meta: + verbose_name = "Addon Base Fee" + unique_together = ("addon", "currency") + ordering = ["currency"] + + def __str__(self): + return f"{self.addon.name} Base Fee - {self.amount} {self.currency}" + + +class VSHNAppCatAddonUnitRate(models.Model): + """Unit rate for an addon (price per unit)""" + + addon = models.ForeignKey( + VSHNAppCatAddon, on_delete=models.CASCADE, related_name="unit_rates" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + service_level = models.CharField( + max_length=2, + choices=VSHNAppCatPrice.ServiceLevel.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=4, + help_text="Price per unit in the specified currency and service level, excl. VAT", + ) + + class Meta: + verbose_name = "Addon Unit Rate" + unique_together = ("addon", "currency", "service_level") + ordering = ["currency", "service_level"] + + def __str__(self): + return f"{self.addon.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}" + + class ExternalPricePlans(models.Model): plan_name = models.CharField() description = models.CharField(max_length=200, blank=True, null=True) diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index f44dddc..1d5e5af 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -275,6 +275,14 @@ + + +
+ +
+ +
+
@@ -345,6 +353,12 @@ Storage - 20 GB CHF 0.00
+ + +
+ +
+
Total Monthly Price @@ -447,5 +461,341 @@
+ + {% endblock %} \ No newline at end of file diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 7173cdc..4901cae 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -7,6 +7,53 @@ {% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %}
@@ -71,6 +118,12 @@ Show discount details
+
+ + +
- {% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %} + {% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level or show_discount_details or show_addon_details or show_price_comparison %}
Active Filters: {% if filter_cloud_provider %}Cloud Provider: {{ filter_cloud_provider }}{% endif %} {% if filter_service %}Service: {{ filter_service }}{% endif %} {% if filter_compute_plan_group %}Group: {{ filter_compute_plan_group }}{% endif %} {% if filter_service_level %}Service Level: {{ filter_service_level }}{% endif %} + {% if show_discount_details %}Discount Details{% endif %} + {% if show_addon_details %}Addon Details{% endif %} + {% if show_price_comparison %}Price Comparison{% endif %}
{% endif %} @@ -174,6 +230,52 @@ Replica Enforce: {{ first_row.replica_enforce }}
+ + {# Display add-on summary #} + {% if show_addon_details and first_row.mandatory_addons or first_row.optional_addons %} +
+
+
Available Add-ons for {{ first_row.service }}
+
+
+ {% if first_row.mandatory_addons %} +
+
Mandatory Add-ons (included in all plans):
+
+ {% for addon in first_row.mandatory_addons %} +
+
+ {{ addon.name }} +
{{ addon.commercial_description|default:addon.description }}
+
{{ addon.price|floatformat:2 }} {{ first_row.currency }}
+ {{ addon.addon_type }} +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if first_row.optional_addons %} +
+
Optional Add-ons (can be added):
+
+ {% for addon in first_row.optional_addons %} +
+
+ {{ addon.name }} +
{{ addon.commercial_description|default:addon.description }}
+
{{ addon.price|floatformat:2 }} {{ first_row.currency }}
+ {{ addon.addon_type }} +
+
+ {% endfor %} +
+
+ {% endif %} +
+
+ {% endif %} {% endwith %}
@@ -191,6 +293,9 @@ SLA Base SLA Per Unit SLA Price + {% if show_addon_details %} + Add-ons + {% endif %} {% if show_discount_details %} Discount Model Discount Details @@ -215,6 +320,54 @@ {{ row.sla_base|floatformat:2 }} {{ row.sla_per_unit|floatformat:4 }} {{ row.sla_price|floatformat:2 }} + {% if show_addon_details %} + + {% if row.mandatory_addons or row.optional_addons %} +
+ {% if row.mandatory_addons %} +
+ Mandatory Add-ons: + {% for addon in row.mandatory_addons %} +
+
+ {{ addon.name }} + {{ addon.price|floatformat:2 }} {{ row.currency }} +
+ {% if addon.commercial_description %} +
{{ addon.commercial_description }}
+ {% elif addon.description %} +
{{ addon.description }}
+ {% endif %} +
Type: {{ addon.addon_type }}
+
+ {% endfor %} +
+ {% endif %} + {% if row.optional_addons %} +
+ Optional Add-ons: + {% for addon in row.optional_addons %} +
+
+ {{ addon.name }} + {{ addon.price|floatformat:2 }} {{ row.currency }} +
+ {% if addon.commercial_description %} +
{{ addon.commercial_description }}
+ {% elif addon.description %} +
{{ addon.description }}
+ {% endif %} +
Type: {{ addon.addon_type }}
+
+ {% endfor %} +
+ {% endif %} +
+ {% else %} + No add-ons + {% endif %} + + {% endif %} {% if show_discount_details %} {% if row.has_discount %} @@ -267,6 +420,9 @@ - - - + {% if show_addon_details %} + - + {% endif %} {% if show_discount_details %} - - @@ -338,6 +494,7 @@ document.addEventListener('DOMContentLoaded', function() { const filterForm = document.getElementById('filter-form'); const filterSelects = document.querySelectorAll('.filter-select'); const discountCheckbox = document.getElementById('discount_details'); + const addonCheckbox = document.getElementById('addon_details'); // Add change event listeners to all filter dropdowns filterSelects.forEach(function(select) { @@ -351,6 +508,11 @@ document.addEventListener('DOMContentLoaded', function() { filterForm.submit(); }); + // Add change event listener to addon details checkbox + addonCheckbox.addEventListener('change', function() { + filterForm.submit(); + }); + // Add change event listener to price comparison checkbox const priceComparisonCheckbox = document.getElementById('price_comparison'); priceComparisonCheckbox.addEventListener('change', function() { diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 2c876a6..8a2c0d1 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -367,6 +367,41 @@ def generate_pricing_data(offering): else: sla_price = standard_sla_price + # Get addons information + addons = appcat_price.addons.filter(active=True) + mandatory_addons = [] + optional_addons = [] + + # Calculate additional price from mandatory addons + addon_total = 0 + + for addon in addons: + addon_price = None + + if addon.addon_type == "BF": # Base Fee + addon_price = addon.get_price(currency) + elif addon.addon_type == "UR": # Unit Rate + addon_price_per_unit = addon.get_price(currency, service_level) + if addon_price_per_unit: + addon_price = addon_price_per_unit * total_units + + addon_info = { + "id": addon.id, + "name": addon.name, + "description": addon.description, + "commercial_description": addon.commercial_description, + "addon_type": addon.get_addon_type_display(), + "price": addon_price, + } + + if addon.mandatory: + mandatory_addons.append(addon_info) + if addon_price: + addon_total += addon_price + sla_price += addon_price + else: + optional_addons.append(addon_info) + final_price = compute_plan_price + sla_price service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[ service_level @@ -393,6 +428,8 @@ def generate_pricing_data(offering): "storage_price": storage_price_data.get(currency, 0), "ha_replica_min": appcat_price.ha_replica_min, "ha_replica_max": appcat_price.ha_replica_max, + "mandatory_addons": mandatory_addons, + "optional_addons": optional_addons, } ) diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 6d59c79..2616522 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -43,6 +43,7 @@ def pricelist(request): """Generate comprehensive price list grouped by compute plan groups and service levels""" # Get filter parameters from request show_discount_details = request.GET.get("discount_details", "").lower() == "true" + show_addon_details = request.GET.get("addon_details", "").lower() == "true" show_price_comparison = request.GET.get("price_comparison", "").lower() == "true" filter_cloud_provider = request.GET.get("cloud_provider", "") filter_service = request.GET.get("service", "") @@ -202,6 +203,40 @@ def pricelist(request): discount_savings = 0 discount_percentage = 0 + # Get addon information + addons = appcat_price.addons.filter(active=True) + mandatory_addons = [] + optional_addons = [] + + # Group addons by mandatory vs optional + for addon in addons: + addon_price = None + + if addon.addon_type == "BF": # Base Fee + addon_price = addon.get_price(currency) + elif addon.addon_type == "UR": # Unit Rate + addon_price_per_unit = addon.get_price( + currency, service_level + ) + if addon_price_per_unit: + addon_price = addon_price_per_unit * total_units + + addon_info = { + "id": addon.id, + "name": addon.name, + "description": addon.description, + "commercial_description": addon.commercial_description, + "addon_type": addon.get_addon_type_display(), + "price": addon_price, + } + + if addon.mandatory: + mandatory_addons.append(addon_info) + if addon_price: + sla_price += addon_price + else: + optional_addons.append(addon_info) + final_price = compute_plan_price + sla_price service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[ service_level @@ -296,6 +331,8 @@ def pricelist(request): and appcat_price.discount_model.active ), "external_comparisons": external_comparisons, + "mandatory_addons": mandatory_addons, + "optional_addons": optional_addons, } ) @@ -344,6 +381,7 @@ def pricelist(request): context = { "pricing_data_by_group_and_service_level": final_context_data, "show_discount_details": show_discount_details, + "show_addon_details": show_addon_details, "show_price_comparison": show_price_comparison, "filter_cloud_provider": filter_cloud_provider, "filter_service": filter_service, diff --git a/hub/settings.py b/hub/settings.py index 68452ad..409b3d6 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -254,6 +254,7 @@ JAZZMIN_SETTINGS = { "changeform_format_overrides": { "services.ProgressiveDiscountModel": "single", "services.VSHNAppCatPrice": "single", + "services.VSHNAppCatAddon": "single", }, "related_modal_active": True, } From 9d423ce61e32aa1f2cb5297e24bbf7710a19a2bd Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Thu, 19 Jun 2025 17:05:49 +0200 Subject: [PATCH 02/16] still buggy price calculator --- hub/services/static/js/price-calculator.js | 273 +++++++++++++- .../templates/services/offering_detail.html | 340 +----------------- hub/services/views/offerings.py | 5 + 3 files changed, 268 insertions(+), 350 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index a65a9f2..30a581a 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -10,6 +10,7 @@ class PriceCalculator { this.currentOffering = null; this.selectedConfiguration = null; this.replicaInfo = null; + this.addonsData = null; this.init(); } @@ -50,6 +51,10 @@ class PriceCalculator { this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); this.planSelect = document.getElementById('planSelect'); + // Addon elements + this.addonsContainer = document.getElementById('addonsContainer'); + this.addonPricingContainer = document.getElementById('addonPricingContainer'); + // Result display elements this.planMatchStatus = document.getElementById('planMatchStatus'); this.selectedPlanDetails = document.getElementById('selectedPlanDetails'); @@ -156,25 +161,36 @@ class PriceCalculator { storage: config.storage, instances: config.instances, serviceLevel: config.serviceLevel, - totalPrice: config.totalPrice + totalPrice: config.totalPrice, + addons: config.addons || [] }); } } // Generate human-readable configuration message generateConfigurationMessage(config) { - return `I would like to order the following configuration: + let message = `I would like to order the following configuration: Plan: ${config.planName} (${config.planGroup}) vCPUs: ${config.vcpus} Memory: ${config.memory} GB Storage: ${config.storage} GB Instances: ${config.instances} -Service Level: ${config.serviceLevel} +Service Level: ${config.serviceLevel}`; -Total Monthly Price: CHF ${config.totalPrice} + // Add addons to the message if any are selected + if (config.addons && config.addons.length > 0) { + message += '\n\nSelected Add-ons:'; + config.addons.forEach(addon => { + message += `\n- ${addon.name}: CHF ${addon.price}`; + }); + } + + message += `\n\nTotal Monthly Price: CHF ${config.totalPrice} Please contact me with next steps for ordering this configuration.`; + + return message; } // Load pricing data from API endpoint @@ -185,13 +201,18 @@ Please contact me with next steps for ordering this configuration.`; throw new Error('Failed to load pricing data'); } - this.pricingData = await response.json(); + const data = await response.json(); + this.pricingData = data.pricing || data; + + // Extract addons data from the plans - addons are embedded in each plan + this.extractAddonsData(); // Extract storage price from the first available plan this.extractStoragePrice(); this.setupEventListeners(); this.populatePlanDropdown(); + this.updateAddons(); this.updatePricing(); } catch (error) { console.error('Error loading pricing data:', error); @@ -220,6 +241,50 @@ Please contact me with next steps for ordering this configuration.`; } } + // Extract addons data from pricing plans + extractAddonsData() { + if (!this.pricingData) return; + + this.addonsData = {}; + + // Extract addons from the first available plan for each service level + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + Object.keys(group).forEach(serviceLevel => { + const plans = group[serviceLevel]; + if (plans.length > 0) { + // Use the first plan's addon data for this service level + const plan = plans[0]; + const allAddons = []; + + // Add mandatory addons + if (plan.mandatory_addons) { + plan.mandatory_addons.forEach(addon => { + allAddons.push({ + ...addon, + is_mandatory: true, + addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" + }); + }); + } + + // Add optional addons + if (plan.optional_addons) { + plan.optional_addons.forEach(addon => { + allAddons.push({ + ...addon, + is_mandatory: false, + addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" + }); + }); + } + + this.addonsData[serviceLevel] = allAddons; + } + }); + }); + } + // Setup event listeners for calculator controls setupEventListeners() { if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; @@ -253,6 +318,7 @@ Please contact me with next steps for ordering this configuration.`; input.addEventListener('change', () => { this.updateInstancesSlider(); this.populatePlanDropdown(); + this.updateAddons(); this.updatePricing(); }); }); @@ -269,8 +335,10 @@ Please contact me with next steps for ordering this configuration.`; this.cpuValue.textContent = selectedPlan.vcpus; this.memoryValue.textContent = selectedPlan.ram; + this.updateAddons(); this.updatePricingWithPlan(selectedPlan); } else { + this.updateAddons(); this.updatePricing(); } }); @@ -356,6 +424,7 @@ Please contact me with next steps for ordering this configuration.`; input.addEventListener('change', () => { this.updateInstancesSlider(); this.populatePlanDropdown(); + this.updateAddons(); this.updatePricing(); }); @@ -445,6 +514,103 @@ Please contact me with next steps for ordering this configuration.`; }); } + // Update addons based on current configuration + updateAddons() { + if (!this.addonsContainer || !this.addonsData) return; + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + if (!serviceLevel || !this.addonsData[serviceLevel]) return; + + const addons = this.addonsData[serviceLevel]; + + // Clear existing addons + this.addonsContainer.innerHTML = ''; + + // Add each addon + addons.forEach(addon => { + const addonElement = document.createElement('div'); + addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`; + + addonElement.innerHTML = ` +
+ + +
+ `; + + this.addonsContainer.appendChild(addonElement); + + // Add event listener for optional addons + if (!addon.is_mandatory) { + const checkbox = addonElement.querySelector('.addon-checkbox'); + checkbox.addEventListener('change', () => { + this.updatePricing(); + }); + } + }); + + // Update addon prices + this.updateAddonPrices(); + } // Update addon prices based on current configuration + updateAddonPrices() { + if (!this.addonsContainer) return; + + const cpus = parseInt(this.cpuRange?.value || 2); + const memory = parseInt(this.memoryRange?.value || 4); + const storage = parseInt(this.storageRange?.value || 20); + const instances = parseInt(this.instancesRange?.value || 1); + + // Find the current plan data to get variable_unit + const matchedPlan = this.getCurrentPlan(); + const variableUnit = matchedPlan?.variable_unit || 'CPU'; + const units = variableUnit === 'CPU' ? cpus : memory; + const totalUnits = units * instances; + + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const priceElement = checkbox.parentElement.querySelector('.addon-price-value'); + + let calculatedPrice = 0; + if (addon.addon_type === 'BASE_FEE') { + calculatedPrice = parseFloat(addon.price || 0) * instances; + } else if (addon.addon_type === 'UNIT_RATE') { + calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits; + } + + if (priceElement) { + priceElement.textContent = calculatedPrice.toFixed(2); + } + + // Update the checkbox data for later calculation + checkbox.dataset.calculatedPrice = calculatedPrice.toString(); + }); + } + + // Get current plan based on configuration + getCurrentPlan() { + const cpus = parseInt(this.cpuRange?.value || 2); + const memory = parseInt(this.memoryRange?.value || 4); + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (this.planSelect?.value) { + return JSON.parse(this.planSelect.value); + } + + return this.findBestMatchingPlan(cpus, memory, serviceLevel); + } + // Find best matching plan based on requirements findBestMatchingPlan(cpus, memory, serviceLevel) { if (!this.pricingData) return null; @@ -488,6 +654,9 @@ Please contact me with next steps for ordering this configuration.`; const storage = parseInt(this.storageRange?.value || 20); const instances = parseInt(this.instancesRange?.value || 1); + // Update addon prices first to ensure calculated prices are current + this.updateAddonPrices(); + this.showPlanDetails(selectedPlan, storage, instances); this.updateStatusMessage('Plan selected directly!', 'success'); } @@ -496,6 +665,9 @@ Please contact me with next steps for ordering this configuration.`; updatePricing() { if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; + // Update addon prices first + this.updateAddonPrices(); + // Reset plan selection if in auto-select mode if (!this.planSelect?.value) { const cpus = parseInt(this.cpuRange.value); @@ -543,16 +715,57 @@ Please contact me with next steps for ordering this configuration.`; if (this.planInstances) this.planInstances.textContent = instances; if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; - // Calculate pricing using storage price from the plan data - const computePriceValue = parseFloat(plan.compute_plan_price); - const servicePriceValue = parseFloat(plan.sla_price); - const managedServicePricePerInstance = computePriceValue + servicePriceValue; + // Calculate pricing using final price from plan data (which already includes mandatory addons) + // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons) + const managedServicePricePerInstance = parseFloat(plan.final_price); + + // Collect mandatory addons for display (but don't add to price since they're already included) + let mandatoryAddonTotal = 0; + const mandatoryAddons = []; + + if (this.addonsContainer) { + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); + + if (addon.is_mandatory) { + // Don't add to mandatoryAddonTotal since it's already in plan.final_price + mandatoryAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); + } + }); + } + const managedServicePrice = managedServicePricePerInstance * instances; // Use storage price from plan data or fallback to instance variable const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; const storagePriceValue = storage * storageUnitPrice * instances; - const totalPriceValue = managedServicePrice + storagePriceValue; + + // Calculate optional addon total + let optionalAddonTotal = 0; + const selectedOptionalAddons = []; + + if (this.addonsContainer) { + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox:checked'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); + + if (!addon.is_mandatory) { + optionalAddonTotal += calculatedPrice; + selectedOptionalAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); + } + }); + } + + const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal; // Update pricing display if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.toFixed(2); @@ -560,6 +773,9 @@ Please contact me with next steps for ordering this configuration.`; if (this.storageAmount) this.storageAmount.textContent = storage; if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2); + // Update addon pricing display + this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons); + // Store current configuration for order button this.selectedConfiguration = { planName: plan.compute_plan, @@ -569,10 +785,45 @@ Please contact me with next steps for ordering this configuration.`; storage: storage, instances: instances, serviceLevel: serviceLevel, - totalPrice: totalPriceValue.toFixed(2) + totalPrice: totalPriceValue.toFixed(2), + addons: [...mandatoryAddons, ...selectedOptionalAddons] }; } + // Update addon pricing display in the results panel + updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons) { + if (!this.addonPricingContainer) return; + + // Clear existing addon pricing display + this.addonPricingContainer.innerHTML = ''; + + // Add mandatory addons to pricing breakdown + if (mandatoryAddons && mandatoryAddons.length > 0) { + mandatoryAddons.forEach(addon => { + const addonRow = document.createElement('div'); + addonRow.className = 'd-flex justify-content-between mb-2'; + addonRow.innerHTML = ` + Add-on: ${addon.name} (Required) + CHF ${addon.price} + `; + this.addonPricingContainer.appendChild(addonRow); + }); + } + + // Add optional addons to pricing breakdown + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + selectedOptionalAddons.forEach(addon => { + const addonRow = document.createElement('div'); + addonRow.className = 'd-flex justify-content-between mb-2'; + addonRow.innerHTML = ` + Add-on: ${addon.name} + CHF ${addon.price} + `; + this.addonPricingContainer.appendChild(addonRow); + }); + } + } + // Show no matching plan found showNoMatch() { if (this.planMatchStatus) this.planMatchStatus.style.display = 'none'; diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 1d5e5af..b8b5a61 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -271,7 +271,7 @@ - +
@@ -460,342 +460,4 @@ - - - - {% endblock %} \ No newline at end of file diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 8a2c0d1..fc5c59e 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -377,6 +377,7 @@ def generate_pricing_data(offering): for addon in addons: addon_price = None + addon_price_per_unit = None if addon.addon_type == "BF": # Base Fee addon_price = addon.get_price(currency) @@ -392,6 +393,7 @@ def generate_pricing_data(offering): "commercial_description": addon.commercial_description, "addon_type": addon.get_addon_type_display(), "price": addon_price, + "price_per_unit": addon_price_per_unit, # Add per-unit price for frontend calculations } if addon.mandatory: @@ -428,6 +430,9 @@ def generate_pricing_data(offering): "storage_price": storage_price_data.get(currency, 0), "ha_replica_min": appcat_price.ha_replica_min, "ha_replica_max": appcat_price.ha_replica_max, + "variable_unit": appcat_price.variable_unit, + "units": units, + "total_units": total_units, "mandatory_addons": mandatory_addons, "optional_addons": optional_addons, } From 3f3b9da9929dd9cff16d9b8387c7aa1a7e6c6325 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 08:57:05 +0200 Subject: [PATCH 03/16] improved behaviour --- hub/services/static/js/price-calculator.js | 143 ++++++++++++++++----- hub/services/views/pricelist.py | 61 +++++++-- 2 files changed, 164 insertions(+), 40 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index 30a581a..377c097 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -335,9 +335,18 @@ Please contact me with next steps for ordering this configuration.`; this.cpuValue.textContent = selectedPlan.vcpus; this.memoryValue.textContent = selectedPlan.ram; + // Fade out CPU and Memory sliders since plan is manually selected + this.fadeOutSliders(['cpu', 'memory']); + + // Update addons for the new configuration this.updateAddons(); + // Update pricing with the selected plan this.updatePricingWithPlan(selectedPlan); } else { + // Auto-select mode - fade sliders back in + this.fadeInSliders(['cpu', 'memory']); + + // Auto-select mode - update addons and recalculate this.updateAddons(); this.updatePricing(); } @@ -555,6 +564,8 @@ Please contact me with next steps for ordering this configuration.`; if (!addon.is_mandatory) { const checkbox = addonElement.querySelector('.addon-checkbox'); checkbox.addEventListener('change', () => { + // Update addon prices and recalculate total + this.updateAddonPrices(); this.updatePricing(); }); } @@ -571,7 +582,7 @@ Please contact me with next steps for ordering this configuration.`; const storage = parseInt(this.storageRange?.value || 20); const instances = parseInt(this.instancesRange?.value || 1); - // Find the current plan data to get variable_unit + // Find the current plan data to get variable_unit for addon calculations const matchedPlan = this.getCurrentPlan(); const variableUnit = matchedPlan?.variable_unit || 'CPU'; const units = variableUnit === 'CPU' ? cpus : memory; @@ -583,17 +594,22 @@ Please contact me with next steps for ordering this configuration.`; const priceElement = checkbox.parentElement.querySelector('.addon-price-value'); let calculatedPrice = 0; + + // Calculate addon price based on type if (addon.addon_type === 'BASE_FEE') { + // Base fee: price per instance calculatedPrice = parseFloat(addon.price || 0) * instances; } else if (addon.addon_type === 'UNIT_RATE') { + // Unit rate: price per unit (CPU or memory) across all instances calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits; } + // Update the display price if (priceElement) { priceElement.textContent = calculatedPrice.toFixed(2); } - // Update the checkbox data for later calculation + // Store the calculated price for later use in total calculations checkbox.dataset.calculatedPrice = calculatedPrice.toString(); }); } @@ -665,7 +681,7 @@ Please contact me with next steps for ordering this configuration.`; updatePricing() { if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; - // Update addon prices first + // Update addon prices first to ensure they're current this.updateAddonPrices(); // Reset plan selection if in auto-select mode @@ -690,7 +706,13 @@ Please contact me with next steps for ordering this configuration.`; } else { // Plan is directly selected, update storage pricing const selectedPlan = JSON.parse(this.planSelect.value); - this.updatePricingWithPlan(selectedPlan); + const storage = parseInt(this.storageRange.value); + const instances = parseInt(this.instancesRange.value); + + // Update addon prices for current configuration + this.updateAddonPrices(); + this.showPlanDetails(selectedPlan, storage, instances); + this.updateStatusMessage('Plan selected directly!', 'success'); } } @@ -715,13 +737,18 @@ Please contact me with next steps for ordering this configuration.`; if (this.planInstances) this.planInstances.textContent = instances; if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; + // Ensure addon prices are calculated with current configuration + this.updateAddonPrices(); + // Calculate pricing using final price from plan data (which already includes mandatory addons) // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons) const managedServicePricePerInstance = parseFloat(plan.final_price); - // Collect mandatory addons for display (but don't add to price since they're already included) + // Collect addon information for display and calculation let mandatoryAddonTotal = 0; + let optionalAddonTotal = 0; const mandatoryAddons = []; + const selectedOptionalAddons = []; if (this.addonsContainer) { const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); @@ -730,11 +757,19 @@ Please contact me with next steps for ordering this configuration.`; const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); if (addon.is_mandatory) { - // Don't add to mandatoryAddonTotal since it's already in plan.final_price + // Mandatory addons are already included in plan.final_price + // We collect them for display purposes only mandatoryAddons.push({ name: addon.name, price: calculatedPrice.toFixed(2) }); + } else if (checkbox.checked) { + // Only count checked optional addons + optionalAddonTotal += calculatedPrice; + selectedOptionalAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); } }); } @@ -745,26 +780,7 @@ Please contact me with next steps for ordering this configuration.`; const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; const storagePriceValue = storage * storageUnitPrice * instances; - // Calculate optional addon total - let optionalAddonTotal = 0; - const selectedOptionalAddons = []; - - if (this.addonsContainer) { - const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox:checked'); - addonCheckboxes.forEach(checkbox => { - const addon = JSON.parse(checkbox.dataset.addon); - const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); - - if (!addon.is_mandatory) { - optionalAddonTotal += calculatedPrice; - selectedOptionalAddons.push({ - name: addon.name, - price: calculatedPrice.toFixed(2) - }); - } - }); - } - + // Total price = managed service price (includes mandatory addons) + storage + optional addons const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal; // Update pricing display @@ -797,20 +813,33 @@ Please contact me with next steps for ordering this configuration.`; // Clear existing addon pricing display this.addonPricingContainer.innerHTML = ''; - // Add mandatory addons to pricing breakdown + // Add mandatory addons to pricing breakdown (for informational purposes only) if (mandatoryAddons && mandatoryAddons.length > 0) { + // Add a note explaining mandatory addons are included + const mandatoryNote = document.createElement('div'); + mandatoryNote.className = 'text-muted small mb-2'; + mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):'; + this.addonPricingContainer.appendChild(mandatoryNote); + mandatoryAddons.forEach(addon => { const addonRow = document.createElement('div'); - addonRow.className = 'd-flex justify-content-between mb-2'; + addonRow.className = 'd-flex justify-content-between mb-1 ps-3'; addonRow.innerHTML = ` - Add-on: ${addon.name} (Required) - CHF ${addon.price} + ${addon.name} + CHF ${addon.price} `; this.addonPricingContainer.appendChild(addonRow); }); + + // Add separator if there are also optional addons + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + const separator = document.createElement('hr'); + separator.className = 'my-2'; + this.addonPricingContainer.appendChild(separator); + } } - // Add optional addons to pricing breakdown + // Add optional addons to pricing breakdown (these are added to total) if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { selectedOptionalAddons.forEach(addon => { const addonRow = document.createElement('div'); @@ -852,6 +881,58 @@ Please contact me with next steps for ordering this configuration.`; this.planMatchStatus.style.display = 'block'; } } + + // Fade out specified sliders when plan is manually selected + fadeOutSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '0.3'; + sliderContainer.style.pointerEvents = 'none'; + + // Add visual indicator that sliders are disabled + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'not-allowed'; + } + } + }); + } + + // Fade in specified sliders when auto-select mode is chosen + fadeInSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '1'; + sliderContainer.style.pointerEvents = 'auto'; + + // Remove visual indicator + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'pointer'; + } + } + }); + } + + // Get slider container element by type + getSliderContainer(type) { + switch (type) { + case 'cpu': + return this.cpuRange?.closest('.mb-4'); + case 'memory': + return this.memoryRange?.closest('.mb-4'); + case 'storage': + return this.storageRange?.closest('.mb-4'); + case 'instances': + return this.instancesRange?.closest('.mb-4'); + default: + return null; + } + } } // Initialize calculator when DOM is loaded diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 2616522..b392f6b 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -203,13 +203,56 @@ def pricelist(request): discount_savings = 0 discount_percentage = 0 - # Get addon information - addons = appcat_price.addons.filter(active=True) + # Calculate final price using the model method to ensure consistency + price_calculation = appcat_price.calculate_final_price( + currency_code=currency, + service_level=service_level, + number_of_units=total_units, + addon_ids=None, # This will include only mandatory addons + ) + + if price_calculation is None: + continue + + # Calculate base service price (without addons) for display purposes + base_sla_price = base_fee + (total_units * unit_rate) + + # Apply discount if available + discount_breakdown = None + if ( + appcat_price.discount_model + and appcat_price.discount_model.active + ): + discounted_price = ( + appcat_price.discount_model.calculate_discount( + unit_rate, total_units + ) + ) + sla_price = base_fee + discounted_price + discount_savings = base_sla_price - sla_price + discount_percentage = ( + (discount_savings / base_sla_price) * 100 + if base_sla_price > 0 + else 0 + ) + discount_breakdown = ( + appcat_price.discount_model.get_discount_breakdown( + unit_rate, total_units + ) + ) + else: + sla_price = base_sla_price + discounted_price = total_units * unit_rate + discount_savings = 0 + discount_percentage = 0 + + # Extract addon information from the calculation mandatory_addons = [] optional_addons = [] - # Group addons by mandatory vs optional - for addon in addons: + # Get all addons to separate mandatory from optional + all_addons = appcat_price.addons.filter(active=True) + for addon in all_addons: addon_price = None if addon.addon_type == "BF": # Base Fee @@ -232,12 +275,12 @@ def pricelist(request): if addon.mandatory: mandatory_addons.append(addon_info) - if addon_price: - sla_price += addon_price else: optional_addons.append(addon_info) - final_price = compute_plan_price + sla_price + # Use the calculated total price which includes mandatory addons + service_price_with_addons = price_calculation["total_price"] + final_price = compute_plan_price + service_price_with_addons service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[ service_level ] @@ -309,8 +352,8 @@ def pricelist(request): "service_level": service_level_display, "sla_base": base_fee, "sla_per_unit": unit_rate, - "sla_price": sla_price, - "standard_sla_price": standard_sla_price, + "sla_price": service_price_with_addons, + "standard_sla_price": base_sla_price, "discounted_sla_price": ( base_fee + discounted_price if appcat_price.discount_model From a8f204dcb4c4dee799918bd75bca7f2ed6c2db0b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 09:30:51 +0200 Subject: [PATCH 04/16] improved pricelist view --- .../templates/services/pricelist.html | 260 +++++++++++++++--- hub/services/templatetags/math_tags.py | 45 +++ 2 files changed, 271 insertions(+), 34 deletions(-) create mode 100644 hub/services/templatetags/math_tags.py diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 4901cae..bc90f6d 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -1,5 +1,6 @@ {% extends 'base.html' %} {% load static %} +{% load math_tags %} {% block title %}Complete Price List{% endblock %} @@ -51,6 +52,69 @@ .servala-row { border-bottom: 2px solid #007bff; } + + /* Price calculation breakdown styling */ + .price-breakdown-header { + background: linear-gradient(135deg, #28a745, #20b2aa); + } + + .compute-plan-col { + background-color: rgba(13, 110, 253, 0.1); + border-right: 2px solid #0d6efd; + } + + .sla-base-col { + background-color: rgba(111, 66, 193, 0.1); + border-right: 2px solid #6f42c1; + } + + .sla-units-col { + background-color: rgba(253, 126, 20, 0.1); + border-right: 2px solid #fd7e14; + } + + .mandatory-addons-col { + background-color: rgba(220, 53, 69, 0.1); + border-right: 2px solid #dc3545; + } + + .total-sla-col { + background-color: rgba(25, 135, 84, 0.2); + border-right: 3px solid #198754; + } + + /* Mathematical operator styling */ + .math-operator { + font-size: 1.2em; + font-weight: bold; + color: #666; + padding: 0 5px; + } + + /* Price calculation formula helper */ + .price-formula { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + padding: 10px; + margin-bottom: 20px; + font-family: monospace; + text-align: center; + } + + .price-formula .formula-part { + display: inline-block; + padding: 2px 8px; + margin: 0 5px; + border-radius: 3px; + font-weight: bold; + } + + .price-formula .compute-part { background-color: rgba(13, 110, 253, 0.2); color: #0d6efd; } + .price-formula .sla-base-part { background-color: rgba(111, 66, 193, 0.2); color: #6f42c1; } + .price-formula .sla-units-part { background-color: rgba(253, 126, 20, 0.2); color: #fd7e14; } + .price-formula .addons-part { background-color: rgba(220, 53, 69, 0.2); color: #dc3545; } + .price-formula .equals-part { background-color: rgba(25, 135, 84, 0.2); color: #198754; } {% endblock %} @@ -60,6 +124,61 @@

Complete Price List - All Service Variants

+ +
+ +
+
+
+
+
Price Components
+
    +
  • + Compute Plan Price + Base infrastructure cost for CPU, memory, and storage +
  • +
  • + SLA Base + Fixed cost for the service level agreement +
  • +
  • + Units ร— SLA Per Unit + Variable cost based on scale/usage +
  • +
  • + Mandatory Add-ons + Required additional services (backup, monitoring, etc.) +
  • +
+
+
+
Final Price Formula
+
+ + Compute Plan Price + + SLA Base + + (Units ร— SLA Per Unit) + + Mandatory Add-ons = + Final Price + +
+

+ + This transparent pricing model ensures you understand exactly what you're paying for. + The table below breaks down each component for every service variant we offer. + +

+
+
+
+
+
+
@@ -278,32 +397,49 @@ {% endif %} {% endwith %} + +
+ Final Price Calculation:
+ Compute Plan Price + + + SLA Base + + + Units ร— SLA Per Unit + + + Mandatory Add-ons + = + Final Price +
+
- - - - - - - - - - - + + + + + + + {% if show_addon_details %} - + {% endif %} {% if show_discount_details %} - - + + {% endif %} {% if show_price_comparison %} - + {% endif %} - + + + + + + + + @@ -315,11 +451,44 @@ - - - - - + + + + + + {% if show_addon_details %} + @@ -462,7 +632,7 @@ {# Price Chart #}
-
Price Chart - Units vs Final Price
+
Price Breakdown Chart - Units vs Price Components
@@ -517,9 +687,7 @@ document.addEventListener('DOMContentLoaded', function() { const priceComparisonCheckbox = document.getElementById('price_comparison'); priceComparisonCheckbox.addEventListener('change', function() { filterForm.submit(); - }); - - // Chart data for each service level + }); // Chart data for each service level {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %} {% for service_level, pricing_data in service_levels.items %} {% if pricing_data %} @@ -536,18 +704,42 @@ document.addEventListener('DOMContentLoaded', function() { fill: false }, { - label: 'SLA Price', - data: [{% for row in pricing_data %}{{ row.sla_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], - borderColor: 'rgb(255, 99, 132)', - backgroundColor: 'rgba(255, 99, 132, 0.2)', + label: 'Compute Plan Price', + data: [{% for row in pricing_data %}{{ row.compute_plan_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], + borderColor: 'rgb(13, 110, 253)', + backgroundColor: 'rgba(13, 110, 253, 0.2)', tension: 0.1, fill: false }, { - label: 'Compute Plan Price', - data: [{% for row in pricing_data %}{{ row.compute_plan_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], - borderColor: 'rgb(54, 162, 235)', - backgroundColor: 'rgba(54, 162, 235, 0.2)', + label: 'SLA Base', + data: [{% for row in pricing_data %}{{ row.sla_base|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], + borderColor: 'rgb(111, 66, 193)', + backgroundColor: 'rgba(111, 66, 193, 0.2)', + tension: 0.1, + fill: false + }, + { + label: 'Units ร— SLA Per Unit', + data: [{% for row in pricing_data %}{{ row.units|multiply:row.sla_per_unit|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], + borderColor: 'rgb(253, 126, 20)', + backgroundColor: 'rgba(253, 126, 20, 0.2)', + tension: 0.1, + fill: false + }, + { + label: 'Mandatory Add-ons', + data: [{% for row in pricing_data %}{{ row.mandatory_addons|calculate_addon_total:row.units|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], + borderColor: 'rgb(220, 53, 69)', + backgroundColor: 'rgba(220, 53, 69, 0.2)', + tension: 0.1, + fill: false + }, + { + label: 'Total SLA Price', + data: [{% for row in pricing_data %}{{ row.sla_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}], + borderColor: 'rgb(25, 135, 84)', + backgroundColor: 'rgba(25, 135, 84, 0.2)', tension: 0.1, fill: false } @@ -580,7 +772,7 @@ document.addEventListener('DOMContentLoaded', function() { plugins: { title: { display: true, - text: '{{ group_name }} - {{ service_level }} Pricing' + text: '{{ group_name }} - {{ service_level }} Price Breakdown' }, legend: { display: true diff --git a/hub/services/templatetags/math_tags.py b/hub/services/templatetags/math_tags.py new file mode 100644 index 0000000..b3d0d35 --- /dev/null +++ b/hub/services/templatetags/math_tags.py @@ -0,0 +1,45 @@ +from django import template + +register = template.Library() + + +@register.filter(name="multiply") +def multiply(value, arg): + """Multiply two numbers in Django templates""" + try: + return float(value) * float(arg) + except (ValueError, TypeError): + return 0 + + +@register.filter(name="add_float") +def add_float(value, arg): + """Add two numbers in Django templates""" + try: + return float(value) + float(arg) + except (ValueError, TypeError): + return 0 + + +@register.filter(name="sum_addon_prices") +def sum_addon_prices(addons): + """Sum the prices of addons""" + try: + return sum(addon.price for addon in addons) + except (AttributeError, TypeError): + return 0 + + +@register.filter(name="calculate_addon_total") +def calculate_addon_total(addons, units): + """Calculate total cost of addons based on their type and units""" + try: + total = 0 + for addon in addons: + if addon.addon_type == "Unit Rate": + total += float(addon.price) * float(units) + else: # Base Fee or other types + total += float(addon.price) + return total + except (AttributeError, TypeError, ValueError): + return 0 From 8c041661833b135d3d7ac6b0ad436694ba18c2b0 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 09:41:47 +0200 Subject: [PATCH 05/16] hide addons when not available --- hub/services/static/js/price-calculator.js | 23 +++++++++++++++++-- .../templates/services/offering_detail.html | 4 ++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index 377c097..96069fe 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -525,16 +525,35 @@ Please contact me with next steps for ordering this configuration.`; // Update addons based on current configuration updateAddons() { - if (!this.addonsContainer || !this.addonsData) return; + if (!this.addonsContainer || !this.addonsData) { + // Hide addons section if no container or data + const addonsSection = document.getElementById('addonsSection'); + if (addonsSection) addonsSection.style.display = 'none'; + return; + } const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; - if (!serviceLevel || !this.addonsData[serviceLevel]) return; + if (!serviceLevel || !this.addonsData[serviceLevel]) { + // Hide addons section if no service level or no addons for this level + const addonsSection = document.getElementById('addonsSection'); + if (addonsSection) addonsSection.style.display = 'none'; + return; + } const addons = this.addonsData[serviceLevel]; // Clear existing addons this.addonsContainer.innerHTML = ''; + // Show or hide addons section based on availability + const addonsSection = document.getElementById('addonsSection'); + if (addons && addons.length > 0) { + if (addonsSection) addonsSection.style.display = 'block'; + } else { + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + // Add each addon addons.forEach(addon => { const addonElement = document.createElement('div'); diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index b8b5a61..000d13d 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -276,8 +276,8 @@
- -
+ +
Compute PlanCloud ProvidervCPUsRAM (GB)TermCurrencyCompute Plan PriceUnitsSLA BaseSLA Per UnitSLA PriceCompute PlanCloud ProvidervCPUsRAM (GB)TermCurrencyPrice Calculation BreakdownAdd-onsAdd-onsDiscount ModelDiscount DetailsDiscount ModelDiscount DetailsExternal ComparisonsExternal ComparisonsFinal PriceFinal Price
Compute Plan PriceSLA BaseUnits ร— SLA Per UnitMandatory Add-ons= Total SLA Price
{{ row.ram }} {{ row.term }} {{ row.currency }}{{ row.compute_plan_price|floatformat:2 }}{{ row.units }}{{ row.sla_base|floatformat:2 }}{{ row.sla_per_unit|floatformat:4 }}{{ row.sla_price|floatformat:2 }} + {{ row.compute_plan_price|floatformat:2 }} + + {{ row.sla_base|floatformat:2 }} + + {{ row.units|floatformat:0 }} ร— {{ row.sla_per_unit|floatformat:4 }}
+ = {{ row.units|multiply:row.sla_per_unit|floatformat:2 }} +
+ {% if row.mandatory_addons %} + {% for addon in row.mandatory_addons %} +
+ {% if addon.addon_type == "Unit Rate" %} + {{ addon.name }}
+ {{ row.units|floatformat:0 }} ร— {{ addon.price|floatformat:4 }}
+ = {{ row.units|multiply:addon.price|floatformat:2 }} + {% elif addon.addon_type == "Base Fee" %} + {{ addon.name }}
+ {{ addon.price|floatformat:2 }} + {% else %} + {{ addon.name }}
+ {{ addon.price|floatformat:2 }} + {% endif %} +
+ {% if not forloop.last %}
{% endif %} + {% endfor %} + {% else %} + n/a + {% endif %} +
+ {% with addon_total=row.mandatory_addons|calculate_addon_total:row.units %} + {{ row.sla_price|add_float:addon_total|floatformat:2 }} + {% endwith %} + {% if row.mandatory_addons or row.optional_addons %} @@ -415,6 +584,7 @@ {{ row.term }} {{ comparison.currency }} - - -