From 22e527bcd9dff6c7fa8b2954ee8598c594a2116b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Thu, 19 Jun 2025 16:19:59 +0200 Subject: [PATCH] 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, }