Compare commits

..

13 commits

12 changed files with 1377 additions and 77 deletions

View file

@ -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",)

View file

@ -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"
),
),
]

View file

@ -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)

View file

@ -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;
}
}

View file

@ -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 = '<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);
});
}
// 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 = `<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';
}
}
}
// 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();
}
});

View file

@ -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 %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {{ title }}
</div>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ title }}</h3>
</div>
<div class="card-body">
<p class="mb-3">You are about to update <strong>{{ queryset.count }}</strong> compute plan(s). Please select the fields you want to update:</p>
<!-- Selected items display -->
<div class="alert alert-info" role="alert">
<h6 class="alert-heading">Selected Compute Plans:</h6>
<div class="row">
{% for obj in queryset %}
<div class="col-md-6 mb-1">
<small><i class="fas fa-server me-1"></i>{{ obj.name }} ({{ obj.cloud_provider }})</small>
</div>
{% endfor %}
</div>
</div>
<!-- Update form -->
<form method="post">
{% csrf_token %}
<!-- Pass selected items -->
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}">
{% endfor %}
<input type="hidden" name="action" value="mass_update_compute_plans">
<input type="hidden" name="post" value="yes">
<!-- Form fields -->
<div class="row mb-3">
<label for="{{ form.active.id_for_label }}" class="col-sm-3 col-form-label">Active Status:</label>
<div class="col-sm-9">
{{ form.active }}
{% if form.active.help_text %}
<div class="form-text">{{ form.active.help_text }}</div>
{% endif %}
</div>
</div>
<div class="row mb-3">
<label for="{{ form.term.id_for_label }}" class="col-sm-3 col-form-label">Term:</label>
<div class="col-sm-9">
{{ form.term }}
{% if form.term.help_text %}
<div class="form-text">{{ form.term.help_text }}</div>
{% endif %}
</div>
</div>
<div class="row mb-3">
<label for="{{ form.group.id_for_label }}" class="col-sm-3 col-form-label">Group:</label>
<div class="col-sm-9">
{{ form.group }}
{% if form.group.help_text %}
<div class="form-text">{{ form.group.help_text }}</div>
{% endif %}
</div>
</div>
<div class="row mb-3">
<label for="{{ form.valid_from.id_for_label }}" class="col-sm-3 col-form-label">Valid From:</label>
<div class="col-sm-9">
{{ form.valid_from }}
{% if form.valid_from.help_text %}
<div class="form-text">{{ form.valid_from.help_text }}</div>
{% endif %}
</div>
</div>
<div class="row mb-3">
<label for="{{ form.valid_to.id_for_label }}" class="col-sm-3 col-form-label">Valid To:</label>
<div class="col-sm-9">
{{ form.valid_to }}
{% if form.valid_to.help_text %}
<div class="form-text">{{ form.valid_to.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Submit buttons -->
<div class="row">
<div class="col-sm-9 offset-sm-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Update Compute Plans
</button>
<a href="{% url opts|admin_urlname:'changelist' %}" class="btn btn-secondary ms-2">
<i class="fas fa-times me-1"></i>Cancel
</a>
</div>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selects = document.querySelectorAll('select');
const inputs = document.querySelectorAll('input[type="date"]');
selects.forEach(select => {
select.classList.add('form-select');
});
inputs.forEach(input => {
input.classList.add('form-control');
});
});
</script>
{% endblock %}

View file

@ -73,14 +73,29 @@
{% endif %}
<div class="mb-3">
<label for="id_message" class="form-label">Your Message (Optional)</label>
<label for="id_message" class="form-label">
{% if source == "Configuration Order" %}
Configuration Details & Additional Message
{% else %}
Your Message (Optional)
{% endif %}
</label>
{{ form.message|addclass:"form-control" }}
{% if form.message.errors %}
<div class="invalid-feedback d-block">{{ form.message.errors }}</div>
{% endif %}
{% if source == "Configuration Order" %}
<small class="form-text text-muted">Your selected configuration will be automatically filled here when you click "Order This Configuration".</small>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Send Message</button>
<button type="submit" class="btn btn-primary">
{% if source == "Configuration Order" %}
Submit Order Request
{% else %}
Send Message
{% endif %}
</button>
</form>
</div>
</div>

