diff --git a/hub/services/admin/content.py b/hub/services/admin/content.py index 9d601ae..2b655d1 100644 --- a/hub/services/admin/content.py +++ b/hub/services/admin/content.py @@ -13,7 +13,7 @@ class PlanInline(admin.StackedInline): model = Plan extra = 1 fieldsets = ( - (None, {"fields": ("name", "description", "pricing", "plan_description")}), + (None, {"fields": ("name", "description", "plan_description")}), ) diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 44f848b..f679c09 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -5,7 +5,7 @@ Admin classes for services and service offerings from django.contrib import admin from django.utils.html import format_html -from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan +from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan, PlanPrice class ExternalLinkInline(admin.TabularInline): @@ -32,7 +32,7 @@ class PlanInline(admin.StackedInline): model = Plan extra = 1 fieldsets = ( - (None, {"fields": ("name", "description", "pricing", "plan_description")}), + (None, {"fields": ("name", "description", "plan_description")}), ) @@ -57,6 +57,18 @@ class OfferingInline(admin.StackedInline): show_change_link = True +class PlanPriceInline(admin.TabularInline): + model = PlanPrice + extra = 1 + + +class PlanAdmin(admin.ModelAdmin): + inlines = [PlanPriceInline] + list_display = ("name", "offering") + search_fields = ("name",) + list_filter = ("offering",) + + @admin.register(Service) class ServiceAdmin(admin.ModelAdmin): """Admin configuration for Service model""" @@ -106,3 +118,7 @@ class ServiceOfferingAdmin(admin.ModelAdmin): list_filter = ("service", "cloud_provider") search_fields = ("service__name", "cloud_provider__name", "description") inlines = [ExternalLinkOfferingInline, PlanInline] + + +admin.site.register(Plan, PlanAdmin) +admin.site.register(PlanPrice) diff --git a/hub/services/migrations/0037_remove_plan_pricing_planprice.py b/hub/services/migrations/0037_remove_plan_pricing_planprice.py new file mode 100644 index 0000000..e5476f1 --- /dev/null +++ b/hub/services/migrations/0037_remove_plan_pricing_planprice.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2 on 2025-06-20 15:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0036_alter_vshnappcataddonbasefee_options_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="plan", + name="pricing", + ), + migrations.CreateModel( + name="PlanPrice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "currency", + models.CharField( + choices=[ + ("CHF", "Swiss Franc"), + ("EUR", "Euro"), + ("USD", "US Dollar"), + ], + max_length=3, + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + help_text="Price in the specified currency, excl. VAT", + max_digits=10, + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plan_prices", + to="services.plan", + ), + ), + ], + options={ + "ordering": ["currency"], + "unique_together": {("plan", "currency")}, + }, + ), + ] diff --git a/hub/services/models/services.py b/hub/services/models/services.py index 5c155b8..aa2e1ba 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -4,10 +4,14 @@ from django.core.validators import URLValidator from django.urls import reverse from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField +from typing import TYPE_CHECKING, Optional -from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size +from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size, Currency from .providers import CloudProvider +if TYPE_CHECKING: + from .services import PlanPrice + class Service(models.Model): name = models.CharField(max_length=200) @@ -97,10 +101,31 @@ class ServiceOffering(models.Model): ) +class PlanPrice(models.Model): + plan = models.ForeignKey( + 'Plan', on_delete=models.CASCADE, related_name='plan_prices' + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Price in the specified currency, excl. VAT", + ) + + class Meta: + unique_together = ("plan", "currency") + ordering = ["currency"] + + def __str__(self): + return f"{self.plan.name} - {self.amount} {self.currency}" + + class Plan(models.Model): name = models.CharField(max_length=100) description = ProseEditorField(blank=True, null=True) - pricing = ProseEditorField(blank=True, null=True) plan_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, @@ -122,6 +147,13 @@ class Plan(models.Model): def __str__(self): return f"{self.offering} - {self.name}" + def get_price(self, currency_code: str) -> Optional[float]: + from hub.services.models.services import PlanPrice + price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first() + if price_obj: + return price_obj.amount + return None + class ExternalLinkOffering(models.Model): offering = models.ForeignKey( diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index 54b2af9..90112b9 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -982,850 +982,6 @@ Please contact me with next steps for ordering this configuration.`; if (this.instancesValue) this.instancesValue.textContent = '1'; } } - - // Setup order button click handler - setupOrderButton() { - if (this.orderButton) { - this.orderButton.addEventListener('click', (e) => { - e.preventDefault(); - this.handleOrderClick(); - }); - } - } - - // Handle order button click - handleOrderClick() { - if (this.selectedConfiguration) { - // Pre-fill the contact form with configuration details - this.prefillContactForm(); - - // Scroll to the contact form - const contactForm = document.getElementById('order-form'); - if (contactForm) { - contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - } - - // Pre-fill contact form with selected configuration - prefillContactForm() { - if (!this.selectedConfiguration) return; - - const config = this.selectedConfiguration; - - // Create configuration summary message - const configMessage = this.generateConfigurationMessage(config); - - // Find and fill the message textarea in the contact form - const messageField = document.querySelector('#order-form textarea[name="message"]'); - if (messageField) { - messageField.value = configMessage; - } - - // Store configuration details in hidden field - const detailsField = document.querySelector('#order-form input[name="details"]'); - if (detailsField) { - detailsField.value = JSON.stringify({ - plan: config.planName, - vcpus: config.vcpus, - memory: config.memory, - storage: config.storage, - instances: config.instances, - serviceLevel: config.serviceLevel, - totalPrice: config.totalPrice, - addons: config.addons || [] - }); - } - } - - // Generate human-readable configuration message - generateConfigurationMessage(config) { - let message = `I would like to order the following configuration: - -Plan: ${config.planName} (${config.planGroup}) -vCPUs: ${config.vcpus} -Memory: ${config.memory} GB -Storage: ${config.storage} GB -Instances: ${config.instances} -Service Level: ${config.serviceLevel}`; - - // Add addons to the message if any are selected - if (config.addons && config.addons.length > 0) { - message += '\n\nSelected Add-ons:'; - config.addons.forEach(addon => { - message += `\n- ${addon.name}: CHF ${addon.price}`; - }); - } - - message += `\n\nTotal Monthly Price: CHF ${config.totalPrice} - -Please contact me with next steps for ordering this configuration.`; - - return message; - } - - // Load pricing data from API endpoint - async loadPricingData() { - try { - const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`); - if (!response.ok) { - throw new Error('Failed to load pricing data'); - } - - const data = await response.json(); - this.pricingData = data.pricing || data; - - // Extract addons data from the plans - addons are embedded in each plan - this.extractAddonsData(); - - // Extract storage price from the first available plan - this.extractStoragePrice(); - - this.setupEventListeners(); - this.populatePlanDropdown(); - this.updateAddons(); - this.updatePricing(); - } catch (error) { - console.error('Error loading pricing data:', error); - this.showError('Failed to load pricing information'); - } - } - - // Extract replica information and storage price from pricing data - extractStoragePrice() { - if (!this.pricingData) return; - - // Find the first plan with storage pricing data and replica info - for (const groupName of Object.keys(this.pricingData)) { - const group = this.pricingData[groupName]; - for (const serviceLevel of Object.keys(group)) { - const plans = group[serviceLevel]; - if (plans.length > 0 && plans[0].storage_price !== undefined) { - this.storagePrice = parseFloat(plans[0].storage_price); - this.replicaInfo = { - ha_replica_min: plans[0].ha_replica_min || 1, - ha_replica_max: plans[0].ha_replica_max || 1 - }; - return; - } - } - } - } - - // Extract addons data from pricing plans - extractAddonsData() { - if (!this.pricingData) return; - - this.addonsData = {}; - - // Extract addons from the first available plan for each service level - Object.keys(this.pricingData).forEach(groupName => { - const group = this.pricingData[groupName]; - Object.keys(group).forEach(serviceLevel => { - const plans = group[serviceLevel]; - if (plans.length > 0) { - // Use the first plan's addon data for this service level - const plan = plans[0]; - const allAddons = []; - - // Add mandatory addons - if (plan.mandatory_addons) { - plan.mandatory_addons.forEach(addon => { - allAddons.push({ - ...addon, - is_mandatory: true, - addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" - }); - }); - } - - // Add optional addons - if (plan.optional_addons) { - plan.optional_addons.forEach(addon => { - allAddons.push({ - ...addon, - is_mandatory: false, - addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" - }); - }); - } - - this.addonsData[serviceLevel] = allAddons; - } - }); - }); - } - - // Setup event listeners for calculator controls - setupEventListeners() { - if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; - - // Setup service levels based on available data - this.setupServiceLevels(); - - // Slider event listeners - this.cpuRange.addEventListener('input', () => { - this.cpuValue.textContent = this.cpuRange.value; - this.updatePricing(); - }); - - this.memoryRange.addEventListener('input', () => { - this.memoryValue.textContent = this.memoryRange.value; - this.updatePricing(); - }); - - this.storageRange.addEventListener('input', () => { - this.storageValue.textContent = this.storageRange.value; - this.updatePricing(); - }); - - this.instancesRange.addEventListener('input', () => { - this.instancesValue.textContent = this.instancesRange.value; - this.updatePricing(); - }); - - // Service level change listeners - this.serviceLevelInputs.forEach(input => { - input.addEventListener('change', () => { - this.updateInstancesSlider(); - this.populatePlanDropdown(); - this.updateAddons(); - this.updatePricing(); - }); - }); - - // Plan selection listener - if (this.planSelect) { - this.planSelect.addEventListener('change', () => { - if (this.planSelect.value) { - const selectedPlan = JSON.parse(this.planSelect.value); - - // Update sliders to match selected plan - this.cpuRange.value = selectedPlan.vcpus; - this.memoryRange.value = selectedPlan.ram; - this.cpuValue.textContent = selectedPlan.vcpus; - this.memoryValue.textContent = selectedPlan.ram; - - // Fade out CPU and Memory sliders since plan is manually selected - this.fadeOutSliders(['cpu', 'memory']); - - // Update addons for the new configuration - this.updateAddons(); - // Update pricing with the selected plan - this.updatePricingWithPlan(selectedPlan); - } else { - // Auto-select mode - reset sliders to default values - this.resetSlidersToDefaults(); - - // Auto-select mode - fade sliders back in - this.fadeInSliders(['cpu', 'memory']); - - // Auto-select mode - update addons and recalculate - this.updateAddons(); - this.updatePricing(); - } - }); - } - - // Initialize instances slider - this.updateInstancesSlider(); - } - - // Update instances slider based on service level and replica info - updateInstancesSlider() { - if (!this.instancesRange || !this.replicaInfo) return; - - const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; - - if (serviceLevel === 'Guaranteed Availability') { - // For GA, min is ha_replica_min - this.instancesRange.min = this.replicaInfo.ha_replica_min; - this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min); - } else { - // For BE, min is 1 - this.instancesRange.min = 1; - this.instancesRange.value = Math.max(this.instancesRange.value, 1); - } - - // Set max to ha_replica_max - this.instancesRange.max = this.replicaInfo.ha_replica_max; - - // Update display value - this.instancesValue.textContent = this.instancesRange.value; - - // Update the min/max display under the slider using direct IDs - const instancesMinDisplay = document.getElementById('instancesMinDisplay'); - const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); - - if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min; - if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max; - } - - // Setup service levels dynamically from pricing data - setupServiceLevels() { - if (!this.pricingData) return; - - const serviceLevelGroup = document.getElementById('serviceLevelGroup'); - if (!serviceLevelGroup) return; - - // Get all available service levels from the pricing data - const availableServiceLevels = new Set(); - Object.keys(this.pricingData).forEach(groupName => { - const group = this.pricingData[groupName]; - Object.keys(group).forEach(serviceLevel => { - availableServiceLevels.add(serviceLevel); - }); - }); - - // Clear existing service level buttons - serviceLevelGroup.innerHTML = ''; - - // Create buttons for each available service level - let isFirst = true; - availableServiceLevels.forEach(serviceLevel => { - const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`; - - // Create radio input - const input = document.createElement('input'); - input.type = 'radio'; - input.className = 'btn-check'; - input.name = 'serviceLevel'; - input.id = inputId; - input.value = serviceLevel; - if (isFirst) { - input.checked = true; - isFirst = false; - } - - // Create label - const label = document.createElement('label'); - label.className = 'btn btn-outline-primary'; - label.setAttribute('for', inputId); - label.textContent = serviceLevel; - - // Add event listener - input.addEventListener('change', () => { - this.updateInstancesSlider(); - this.populatePlanDropdown(); - this.updateAddons(); - this.updatePricing(); - }); - - serviceLevelGroup.appendChild(input); - serviceLevelGroup.appendChild(label); - }); - - // Update the serviceLevelInputs reference - this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); - - // Calculate and set slider maximums based on available plans - this will call updateSliderDisplayValues() - this.updateSliderMaximums(); - } - - // Calculate maximum values for sliders based on available plans - updateSliderMaximums() { - if (!this.pricingData || !this.cpuRange || !this.memoryRange) return; - - let maxCpus = 0; - let maxMemory = 0; - - // Find maximum CPU and memory across all plans - Object.keys(this.pricingData).forEach(groupName => { - const group = this.pricingData[groupName]; - Object.keys(group).forEach(serviceLevel => { - group[serviceLevel].forEach(plan => { - const planCpus = parseFloat(plan.vcpus); - const planMemory = parseFloat(plan.ram); - - if (planCpus > maxCpus) maxCpus = planCpus; - if (planMemory > maxMemory) maxMemory = planMemory; - }); - }); - }); - - // Set slider maximums with some padding - if (maxCpus > 0) { - this.cpuRange.max = Math.ceil(maxCpus); - } - - if (maxMemory > 0) { - this.memoryRange.max = Math.ceil(maxMemory); - } - - // Update display values after changing min/max - moved to end and call explicitly - this.updateSliderDisplayValues(); - } - - // Populate plan dropdown based on selected service level - populatePlanDropdown() { - if (!this.planSelect || !this.pricingData) return; - - const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; - if (!serviceLevel) return; - - // Clear existing options - this.planSelect.innerHTML = ''; - - // Collect all plans for selected service level - const availablePlans = []; - Object.keys(this.pricingData).forEach(groupName => { - const group = this.pricingData[groupName]; - if (group[serviceLevel]) { - group[serviceLevel].forEach(plan => { - availablePlans.push({ - ...plan, - groupName: groupName - }); - }); - } - }); - - // Sort plans by vCPU, then by RAM - availablePlans.sort((a, b) => { - if (parseInt(a.vcpus) !== parseInt(b.vcpus)) { - return parseInt(a.vcpus) - parseInt(b.vcpus); - } - return parseInt(a.ram) - parseInt(b.ram); - }); - - // Add plans to dropdown - availablePlans.forEach(plan => { - const option = document.createElement('option'); - option.value = JSON.stringify(plan); - option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`; - this.planSelect.appendChild(option); - }); - } - - // Update addons based on current configuration - updateAddons() { - if (!this.addonsContainer || !this.addonsData) { - // Hide addons section if no container or data - const addonsSection = document.getElementById('addonsSection'); - if (addonsSection) addonsSection.style.display = 'none'; - return; - } - - const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; - if (!serviceLevel || !this.addonsData[serviceLevel]) { - // Hide addons section if no service level or no addons for this level - const addonsSection = document.getElementById('addonsSection'); - if (addonsSection) addonsSection.style.display = 'none'; - return; - } - - const addons = this.addonsData[serviceLevel]; - - // Clear existing addons - this.addonsContainer.innerHTML = ''; - - // Show or hide addons section based on availability - const addonsSection = document.getElementById('addonsSection'); - if (addons && addons.length > 0) { - if (addonsSection) addonsSection.style.display = 'block'; - } else { - if (addonsSection) addonsSection.style.display = 'none'; - return; - } - - // Add each addon - addons.forEach(addon => { - const addonElement = document.createElement('div'); - addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`; - - addonElement.innerHTML = ` -
Selecting a plan will override the slider configuration
-Interested in a custom plan? Let us know via the contact form.
-Plan Name | +Description | +Price | +
---|