diff --git a/hub/services/admin/content.py b/hub/services/admin/content.py
index 2b655d1..9d601ae 100644
--- a/hub/services/admin/content.py
+++ b/hub/services/admin/content.py
@@ -13,7 +13,7 @@ class PlanInline(admin.StackedInline):
model = Plan
extra = 1
fieldsets = (
- (None, {"fields": ("name", "description", "plan_description")}),
+ (None, {"fields": ("name", "description", "pricing", "plan_description")}),
)
diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py
index f679c09..44f848b 100644
--- a/hub/services/admin/services.py
+++ b/hub/services/admin/services.py
@@ -5,7 +5,7 @@ Admin classes for services and service offerings
from django.contrib import admin
from django.utils.html import format_html
-from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan, PlanPrice
+from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan
class ExternalLinkInline(admin.TabularInline):
@@ -32,7 +32,7 @@ class PlanInline(admin.StackedInline):
model = Plan
extra = 1
fieldsets = (
- (None, {"fields": ("name", "description", "plan_description")}),
+ (None, {"fields": ("name", "description", "pricing", "plan_description")}),
)
@@ -57,18 +57,6 @@ class OfferingInline(admin.StackedInline):
show_change_link = True
-class PlanPriceInline(admin.TabularInline):
- model = PlanPrice
- extra = 1
-
-
-class PlanAdmin(admin.ModelAdmin):
- inlines = [PlanPriceInline]
- list_display = ("name", "offering")
- search_fields = ("name",)
- list_filter = ("offering",)
-
-
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
"""Admin configuration for Service model"""
@@ -118,7 +106,3 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
list_filter = ("service", "cloud_provider")
search_fields = ("service__name", "cloud_provider__name", "description")
inlines = [ExternalLinkOfferingInline, PlanInline]
-
-
-admin.site.register(Plan, PlanAdmin)
-admin.site.register(PlanPrice)
diff --git a/hub/services/migrations/0037_remove_plan_pricing_planprice.py b/hub/services/migrations/0037_remove_plan_pricing_planprice.py
deleted file mode 100644
index e5476f1..0000000
--- a/hub/services/migrations/0037_remove_plan_pricing_planprice.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# Generated by Django 5.2 on 2025-06-20 15:28
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("services", "0036_alter_vshnappcataddonbasefee_options_and_more"),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name="plan",
- name="pricing",
- ),
- migrations.CreateModel(
- name="PlanPrice",
- 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="Price in the specified currency, excl. VAT",
- max_digits=10,
- ),
- ),
- (
- "plan",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="plan_prices",
- to="services.plan",
- ),
- ),
- ],
- options={
- "ordering": ["currency"],
- "unique_together": {("plan", "currency")},
- },
- ),
- ]
diff --git a/hub/services/models/services.py b/hub/services/models/services.py
index aa2e1ba..5c155b8 100644
--- a/hub/services/models/services.py
+++ b/hub/services/models/services.py
@@ -4,14 +4,10 @@ from django.core.validators import URLValidator
from django.urls import reverse
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
-from typing import TYPE_CHECKING, Optional
-from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size, Currency
+from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size
from .providers import CloudProvider
-if TYPE_CHECKING:
- from .services import PlanPrice
-
class Service(models.Model):
name = models.CharField(max_length=200)
@@ -101,31 +97,10 @@ class ServiceOffering(models.Model):
)
-class PlanPrice(models.Model):
- plan = models.ForeignKey(
- 'Plan', on_delete=models.CASCADE, related_name='plan_prices'
- )
- currency = models.CharField(
- max_length=3,
- choices=Currency.choices,
- )
- amount = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- help_text="Price in the specified currency, excl. VAT",
- )
-
- class Meta:
- unique_together = ("plan", "currency")
- ordering = ["currency"]
-
- def __str__(self):
- return f"{self.plan.name} - {self.amount} {self.currency}"
-
-
class Plan(models.Model):
name = models.CharField(max_length=100)
description = ProseEditorField(blank=True, null=True)
+ pricing = ProseEditorField(blank=True, null=True)
plan_description = models.ForeignKey(
ReusableText,
on_delete=models.PROTECT,
@@ -147,13 +122,6 @@ class Plan(models.Model):
def __str__(self):
return f"{self.offering} - {self.name}"
- def get_price(self, currency_code: str) -> Optional[float]:
- from hub.services.models.services import PlanPrice
- price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
- if price_obj:
- return price_obj.amount
- return None
-
class ExternalLinkOffering(models.Model):
offering = models.ForeignKey(
diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js
index 90112b9..54b2af9 100644
--- a/hub/services/static/js/price-calculator.js
+++ b/hub/services/static/js/price-calculator.js
@@ -982,6 +982,850 @@ Please contact me with next steps for ordering this configuration.`;
if (this.instancesValue) this.instancesValue.textContent = '1';
}
}
+
+ // Setup order button click handler
+ setupOrderButton() {
+ if (this.orderButton) {
+ this.orderButton.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.handleOrderClick();
+ });
+ }
+ }
+
+ // Handle order button click
+ handleOrderClick() {
+ if (this.selectedConfiguration) {
+ // Pre-fill the contact form with configuration details
+ this.prefillContactForm();
+
+ // Scroll to the contact form
+ const contactForm = document.getElementById('order-form');
+ if (contactForm) {
+ contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }
+ }
+
+ // Pre-fill contact form with selected configuration
+ prefillContactForm() {
+ if (!this.selectedConfiguration) return;
+
+ const config = this.selectedConfiguration;
+
+ // Create configuration summary message
+ const configMessage = this.generateConfigurationMessage(config);
+
+ // Find and fill the message textarea in the contact form
+ const messageField = document.querySelector('#order-form textarea[name="message"]');
+ if (messageField) {
+ messageField.value = configMessage;
+ }
+
+ // Store configuration details in hidden field
+ const detailsField = document.querySelector('#order-form input[name="details"]');
+ if (detailsField) {
+ detailsField.value = JSON.stringify({
+ plan: config.planName,
+ vcpus: config.vcpus,
+ memory: config.memory,
+ storage: config.storage,
+ instances: config.instances,
+ serviceLevel: config.serviceLevel,
+ totalPrice: config.totalPrice,
+ addons: config.addons || []
+ });
+ }
+ }
+
+ // Generate human-readable configuration message
+ generateConfigurationMessage(config) {
+ 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}`;
+
+ // 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
+ async loadPricingData() {
+ try {
+ const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`);
+ if (!response.ok) {
+ throw new Error('Failed to load pricing data');
+ }
+
+ 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);
+ this.showError('Failed to load pricing information');
+ }
+ }
+
+ // Extract replica information and storage price from pricing data
+ extractStoragePrice() {
+ if (!this.pricingData) return;
+
+ // Find the first plan with storage pricing data and replica info
+ for (const groupName of Object.keys(this.pricingData)) {
+ const group = this.pricingData[groupName];
+ for (const serviceLevel of Object.keys(group)) {
+ const plans = group[serviceLevel];
+ if (plans.length > 0 && plans[0].storage_price !== undefined) {
+ this.storagePrice = parseFloat(plans[0].storage_price);
+ this.replicaInfo = {
+ ha_replica_min: plans[0].ha_replica_min || 1,
+ ha_replica_max: plans[0].ha_replica_max || 1
+ };
+ return;
+ }
+ }
+ }
+ }
+
+ // 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;
+
+ // Setup service levels based on available data
+ this.setupServiceLevels();
+
+ // Slider event listeners
+ this.cpuRange.addEventListener('input', () => {
+ this.cpuValue.textContent = this.cpuRange.value;
+ this.updatePricing();
+ });
+
+ this.memoryRange.addEventListener('input', () => {
+ this.memoryValue.textContent = this.memoryRange.value;
+ this.updatePricing();
+ });
+
+ this.storageRange.addEventListener('input', () => {
+ this.storageValue.textContent = this.storageRange.value;
+ this.updatePricing();
+ });
+
+ this.instancesRange.addEventListener('input', () => {
+ this.instancesValue.textContent = this.instancesRange.value;
+ this.updatePricing();
+ });
+
+ // Service level change listeners
+ this.serviceLevelInputs.forEach(input => {
+ input.addEventListener('change', () => {
+ this.updateInstancesSlider();
+ this.populatePlanDropdown();
+ this.updateAddons();
+ this.updatePricing();
+ });
+ });
+
+ // Plan selection listener
+ if (this.planSelect) {
+ this.planSelect.addEventListener('change', () => {
+ if (this.planSelect.value) {
+ const selectedPlan = JSON.parse(this.planSelect.value);
+
+ // Update sliders to match selected plan
+ this.cpuRange.value = selectedPlan.vcpus;
+ this.memoryRange.value = selectedPlan.ram;
+ 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 - reset sliders to default values
+ this.resetSlidersToDefaults();
+
+ // Auto-select mode - fade sliders back in
+ this.fadeInSliders(['cpu', 'memory']);
+
+ // Auto-select mode - update addons and recalculate
+ this.updateAddons();
+ this.updatePricing();
+ }
+ });
+ }
+
+ // Initialize instances slider
+ this.updateInstancesSlider();
+ }
+
+ // Update instances slider based on service level and replica info
+ updateInstancesSlider() {
+ if (!this.instancesRange || !this.replicaInfo) return;
+
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+
+ if (serviceLevel === 'Guaranteed Availability') {
+ // For GA, min is ha_replica_min
+ this.instancesRange.min = this.replicaInfo.ha_replica_min;
+ this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min);
+ } else {
+ // For BE, min is 1
+ this.instancesRange.min = 1;
+ this.instancesRange.value = Math.max(this.instancesRange.value, 1);
+ }
+
+ // Set max to ha_replica_max
+ this.instancesRange.max = this.replicaInfo.ha_replica_max;
+
+ // Update display value
+ this.instancesValue.textContent = this.instancesRange.value;
+
+ // Update the min/max display under the slider using direct IDs
+ const instancesMinDisplay = document.getElementById('instancesMinDisplay');
+ const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
+
+ if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min;
+ if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max;
+ }
+
+ // Setup service levels dynamically from pricing data
+ setupServiceLevels() {
+ if (!this.pricingData) return;
+
+ const serviceLevelGroup = document.getElementById('serviceLevelGroup');
+ if (!serviceLevelGroup) return;
+
+ // Get all available service levels from the pricing data
+ const availableServiceLevels = new Set();
+ Object.keys(this.pricingData).forEach(groupName => {
+ const group = this.pricingData[groupName];
+ Object.keys(group).forEach(serviceLevel => {
+ availableServiceLevels.add(serviceLevel);
+ });
+ });
+
+ // Clear existing service level buttons
+ serviceLevelGroup.innerHTML = '';
+
+ // Create buttons for each available service level
+ let isFirst = true;
+ availableServiceLevels.forEach(serviceLevel => {
+ const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`;
+
+ // Create radio input
+ const input = document.createElement('input');
+ input.type = 'radio';
+ input.className = 'btn-check';
+ input.name = 'serviceLevel';
+ input.id = inputId;
+ input.value = serviceLevel;
+ if (isFirst) {
+ input.checked = true;
+ isFirst = false;
+ }
+
+ // Create label
+ const label = document.createElement('label');
+ label.className = 'btn btn-outline-primary';
+ label.setAttribute('for', inputId);
+ label.textContent = serviceLevel;
+
+ // Add event listener
+ input.addEventListener('change', () => {
+ this.updateInstancesSlider();
+ this.populatePlanDropdown();
+ this.updateAddons();
+ this.updatePricing();
+ });
+
+ serviceLevelGroup.appendChild(input);
+ serviceLevelGroup.appendChild(label);
+ });
+
+ // Update the serviceLevelInputs reference
+ this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
+
+ // Calculate and set slider maximums based on available plans - this will call updateSliderDisplayValues()
+ this.updateSliderMaximums();
+ }
+
+ // Calculate maximum values for sliders based on available plans
+ updateSliderMaximums() {
+ if (!this.pricingData || !this.cpuRange || !this.memoryRange) return;
+
+ let maxCpus = 0;
+ let maxMemory = 0;
+
+ // Find maximum CPU and memory across all plans
+ Object.keys(this.pricingData).forEach(groupName => {
+ const group = this.pricingData[groupName];
+ Object.keys(group).forEach(serviceLevel => {
+ group[serviceLevel].forEach(plan => {
+ const planCpus = parseFloat(plan.vcpus);
+ const planMemory = parseFloat(plan.ram);
+
+ if (planCpus > maxCpus) maxCpus = planCpus;
+ if (planMemory > maxMemory) maxMemory = planMemory;
+ });
+ });
+ });
+
+ // Set slider maximums with some padding
+ if (maxCpus > 0) {
+ this.cpuRange.max = Math.ceil(maxCpus);
+ }
+
+ if (maxMemory > 0) {
+ this.memoryRange.max = Math.ceil(maxMemory);
+ }
+
+ // Update display values after changing min/max - moved to end and call explicitly
+ this.updateSliderDisplayValues();
+ }
+
+ // Populate plan dropdown based on selected service level
+ populatePlanDropdown() {
+ if (!this.planSelect || !this.pricingData) return;
+
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+ if (!serviceLevel) return;
+
+ // Clear existing options
+ this.planSelect.innerHTML = '';
+
+ // Collect all plans for selected service level
+ const availablePlans = [];
+ Object.keys(this.pricingData).forEach(groupName => {
+ const group = this.pricingData[groupName];
+ if (group[serviceLevel]) {
+ group[serviceLevel].forEach(plan => {
+ availablePlans.push({
+ ...plan,
+ groupName: groupName
+ });
+ });
+ }
+ });
+
+ // Sort plans by vCPU, then by RAM
+ availablePlans.sort((a, b) => {
+ if (parseInt(a.vcpus) !== parseInt(b.vcpus)) {
+ return parseInt(a.vcpus) - parseInt(b.vcpus);
+ }
+ return parseInt(a.ram) - parseInt(b.ram);
+ });
+
+ // Add plans to dropdown
+ availablePlans.forEach(plan => {
+ const option = document.createElement('option');
+ option.value = JSON.stringify(plan);
+ option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`;
+ this.planSelect.appendChild(option);
+ });
+ }
+
+ // Update addons based on current configuration
+ updateAddons() {
+ 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]) {
+ // 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');
+ 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', () => {
+ // Update addon prices and recalculate total
+ this.updateAddonPrices();
+ 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 for addon calculations
+ 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;
+
+ // 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);
+ }
+
+ // Store the calculated price for later use in total calculations
+ 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;
+
+ let bestMatch = null;
+ let bestScore = Infinity;
+
+ // Iterate through all groups and service levels
+ Object.keys(this.pricingData).forEach(groupName => {
+ const group = this.pricingData[groupName];
+
+ if (group[serviceLevel]) {
+ group[serviceLevel].forEach(plan => {
+ const planCpus = parseInt(plan.vcpus);
+ const planMemory = parseInt(plan.ram);
+
+ // Check if plan meets minimum requirements
+ if (planCpus >= cpus && planMemory >= memory) {
+ // Calculate efficiency score (lower is better)
+ const cpuOverhead = planCpus - cpus;
+ const memoryOverhead = planMemory - memory;
+ const score = cpuOverhead + memoryOverhead + plan.final_price * 0.1;
+
+ if (score < bestScore) {
+ bestScore = score;
+ bestMatch = {
+ ...plan,
+ groupName: groupName
+ };
+ }
+ }
+ });
+ }
+ });
+
+ return bestMatch;
+ }
+
+ // Update pricing with specific plan
+ updatePricingWithPlan(selectedPlan) {
+ 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');
+ }
+
+ // Main pricing update function
+ updatePricing() {
+ if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
+
+ // Update addon prices first to ensure they're current
+ this.updateAddonPrices();
+
+ // Reset plan selection if in auto-select mode
+ if (!this.planSelect?.value) {
+ const cpus = parseInt(this.cpuRange.value);
+ const memory = parseInt(this.memoryRange.value);
+ const storage = parseInt(this.storageRange.value);
+ const instances = parseInt(this.instancesRange.value);
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+
+ if (!serviceLevel) return;
+
+ // Find best matching plan
+ const matchedPlan = this.findBestMatchingPlan(cpus, memory, serviceLevel);
+
+ if (matchedPlan) {
+ this.showPlanDetails(matchedPlan, storage, instances);
+ this.updateStatusMessage('Perfect match found!', 'success');
+ } else {
+ this.showNoMatch();
+ }
+ } else {
+ // Plan is directly selected, update storage pricing
+ const selectedPlan = JSON.parse(this.planSelect.value);
+ 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');
+ }
+ }
+
+ // Show plan details in the UI
+ showPlanDetails(plan, storage, instances) {
+ if (!this.selectedPlanDetails) return;
+
+ // Show plan details section
+ this.planMatchStatus.style.display = 'block';
+ this.selectedPlanDetails.style.display = 'block';
+ if (this.noMatchFound) this.noMatchFound.style.display = 'none';
+
+ // Get current service level
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort';
+
+ // Update plan information
+ if (this.planGroup) this.planGroup.textContent = plan.groupName;
+ if (this.planName) this.planName.textContent = plan.compute_plan;
+ if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || '';
+ if (this.planCpus) this.planCpus.textContent = plan.vcpus;
+ if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB';
+ 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 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');
+ addonCheckboxes.forEach(checkbox => {
+ const addon = JSON.parse(checkbox.dataset.addon);
+ const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
+
+ if (addon.is_mandatory) {
+ // 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)
+ });
+ }
+ });
+ }
+
+ 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;
+
+ // Total price = managed service price (includes mandatory addons) + storage + optional addons
+ const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
+
+ // Update pricing display
+ if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.toFixed(2);
+ if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2);
+ 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,
+ planGroup: plan.groupName,
+ vcpus: plan.vcpus,
+ memory: plan.ram,
+ storage: storage,
+ instances: instances,
+ serviceLevel: serviceLevel,
+ 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 (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-1 ps-3';
+ addonRow.innerHTML = `
+ ${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 (these are added to total)
+ 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';
+ if (this.selectedPlanDetails) this.selectedPlanDetails.style.display = 'none';
+ if (this.noMatchFound) this.noMatchFound.style.display = 'block';
+ }
+
+ // Update status message
+ updateStatusMessage(message, type) {
+ if (!this.planMatchStatus) return;
+
+ const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle';
+ const textClass = type === 'success' ? 'text-success' : '';
+ const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
+
+ this.planMatchStatus.innerHTML = `${message}`;
+ this.planMatchStatus.className = `alert ${alertClass} mb-3`;
+ this.planMatchStatus.style.display = 'block';
+ }
+
+ // Show error message
+ showError(message) {
+ if (this.planMatchStatus) {
+ this.planMatchStatus.innerHTML = `${message}`;
+ this.planMatchStatus.className = 'alert alert-danger mb-3';
+ 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
@@ -990,45 +1834,4 @@ document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('cpuRange')) {
new PriceCalculator();
}
-});
-
-// New simple plan table population for multi-currency pricing
-
-document.addEventListener('DOMContentLoaded', function() {
- const currencySelect = document.getElementById('currencySelect');
- const plansTableBody = document.querySelector('#plansTable tbody');
- if (!currencySelect || !plansTableBody) return;
-
- let allPlans = [];
-
- function fetchPlans() {
- fetch(window.location.pathname + '?pricing=json')
- .then(response => response.json())
- .then(data => {
- allPlans = data.plans || [];
- updateTable();
- });
- }
-
- function updateTable() {
- const selectedCurrency = currencySelect.value;
- plansTableBody.innerHTML = '';
- const filtered = allPlans.filter(plan => plan.currency === selectedCurrency);
- if (filtered.length === 0) {
- plansTableBody.innerHTML = 'No plans available in this currency. |
';
- return;
- }
- filtered.forEach(plan => {
- const row = document.createElement('tr');
- row.innerHTML = `
- ${plan.plan_name} |
- ${plan.description || ''} |
- ${plan.amount.toFixed(2)} ${plan.currency} |
- `;
- plansTableBody.appendChild(row);
- });
- }
-
- currencySelect.addEventListener('change', updateTable);
- fetchPlans();
});
\ No newline at end of file
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html
index 8d97ada..000d13d 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -204,28 +204,199 @@
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
-
Available Plans & Pricing
-
-
-
+
+
Choose your Plan
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ 32
+
+
+
+
+
+
+
+
+ 1 GB
+ 128 GB
+
+
+
+
+
+
+
+
+ 10 GB
+ 1000 GB
+
+
+
+
+
+
+
+
+ 1
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Selecting a plan will override the slider configuration
+
Interested in a custom plan? Let us know via the contact form.
+
+
+
+
+
+
+
+
+
+
Your Plan
+
+
+
+
+ Finding best matching plan...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Managed Service (incl. Compute)
+ CHF 0.00
+
+
+ Storage - 20 GB
+ CHF 0.00
+
+
+
+
+
+
+
+
+
+ Total Monthly Price
+ CHF 0.00
+
+
+
+ Monthly pricing based on 30 days (720 hours). Billing is conducted per hour.
+
+
+
+
+
+
+
+ No matching plan found for your requirements. Please adjust your configuration.
+
+
+
+
+
-
-
-
-
- Plan Name |
- Description |
- Price |
-
-
-
-
-
-
+
+
+
+
+
+
{% elif offering.plans.all %}
diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html
index 5cdec7b..08cf877 100644
--- a/hub/services/templates/services/pricelist.html
+++ b/hub/services/templates/services/pricelist.html
@@ -477,13 +477,8 @@
{% for row in pricing_data %}
-
-
- {{ row.compute_plan }}
- {% if not row.is_active %}
- Inactive plan
- {% endif %}
- |
+
+ {{ row.compute_plan }} |
{{ row.cloud_provider }} |
{{ row.vcpus }} |
{{ row.ram }} |
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index fe01c7c..e135aec 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -242,19 +242,221 @@ def generate_exoscale_marketplace_yaml(offering):
def generate_pricing_data(offering):
- """Generate pricing data for a specific offering and its plans with multi-currency support"""
- # Fetch all plans for this offering
- plans = offering.plans.prefetch_related("plan_prices")
+ """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")
+ )
- pricing_data = []
- for plan in plans:
- for plan_price in plan.plan_prices.all():
- pricing_data.append({
- "plan_id": plan.id,
- "plan_name": plan.name,
- "description": plan.description,
- "currency": plan_price.currency,
- "amount": float(plan_price.amount),
- })
+ # 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),
+ ),
+ )
- return {"plans": pricing_data}
+ # Fetch storage plans for this cloud provider
+ storage_plans = (
+ StoragePlan.objects.filter(cloud_provider=offering.cloud_provider)
+ .prefetch_related("prices")
+ .order_by("name")
+ )
+
+ # Get default storage pricing (use first available storage plan)
+ storage_price_data = {}
+ if storage_plans.exists():
+ default_storage_plan = storage_plans.first()
+ for currency in ["CHF", "EUR", "USD"]: # Add currencies as needed
+ price = default_storage_plan.get_price(currency)
+ if price is not None:
+ storage_price_data[currency] = price
+
+ # 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, service_level)
+ 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
+
+ # 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
+ addon_price_per_unit = None
+
+ if addon.addon_type == "BF": # Base Fee
+ addon_price = addon.get_price(currency, service_level)
+ 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,
+ "price_per_unit": addon_price_per_unit, # Add per-unit price for frontend calculations
+ }
+
+ 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
+ ]
+
+ 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,
+ "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,
+ }
+ )
+
+ # 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
index 34d34b6..add5b22 100644
--- a/hub/services/views/pricelist.py
+++ b/hub/services/views/pricelist.py
@@ -2,16 +2,20 @@ import re
from django.shortcuts import render
from collections import defaultdict
-from hub.services.models.pricing import ComputePlan, StoragePlan, ExternalPricePlans, VSHNAppCatPrice
+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(obj):
- """Extract numeric parts for natural sorting (works for any plan name)"""
- name = obj.name if hasattr(obj, 'name') else str(obj)
- parts = re.split(r"(\d+)", name)
- return [int(part) if part.isdigit() else part for part in parts]
+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):
@@ -120,7 +124,7 @@ def get_internal_cloud_provider_comparisons(
@staff_member_required
def pricelist(request):
- """Generate comprehensive price list grouped by compute plan groups and service levels (optimized)"""
+ """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"
@@ -130,47 +134,45 @@ def pricelist(request):
filter_compute_plan_group = request.GET.get("compute_plan_group", "")
filter_service_level = request.GET.get("service_level", "")
- # Fetch all compute plans (active and inactive) with related data
- compute_plans_qs = ComputePlan.objects.all()
- if filter_cloud_provider:
- compute_plans_qs = compute_plans_qs.filter(cloud_provider__name=filter_cloud_provider)
- if filter_compute_plan_group:
- if filter_compute_plan_group == "No Group":
- compute_plans_qs = compute_plans_qs.filter(group__isnull=True)
- else:
- compute_plans_qs = compute_plans_qs.filter(group__name=filter_compute_plan_group)
- compute_plans = list(
- compute_plans_qs
+ # 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", "name")
+ .order_by("group__order", "group__name", "cloud_provider__name")
)
- # Restore natural sorting of compute plan names
+
+ # 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 p: (
- p.group.order if p.group else 999,
- p.group.name if p.group else "ZZZ",
- natural_sort_key(p),
+ 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 (prefetch addons)
- appcat_prices_qs = (
+ # Fetch all appcat price configurations
+ appcat_prices = (
VSHNAppCatPrice.objects.all()
.select_related("service", "discount_model")
- .prefetch_related("base_fees", "unit_rates", "discount_model__tiers", "addons")
+ .prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
.order_by("service__name")
)
- if filter_service:
- appcat_prices_qs = appcat_prices_qs.filter(service__name=filter_service)
- appcat_prices = list(appcat_prices_qs)
- # Prefetch all storage plans for all cloud providers and build a lookup
- all_storage_plans = StoragePlan.objects.all().prefetch_related("prices")
- storage_plans_by_provider = defaultdict(list)
- for sp in all_storage_plans:
- storage_plans_by_provider[sp.cloud_provider_id].append(sp)
+ # 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()
@@ -178,6 +180,7 @@ def pricelist(request):
# 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:
@@ -186,22 +189,39 @@ def pricelist(request):
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()
+
+ 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
+ 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)
+ 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)
+ 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,
@@ -210,35 +230,63 @@ def pricelist(request):
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, service_level)
unit_rate = appcat_price.get_unit_rate(currency, service_level)
- if any(price is None for price in [compute_plan_price, base_fee, unit_rate]):
+
+ # 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)
+ 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)
+ 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
+
# Calculate final price using the model method to ensure consistency
price_calculation = appcat_price.calculate_final_price(
currency_code=currency,
@@ -246,22 +294,60 @@ def pricelist(request):
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)
- # Extract addon information from the calculation (use prefetched addons)
+
+ # 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 = []
- all_addons = [a for a in appcat_price.addons.all() if a.active]
+
+ # 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
addon_price = addon.get_price(currency, service_level)
elif addon.addon_type == "UR": # Unit Rate
- addon_price_per_unit = addon.get_price(currency, service_level)
+ 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,
@@ -270,103 +356,165 @@ def pricelist(request):
"addon_type": addon.get_addon_type_display(),
"price": addon_price,
}
+
if addon.mandatory:
mandatory_addons.append(addon_info)
else:
optional_addons.append(addon_info)
+
+ # 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).get(service_level, service_level)
- # Get external/internal price comparisons if enabled (unchanged, but could be optimized further)
+ service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
+ service_level
+ ]
+
+ # Get external price comparisons if enabled
external_comparisons = []
internal_comparisons = []
if show_price_comparison:
- external_prices = get_external_price_comparisons(plan, appcat_price, currency, service_level)
+ # Get external price comparisons
+ 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,
- "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,
- "is_internal": False,
- })
- internal_price_comparisons = get_internal_cloud_provider_comparisons(plan, appcat_price, currency, service_level)
+ 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,
+ "is_internal": False,
+ }
+ )
+
+ # Get internal cloud provider comparisons
+ internal_price_comparisons = (
+ get_internal_cloud_provider_comparisons(
+ plan, appcat_price, currency, service_level
+ )
+ )
for int_price in internal_price_comparisons:
+ # Calculate price difference
difference = int_price["final_price"] - final_price
- ratio = int_price["final_price"] / final_price if final_price > 0 else 0
- internal_comparisons.append({
- "plan_name": int_price["plan_name"],
- "provider": int_price["provider"],
- "description": f"Same specs with {int_price['provider']}",
- "amount": int_price["final_price"],
- "currency": int_price["currency"],
- "vcpus": int_price["vcpus"],
- "ram": int_price["ram"],
- "group_name": int_price["group_name"],
- "compute_plan_price": int_price["compute_plan_price"],
- "service_price": int_price["service_price"],
- "difference": difference,
- "ratio": ratio,
- "is_internal": True,
- })
+ ratio = (
+ int_price["final_price"] / final_price
+ if final_price > 0
+ else 0
+ )
+
+ internal_comparisons.append(
+ {
+ "plan_name": int_price["plan_name"],
+ "provider": int_price["provider"],
+ "description": f"Same specs with {int_price['provider']}",
+ "amount": int_price["final_price"],
+ "currency": int_price["currency"],
+ "vcpus": int_price["vcpus"],
+ "ram": int_price["ram"],
+ "group_name": int_price["group_name"],
+ "compute_plan_price": int_price[
+ "compute_plan_price"
+ ],
+ "service_price": int_price["service_price"],
+ "difference": difference,
+ "ratio": ratio,
+ "is_internal": True,
+ }
+ )
+
group_name = plan.group.name if plan.group else "No Group"
- # Use prefetched storage plans
- storage_plans = storage_plans_by_provider.get(plan.cloud_provider_id, [])
- 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": service_price_with_addons,
- "standard_sla_price": base_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,
- "internal_comparisons": internal_comparisons,
- "mandatory_addons": mandatory_addons,
- "optional_addons": optional_addons,
- "is_active": plan.active,
- })
+
+ # 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": service_price_with_addons,
+ "standard_sla_price": base_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,
+ "internal_comparisons": internal_comparisons,
+ "mandatory_addons": mandatory_addons,
+ "optional_addons": optional_addons,
+ }
+ )
+
# 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]
+ 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():
@@ -374,9 +522,10 @@ def pricelist(request):
sl_key: list(plans_list)
for sl_key, plans_list in service_levels_dict.items()
}
- # Get filter options for dropdowns (include all providers/groups from all plans, not just active)
+
+ # Get filter options for dropdowns
all_cloud_providers = (
- ComputePlan.objects.all()
+ ComputePlan.objects.filter(active=True)
.values_list("cloud_provider__name", flat=True)
.distinct()
.order_by("cloud_provider__name")
@@ -387,18 +536,20 @@ def pricelist(request):
.order_by("service__name")
)
all_compute_plan_groups = list(
- ComputePlan.objects.filter(group__isnull=False)
+ 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]
+
# If no filter is specified, select the first available provider/service by default
if not filter_cloud_provider and all_cloud_providers:
filter_cloud_provider = all_cloud_providers[0]
if not filter_service and all_services:
filter_service = all_services[0]
+
context = {
"pricing_data_by_group_and_service_level": final_context_data,
"show_discount_details": show_discount_details,