View file

@ -4,6 +4,11 @@
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
{% block extra_js %}
<script defer src="{% static "js/price-calculator.js" %}"></script>
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
{% endblock %}
{% block content %}
<section class="section bg-primary-subtle">
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
@ -56,7 +61,7 @@
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ link.url }}" target="_blank">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-up-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" fill="#9A63EC"/>
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a.5.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" fill="#9A63EC"/>
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z" fill="#9A63EC"/>
</svg>
</span>
@ -152,76 +157,188 @@
</div>
{% endif %}
<!-- Plans or Service Plans -->
<!-- Price Calculator -->
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
<!-- Service Plans with Pricing Data -->
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Plans</h3>
<div class="accordion" id="servicePlansAccordion">
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
<div class="accordion-item">
<h2 class="accordion-header" id="heading{{ forloop.counter }}">
<button class="accordion-button{% if not forloop.first %} collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="{% if forloop.first %}true{% else %}false{% endif %}" aria-controls="collapse{{ forloop.counter }}">
<strong>{{ group_name }}</strong>
</button>
</h2>
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#servicePlansAccordion">
<div class="accordion-body">
{% comment %} Display group description from first available plan {% endcomment %}
{% for service_level, pricing_data in service_levels.items %}
{% if pricing_data and forloop.first %}
{% with pricing_data.0 as representative_plan %}
{% if representative_plan.compute_plan_group_description %}
<p class="text-muted mb-3">{{ representative_plan.compute_plan_group_description }}</p>
{% 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 %}
<div class="mb-4">
<h4 class="mb-3 text-primary">{{ service_level }}</h4>
{% if pricing_data %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead class="table-dark">
<tr>
<th>Compute Plan</th>
<th>vCPUs</th>
<th>RAM (GB)</th>
<th>Currency</th>
<th>Compute Price</th>
<th>Service Price</th>
<th class="table-warning">Total Price</th>
</tr>
</thead>
<tbody>
{% for row in pricing_data %}
<tr>
<td>{{ row.compute_plan }}</td>
<td>{{ row.vcpus }}</td>
<td>{{ row.ram }}</td>
<td>{{ row.currency }}</td>
<td>{{ row.compute_plan_price|floatformat:2 }}</td>
<td>{{ row.sla_price|floatformat:2 }}</td>
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No pricing data available for {{ service_level }}.</p>
{% endif %}
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
<!-- Interactive Price Calculator -->
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
<div class="bg-light rounded-4 p-4 mb-4">
<div class="row">
<!-- Calculator Controls -->
<div class="col-12 col-lg-6">
<div class="card h-100">
<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>
{% endfor %}
</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">
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
</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>
{% endfor %}
<!-- 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>
<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>
{% elif offering.plans.all %}
<!-- Traditional Plans -->
@ -269,8 +386,8 @@
</div>
{% endif %}
{% if offering.plans.exists %}
<div class="pt-40">
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
<div id="form" class="pt-40">
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
<div class="row">
<div class="col-12">

View file

@ -291,9 +291,9 @@
<td class="fw-bold">
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
{% if comparison.difference > 0 %}
<span class="badge bg-danger ms-1">+{{ comparison.difference|floatformat:2 }}</span>
<span class="badge bg-success ms-1">+{{ comparison.difference|floatformat:2 }}</span>
{% elif comparison.difference < 0 %}
<span class="badge bg-success ms-1">{{ comparison.difference|floatformat:2 }}</span>
<span class="badge bg-danger ms-1">{{ comparison.difference|floatformat:2 }}</span>
{% endif %}
</td>
</tr>

View file

@ -1,5 +1,11 @@
import re
import yaml
from decimal import Decimal
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
from django.template.loader import render_to_string
from hub.services.models import (
ServiceOffering,
CloudProvider,
@ -7,9 +13,21 @@ from hub.services.models import (
Service,
ComputePlan,
VSHNAppCatPrice,
StoragePlan,
)
import re
from collections import defaultdict
from markdownify import markdownify
def decimal_to_float(obj):
"""Convert Decimal objects to float for JSON serialization"""
if isinstance(obj, Decimal):
return float(obj)
elif isinstance(obj, dict):
return {key: decimal_to_float(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [decimal_to_float(item) for item in obj]
return obj
def natural_sort_key(name):
@ -79,19 +97,140 @@ def offering_detail(request, provider_slug, service_slug):
service__slug=service_slug,
)
# Check if JSON pricing data is requested
if request.GET.get("pricing") == "json":
pricing_data = None
if offering.msp == "VS":
pricing_data = generate_pricing_data(offering)
if pricing_data:
# Convert Decimal objects to float for JSON serialization
pricing_data = decimal_to_float(pricing_data)
return JsonResponse(pricing_data or {})
# Check if Exoscale marketplace YAML is requested
if request.GET.get("exo_marketplace") == "true":
return generate_exoscale_marketplace_yaml(offering)
pricing_data_by_group_and_service_level = None
price_calculator_enabled = False
# Generate pricing data for VSHN offerings
if offering.msp == "VS":
pricing_data_by_group_and_service_level = generate_pricing_data(offering)
try:
appcat_price = offering.service.vshn_appcat_price.get()
price_calculator_enabled = appcat_price.public_display_enabled
# Only generate pricing data if public display is enabled
if price_calculator_enabled:
pricing_data_by_group_and_service_level = generate_pricing_data(
offering
)
except VSHNAppCatPrice.DoesNotExist:
pass
context = {
"offering": offering,
"pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level,
"price_calculator_enabled": price_calculator_enabled,
}
return render(request, "services/offering_detail.html", context)
def generate_exoscale_marketplace_yaml(offering):
"""Generate YAML structure for Exoscale marketplace"""
# Create service name slug for YAML key
service_slug = offering.service.slug.replace("-", "")
yaml_key = f"marketplace_PRODUCTS_servala-{service_slug}"
# Generate product overview content from service description (convert HTML to Markdown)
product_overview = ""
if offering.service.description:
product_overview = markdownify(
offering.service.description, heading_style="ATX"
).strip()
# Generate highlights content from offering description and offer_description (convert HTML to Markdown)
highlights = ""
if offering.description:
highlights += markdownify(offering.description, heading_style="ATX").strip()
if offering.offer_description:
if highlights:
highlights += "\n\n"
highlights += markdownify(
offering.offer_description.get_full_text(), heading_style="ATX"
).strip()
# Build YAML structure
yaml_structure = {
yaml_key: {
"page_class": "tmpl-marketplace-product",
"html_title": f"Managed {offering.service.name} by VSHN via Servala",
"meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
"page_header_title": f"Managed {offering.service.name} by VSHN via Servala",
"provider_key": "vshn",
"slug": f"servala-managed-{offering.service.slug}",
"title": f"Managed {offering.service.name} by VSHN via Servala",
"logo": f"img/servala-{offering.service.slug}.svg",
"list_display": [],
"meta": [
{"key": "exoscale-iaas", "value": True},
{"key": "availability", "zones": "all"},
],
"action_link": f"https://servala.com/offering/{offering.cloud_provider.slug}/{offering.service.slug}/?source=exoscale_marketplace",
"action_link_text": "Subscribe now",
"blobs": [
{
"key": "product-overview",
"blob": (
product_overview.strip()
if product_overview
else "Service description not available."
),
},
{
"key": "highlights",
"blob": (
highlights.strip()
if highlights
else "Offering highlights not available."
),
},
"editor",
{
"key": "pricing",
"blob": f"Find all the pricing information on the [Servala website](https://servala.com/offering/{offering.cloud_provider.slug}/{offering.service.slug}/?source=exoscale_marketplace#plans)",
},
{
"key": "service-and-support",
"blob": "Servala is operated by VSHN AG in Zurich, Switzerland.\n\nSeveral SLAs are available on request, offering support 24/7.\n\nMore details can be found in the [VSHN Service Levels Documentation](https://products.vshn.ch/service_levels.html).",
},
{
"key": "terms-of-service",
"blob": "- [Product Description](https://products.vshn.ch/servala/index.html)\n- [General Terms and Conditions](https://products.vshn.ch/legal/gtc_en.html)\n- [SLA](https://products.vshn.ch/service_levels.html)\n- [DPA](https://products.vshn.ch/legal/dpa_en.html)\n- [Privacy Policy](https://products.vshn.ch/legal/privacy_policy_en.html)",
},
],
}
}
# Generate YAML response for browser display
yaml_content = yaml.dump(
yaml_structure,
default_flow_style=False,
allow_unicode=True,
indent=2,
width=120,
sort_keys=False,
default_style=None,
)
# Return as plain text for browser display
response = HttpResponse(yaml_content, content_type="text/plain")
return response
def generate_pricing_data(offering):
"""Generate pricing data for a specific offering and cloud provider"""
# Fetch compute plans for this cloud provider
@ -112,6 +251,22 @@ def generate_pricing_data(offering):
),
)
# 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 = (
@ -225,6 +380,9 @@ def generate_pricing_data(offering):
"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,
}
)

