diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 1077b68..6da4852 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -2,8 +2,13 @@ Admin classes for pricing models including compute plans, storage plans, and VSHN AppCat pricing """ -from django.contrib import admin +import re +from django.contrib import admin, messages +from django.contrib.admin import helpers from django.utils.html import format_html +from django import forms +from django.shortcuts import render +from django.http import HttpResponseRedirect from adminsortable2.admin import SortableAdminMixin from import_export.admin import ImportExportModelAdmin from import_export import resources @@ -26,6 +31,14 @@ from ..models import ( Service, ) +from ..models.base import Term + + +def natural_sort_key(obj): + """Extract numeric parts for natural sorting""" + parts = re.split(r"(\d+)", obj.name) + return [int(part) if part.isdigit() else part for part in parts] + class ComputePlanPriceInline(admin.TabularInline): """Inline admin for ComputePlanPrice model""" @@ -116,8 +129,43 @@ class ComputePlanResource(resources.ModelResource): ) +class MassUpdateComputePlanForm(forms.Form): + """Form for mass updating ComputePlan fields""" + + active = forms.ChoiceField( + choices=[("", "-- No change --"), ("True", "Active"), ("False", "Inactive")], + required=False, + help_text="Set active status for selected compute plans", + ) + + term = forms.ChoiceField( + choices=[("", "-- No change --")] + Term.choices, + required=False, + help_text="Set billing term for selected compute plans", + ) + + group = forms.ModelChoiceField( + queryset=ComputePlanGroup.objects.all(), + required=False, + empty_label="-- No change --", + help_text="Set group for selected compute plans", + ) + + valid_from = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), + required=False, + help_text="Set valid from date for selected compute plans", + ) + + valid_to = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), + required=False, + help_text="Set valid to date for selected compute plans", + ) + + @admin.register(ComputePlan) -class ComputePlansAdmin(ImportExportModelAdmin): +class ComputePlanAdmin(ImportExportModelAdmin): """Admin configuration for ComputePlan model with import/export functionality""" resource_class = ComputePlanResource @@ -133,8 +181,21 @@ class ComputePlansAdmin(ImportExportModelAdmin): ) search_fields = ("name", "cloud_provider__name", "group__name") list_filter = ("active", "cloud_provider", "group") - ordering = ("name",) inlines = [ComputePlanPriceInline] + actions = ["mass_update_compute_plans"] + + def changelist_view(self, request, extra_context=None): + """Override changelist view to apply natural sorting""" + # Get the response from parent + response = super().changelist_view(request, extra_context) + + # If it's a TemplateResponse, we can modify the context + if hasattr(response, "context_data") and "cl" in response.context_data: + cl = response.context_data["cl"] + if hasattr(cl, "result_list"): + cl.result_list = sorted(cl.result_list, key=natural_sort_key) + + return response def display_prices(self, obj): """Display formatted prices for the list view""" @@ -145,6 +206,80 @@ class ComputePlansAdmin(ImportExportModelAdmin): display_prices.short_description = "Prices (Amount Currency)" + def mass_update_compute_plans(self, request, queryset): + """Admin action to mass update compute plan fields""" + if request.POST.get("post"): + # Process the form submission + form = MassUpdateComputePlanForm(request.POST) + if form.is_valid(): + updated_count = 0 + updated_fields = [] + + # Prepare update data + update_data = {} + + # Handle active field + if form.cleaned_data["active"]: + update_data["active"] = form.cleaned_data["active"] == "True" + updated_fields.append("active") + + # Handle term field + if form.cleaned_data["term"]: + update_data["term"] = form.cleaned_data["term"] + updated_fields.append("term") + + # Handle group field + if form.cleaned_data["group"]: + update_data["group"] = form.cleaned_data["group"] + updated_fields.append("group") + + # Handle valid_from field + if form.cleaned_data["valid_from"]: + update_data["valid_from"] = form.cleaned_data["valid_from"] + updated_fields.append("valid_from") + + # Handle valid_to field + if form.cleaned_data["valid_to"]: + update_data["valid_to"] = form.cleaned_data["valid_to"] + updated_fields.append("valid_to") + + # Perform the bulk update + if update_data: + updated_count = queryset.update(**update_data) + + # Create success message + field_list = ", ".join(updated_fields) + self.message_user( + request, + f"Successfully updated {updated_count} compute plan(s). " + f"Updated fields: {field_list}", + messages.SUCCESS, + ) + else: + self.message_user( + request, "No fields were selected for update.", messages.WARNING + ) + + return HttpResponseRedirect(request.get_full_path()) + else: + # Show the form + form = MassUpdateComputePlanForm() + + # Render the mass update template + return render( + request, + "admin/mass_update_compute_plans.html", + { + "form": form, + "queryset": queryset, + "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME, + "opts": self.model._meta, + "title": f"Mass Update {queryset.count()} Compute Plans", + }, + ) + + mass_update_compute_plans.short_description = "Mass update selected compute plans" + class VSHNAppCatBaseFeeInline(admin.TabularInline): """Inline admin for VSHNAppCatBaseFee model""" @@ -191,6 +326,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin): "discount_model", "admin_display_base_fees", "admin_display_unit_rates", + "public_display_enabled", ) list_filter = ("variable_unit", "service", "discount_model") search_fields = ("service__name",) diff --git a/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py b/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py new file mode 100644 index 0000000..6089f11 --- /dev/null +++ b/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-06-04 15:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0032_externalpriceplans_service_level"), + ] + + operations = [ + migrations.AddField( + model_name="vshnappcatprice", + name="public_display_enabled", + field=models.BooleanField( + default=True, + help_text="Enable public display of price calculator on offering detail page", + ), + ), + migrations.AlterField( + model_name="externalpriceplans", + name="compare_to", + field=models.ManyToManyField( + blank=True, related_name="external_prices", to="services.computeplan" + ), + ), + ] diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py index dc557ea..42b2778 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -310,6 +310,11 @@ class VSHNAppCatPrice(models.Model): default=1, help_text="Maximum supported replicas" ) + public_display_enabled = models.BooleanField( + default=True, + help_text="Enable public display of price calculator on offering detail page", + ) + valid_from = models.DateTimeField(blank=True, null=True) valid_to = models.DateTimeField(blank=True, null=True) diff --git a/hub/services/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css new file mode 100644 index 0000000..de8074e --- /dev/null +++ b/hub/services/static/css/price-calculator.css @@ -0,0 +1,36 @@ +.form-range::-webkit-slider-thumb { + background: #6f42c1; +} + +.form-range::-moz-range-thumb { + background: #6f42c1; + border: none; +} + +.btn-check:checked+.btn-outline-primary { + background-color: #6f42c1; + border-color: #6f42c1; + color: white; +} + +.card { + transition: box-shadow 0.2s ease-in-out; +} + +.card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +#selectedPlanDetails { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} \ No newline at end of file diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js new file mode 100644 index 0000000..a65a9f2 --- /dev/null +++ b/hub/services/static/js/price-calculator.js @@ -0,0 +1,612 @@ +/** + * Price Calculator for Service Offerings + * Handles interactive pricing calculation with sliders and plan selection + */ + +class PriceCalculator { + constructor() { + this.pricingData = null; + this.storagePrice = null; + this.currentOffering = null; + this.selectedConfiguration = null; + this.replicaInfo = null; + this.init(); + } + + // Initialize calculator elements and event listeners + init() { + // Get offering info from URL + const pathParts = window.location.pathname.split('/'); + if (pathParts.length >= 4 && pathParts[1] === 'offering') { + this.currentOffering = { + provider_slug: pathParts[2], + service_slug: pathParts[3] + }; + } + + // Initialize DOM elements + this.initElements(); + + // Load pricing data and setup calculator + if (this.currentOffering) { + this.loadPricingData(); + } + + // Setup order button click handler + this.setupOrderButton(); + } + + // Initialize DOM element references + initElements() { + // Calculator controls + this.cpuRange = document.getElementById('cpuRange'); + this.memoryRange = document.getElementById('memoryRange'); + this.storageRange = document.getElementById('storageRange'); + this.instancesRange = document.getElementById('instancesRange'); + this.cpuValue = document.getElementById('cpuValue'); + this.memoryValue = document.getElementById('memoryValue'); + this.storageValue = document.getElementById('storageValue'); + this.instancesValue = document.getElementById('instancesValue'); + this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); + this.planSelect = document.getElementById('planSelect'); + + // Result display elements + this.planMatchStatus = document.getElementById('planMatchStatus'); + this.selectedPlanDetails = document.getElementById('selectedPlanDetails'); + this.noMatchFound = document.getElementById('noMatchFound'); + + // Plan detail elements + this.planGroup = document.getElementById('planGroup'); + this.planName = document.getElementById('planName'); + this.planDescription = document.getElementById('planDescription'); + this.planCpus = document.getElementById('planCpus'); + this.planMemory = document.getElementById('planMemory'); + this.planInstances = document.getElementById('planInstances'); + this.planServiceLevel = document.getElementById('planServiceLevel'); + this.managedServicePrice = document.getElementById('managedServicePrice'); + this.storagePriceEl = document.getElementById('storagePrice'); + this.storageAmount = document.getElementById('storageAmount'); + this.totalPrice = document.getElementById('totalPrice'); + + // Order button + this.orderButton = document.querySelector('a[href="#order-form"]'); + } + + // Update slider display values (min/max text below sliders) + updateSliderDisplayValues() { + // Update CPU slider display + if (this.cpuRange) { + const cpuMinDisplay = document.getElementById('cpuMinDisplay'); + const cpuMaxDisplay = document.getElementById('cpuMaxDisplay'); + if (cpuMinDisplay) cpuMinDisplay.textContent = this.cpuRange.min; + if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.cpuRange.max; + } + + // Update Memory slider display + if (this.memoryRange) { + const memoryMinDisplay = document.getElementById('memoryMinDisplay'); + const memoryMaxDisplay = document.getElementById('memoryMaxDisplay'); + if (memoryMinDisplay) memoryMinDisplay.textContent = this.memoryRange.min + ' GB'; + if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.memoryRange.max + ' GB'; + } + + // Update Storage slider display + if (this.storageRange) { + const storageMinDisplay = document.getElementById('storageMinDisplay'); + const storageMaxDisplay = document.getElementById('storageMaxDisplay'); + if (storageMinDisplay) storageMinDisplay.textContent = this.storageRange.min + ' GB'; + if (storageMaxDisplay) storageMaxDisplay.textContent = this.storageRange.max + ' GB'; + } + + // Update Instances slider display + if (this.instancesRange) { + 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 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 + }); + } + } + + // Generate human-readable configuration message + generateConfigurationMessage(config) { + return `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} + +Total Monthly Price: CHF ${config.totalPrice} + +Please contact me with next steps for ordering this configuration.`; + } + + // 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'); + } + + this.pricingData = await response.json(); + + // Extract storage price from the first available plan + this.extractStoragePrice(); + + this.setupEventListeners(); + this.populatePlanDropdown(); + 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; + } + } + } + } + + // 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.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; + + this.updatePricingWithPlan(selectedPlan); + } else { + 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.updatePricing(); + }); + + serviceLevelGroup.appendChild(input); + serviceLevelGroup.appendChild(label); + }); + + // Update the serviceLevelInputs reference + this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); + + // Calculate and set slider maximums based on available plans - this will call updateSliderDisplayValues() + this.updateSliderMaximums(); + } + + // Calculate maximum values for sliders based on available plans + updateSliderMaximums() { + if (!this.pricingData || !this.cpuRange || !this.memoryRange) return; + + let maxCpus = 0; + let maxMemory = 0; + + // Find maximum CPU and memory across all plans + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + Object.keys(group).forEach(serviceLevel => { + group[serviceLevel].forEach(plan => { + const planCpus = parseFloat(plan.vcpus); + const planMemory = parseFloat(plan.ram); + + if (planCpus > maxCpus) maxCpus = planCpus; + if (planMemory > maxMemory) maxMemory = planMemory; + }); + }); + }); + + // Set slider maximums with some padding + if (maxCpus > 0) { + this.cpuRange.max = Math.ceil(maxCpus); + } + + if (maxMemory > 0) { + this.memoryRange.max = Math.ceil(maxMemory); + } + + // Update display values after changing min/max - moved to end and call explicitly + this.updateSliderDisplayValues(); + } + + // Populate plan dropdown based on selected service level + populatePlanDropdown() { + if (!this.planSelect || !this.pricingData) return; + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + if (!serviceLevel) return; + + // Clear existing options + this.planSelect.innerHTML = ''; + + // Collect all plans for selected service level + const availablePlans = []; + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + if (group[serviceLevel]) { + group[serviceLevel].forEach(plan => { + availablePlans.push({ + ...plan, + groupName: groupName + }); + }); + } + }); + + // Sort plans by vCPU, then by RAM + availablePlans.sort((a, b) => { + if (parseInt(a.vcpus) !== parseInt(b.vcpus)) { + return parseInt(a.vcpus) - parseInt(b.vcpus); + } + return parseInt(a.ram) - parseInt(b.ram); + }); + + // Add plans to dropdown + availablePlans.forEach(plan => { + const option = document.createElement('option'); + option.value = JSON.stringify(plan); + option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`; + this.planSelect.appendChild(option); + }); + } + + // 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); + + 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; + + // 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); + this.updatePricingWithPlan(selectedPlan); + } + } + + // 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; + + // Calculate pricing using storage price from the plan data + const computePriceValue = parseFloat(plan.compute_plan_price); + const servicePriceValue = parseFloat(plan.sla_price); + const managedServicePricePerInstance = computePriceValue + servicePriceValue; + 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; + const totalPriceValue = managedServicePrice + storagePriceValue; + + // 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); + + // 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) + }; + } + + // Show no matching plan found + showNoMatch() { + if (this.planMatchStatus) this.planMatchStatus.style.display = 'none'; + if (this.selectedPlanDetails) this.selectedPlanDetails.style.display = 'none'; + if (this.noMatchFound) this.noMatchFound.style.display = 'block'; + } + + // Update status message + updateStatusMessage(message, type) { + if (!this.planMatchStatus) return; + + const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle'; + const textClass = type === 'success' ? 'text-success' : ''; + const alertClass = type === 'success' ? 'alert-success' : 'alert-info'; + + this.planMatchStatus.innerHTML = `${message}`; + this.planMatchStatus.className = `alert ${alertClass} mb-3`; + this.planMatchStatus.style.display = 'block'; + } + + // Show error message + showError(message) { + if (this.planMatchStatus) { + this.planMatchStatus.innerHTML = `${message}`; + this.planMatchStatus.className = 'alert alert-danger mb-3'; + this.planMatchStatus.style.display = 'block'; + } + } +} + +// Initialize calculator when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + // Only initialize if we're on an offering detail page with pricing calculator + if (document.getElementById('cpuRange')) { + new PriceCalculator(); + } +}); \ No newline at end of file diff --git a/hub/services/templates/admin/mass_update_compute_plans.html b/hub/services/templates/admin/mass_update_compute_plans.html new file mode 100644 index 0000000..df2c558 --- /dev/null +++ b/hub/services/templates/admin/mass_update_compute_plans.html @@ -0,0 +1,126 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block breadcrumbs %} +
+{% endblock %} + +{% block content %} +You are about to update {{ queryset.count }} compute plan(s). Please select the fields you want to update:
+ + +{{ representative_plan.compute_plan_group_description }}
- {% endif %} - {% endwith %} - {% endif %} - {% if forloop.first %} - {% comment %} Only show description for first service level {% endcomment %} - {% endif %} - {% endfor %} - - {% for service_level, pricing_data in service_levels.items %} -Compute Plan | -vCPUs | -RAM (GB) | -Currency | -Compute Price | -Service Price | -Total Price | -
---|---|---|---|---|---|---|
{{ row.compute_plan }} | -{{ row.vcpus }} | -{{ row.ram }} | -{{ row.currency }} | -{{ row.compute_plan_price|floatformat:2 }} | -{{ row.sla_price|floatformat:2 }} | -{{ row.final_price|floatformat:2 }} | -
No pricing data available for {{ service_level }}.
- {% endif %} + {% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %} + +Selecting a plan will override the slider configuration
+Interested in a custom plan? Let us know via the contact form.
+