diff --git a/hub/services/models.py b/hub/services/models.py index 0905f4f..89831d3 100644 --- a/hub/services/models.py +++ b/hub/services/models.py @@ -483,7 +483,7 @@ class StoragePlan(models.Model): def get_price(self, currency_code: str): try: return self.prices.get(currency=currency_code).amount - except ComputePlanPrice.DoesNotExist: + except StoragePlanPrice.DoesNotExist: return None diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 12ed6c0..4113a1e 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -1,94 +1,81 @@ {% extends 'base.html' %} -{% block content %} -

Compute Plan Price Comparison

+{% block title %}Complete Price List{% endblock %} -{% for plan_data in plans_data %} -
-
-

{{ plan_data.plan.name }}

+{% block content %} +
+
+
+

Complete Price List - All Service Variants

+ + {% if pricing_data_by_service_level %} + {% for service_level, pricing_data in pricing_data_by_service_level.items %} +
+

{{ service_level }}

+
+ + + + + + + + + + + + + + + + + + + + + + + + {% for row in pricing_data %} + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
Cloud ProviderServiceCompute PlanvCPUsRAM (GB)CPU/Memory RatioTermCurrencyCompute Plan PriceVariable UnitUnitsReplica EnforceSLA BaseSLA Per UnitSLA PriceDiscount ModelFinal Price
{{ row.cloud_provider }}{{ row.service }}{{ row.compute_plan }}{{ row.vcpus }}{{ row.ram }}{{ row.cpu_mem_ratio }}{{ row.term }}{{ row.currency }}{{ row.compute_plan_price|floatformat:2 }}{{ row.variable_unit }}{{ row.units }}{{ row.replica_enforce }}{{ row.sla_base|floatformat:2 }}{{ row.sla_per_unit|floatformat:4 }}{{ row.sla_price|floatformat:2 }} + {% if row.has_discount %} + {{ row.discount_model }} + {% else %} + None + {% endif %} + {{ row.final_price|floatformat:2 }}
+
+

{{ pricing_data|length }} variants for {{ service_level }}

+
+ {% endfor %} + {% else %} +
+

No pricing data available

+

Please ensure you have active compute plans with prices and VSHNAppCat price configurations.

+
+ {% endif %} +
-
-

Plan Details

- - - - - - - - - - - - - - - - - - - - - - - - - -
Cloud Provider:{{ plan_data.plan.cloud_provider.name }}
vCPUs:{{ plan_data.plan.vcpus }}
RAM:{{ plan_data.plan.ram }} GB
CPU/Memory Ratio:{{ plan_data.plan.cpu_mem_ratio }}
Term:{{ plan_data.plan.get_term_display }}
Compute Plan Prices: - {% for price in plan_data.plan.prices.all %} - {{ price.amount }} {{ price.currency }}
- {% empty %} - No prices set - {% endfor %} -
- -

Calculated AppCat Prices

- {% if plan_data.calculated_prices %} - - - - - - - - - - - - - - - - {% for price in plan_data.calculated_prices %} - - - - - - - - - - - - {% endfor %} - -
ServiceVariable UnitService LevelUnitsPlan TermService TermCurrencyFinal PriceDiscount
{{ price.service }}{{ price.variable_unit }}{{ price.service_level }}{{ price.units }}{{ price.plan_term }}{{ price.service_term }}{{ price.currency }}{{ price.price }} - {% if price.discount_model %} - {{ price.discount_model.name }} - {% else %} - - - {% endif %} -
- {% else %} -

No AppCat prices calculated for this plan.

