diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index ac34e12..973679a 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -22,6 +22,7 @@ from ..models import ( VSHNAppCatUnitRate, ProgressiveDiscountModel, DiscountTier, + ExternalPricePlans, ) @@ -308,3 +309,33 @@ class StoragePlanAdmin(ImportExportModelAdmin): return format_html("
".join([f"{p.amount} {p.currency}" for p in prices])) display_prices.short_description = "Prices (Amount Currency)" + + +@admin.register(ExternalPricePlans) +class ExternalPricePlansAdmin(admin.ModelAdmin): + """Admin configuration for ExternalPricePlans model""" + + list_display = ( + "plan_name", + "cloud_provider", + "service", + "currency", + "amount", + "display_compare_to_count", + "date_retrieved", + ) + list_filter = ("cloud_provider", "service", "currency", "term") + search_fields = ("plan_name", "cloud_provider__name", "service__name") + ordering = ("cloud_provider", "service", "plan_name") + + # Configure many-to-many field display + filter_horizontal = ("compare_to",) + + def display_compare_to_count(self, obj): + """Display count of compute plans this external price compares to""" + count = obj.compare_to.count() + if count == 0: + return "No comparisons" + return f"{count} plan{'s' if count != 1 else ''}" + + display_compare_to_count.short_description = "Compare To" diff --git a/hub/services/migrations/0031_externalpriceplans.py b/hub/services/migrations/0031_externalpriceplans.py new file mode 100644 index 0000000..12f24d4 --- /dev/null +++ b/hub/services/migrations/0031_externalpriceplans.py @@ -0,0 +1,127 @@ +# Generated by Django 5.2 on 2025-05-27 14:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0030_serviceoffering_msp"), + ] + + operations = [ + migrations.CreateModel( + name="ExternalPricePlans", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("plan_name", models.CharField()), + ( + "description", + models.CharField(blank=True, max_length=200, null=True), + ), + ("source", models.URLField(blank=True, null=True)), + ("date_retrieved", models.DateField(blank=True, null=True)), + ( + "currency", + models.CharField( + choices=[ + ("CHF", "Swiss Franc"), + ("EUR", "Euro"), + ("USD", "US Dollar"), + ], + default="CHF", + max_length=3, + ), + ), + ( + "term", + models.CharField( + choices=[ + ("MTH", "per Month (30d)"), + ("DAY", "per Day"), + ("HR", "per Hour"), + ("MIN", "per Minute"), + ], + default="MTH", + max_length=3, + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=4, + help_text="Price per unit in the specified currency, excl. VAT", + max_digits=10, + ), + ), + ( + "vcpus", + models.FloatField( + blank=True, help_text="Number of included vCPUs", null=True + ), + ), + ( + "ram", + models.FloatField( + blank=True, help_text="Amount of GiB RAM included", null=True + ), + ), + ( + "storage", + models.FloatField( + blank=True, help_text="Amount of GiB included", null=True + ), + ), + ("competitor_sla", models.CharField(blank=True, null=True)), + ("replicas", models.IntegerField(blank=True, null=True)), + ( + "cloud_provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="external_price", + to="services.cloudprovider", + ), + ), + ( + "compare_to", + models.ManyToManyField( + blank=True, + null=True, + related_name="external_prices", + to="services.computeplan", + ), + ), + ( + "service", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="external_price", + to="services.service", + ), + ), + ( + "vshn_appcat_price", + models.ForeignKey( + blank=True, + help_text="Specific VSHN AppCat price configuration to compare against", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="external_comparisons", + to="services.vshnappcatprice", + ), + ), + ], + options={ + "verbose_name": "External Price", + }, + ), + ] diff --git a/hub/services/migrations/0032_externalpriceplans_service_level.py b/hub/services/migrations/0032_externalpriceplans_service_level.py new file mode 100644 index 0000000..b230add --- /dev/null +++ b/hub/services/migrations/0032_externalpriceplans_service_level.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2 on 2025-05-27 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0031_externalpriceplans"), + ] + + operations = [ + migrations.AddField( + model_name="externalpriceplans", + name="service_level", + field=models.CharField( + blank=True, + choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")], + help_text="Service level equivalent for comparison", + max_length=2, + null=True, + ), + ), + ] diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py index 6816fe8..4079d16 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -382,3 +382,72 @@ class VSHNAppCatUnitRate(models.Model): def __str__(self): return f"{self.vshn_appcat_price_config.service.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) + source = models.URLField(blank=True, null=True) + date_retrieved = models.DateField(blank=True, null=True) + + ## Relations + cloud_provider = models.ForeignKey( + CloudProvider, on_delete=models.CASCADE, related_name="external_price" + ) + service = models.ForeignKey( + Service, on_delete=models.CASCADE, related_name="external_price" + ) + compare_to = models.ManyToManyField( + ComputePlan, related_name="external_prices", blank=True, null=True + ) + vshn_appcat_price = models.ForeignKey( + VSHNAppCatPrice, + on_delete=models.CASCADE, + related_name="external_comparisons", + blank=True, + null=True, + help_text="Specific VSHN AppCat price configuration to compare against", + ) + service_level = models.CharField( + max_length=2, + choices=VSHNAppCatPrice.ServiceLevel.choices, + blank=True, + null=True, + help_text="Service level equivalent for comparison", + ) + + ## Money + currency = models.CharField( + max_length=3, + default=Currency.CHF, + choices=Currency.choices, + ) + term = models.CharField( + max_length=3, + default=Term.MTH, + choices=Term.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=4, + help_text="Price per unit in the specified currency, excl. VAT", + ) + + ## Offering + vcpus = models.FloatField( + help_text="Number of included vCPUs", blank=True, null=True + ) + ram = models.FloatField( + help_text="Amount of GiB RAM included", blank=True, null=True + ) + storage = models.FloatField( + help_text="Amount of GiB included", blank=True, null=True + ) + competitor_sla = models.CharField(blank=True, null=True) + replicas = models.IntegerField(blank=True, null=True) + + class Meta: + verbose_name = "External Price" + + def __str__(self): + return f"{self.cloud_provider.name} - {self.service.name} - {self.plan_name}" diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 2fc25f6..e5b5ef7 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -71,6 +71,12 @@ Show discount details +
+ + +
@@ -153,6 +159,9 @@ Discount Model Discount Details {% endif %} + {% if show_price_comparison %} + External Comparisons + {% endif %} Final Price @@ -196,6 +205,39 @@ {% endif %} {% endif %} + {% if show_price_comparison %} + + {% if row.external_comparisons %} + + {% for comparison in row.external_comparisons %} +
+ {{ comparison.provider }}: {{ comparison.plan_name }}
+ {{ comparison.amount|floatformat:2 }} {{ row.currency }} + {% if comparison.difference > 0 %} + +{{ comparison.difference|floatformat:2 }} + {% elif comparison.difference < 0 %} + {{ comparison.difference|floatformat:2 }} + {% endif %} + {% if comparison.ratio %} +
Price ratio: {{ comparison.ratio|floatformat:2 }}x + {% endif %} + {% if comparison.description %} +
{{ comparison.description }} + {% endif %} + {% if comparison.vcpus or comparison.ram %} +
+ {% if comparison.vcpus %}{{ comparison.vcpus }} vCPU{% endif %} + {% if comparison.ram %}{{ comparison.ram }} GB RAM{% endif %} + + {% endif %} +
+ {% endfor %} +
+ {% else %} + No comparisons + {% endif %} + + {% endif %} {{ row.final_price|floatformat:2 }} {% endfor %} @@ -250,6 +292,12 @@ document.addEventListener('DOMContentLoaded', function() { filterForm.submit(); }); + // Add change event listener to price comparison checkbox + const priceComparisonCheckbox = document.getElementById('price_comparison'); + priceComparisonCheckbox.addEventListener('change', function() { + filterForm.submit(); + }); + // 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 %} diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 6eb8073..881bbd7 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -1,8 +1,10 @@ -from django.shortcuts import render import re + +from django.shortcuts import render from collections import defaultdict -from hub.services.models import ComputePlan, VSHNAppCatPrice +from hub.services.models import ComputePlan, VSHNAppCatPrice, ExternalPricePlans from django.contrib.admin.views.decorators import staff_member_required +from django.db import models def natural_sort_key(name): @@ -11,11 +13,32 @@ def natural_sort_key(name): return int(match.group(1)) if match else 0 +def get_external_price_comparisons(plan, appcat_price, currency, service_level): + """Get external price comparisons for a specific compute plan and service""" + try: + # Filter by service level if external price has one set + external_prices = ExternalPricePlans.objects.filter( + compare_to=plan, service=appcat_price.service, currency=currency + ).select_related("cloud_provider") + + # Filter by service level if the external price has it configured + if service_level: + external_prices = external_prices.filter( + models.Q(service_level=service_level) + | models.Q(service_level__isnull=True) + ) + + return external_prices + except Exception: + return [] + + @staff_member_required 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_price_comparison = request.GET.get("price_comparison", "").lower() == "true" filter_cloud_provider = request.GET.get("cloud_provider", "") filter_service = request.GET.get("service", "") filter_compute_plan_group = request.GET.get("compute_plan_group", "") @@ -179,6 +202,38 @@ def pricelist(request): service_level ] + # Get external price comparisons if enabled + external_comparisons = [] + if show_price_comparison: + external_prices = get_external_price_comparisons( + plan, appcat_price, currency, service_level + ) + + for ext_price in external_prices: + price_difference = float(ext_price.amount) - float( + final_price + ) + price_ratio = ( + float(ext_price.amount) / float(final_price) + if final_price > 0 + else None + ) + + external_comparisons.append( + { + "provider": ext_price.cloud_provider.name, + "plan_name": ext_price.plan_name, + "amount": ext_price.amount, + "difference": price_difference, + "ratio": price_ratio, + "description": ext_price.description, + "vcpus": ext_price.vcpus, + "ram": ext_price.ram, + "storage": ext_price.storage, + "replicas": ext_price.replicas, + } + ) + group_name = plan.group.name if plan.group else "No Group" # Add pricing data to the grouped structure @@ -230,6 +285,7 @@ def pricelist(request): appcat_price.discount_model and appcat_price.discount_model.active ), + "external_comparisons": external_comparisons, } ) @@ -278,6 +334,7 @@ def pricelist(request): context = { "pricing_data_by_group_and_service_level": final_context_data, "show_discount_details": show_discount_details, + "show_price_comparison": show_price_comparison, "filter_cloud_provider": filter_cloud_provider, "filter_service": filter_service, "filter_compute_plan_group": filter_compute_plan_group,