- {{ plan.plan_description.text|safe }}
+ {% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
+
+
+ {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
+
+
+
+
+ {% comment %} Display group description from first available plan {% endcomment %}
+ {% for service_level, pricing_data in service_levels.items %}
+ {% if pricing_data and forloop.first %}
+ {% with pricing_data.0 as representative_plan %}
+ {% if representative_plan.compute_plan_group_description %}
+
{{ representative_plan.compute_plan_group_description }}
+ {% endif %}
+ {% endwith %}
+ {% endif %}
+ {% if forloop.first %}
+ {% comment %} Only show description for first service level {% endcomment %}
+ {% endif %}
+ {% endfor %}
+
+ {% for service_level, pricing_data in service_levels.items %}
+
+
{{ service_level }}
+ {% if pricing_data %}
+
+
+
+
+ Compute Plan |
+ vCPUs |
+ RAM (GB) |
+ Currency |
+ Compute Price |
+ Service Price |
+ Total Price |
+
+
+
+ {% for row in pricing_data %}
+
+ {{ row.compute_plan }} |
+ {{ row.vcpus }} |
+ {{ row.ram }} |
+ {{ row.currency }} |
+ {{ row.compute_plan_price|floatformat:2 }} |
+ {{ row.sla_price|floatformat:2 }} |
+ {{ row.final_price|floatformat:2 }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No pricing data available for {{ service_level }}.
+ {% endif %}
+
+ {% endfor %}
+
- {% endif %}
- {% if plan.description %}
-
- {{ plan.description|safe }}
+
+ {% endfor %}
+
+ {% elif offering.plans.all %}
+
+
Available Plans
+
+ {% for plan in offering.plans.all %}
+
+
+
+
{{ plan.name }}
+ {% if plan.plan_description %}
+
+ {{ plan.plan_description.text|safe }}
+
+ {% endif %}
+ {% if plan.description %}
+
+ {{ plan.description|safe }}
+
+ {% endif %}
+ {% if plan.pricing %}
+
+ {{ plan.pricing|safe }}
+
+ {% endif %}
- {% endif %}
- {% if plan.pricing %}
-
- {{ plan.pricing|safe }}
-
- {% endif %}
-
- {% empty %}
-
-
-
No plans available yet.
-
I'm interested in this offering
- {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
+ {% empty %}
+
+
+
No plans available yet.
+
I'm interested in this offering
+ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
+
+ {% endfor %}
- {% endfor %}
-
{% else %}
-
-
I'm interested in this offering
- {% load contact_tags %}
- {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
-
+
+
+
I'm interested in this offering
+ {% load contact_tags %}
+ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
+
{% endif %}
{% if offering.plans.exists %}
diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html
new file mode 100644
index 0000000..ca865ae
--- /dev/null
+++ b/hub/services/templates/services/pricelist.html
@@ -0,0 +1,440 @@
+{% extends 'base.html' %}
+{% load static %}
+
+{% block title %}Complete Price List{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
Complete Price List - All Service Variants
+
+
+
+
+
+ {% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}
+
+ 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 %}
+
+ {% endif %}
+
+ {% if pricing_data_by_group_and_service_level %}
+ {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
+
+
{{ group_name }}
+
+ {# Display group description and node_label from first available plan #}
+ {% for service_level, pricing_data in service_levels.items %}
+ {% if pricing_data and forloop.first %}
+ {% with pricing_data.0 as representative_plan %}
+ {% if representative_plan.compute_plan_group_description %}
+
Description: {{ representative_plan.compute_plan_group_description }}
+ {% endif %}
+ {% if representative_plan.compute_plan_group_node_label %}
+
Node Label: {{ representative_plan.compute_plan_group_node_label }}
+ {% endif %}
+
+ {# Display storage pricing for this cloud provider #}
+ {% if representative_plan.storage_plans %}
+
+
Storage Options:
+
+
+
+
+ Storage Plan |
+ Term |
+ Unit |
+ Prices |
+
+
+
+ {% for storage_plan in representative_plan.storage_plans %}
+
+ {{ storage_plan.name }} |
+ {{ storage_plan.get_term_display }} |
+ {{ storage_plan.get_unit_display }} |
+
+ {% for price in storage_plan.prices.all %}
+ {{ price.amount }} {{ price.currency }}
+ {% empty %}
+ No prices
+ {% endfor %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+ {% endwith %}
+ {% endif %}
+ {% endfor %}
+
+ {% for service_level, pricing_data in service_levels.items %}
+
+
SLA: {{ service_level }}
+ {% if pricing_data %}
+ {# Display common values for this service level #}
+ {% with pricing_data.0 as first_row %}
+
+
+ Cloud Provider: {{ first_row.cloud_provider }}
+
+
+ Service: {{ first_row.service }}
+
+
+ CPU/Memory Ratio: {{ first_row.cpu_mem_ratio }}
+
+
+ Variable Unit: {{ first_row.variable_unit }}
+
+
+ Replica Enforce: {{ first_row.replica_enforce }}
+
+
+ {% endwith %}
+
+
+
+
+
+ Compute Plan |
+ Cloud Provider |
+ vCPUs |
+ RAM (GB) |
+ Term |
+ Currency |
+ Compute Plan Price |
+ Units |
+ SLA Base |
+ SLA Per Unit |
+ SLA Price |
+ {% if show_discount_details %}
+ Discount Model |
+ Discount Details |
+ {% endif %}
+ {% if show_price_comparison %}
+ External Comparisons |
+ {% endif %}
+
+
+
+
+ {% for row in pricing_data %}
+
+ {{ row.compute_plan }} |
+ {{ row.cloud_provider }} |
+ {{ row.vcpus }} |
+ {{ 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 }} |
+ {% if show_discount_details %}
+
+ {% if row.has_discount %}
+ {{ row.discount_model }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
+ {% if row.has_discount %}
+
+ Total Units: {{ row.total_units }}
+ Standard Price: {{ row.standard_sla_price|floatformat:2 }}
+ Discounted Price: {{ row.discounted_sla_price|floatformat:2 }}
+ Savings: {{ row.discount_savings|floatformat:2 }} ({{ row.discount_percentage|floatformat:1 }}%)
+ {% if row.discount_breakdown %}
+ Breakdown:
+ {% for tier in row.discount_breakdown %}
+ {{ tier.tier_range }} units: {{ tier.units }} × {{ tier.rate|floatformat:4 }} = {{ tier.subtotal|floatformat:2 }}
+ {% endfor %}
+ {% endif %}
+
+ {% else %}
+ No discount applied
+ {% endif %}
+ |
+ {% endif %}
+ {% if show_price_comparison %}
+
+ -
+ |
+ {% endif %}
+ {{ row.final_price|floatformat:2 }} |
+
+ {% if show_price_comparison and row.external_comparisons %}
+ {% for comparison in row.external_comparisons %}
+
+ {{ comparison.plan_name }} |
+ {{ comparison.provider }} |
+
+ {% if comparison.vcpus %}{{ comparison.vcpus }}{% else %}-{% endif %}
+ |
+
+ {% if comparison.ram %}{{ comparison.ram }}{% else %}-{% endif %}
+ |
+ {{ row.term }} |
+ {{ comparison.currency }} |
+ - |
+ - |
+ - |
+ - |
+ - |
+ {% if show_discount_details %}
+ - |
+ - |
+ {% endif %}
+
+
+ {% if comparison.source %}{{ comparison.provider }}{% else %}{{ comparison.provider }}{% endif %}
+ {% if comparison.description %}
+ {{ comparison.description }}
+ {% endif %}
+ {% if comparison.storage %}
+ Storage: {{ comparison.storage }} GB
+ {% endif %}
+ {% if comparison.replicas %}
+ Replicas: {{ comparison.replicas }}
+ {% endif %}
+ {% if comparison.ratio %}
+ Price ratio: {{ comparison.ratio|floatformat:2 }}x
+ {% endif %}
+
+ |
+
+ {{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
+ {% if comparison.difference > 0 %}
+ +{{ comparison.difference|floatformat:2 }}
+ {% elif comparison.difference < 0 %}
+ {{ comparison.difference|floatformat:2 }}
+ {% endif %}
+ |
+
+ {% endfor %}
+ {% endif %}
+ {% endfor %}
+
+
+
+
+ {# Price Chart #}
+
+
Price Chart - Units vs Final Price
+
+
+
+
+
+
{{ pricing_data|length }} variants for {{ service_level }} in {{ group_name }}
+ {% else %}
+
No pricing variants available for {{ service_level }} in {{ group_name }}.
+ {% endif %}
+
+ {% empty %}
+
No service levels with pricing data found for group: {{ group_name }}.
+ {% endfor %}
+
+ {% endfor %}
+ {% else %}
+
+
No pricing data available
+
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/hub/services/urls.py b/hub/services/urls.py
index dc4111e..552ead5 100644
--- a/hub/services/urls.py
+++ b/hub/services/urls.py
@@ -22,4 +22,9 @@ urlpatterns = [
path("contact/thank-you/", views.thank_you, name="thank_you"),
path("contact-form/", views.contact_form, name="contact_form"),
path("subscribe/", views.subscribe, name="subscribe"),
+ path(
+ "pricelist/",
+ views.pricelist,
+ name="pricelist",
+ ),
]
diff --git a/hub/services/views/__init__.py b/hub/services/views/__init__.py
index 6215d7a..0af9c30 100644
--- a/hub/services/views/__init__.py
+++ b/hub/services/views/__init__.py
@@ -5,3 +5,4 @@ from .providers import *
from .services import *
from .pages import *
from .subscriptions import *
+from .pricelist import *
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index 2414c51..c6e13a9 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -1,6 +1,21 @@
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
-from hub.services.models import ServiceOffering, CloudProvider, Category, Service
+from hub.services.models import (
+ ServiceOffering,
+ CloudProvider,
+ Category,
+ Service,
+ ComputePlan,
+ VSHNAppCatPrice,
+)
+import re
+from collections import defaultdict
+
+
+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 offering_list(request):
@@ -64,7 +79,174 @@ def offering_detail(request, provider_slug, service_slug):
service__slug=service_slug,
)
+ pricing_data_by_group_and_service_level = None
+
+ # Generate pricing data for VSHN offerings
+ if offering.msp == "VS":
+ pricing_data_by_group_and_service_level = generate_pricing_data(offering)
+
context = {
"offering": offering,
+ "pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level,
}
return render(request, "services/offering_detail.html", context)
+
+
+def generate_pricing_data(offering):
+ """Generate pricing data for a specific offering and cloud provider"""
+ # Fetch compute plans for this cloud provider
+ compute_plans = (
+ ComputePlan.objects.filter(active=True, cloud_provider=offering.cloud_provider)
+ .select_related("cloud_provider", "group")
+ .prefetch_related("prices")
+ .order_by("group__order", "group__name")
+ )
+
+ # Apply natural sorting for compute plan names
+ compute_plans = sorted(
+ compute_plans,
+ key=lambda x: (
+ x.group.order if x.group else 999,
+ x.group.name if x.group else "ZZZ",
+ natural_sort_key(x.name),
+ ),
+ )
+
+ # Fetch pricing for this specific service
+ try:
+ appcat_price = (
+ VSHNAppCatPrice.objects.select_related("service", "discount_model")
+ .prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
+ .get(service=offering.service)
+ )
+ except VSHNAppCatPrice.DoesNotExist:
+ return None
+
+ pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
+ processed_combinations = set()
+
+ # Generate pricing combinations for each compute plan
+ for plan in compute_plans:
+ plan_currencies = set(plan.prices.values_list("currency", flat=True))
+
+ # Determine units based on variable unit type
+ if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
+ units = int(plan.ram)
+ elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
+ units = int(plan.vcpus)
+ else:
+ continue
+
+ base_fee_currencies = set(
+ appcat_price.base_fees.values_list("currency", flat=True)
+ )
+
+ service_levels = appcat_price.unit_rates.values_list(
+ "service_level", flat=True
+ ).distinct()
+
+ for service_level in service_levels:
+ unit_rate_currencies = set(
+ appcat_price.unit_rates.filter(service_level=service_level).values_list(
+ "currency", flat=True
+ )
+ )
+
+ # Find currencies that exist across all pricing components
+ matching_currencies = plan_currencies.intersection(
+ base_fee_currencies
+ ).intersection(unit_rate_currencies)
+
+ if not matching_currencies:
+ continue
+
+ for currency in matching_currencies:
+ combination_key = (
+ plan.name,
+ service_level,
+ currency,
+ )
+
+ # Skip if combination already processed
+ if combination_key in processed_combinations:
+ continue
+
+ processed_combinations.add(combination_key)
+
+ # Get pricing components
+ compute_plan_price = plan.get_price(currency)
+ base_fee = appcat_price.get_base_fee(currency)
+ unit_rate = appcat_price.get_unit_rate(currency, service_level)
+
+ # Skip if any pricing component is missing
+ if any(
+ price is None for price in [compute_plan_price, base_fee, unit_rate]
+ ):
+ 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
+ standard_sla_price = base_fee + (total_units * unit_rate)
+
+ # Apply discount if available
+ 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:
+ sla_price = standard_sla_price
+
+ final_price = compute_plan_price + sla_price
+ service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
+ service_level
+ ]
+
+ group_name = plan.group.name if plan.group else "No Group"
+
+ # Add pricing data to the grouped structure
+ pricing_data_by_group_and_service_level[group_name][
+ service_level_display
+ ].append(
+ {
+ "compute_plan": plan.name,
+ "compute_plan_group": group_name,
+ "compute_plan_group_description": (
+ plan.group.description if plan.group else ""
+ ),
+ "vcpus": plan.vcpus,
+ "ram": plan.ram,
+ "currency": currency,
+ "compute_plan_price": compute_plan_price,
+ "sla_price": sla_price,
+ "final_price": final_price,
+ }
+ )
+
+ # Order groups correctly, placing "No Group" last
+ ordered_groups = {}
+ all_group_names = list(pricing_data_by_group_and_service_level.keys())
+
+ if "No Group" in all_group_names:
+ all_group_names.remove("No Group")
+ all_group_names.append("No Group")
+
+ for group_name_key in all_group_names:
+ ordered_groups[group_name_key] = pricing_data_by_group_and_service_level[
+ group_name_key
+ ]
+
+ # Convert defaultdicts to regular dicts for the template
+ final_context_data = {}
+ for group_key, service_levels_dict in ordered_groups.items():
+ final_context_data[group_key] = {
+ sl_key: list(plans_list)
+ for sl_key, plans_list in service_levels_dict.items()
+ }
+
+ return final_context_data
diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py
new file mode 100644
index 0000000..6d59c79
--- /dev/null
+++ b/hub/services/views/pricelist.py
@@ -0,0 +1,357 @@
+import re
+
+from django.shortcuts import render
+from collections import defaultdict
+from hub.services.models import (
+ ComputePlan,
+ VSHNAppCatPrice,
+ ExternalPricePlans,
+ StoragePlan,
+)
+from django.contrib.admin.views.decorators import staff_member_required
+from django.db import models
+
+
+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 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, ignore currency for comparison
+ external_prices = ExternalPricePlans.objects.filter(
+ compare_to=plan, service=appcat_price.service
+ ).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", "")
+ filter_service_level = request.GET.get("service_level", "")
+
+ # Fetch all active compute plans with related data
+ compute_plans = (
+ ComputePlan.objects.filter(active=True)
+ .select_related("cloud_provider", "group")
+ .prefetch_related("prices")
+ .order_by("group__order", "group__name", "cloud_provider__name")
+ )
+
+ # Apply compute plan filters
+ if filter_cloud_provider:
+ compute_plans = compute_plans.filter(cloud_provider__name=filter_cloud_provider)
+ if filter_compute_plan_group:
+ if filter_compute_plan_group == "No Group":
+ compute_plans = compute_plans.filter(group__isnull=True)
+ else:
+ compute_plans = compute_plans.filter(group__name=filter_compute_plan_group)
+
+ # Apply natural sorting for compute plan names
+ compute_plans = sorted(
+ compute_plans,
+ key=lambda x: (
+ x.group.order if x.group else 999, # No group plans at the end
+ x.group.name if x.group else "ZZZ",
+ x.cloud_provider.name,
+ natural_sort_key(x.name),
+ ),
+ )
+
+ # Fetch all appcat price configurations
+ appcat_prices = (
+ VSHNAppCatPrice.objects.all()
+ .select_related("service", "discount_model")
+ .prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
+ .order_by("service__name")
+ )
+
+ # Apply service filter
+ if filter_service:
+ appcat_prices = appcat_prices.filter(service__name=filter_service)
+
+ pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
+ processed_combinations = set()
+
+ # Generate pricing combinations for each compute plan and service
+ for plan in compute_plans:
+ plan_currencies = set(plan.prices.values_list("currency", flat=True))
+
+ for appcat_price in appcat_prices:
+ # Determine units based on variable unit type
+ if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
+ units = int(plan.ram)
+ elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
+ units = int(plan.vcpus)
+ else:
+ continue
+
+ base_fee_currencies = set(
+ appcat_price.base_fees.values_list("currency", flat=True)
+ )
+
+ service_levels = appcat_price.unit_rates.values_list(
+ "service_level", flat=True
+ ).distinct()
+
+ # Apply service level filter
+ if filter_service_level:
+ service_levels = [
+ sl
+ for sl in service_levels
+ if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl]
+ == filter_service_level
+ ]
+
+ for service_level in service_levels:
+ unit_rate_currencies = set(
+ appcat_price.unit_rates.filter(
+ service_level=service_level
+ ).values_list("currency", flat=True)
+ )
+
+ # Find currencies that exist across all pricing components
+ matching_currencies = plan_currencies.intersection(
+ base_fee_currencies
+ ).intersection(unit_rate_currencies)
+
+ if not matching_currencies:
+ continue
+
+ for currency in matching_currencies:
+ combination_key = (
+ plan.cloud_provider.name,
+ plan.name,
+ appcat_price.service.name,
+ service_level,
+ currency,
+ )
+
+ # Skip if combination already processed
+ if combination_key in processed_combinations:
+ continue
+
+ processed_combinations.add(combination_key)
+
+ # Get pricing components
+ compute_plan_price = plan.get_price(currency)
+ base_fee = appcat_price.get_base_fee(currency)
+ unit_rate = appcat_price.get_unit_rate(currency, service_level)
+
+ # Skip if any pricing component is missing
+ if any(
+ price is None
+ for price in [compute_plan_price, base_fee, unit_rate]
+ ):
+ 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
+ standard_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 = standard_sla_price - sla_price
+ discount_percentage = (
+ (discount_savings / standard_sla_price) * 100
+ if standard_sla_price > 0
+ else 0
+ )
+ discount_breakdown = (
+ appcat_price.discount_model.get_discount_breakdown(
+ unit_rate, total_units
+ )
+ )
+ else:
+ sla_price = standard_sla_price
+ discounted_price = total_units * unit_rate
+ discount_savings = 0
+ discount_percentage = 0
+
+ final_price = compute_plan_price + sla_price
+ service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
+ 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:
+ # Calculate price difference using external price currency
+ difference = ext_price.amount - final_price
+ ratio = (
+ ext_price.amount / final_price if final_price > 0 else 0
+ )
+
+ external_comparisons.append(
+ {
+ "plan_name": ext_price.plan_name,
+ "provider": ext_price.cloud_provider.name,
+ "description": ext_price.description,
+ "amount": ext_price.amount,
+ "currency": ext_price.currency, # Use external price currency
+ "vcpus": ext_price.vcpus,
+ "ram": ext_price.ram,
+ "storage": ext_price.storage,
+ "replicas": ext_price.replicas,
+ "difference": difference,
+ "ratio": ratio,
+ "source": ext_price.source,
+ "date_retrieved": ext_price.date_retrieved,
+ }
+ )
+
+ group_name = plan.group.name if plan.group else "No Group"
+
+ # Get storage plans for this cloud provider
+ storage_plans = StoragePlan.objects.filter(
+ cloud_provider=plan.cloud_provider
+ ).prefetch_related("prices")
+
+ # Add pricing data to the grouped structure
+ pricing_data_by_group_and_service_level[group_name][
+ service_level_display
+ ].append(
+ {
+ "cloud_provider": plan.cloud_provider.name,
+ "service": appcat_price.service.name,
+ "compute_plan": plan.name,
+ "compute_plan_group": group_name,
+ "compute_plan_group_description": (
+ plan.group.description if plan.group else ""
+ ),
+ "compute_plan_group_node_label": (
+ plan.group.node_label if plan.group else ""
+ ),
+ "storage_plans": storage_plans,
+ "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,
+ "total_units": total_units,
+ "service_level": service_level_display,
+ "sla_base": base_fee,
+ "sla_per_unit": unit_rate,
+ "sla_price": sla_price,
+ "standard_sla_price": standard_sla_price,
+ "discounted_sla_price": (
+ base_fee + discounted_price
+ if appcat_price.discount_model
+ and appcat_price.discount_model.active
+ else None
+ ),
+ "discount_savings": discount_savings,
+ "discount_percentage": discount_percentage,
+ "discount_breakdown": discount_breakdown,
+ "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
+ ),
+ "external_comparisons": external_comparisons,
+ }
+ )
+
+ # Order groups correctly, placing "No Group" last
+ ordered_groups_intermediate = {}
+ all_group_names = list(pricing_data_by_group_and_service_level.keys())
+
+ if "No Group" in all_group_names:
+ all_group_names.remove("No Group")
+ all_group_names.append("No Group")
+
+ for group_name_key in all_group_names:
+ ordered_groups_intermediate[group_name_key] = (
+ pricing_data_by_group_and_service_level[group_name_key]
+ )
+
+ # Convert defaultdicts to regular dicts for the template
+ final_context_data = {}
+ for group_key, service_levels_dict in ordered_groups_intermediate.items():
+ final_context_data[group_key] = {
+ sl_key: list(plans_list)
+ for sl_key, plans_list in service_levels_dict.items()
+ }
+
+ # Get filter options for dropdowns
+ all_cloud_providers = (
+ ComputePlan.objects.filter(active=True)
+ .values_list("cloud_provider__name", flat=True)
+ .distinct()
+ .order_by("cloud_provider__name")
+ )
+ all_services = (
+ VSHNAppCatPrice.objects.values_list("service__name", flat=True)
+ .distinct()
+ .order_by("service__name")
+ )
+ all_compute_plan_groups = list(
+ ComputePlan.objects.filter(active=True, group__isnull=False)
+ .values_list("group__name", flat=True)
+ .distinct()
+ .order_by("group__name")
+ )
+ all_compute_plan_groups.append("No Group") # Add option for plans without groups
+ all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
+
+ 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,
+ "filter_service_level": filter_service_level,
+ "all_cloud_providers": all_cloud_providers,
+ "all_services": all_services,
+ "all_compute_plan_groups": all_compute_plan_groups,
+ "all_service_levels": all_service_levels,
+ }
+ return render(request, "services/pricelist.html", context)
diff --git a/hub/settings.py b/hub/settings.py
index dad9ff9..7ffec5a 100644
--- a/hub/settings.py
+++ b/hub/settings.py
@@ -1,5 +1,6 @@
from pathlib import Path
from environs import Env
+from import_export.formats.base_formats import CSV
env = Env()
env.read_env()
@@ -40,6 +41,7 @@ SECRET_KEY = env.str("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DEBUG", default=False)
+INTERNAL_IPS = ["127.0.0.1"]
# Get all allowed hosts
original_hosts = env.list("ALLOWED_HOSTS", default=[])
@@ -79,6 +81,7 @@ INSTALLED_APPS = [
"schema_viewer",
"nested_admin",
"adminsortable2",
+ "import_export",
# local
"hub.services",
"hub.broker",
@@ -244,4 +247,13 @@ JAZZMIN_SETTINGS = {
],
"show_sidebar": True,
"navigation_expanded": True,
+ "hide_apps": ["broker"],
+ "order_with_respect_to": ["services", "auth"],
+ "changeform_format_overrides": {
+ "services.ProgressiveDiscountModel": "single",
+ "services.VSHNAppCatPrice": "single",
+ },
+ "related_modal_active": True,
}
+
+IMPORT_EXPORT_FORMATS = [CSV]
diff --git a/pyproject.toml b/pyproject.toml
index 84f0dd2..c1cd69f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [
"django>=5.2",
"django-admin-sortable2>=2.2.4",
+ "django-import-export>=4.3.7",
"django-jazzmin>=3.0.1",
"django-nested-admin>=4.1.1",
"django-prose-editor[sanitize]>=0.10.3",
diff --git a/uv.lock b/uv.lock
index 5184ef3..9f8cfae 100644
--- a/uv.lock
+++ b/uv.lock
@@ -11,6 +11,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
]
+[[package]]
+name = "diff-match-patch"
+version = "20241021"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/ad/32e1777dd57d8e85fa31e3a243af66c538245b8d64b7265bec9a61f2ca33/diff_match_patch-20241021.tar.gz", hash = "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073", size = 39962, upload-time = "2024-10-21T19:41:21.094Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/bb/2aa9b46a01197398b901e458974c20ed107935c26e44e37ad5b0e5511e44/diff_match_patch-20241021-py3-none-any.whl", hash = "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", size = 43252, upload-time = "2024-10-21T19:41:19.914Z" },
+]
+
[[package]]
name = "dj-database-url"
version = "2.3.0"
@@ -81,6 +90,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/90/01755e4a42558b763f7021e9369aa6aa94c2ede7313deed56cb7483834ab/django_cache_url-3.4.5-py2.py3-none-any.whl", hash = "sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c", size = 4760, upload-time = "2023-12-04T17:19:44.355Z" },
]
+[[package]]
+name = "django-import-export"
+version = "4.3.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "diff-match-patch" },
+ { name = "django" },
+ { name = "tablib" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/ae/52275e8a49a963468f9f807c24df17416fad0220169a8e5d7bfd4778f17f/django_import_export-4.3.7.tar.gz", hash = "sha256:bd3fe0aa15a2bce9de4be1a2f882e2c4539fdbfdfa16f2052c98dd7aec0f085c", size = 2222150, upload-time = "2025-02-25T12:38:47.076Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/ad/b1f4aef18fd4ab86ce68f3f0fff0e9c14e5fd2c866c47f0b1cfb9ccd85c8/django_import_export-4.3.7-py3-none-any.whl", hash = "sha256:5514d09636e84e823a42cd5e79292f70f20d6d2feed117a145f5b64a5b44f168", size = 142815, upload-time = "2025-02-25T12:38:43.654Z" },
+]
+
[[package]]
name = "django-jazzmin"
version = "3.0.1"
@@ -292,6 +315,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-admin-sortable2" },
+ { name = "django-import-export" },
{ name = "django-jazzmin" },
{ name = "django-nested-admin" },
{ name = "django-prose-editor", extra = ["sanitize"] },
@@ -312,6 +336,7 @@ requires-dist = [
{ name = "django", specifier = ">=5.2" },
{ name = "django-admin-sortable2", specifier = ">=2.2.4" },
{ name = "django-browser-reload", marker = "extra == 'dev'", specifier = "~=1.13" },
+ { name = "django-import-export", specifier = ">=4.3.7" },
{ name = "django-jazzmin", specifier = ">=3.0.1" },
{ name = "django-nested-admin", specifier = ">=4.1.1" },
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.10.3" },
@@ -332,6 +357,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
]
+[[package]]
+name = "tablib"
+version = "3.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/cc/fe19d9c2ac1088794a51fc72f49b7226f88a0361f924fb3d17a9ec80e657/tablib-3.8.0.tar.gz", hash = "sha256:94d8bcdc65a715a0024a6d5b701a5f31e45bd159269e62c73731de79f048db2b", size = 122247, upload-time = "2025-01-22T15:29:27.276Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/95/6542f54ebd90539b12ed6189cb54a6550a28407b1c503c2e55190c29a4c9/tablib-3.8.0-py3-none-any.whl", hash = "sha256:35bdb9d4ec7052232f8803908f9c7a9c3c65807188b70618fa7a7d8ccd560b4d", size = 47935, upload-time = "2025-01-22T15:28:44.499Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.12.2"