- {% endif %} -
-
-{% empty %} -
- No compute plans found. -
-{% endfor %} +
{% endblock %} \ No newline at end of file diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 227fb75..3ba2474 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -1,86 +1,174 @@ from django.shortcuts import render -from hub.services.models import ComputePlan, VSHNAppCatPrice, VSHNAppCatUnitRate +import re +from collections import defaultdict +from hub.services.models import ComputePlan, VSHNAppCatPrice + + +def natural_sort_key(name): + """Extract numeric part from compute plan name for natural sorting""" + match = re.search(r"compute-std-(\d+)", name) + return int(match.group(1)) if match else 0 def pricelist(request): - # Get all compute plans and app catalog prices + # Get all active compute plans with their pricing data compute_plans = ( - ComputePlan.objects.all() + ComputePlan.objects.filter(active=True) .select_related("cloud_provider") .prefetch_related("prices") + .order_by("cloud_provider__name") ) + + # Sort compute plans naturally by provider and plan number + compute_plans = sorted( + compute_plans, key=lambda x: (x.cloud_provider.name, natural_sort_key(x.name)) + ) + + # Get all VSHNAppCat pricing configurations appcat_prices = ( VSHNAppCatPrice.objects.all() .select_related("service", "discount_model") - .prefetch_related("base_fees", "unit_rates") + .prefetch_related("base_fees", "unit_rates", "discount_model__tiers") + .order_by("service__name") ) - plans_data = [] + pricing_data_by_service_level = defaultdict(list) + + # Track processed combinations to avoid duplicates + processed_combinations = set() for plan in compute_plans: - plan_data = {"plan": plan, "calculated_prices": []} + # Get all currencies available for this compute plan + plan_currencies = set(plan.prices.values_list("currency", flat=True)) - for price_config in appcat_prices: - # Get all service levels for this price config - service_levels = ( - VSHNAppCatUnitRate.objects.filter(vshn_appcat_price_config=price_config) - .values_list("service_level", flat=True) - .distinct() - ) - - # Determine number of units based on variable_unit - if price_config.variable_unit == VSHNAppCatPrice.VariableUnit.RAM: + for appcat_price in appcat_prices: + # Determine units based on variable unit type (RAM or CPU) + if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM: units = int(plan.ram) - elif price_config.variable_unit == VSHNAppCatPrice.VariableUnit.CPU: + elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU: units = int(plan.vcpus) else: - continue # Skip other unit type as we don't know yet how to handle them + continue - # Get all currencies used in base fees - currencies = price_config.base_fees.values_list( - "currency", flat=True + # Get currencies available for base fees and unit rates + base_fee_currencies = set( + appcat_price.base_fees.values_list("currency", flat=True) + ) + + # Get all distinct service levels for this pricing config + service_levels = appcat_price.unit_rates.values_list( + "service_level", flat=True ).distinct() - # Calculate prices for all combinations for service_level in service_levels: - for currency in currencies: - final_price = price_config.calculate_final_price( - currency_code=currency, - service_level=service_level, - number_of_units=units, + # Get currencies available for this specific service level + unit_rate_currencies = set( + appcat_price.unit_rates.filter( + service_level=service_level + ).values_list("currency", flat=True) + ) + + # Find currencies that exist in ALL three places: plan, base fee, and unit rate + matching_currencies = plan_currencies.intersection( + base_fee_currencies + ).intersection(unit_rate_currencies) + + # Skip if no common currencies found + if not matching_currencies: + continue + + # Process each matching currency + for currency in matching_currencies: + # Create unique combination key to prevent duplicates + combination_key = ( + plan.cloud_provider.name, + plan.name, + appcat_price.service.name, + service_level, + currency, ) - if final_price is not None: - service_level_display = dict( - VSHNAppCatPrice.ServiceLevel.choices - )[service_level] + # Skip if this combination was already processed + if combination_key in processed_combinations: + continue - # Include discount model information - discount_info = None - if ( - price_config.discount_model - and price_config.discount_model.active - ): - discount_info = { - "name": price_config.discount_model.name, - "description": price_config.discount_model.description, - } + processed_combinations.add(combination_key) - plan_data["calculated_prices"].append( - { - "service": price_config.service.name, - "variable_unit": price_config.get_variable_unit_display(), - "service_level": service_level_display, - "units": units, - "currency": currency, - "price": final_price, - "plan_term": plan.get_term_display(), - "service_term": price_config.get_term_display(), - "discount_model": discount_info, - } + # Get pricing data for this currency - skip if any is missing + compute_plan_price = plan.get_price(currency) + if compute_plan_price is None: + continue + + base_fee = appcat_price.get_base_fee(currency) + if base_fee is None: + continue + + unit_rate = appcat_price.get_unit_rate(currency, service_level) + if unit_rate is None: + continue + + # Calculate replica enforcement based on service level + if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED: + replica_enforce = appcat_price.ha_replica_min + else: + replica_enforce = 1 + + total_units = units * replica_enforce + + # Apply discount model if available and active + 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 + else: + # Standard pricing without discount + sla_price = base_fee + (total_units * unit_rate) - plans_data.append(plan_data) + # Calculate final price (compute + SLA) + final_price = compute_plan_price + sla_price - context = {"plans_data": plans_data} + # Get human-readable service level name + service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[ + service_level + ] + + # Add row to the appropriate service level group + pricing_data_by_service_level[service_level_display].append( + { + "cloud_provider": plan.cloud_provider.name, + "service": appcat_price.service.name, + "compute_plan": plan.name, + "vcpus": plan.vcpus, + "ram": plan.ram, + "cpu_mem_ratio": plan.cpu_mem_ratio, + "term": plan.get_term_display(), + "currency": currency, + "compute_plan_price": compute_plan_price, + "variable_unit": appcat_price.get_variable_unit_display(), + "units": units, + "replica_enforce": replica_enforce, + "service_level": service_level_display, + "sla_base": base_fee, + "sla_per_unit": unit_rate, + "sla_price": sla_price, + "final_price": final_price, + "discount_model": ( + appcat_price.discount_model.name + if appcat_price.discount_model + else None + ), + "has_discount": bool( + appcat_price.discount_model + and appcat_price.discount_model.active + ), + } + ) + + context = {"pricing_data_by_service_level": dict(pricing_data_by_service_level)} return render(request, "services/pricelist.html", context)