View file

@ -14,8 +14,10 @@ dependencies = [
"django-schema-viewer>=0.5.2",
"djangorestframework>=3.15.2",
"environs[django]~=14.0",
"markdownify>=1.1.0",
"odoorpc>=0.10.1",
"pillow>=11.1.0",
"pyyaml>=6.0.2",
]
[project.optional-dependencies]

65
uv.lock generated
View file

@ -11,6 +11,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
]
[[package]]
name = "diff-match-patch"
version = "20241021"
@ -202,6 +215,19 @@ django = [
{ name = "django-cache-url" },
]
[[package]]
name = "markdownify"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/78/c48fed23c7aebc2c16049062e72de1da3220c274de59d28c942acdc9ffb2/markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd", size = 17127, upload-time = "2025-03-05T11:54:40.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/11/b751af7ad41b254a802cf52f7bc1fca7cabe2388132f2ce60a1a6b9b9622/markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef", size = 13901, upload-time = "2025-03-05T11:54:39.454Z" },
]
[[package]]
name = "marshmallow"
version = "3.26.0"
@ -308,6 +334,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/a2/b6a5cbd5822b4d049adfedf496ce0908480e5a41722fda7b7ffaacb086d6/python_monkey_business-1.1.0-py2.py3-none-any.whl", hash = "sha256:15b4f603c749ba9a7b4f1acd36af023a6c5ba0f7e591c945f8253f0ef44bf389", size = 4670, upload-time = "2024-07-11T16:34:58.565Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "servala-fe"
version = "0.1.0"
@ -322,8 +365,10 @@ dependencies = [
{ name = "django-schema-viewer" },
{ name = "djangorestframework" },
{ name = "environs", extra = ["django"] },
{ name = "markdownify" },
{ name = "odoorpc" },
{ name = "pillow" },
{ name = "pyyaml" },
]
[package.optional-dependencies]
@ -343,11 +388,31 @@ requires-dist = [
{ name = "django-schema-viewer", specifier = ">=0.5.2" },
{ name = "djangorestframework", specifier = ">=3.15.2" },
{ name = "environs", extras = ["django"], specifier = "~=14.0" },
{ name = "markdownify", specifier = ">=1.1.0" },
{ name = "odoorpc", specifier = ">=0.10.1" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "pyyaml", specifier = ">=6.0.2" },
]
provides-extras = ["dev"]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "soupsieve"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.3"