Compare commits
No commits in common. "61cabd1b1e49e75914f8360856a7b645b0bfa91c" and "fbca67ef66b394c388190f469ee4492fda4bf96a" have entirely different histories.
61cabd1b1e
...
fbca67ef66
9 changed files with 1538 additions and 327 deletions
|
@ -13,7 +13,7 @@ class PlanInline(admin.StackedInline):
|
||||||
model = Plan
|
model = Plan
|
||||||
extra = 1
|
extra = 1
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "description", "plan_description")}),
|
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ Admin classes for services and service offerings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
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):
|
class ExternalLinkInline(admin.TabularInline):
|
||||||
|
@ -32,7 +32,7 @@ class PlanInline(admin.StackedInline):
|
||||||
model = Plan
|
model = Plan
|
||||||
extra = 1
|
extra = 1
|
||||||
fieldsets = (
|
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
|
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)
|
@admin.register(Service)
|
||||||
class ServiceAdmin(admin.ModelAdmin):
|
class ServiceAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for Service model"""
|
"""Admin configuration for Service model"""
|
||||||
|
@ -118,7 +106,3 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
list_filter = ("service", "cloud_provider")
|
list_filter = ("service", "cloud_provider")
|
||||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||||
inlines = [ExternalLinkOfferingInline, PlanInline]
|
inlines = [ExternalLinkOfferingInline, PlanInline]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Plan, PlanAdmin)
|
|
||||||
admin.site.register(PlanPrice)
|
|
||||||
|
|
|
@ -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")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -4,14 +4,10 @@ from django.core.validators import URLValidator
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django_prose_editor.fields import ProseEditorField
|
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
|
from .providers import CloudProvider
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .services import PlanPrice
|
|
||||||
|
|
||||||
|
|
||||||
class Service(models.Model):
|
class Service(models.Model):
|
||||||
name = models.CharField(max_length=200)
|
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):
|
class Plan(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = ProseEditorField(blank=True, null=True)
|
description = ProseEditorField(blank=True, null=True)
|
||||||
|
pricing = ProseEditorField(blank=True, null=True)
|
||||||
plan_description = models.ForeignKey(
|
plan_description = models.ForeignKey(
|
||||||
ReusableText,
|
ReusableText,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -147,13 +122,6 @@ class Plan(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.offering} - {self.name}"
|
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):
|
class ExternalLinkOffering(models.Model):
|
||||||
offering = models.ForeignKey(
|
offering = models.ForeignKey(
|
||||||
|
|
|
@ -982,6 +982,850 @@ Please contact me with next steps for ordering this configuration.`;
|
||||||
if (this.instancesValue) this.instancesValue.textContent = '1';
|
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 = '<option value="">Auto-select best matching plan</option>';
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input addon-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
id="addon-${addon.id}"
|
||||||
|
value="${addon.id}"
|
||||||
|
data-addon='${JSON.stringify(addon)}'
|
||||||
|
${addon.is_mandatory ? 'checked disabled' : ''}>
|
||||||
|
<label class="form-check-label" for="addon-${addon.id}">
|
||||||
|
<strong>${addon.name}</strong>
|
||||||
|
<div class="text-muted small">${addon.commercial_description || ''}</div>
|
||||||
|
<div class="text-primary addon-price-display">
|
||||||
|
${addon.is_mandatory ? 'Required - ' : ''}CHF <span class="addon-price-value">0.00</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<em>Required add-ons (included in managed service price):</em>';
|
||||||
|
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 = `
|
||||||
|
<span class="text-muted small">${addon.name}</span>
|
||||||
|
<span class="text-muted small">CHF ${addon.price}</span>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<span>Add-on: ${addon.name}</span>
|
||||||
|
<span class="fw-bold">CHF ${addon.price}</span>
|
||||||
|
`;
|
||||||
|
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 = `<i class="bi ${iconClass} me-2 ${textClass}"></i><span class="${textClass}">${message}</span>`;
|
||||||
|
this.planMatchStatus.className = `alert ${alertClass} mb-3`;
|
||||||
|
this.planMatchStatus.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
showError(message) {
|
||||||
|
if (this.planMatchStatus) {
|
||||||
|
this.planMatchStatus.innerHTML = `<i class="bi bi-exclamation-triangle me-2 text-danger"></i><span class="text-danger">${message}</span>`;
|
||||||
|
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
|
// Initialize calculator when DOM is loaded
|
||||||
|
@ -991,44 +1835,3 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
new PriceCalculator();
|
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 = '<tr><td colspan="3" class="text-center">No plans available in this currency.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
filtered.forEach(plan => {
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
row.innerHTML = `
|
|
||||||
<td>${plan.plan_name}</td>
|
|
||||||
<td>${plan.description || ''}</td>
|
|
||||||
<td><strong>${plan.amount.toFixed(2)} ${plan.currency}</strong></td>
|
|
||||||
`;
|
|
||||||
plansTableBody.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currencySelect.addEventListener('change', updateTable);
|
|
||||||
fetchPlans();
|
|
||||||
});
|
|
|
@ -204,28 +204,199 @@
|
||||||
<!-- Price Calculator -->
|
<!-- Price Calculator -->
|
||||||
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
||||||
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
|
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans & Pricing</h3>
|
<!-- Interactive Price Calculator -->
|
||||||
<div class="mb-3">
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
||||||
<label for="currencySelect" class="form-label">Select Currency:</label>
|
<div class="bg-light rounded-4 p-4 mb-4">
|
||||||
<select id="currencySelect" class="form-select w-auto d-inline-block">
|
<div class="row">
|
||||||
<option value="CHF">CHF</option>
|
<!-- Calculator Controls -->
|
||||||
<option value="EUR">EUR</option>
|
<div class="col-12 col-lg-6">
|
||||||
<option value="USD">USD</option>
|
<div class="card h-100">
|
||||||
</select>
|
<div class="card-body">
|
||||||
|
<!-- CPU Slider -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="cpuRange" class="form-label d-flex justify-content-between">
|
||||||
|
<span>vCPUs</span>
|
||||||
|
<span class="fw-bold" id="cpuValue">2</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" class="form-range" id="cpuRange" min="1" max="32" value="2" step="1">
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span id="cpuMinDisplay">1</span>
|
||||||
|
<span id="cpuMaxDisplay">32</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Slider -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="memoryRange" class="form-label d-flex justify-content-between">
|
||||||
|
<span>Memory (GB)</span>
|
||||||
|
<span class="fw-bold" id="memoryValue">4</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" class="form-range" id="memoryRange" min="1" max="128" value="4" step="1">
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span id="memoryMinDisplay">1 GB</span>
|
||||||
|
<span id="memoryMaxDisplay">128 GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Slider -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="storageRange" class="form-label d-flex justify-content-between">
|
||||||
|
<span>Storage (GB)</span>
|
||||||
|
<span class="fw-bold" id="storageValue">20</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" class="form-range" id="storageRange" min="10" max="1000" value="20" step="10">
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span id="storageMinDisplay">10 GB</span>
|
||||||
|
<span id="storageMaxDisplay">1000 GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instances Slider -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="instancesRange" class="form-label d-flex justify-content-between">
|
||||||
|
<span>Instances</span>
|
||||||
|
<span class="fw-bold" id="instancesValue">1</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" class="form-range" id="instancesRange" min="1" max="1" value="1" step="1">
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span id="instancesMinDisplay">1</span>
|
||||||
|
<span id="instancesMaxDisplay">1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Level Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Service Level</label>
|
||||||
|
<div class="btn-group w-100" role="group" id="serviceLevelGroup">
|
||||||
|
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelBestEffort" value="Best Effort" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="serviceLevelBestEffort">Best Effort</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelGuaranteed" value="Guaranteed Availability">
|
||||||
|
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addons Section - Hidden by default, shown by JS if addons exist -->
|
||||||
|
<div class="mb-4" id="addonsSection" style="display: none;">
|
||||||
|
<label class="form-label">Add-ons (Optional)</label>
|
||||||
|
<div id="addonsContainer">
|
||||||
|
<!-- Add-ons will be dynamically populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Direct Plan Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="planSelect" class="form-label">Or choose a specific plan</label>
|
||||||
|
<select class="form-select" id="planSelect">
|
||||||
|
<option value="">Auto-select best matching plan</option>
|
||||||
|
</select>
|
||||||
|
<p><small class="form-text text-muted">Selecting a plan will override the slider configuration</small></p>
|
||||||
|
<p><small class="form-text text-muted"><i class="bi bi-info-circle me-1"></i> Interested in a custom plan? Let us know via the <a href="#form">contact form</a>.</small></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Panel -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card h-100 border-primary">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-primary mb-4">Your Plan</h5>
|
||||||
|
|
||||||
|
<!-- Plan Match Status -->
|
||||||
|
<div id="planMatchStatus" class="alert alert-info mb-3">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<span>Finding best matching plan...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Plan Details -->
|
||||||
|
<div id="selectedPlanDetails" style="display: none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge me-2" id="planGroup"></span>
|
||||||
|
<strong id="planName"></strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" id="planDescription"></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-3">
|
||||||
|
<small class="text-muted">vCPUs</small>
|
||||||
|
<div class="fw-bold" id="planCpus"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<small class="text-muted">Memory</small>
|
||||||
|
<div class="fw-bold" id="planMemory"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<small class="text-muted">Instances</small>
|
||||||
|
<div class="fw-bold" id="planInstances"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<small class="text-muted">Service Level</small>
|
||||||
|
<div class="fw-bold">
|
||||||
|
<a href="https://products.vshn.ch/service_levels.html" target="_blank" class="text-decoration-none" id="planServiceLevel"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing Breakdown -->
|
||||||
|
<div class="border-top pt-3">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>Managed Service (incl. Compute)</span>
|
||||||
|
<span class="fw-bold">CHF <span id="managedServicePrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>Storage - <span id="storageAmount">20</span> GB</span>
|
||||||
|
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addons Pricing -->
|
||||||
|
<div id="addonPricingContainer">
|
||||||
|
<!-- Addon pricing will be dynamically added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="fs-5 fw-bold">Total Monthly Price</span>
|
||||||
|
<span class="fs-4 fw-bold text-primary">CHF <span id="totalPrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted mt-2 d-block">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Monthly pricing based on 30 days (720 hours). Billing is conducted per hour.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Match Found -->
|
||||||
|
<div id="noMatchFound" style="display: none;" class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
No matching plan found for your requirements. Please adjust your configuration.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Button -->
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="#order-form" class="btn btn-primary btn-lg px-5 py-3 fw-semibold">
|
||||||
|
<i class="bi bi-cart me-2"></i>Order This Configuration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Form Section -->
|
||||||
|
<div id="order-form" class="pt-40" style="scroll-margin-top: 30px;">
|
||||||
|
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Configuration</h4>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-bordered" id="plansTable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Plan Name</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Price</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- Plan rows will be populated by JS -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{% elif offering.plans.all %}
|
{% elif offering.plans.all %}
|
||||||
<!-- Traditional Plans -->
|
<!-- Traditional Plans -->
|
||||||
|
|
|
@ -477,13 +477,8 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in pricing_data %}
|
{% for row in pricing_data %}
|
||||||
<tr class="servala-row {% if not row.is_active %}text-muted opacity-50{% endif %} {% if show_price_comparison and row.external_comparisons or row.internal_comparisons %}has-comparisons{% endif %}">
|
<tr class="servala-row {% if show_price_comparison and row.external_comparisons or row.internal_comparisons %}has-comparisons{% endif %}">
|
||||||
<td>
|
<td>{{ row.compute_plan }}</td>
|
||||||
{{ row.compute_plan }}
|
|
||||||
{% if not row.is_active %}
|
|
||||||
<span class="badge bg-secondary ms-1" title="This compute plan is not active and not available for new public offerings.">Inactive plan</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ row.cloud_provider }}</td>
|
<td>{{ row.cloud_provider }}</td>
|
||||||
<td>{{ row.vcpus }}</td>
|
<td>{{ row.vcpus }}</td>
|
||||||
<td>{{ row.ram }}</td>
|
<td>{{ row.ram }}</td>
|
||||||
|
|
|
@ -242,19 +242,221 @@ def generate_exoscale_marketplace_yaml(offering):
|
||||||
|
|
||||||
|
|
||||||
def generate_pricing_data(offering):
|
def generate_pricing_data(offering):
|
||||||
"""Generate pricing data for a specific offering and its plans with multi-currency support"""
|
"""Generate pricing data for a specific offering and cloud provider"""
|
||||||
# Fetch all plans for this offering
|
# Fetch compute plans for this cloud provider
|
||||||
plans = offering.plans.prefetch_related("plan_prices")
|
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 = []
|
# Apply natural sorting for compute plan names
|
||||||
for plan in plans:
|
compute_plans = sorted(
|
||||||
for plan_price in plan.plan_prices.all():
|
compute_plans,
|
||||||
pricing_data.append({
|
key=lambda x: (
|
||||||
"plan_id": plan.id,
|
x.group.order if x.group else 999,
|
||||||
"plan_name": plan.name,
|
x.group.name if x.group else "ZZZ",
|
||||||
"description": plan.description,
|
natural_sort_key(x.name),
|
||||||
"currency": plan_price.currency,
|
),
|
||||||
"amount": float(plan_price.amount),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -2,16 +2,20 @@ import re
|
||||||
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from collections import defaultdict
|
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.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
def natural_sort_key(obj):
|
def natural_sort_key(name):
|
||||||
"""Extract numeric parts for natural sorting (works for any plan name)"""
|
"""Extract numeric part from compute plan name for natural sorting"""
|
||||||
name = obj.name if hasattr(obj, 'name') else str(obj)
|
match = re.search(r"compute-std-(\d+)", name)
|
||||||
parts = re.split(r"(\d+)", name)
|
return int(match.group(1)) if match else 0
|
||||||
return [int(part) if part.isdigit() else part for part in parts]
|
|
||||||
|
|
||||||
|
|
||||||
def get_external_price_comparisons(plan, appcat_price, currency, service_level):
|
def get_external_price_comparisons(plan, appcat_price, currency, service_level):
|
||||||
|
@ -120,7 +124,7 @@ def get_internal_cloud_provider_comparisons(
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def pricelist(request):
|
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
|
# Get filter parameters from request
|
||||||
show_discount_details = request.GET.get("discount_details", "").lower() == "true"
|
show_discount_details = request.GET.get("discount_details", "").lower() == "true"
|
||||||
show_addon_details = request.GET.get("addon_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_compute_plan_group = request.GET.get("compute_plan_group", "")
|
||||||
filter_service_level = request.GET.get("service_level", "")
|
filter_service_level = request.GET.get("service_level", "")
|
||||||
|
|
||||||
# Fetch all compute plans (active and inactive) with related data
|
# Fetch all active compute plans with related data
|
||||||
compute_plans_qs = ComputePlan.objects.all()
|
compute_plans = (
|
||||||
if filter_cloud_provider:
|
ComputePlan.objects.filter(active=True)
|
||||||
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
|
|
||||||
.select_related("cloud_provider", "group")
|
.select_related("cloud_provider", "group")
|
||||||
.prefetch_related("prices")
|
.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 = sorted(
|
||||||
compute_plans,
|
compute_plans,
|
||||||
key=lambda p: (
|
key=lambda x: (
|
||||||
p.group.order if p.group else 999,
|
x.group.order if x.group else 999, # No group plans at the end
|
||||||
p.group.name if p.group else "ZZZ",
|
x.group.name if x.group else "ZZZ",
|
||||||
natural_sort_key(p),
|
x.cloud_provider.name,
|
||||||
|
natural_sort_key(x.name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch all appcat price configurations (prefetch addons)
|
# Fetch all appcat price configurations
|
||||||
appcat_prices_qs = (
|
appcat_prices = (
|
||||||
VSHNAppCatPrice.objects.all()
|
VSHNAppCatPrice.objects.all()
|
||||||
.select_related("service", "discount_model")
|
.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")
|
.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
|
# Apply service filter
|
||||||
all_storage_plans = StoragePlan.objects.all().prefetch_related("prices")
|
if filter_service:
|
||||||
storage_plans_by_provider = defaultdict(list)
|
appcat_prices = appcat_prices.filter(service__name=filter_service)
|
||||||
for sp in all_storage_plans:
|
|
||||||
storage_plans_by_provider[sp.cloud_provider_id].append(sp)
|
|
||||||
|
|
||||||
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
||||||
processed_combinations = set()
|
processed_combinations = set()
|
||||||
|
@ -178,6 +180,7 @@ def pricelist(request):
|
||||||
# Generate pricing combinations for each compute plan and service
|
# Generate pricing combinations for each compute plan and service
|
||||||
for plan in compute_plans:
|
for plan in compute_plans:
|
||||||
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||||
|
|
||||||
for appcat_price in appcat_prices:
|
for appcat_price in appcat_prices:
|
||||||
# Determine units based on variable unit type
|
# Determine units based on variable unit type
|
||||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||||
|
@ -186,22 +189,39 @@ def pricelist(request):
|
||||||
units = int(plan.vcpus)
|
units = int(plan.vcpus)
|
||||||
else:
|
else:
|
||||||
continue
|
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
|
# Apply service level filter
|
||||||
if filter_service_level:
|
if filter_service_level:
|
||||||
service_levels = [
|
service_levels = [
|
||||||
sl for sl in service_levels
|
sl
|
||||||
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl] == filter_service_level
|
for sl in service_levels
|
||||||
|
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl]
|
||||||
|
== filter_service_level
|
||||||
]
|
]
|
||||||
|
|
||||||
for service_level in service_levels:
|
for service_level in service_levels:
|
||||||
unit_rate_currencies = set(
|
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
|
# 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:
|
if not matching_currencies:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for currency in matching_currencies:
|
for currency in matching_currencies:
|
||||||
combination_key = (
|
combination_key = (
|
||||||
plan.cloud_provider.name,
|
plan.cloud_provider.name,
|
||||||
|
@ -210,35 +230,63 @@ def pricelist(request):
|
||||||
service_level,
|
service_level,
|
||||||
currency,
|
currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Skip if combination already processed
|
||||||
if combination_key in processed_combinations:
|
if combination_key in processed_combinations:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
processed_combinations.add(combination_key)
|
processed_combinations.add(combination_key)
|
||||||
|
|
||||||
# Get pricing components
|
# Get pricing components
|
||||||
compute_plan_price = plan.get_price(currency)
|
compute_plan_price = plan.get_price(currency)
|
||||||
base_fee = appcat_price.get_base_fee(currency, service_level)
|
base_fee = appcat_price.get_base_fee(currency, service_level)
|
||||||
unit_rate = appcat_price.get_unit_rate(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
|
continue
|
||||||
|
|
||||||
# Calculate replica enforcement based on service level
|
# Calculate replica enforcement based on service level
|
||||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||||
replica_enforce = appcat_price.ha_replica_min
|
replica_enforce = appcat_price.ha_replica_min
|
||||||
else:
|
else:
|
||||||
replica_enforce = 1
|
replica_enforce = 1
|
||||||
|
|
||||||
total_units = units * replica_enforce
|
total_units = units * replica_enforce
|
||||||
standard_sla_price = base_fee + (total_units * unit_rate)
|
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||||
|
|
||||||
# Apply discount if available
|
# Apply discount if available
|
||||||
discount_breakdown = None
|
discount_breakdown = None
|
||||||
if appcat_price.discount_model and appcat_price.discount_model.active:
|
if (
|
||||||
discounted_price = appcat_price.discount_model.calculate_discount(unit_rate, total_units)
|
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
|
sla_price = base_fee + discounted_price
|
||||||
discount_savings = standard_sla_price - sla_price
|
discount_savings = standard_sla_price - sla_price
|
||||||
discount_percentage = (discount_savings / standard_sla_price) * 100 if standard_sla_price > 0 else 0
|
discount_percentage = (
|
||||||
discount_breakdown = appcat_price.discount_model.get_discount_breakdown(unit_rate, total_units)
|
(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:
|
else:
|
||||||
sla_price = standard_sla_price
|
sla_price = standard_sla_price
|
||||||
discounted_price = total_units * unit_rate
|
discounted_price = total_units * unit_rate
|
||||||
discount_savings = 0
|
discount_savings = 0
|
||||||
discount_percentage = 0
|
discount_percentage = 0
|
||||||
|
|
||||||
# Calculate final price using the model method to ensure consistency
|
# Calculate final price using the model method to ensure consistency
|
||||||
price_calculation = appcat_price.calculate_final_price(
|
price_calculation = appcat_price.calculate_final_price(
|
||||||
currency_code=currency,
|
currency_code=currency,
|
||||||
|
@ -246,22 +294,60 @@ def pricelist(request):
|
||||||
number_of_units=total_units,
|
number_of_units=total_units,
|
||||||
addon_ids=None, # This will include only mandatory addons
|
addon_ids=None, # This will include only mandatory addons
|
||||||
)
|
)
|
||||||
|
|
||||||
if price_calculation is None:
|
if price_calculation is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate base service price (without addons) for display purposes
|
# Calculate base service price (without addons) for display purposes
|
||||||
base_sla_price = base_fee + (total_units * unit_rate)
|
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 = []
|
mandatory_addons = []
|
||||||
optional_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:
|
for addon in all_addons:
|
||||||
addon_price = None
|
addon_price = None
|
||||||
|
|
||||||
if addon.addon_type == "BF": # Base Fee
|
if addon.addon_type == "BF": # Base Fee
|
||||||
addon_price = addon.get_price(currency, service_level)
|
addon_price = addon.get_price(currency, service_level)
|
||||||
elif addon.addon_type == "UR": # Unit Rate
|
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:
|
if addon_price_per_unit:
|
||||||
addon_price = addon_price_per_unit * total_units
|
addon_price = addon_price_per_unit * total_units
|
||||||
|
|
||||||
addon_info = {
|
addon_info = {
|
||||||
"id": addon.id,
|
"id": addon.id,
|
||||||
"name": addon.name,
|
"name": addon.name,
|
||||||
|
@ -270,27 +356,41 @@ def pricelist(request):
|
||||||
"addon_type": addon.get_addon_type_display(),
|
"addon_type": addon.get_addon_type_display(),
|
||||||
"price": addon_price,
|
"price": addon_price,
|
||||||
}
|
}
|
||||||
|
|
||||||
if addon.mandatory:
|
if addon.mandatory:
|
||||||
mandatory_addons.append(addon_info)
|
mandatory_addons.append(addon_info)
|
||||||
else:
|
else:
|
||||||
optional_addons.append(addon_info)
|
optional_addons.append(addon_info)
|
||||||
|
|
||||||
|
# Use the calculated total price which includes mandatory addons
|
||||||
service_price_with_addons = price_calculation["total_price"]
|
service_price_with_addons = price_calculation["total_price"]
|
||||||
final_price = compute_plan_price + service_price_with_addons
|
final_price = compute_plan_price + service_price_with_addons
|
||||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices).get(service_level, service_level)
|
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||||
# Get external/internal price comparisons if enabled (unchanged, but could be optimized further)
|
service_level
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get external price comparisons if enabled
|
||||||
external_comparisons = []
|
external_comparisons = []
|
||||||
internal_comparisons = []
|
internal_comparisons = []
|
||||||
if show_price_comparison:
|
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:
|
for ext_price in external_prices:
|
||||||
|
# Calculate price difference using external price currency
|
||||||
difference = ext_price.amount - final_price
|
difference = ext_price.amount - final_price
|
||||||
ratio = ext_price.amount / final_price if final_price > 0 else 0
|
ratio = (
|
||||||
external_comparisons.append({
|
ext_price.amount / final_price if final_price > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
external_comparisons.append(
|
||||||
|
{
|
||||||
"plan_name": ext_price.plan_name,
|
"plan_name": ext_price.plan_name,
|
||||||
"provider": ext_price.cloud_provider.name,
|
"provider": ext_price.cloud_provider.name,
|
||||||
"description": ext_price.description,
|
"description": ext_price.description,
|
||||||
"amount": ext_price.amount,
|
"amount": ext_price.amount,
|
||||||
"currency": ext_price.currency,
|
"currency": ext_price.currency, # Use external price currency
|
||||||
"vcpus": ext_price.vcpus,
|
"vcpus": ext_price.vcpus,
|
||||||
"ram": ext_price.ram,
|
"ram": ext_price.ram,
|
||||||
"storage": ext_price.storage,
|
"storage": ext_price.storage,
|
||||||
|
@ -300,12 +400,26 @@ def pricelist(request):
|
||||||
"source": ext_price.source,
|
"source": ext_price.source,
|
||||||
"date_retrieved": ext_price.date_retrieved,
|
"date_retrieved": ext_price.date_retrieved,
|
||||||
"is_internal": False,
|
"is_internal": False,
|
||||||
})
|
}
|
||||||
internal_price_comparisons = get_internal_cloud_provider_comparisons(plan, appcat_price, currency, service_level)
|
)
|
||||||
|
|
||||||
|
# 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:
|
for int_price in internal_price_comparisons:
|
||||||
|
# Calculate price difference
|
||||||
difference = int_price["final_price"] - final_price
|
difference = int_price["final_price"] - final_price
|
||||||
ratio = int_price["final_price"] / final_price if final_price > 0 else 0
|
ratio = (
|
||||||
internal_comparisons.append({
|
int_price["final_price"] / final_price
|
||||||
|
if final_price > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_comparisons.append(
|
||||||
|
{
|
||||||
"plan_name": int_price["plan_name"],
|
"plan_name": int_price["plan_name"],
|
||||||
"provider": int_price["provider"],
|
"provider": int_price["provider"],
|
||||||
"description": f"Same specs with {int_price['provider']}",
|
"description": f"Same specs with {int_price['provider']}",
|
||||||
|
@ -314,22 +428,38 @@ def pricelist(request):
|
||||||
"vcpus": int_price["vcpus"],
|
"vcpus": int_price["vcpus"],
|
||||||
"ram": int_price["ram"],
|
"ram": int_price["ram"],
|
||||||
"group_name": int_price["group_name"],
|
"group_name": int_price["group_name"],
|
||||||
"compute_plan_price": int_price["compute_plan_price"],
|
"compute_plan_price": int_price[
|
||||||
|
"compute_plan_price"
|
||||||
|
],
|
||||||
"service_price": int_price["service_price"],
|
"service_price": int_price["service_price"],
|
||||||
"difference": difference,
|
"difference": difference,
|
||||||
"ratio": ratio,
|
"ratio": ratio,
|
||||||
"is_internal": True,
|
"is_internal": True,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
group_name = plan.group.name if plan.group else "No Group"
|
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, [])
|
# Get storage plans for this cloud provider
|
||||||
pricing_data_by_group_and_service_level[group_name][service_level_display].append({
|
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,
|
"cloud_provider": plan.cloud_provider.name,
|
||||||
"service": appcat_price.service.name,
|
"service": appcat_price.service.name,
|
||||||
"compute_plan": plan.name,
|
"compute_plan": plan.name,
|
||||||
"compute_plan_group": group_name,
|
"compute_plan_group": group_name,
|
||||||
"compute_plan_group_description": (plan.group.description if plan.group else ""),
|
"compute_plan_group_description": (
|
||||||
"compute_plan_group_node_label": (plan.group.node_label if plan.group else ""),
|
plan.group.description if plan.group else ""
|
||||||
|
),
|
||||||
|
"compute_plan_group_node_label": (
|
||||||
|
plan.group.node_label if plan.group else ""
|
||||||
|
),
|
||||||
"storage_plans": storage_plans,
|
"storage_plans": storage_plans,
|
||||||
"vcpus": plan.vcpus,
|
"vcpus": plan.vcpus,
|
||||||
"ram": plan.ram,
|
"ram": plan.ram,
|
||||||
|
@ -346,27 +476,45 @@ def pricelist(request):
|
||||||
"sla_per_unit": unit_rate,
|
"sla_per_unit": unit_rate,
|
||||||
"sla_price": service_price_with_addons,
|
"sla_price": service_price_with_addons,
|
||||||
"standard_sla_price": base_sla_price,
|
"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),
|
"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_savings": discount_savings,
|
||||||
"discount_percentage": discount_percentage,
|
"discount_percentage": discount_percentage,
|
||||||
"discount_breakdown": discount_breakdown,
|
"discount_breakdown": discount_breakdown,
|
||||||
"final_price": final_price,
|
"final_price": final_price,
|
||||||
"discount_model": (appcat_price.discount_model.name if appcat_price.discount_model else None),
|
"discount_model": (
|
||||||
"has_discount": bool(appcat_price.discount_model and appcat_price.discount_model.active),
|
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,
|
"external_comparisons": external_comparisons,
|
||||||
"internal_comparisons": internal_comparisons,
|
"internal_comparisons": internal_comparisons,
|
||||||
"mandatory_addons": mandatory_addons,
|
"mandatory_addons": mandatory_addons,
|
||||||
"optional_addons": optional_addons,
|
"optional_addons": optional_addons,
|
||||||
"is_active": plan.active,
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
# Order groups correctly, placing "No Group" last
|
# Order groups correctly, placing "No Group" last
|
||||||
ordered_groups_intermediate = {}
|
ordered_groups_intermediate = {}
|
||||||
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
||||||
|
|
||||||
if "No Group" in all_group_names:
|
if "No Group" in all_group_names:
|
||||||
all_group_names.remove("No Group")
|
all_group_names.remove("No Group")
|
||||||
all_group_names.append("No Group")
|
all_group_names.append("No Group")
|
||||||
|
|
||||||
for group_name_key in all_group_names:
|
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
|
# Convert defaultdicts to regular dicts for the template
|
||||||
final_context_data = {}
|
final_context_data = {}
|
||||||
for group_key, service_levels_dict in ordered_groups_intermediate.items():
|
for group_key, service_levels_dict in ordered_groups_intermediate.items():
|
||||||
|
@ -374,9 +522,10 @@ def pricelist(request):
|
||||||
sl_key: list(plans_list)
|
sl_key: list(plans_list)
|
||||||
for sl_key, plans_list in service_levels_dict.items()
|
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 = (
|
all_cloud_providers = (
|
||||||
ComputePlan.objects.all()
|
ComputePlan.objects.filter(active=True)
|
||||||
.values_list("cloud_provider__name", flat=True)
|
.values_list("cloud_provider__name", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("cloud_provider__name")
|
.order_by("cloud_provider__name")
|
||||||
|
@ -387,18 +536,20 @@ def pricelist(request):
|
||||||
.order_by("service__name")
|
.order_by("service__name")
|
||||||
)
|
)
|
||||||
all_compute_plan_groups = list(
|
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)
|
.values_list("group__name", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("group__name")
|
.order_by("group__name")
|
||||||
)
|
)
|
||||||
all_compute_plan_groups.append("No Group") # Add option for plans without groups
|
all_compute_plan_groups.append("No Group") # Add option for plans without groups
|
||||||
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
|
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 no filter is specified, select the first available provider/service by default
|
||||||
if not filter_cloud_provider and all_cloud_providers:
|
if not filter_cloud_provider and all_cloud_providers:
|
||||||
filter_cloud_provider = all_cloud_providers[0]
|
filter_cloud_provider = all_cloud_providers[0]
|
||||||
if not filter_service and all_services:
|
if not filter_service and all_services:
|
||||||
filter_service = all_services[0]
|
filter_service = all_services[0]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"pricing_data_by_group_and_service_level": final_context_data,
|
"pricing_data_by_group_and_service_level": final_context_data,
|
||||||
"show_discount_details": show_discount_details,
|
"show_discount_details": show_discount_details,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue