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... +
+ + + + + + +
+
+
+
-
- - - - - - - - - - - -
Plan NameDescriptionPrice
+ + + + + +
+

Order Your Configuration

+
+
+ {% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %} +
+
{% 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,