Compare commits
13 commits
86df11505f
...
01d35a461b
Author | SHA1 | Date | |
---|---|---|---|
01d35a461b | |||
6ad8b9aa49 | |||
8bb8930361 | |||
c8c224cfb8 | |||
206e46aa6a | |||
21c9734fd3 | |||
15ede53cc3 | |||
2da6285800 | |||
b32a19ffa2 | |||
227feb3a55 | |||
475a4643fd | |||
4f9a39fd36 | |||
bfb64efdec |
12 changed files with 1377 additions and 77 deletions
|
@ -2,8 +2,13 @@
|
||||||
Admin classes for pricing models including compute plans, storage plans, and VSHN AppCat pricing
|
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.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 adminsortable2.admin import SortableAdminMixin
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from import_export import resources
|
from import_export import resources
|
||||||
|
@ -26,6 +31,14 @@ from ..models import (
|
||||||
Service,
|
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):
|
class ComputePlanPriceInline(admin.TabularInline):
|
||||||
"""Inline admin for ComputePlanPrice model"""
|
"""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)
|
@admin.register(ComputePlan)
|
||||||
class ComputePlansAdmin(ImportExportModelAdmin):
|
class ComputePlanAdmin(ImportExportModelAdmin):
|
||||||
"""Admin configuration for ComputePlan model with import/export functionality"""
|
"""Admin configuration for ComputePlan model with import/export functionality"""
|
||||||
|
|
||||||
resource_class = ComputePlanResource
|
resource_class = ComputePlanResource
|
||||||
|
@ -133,8 +181,21 @@ class ComputePlansAdmin(ImportExportModelAdmin):
|
||||||
)
|
)
|
||||||
search_fields = ("name", "cloud_provider__name", "group__name")
|
search_fields = ("name", "cloud_provider__name", "group__name")
|
||||||
list_filter = ("active", "cloud_provider", "group")
|
list_filter = ("active", "cloud_provider", "group")
|
||||||
ordering = ("name",)
|
|
||||||
inlines = [ComputePlanPriceInline]
|
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):
|
def display_prices(self, obj):
|
||||||
"""Display formatted prices for the list view"""
|
"""Display formatted prices for the list view"""
|
||||||
|
@ -145,6 +206,80 @@ class ComputePlansAdmin(ImportExportModelAdmin):
|
||||||
|
|
||||||
display_prices.short_description = "Prices (Amount Currency)"
|
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):
|
class VSHNAppCatBaseFeeInline(admin.TabularInline):
|
||||||
"""Inline admin for VSHNAppCatBaseFee model"""
|
"""Inline admin for VSHNAppCatBaseFee model"""
|
||||||
|
@ -191,6 +326,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
|
||||||
"discount_model",
|
"discount_model",
|
||||||
"admin_display_base_fees",
|
"admin_display_base_fees",
|
||||||
"admin_display_unit_rates",
|
"admin_display_unit_rates",
|
||||||
|
"public_display_enabled",
|
||||||
)
|
)
|
||||||
list_filter = ("variable_unit", "service", "discount_model")
|
list_filter = ("variable_unit", "service", "discount_model")
|
||||||
search_fields = ("service__name",)
|
search_fields = ("service__name",)
|
||||||
|
|
|
@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -310,6 +310,11 @@ class VSHNAppCatPrice(models.Model):
|
||||||
default=1, help_text="Maximum supported replicas"
|
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_from = models.DateTimeField(blank=True, null=True)
|
||||||
valid_to = models.DateTimeField(blank=True, null=True)
|
valid_to = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
|
36
hub/services/static/css/price-calculator.css
Normal file
36
hub/services/static/css/price-calculator.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
612
hub/services/static/js/price-calculator.js
Normal file
612
hub/services/static/js/price-calculator.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
126
hub/services/templates/admin/mass_update_compute_plans.html
Normal file
126
hub/services/templates/admin/mass_update_compute_plans.html
Normal 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>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› {{ 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 %}
|
|
@ -73,14 +73,29 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-3">
|
<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" }}
|
{{ form.message|addclass:"form-control" }}
|
||||||
{% if form.message.errors %}
|
{% if form.message.errors %}
|
||||||
<div class="invalid-feedback d-block">{{ form.message.errors }}</div>
|
<div class="invalid-feedback d-block">{{ form.message.errors }}</div>
|
||||||
{% endif %}
|
{% 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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -4,6 +4,11 @@
|
||||||
|
|
||||||
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="section bg-primary-subtle">
|
<section class="section bg-primary-subtle">
|
||||||
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
<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">
|
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ link.url }}" target="_blank">
|
||||||
<span class="pr-10">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
@ -152,76 +157,188 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Plans or Service Plans -->
|
<!-- Price Calculator -->
|
||||||
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
||||||
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
|
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
|
||||||
<!-- Service Plans with Pricing Data -->
|
<!-- Interactive Price Calculator -->
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Plans</h3>
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
||||||
<div class="accordion" id="servicePlansAccordion">
|
<div class="bg-light rounded-4 p-4 mb-4">
|
||||||
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
<div class="row">
|
||||||
<div class="accordion-item">
|
<!-- Calculator Controls -->
|
||||||
<h2 class="accordion-header" id="heading{{ forloop.counter }}">
|
<div class="col-12 col-lg-6">
|
||||||
<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 }}">
|
<div class="card h-100">
|
||||||
<strong>{{ group_name }}</strong>
|
<div class="card-body">
|
||||||
</button>
|
<!-- CPU Slider -->
|
||||||
</h2>
|
<div class="mb-4">
|
||||||
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#servicePlansAccordion">
|
<label for="cpuRange" class="form-label d-flex justify-content-between">
|
||||||
<div class="accordion-body">
|
<span>vCPUs</span>
|
||||||
{% comment %} Display group description from first available plan {% endcomment %}
|
<span class="fw-bold" id="cpuValue">2</span>
|
||||||
{% for service_level, pricing_data in service_levels.items %}
|
</label>
|
||||||
{% if pricing_data and forloop.first %}
|
<input type="range" class="form-range" id="cpuRange" min="1" max="32" value="2" step="1">
|
||||||
{% with pricing_data.0 as representative_plan %}
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
{% if representative_plan.compute_plan_group_description %}
|
<span id="cpuMinDisplay">1</span>
|
||||||
<p class="text-muted mb-3">{{ representative_plan.compute_plan_group_description }}</p>
|
<span id="cpuMaxDisplay">32</span>
|
||||||
{% 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 %}
|
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
{% elif offering.plans.all %}
|
{% elif offering.plans.all %}
|
||||||
<!-- Traditional Plans -->
|
<!-- Traditional Plans -->
|
||||||
|
@ -269,8 +386,8 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if offering.plans.exists %}
|
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
|
||||||
<div class="pt-40">
|
<div id="form" class="pt-40">
|
||||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
|
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|
|
@ -291,9 +291,9 @@
|
||||||
<td class="fw-bold">
|
<td class="fw-bold">
|
||||||
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
||||||
{% if comparison.difference > 0 %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.db.models import Q
|
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 (
|
from hub.services.models import (
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
|
@ -7,9 +13,21 @@ from hub.services.models import (
|
||||||
Service,
|
Service,
|
||||||
ComputePlan,
|
ComputePlan,
|
||||||
VSHNAppCatPrice,
|
VSHNAppCatPrice,
|
||||||
|
StoragePlan,
|
||||||
)
|
)
|
||||||
import re
|
|
||||||
from collections import defaultdict
|
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):
|
def natural_sort_key(name):
|
||||||
|
@ -79,19 +97,140 @@ def offering_detail(request, provider_slug, service_slug):
|
||||||
service__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
|
pricing_data_by_group_and_service_level = None
|
||||||
|
price_calculator_enabled = False
|
||||||
|
|
||||||
# Generate pricing data for VSHN offerings
|
# Generate pricing data for VSHN offerings
|
||||||
if offering.msp == "VS":
|
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 = {
|
context = {
|
||||||
"offering": offering,
|
"offering": offering,
|
||||||
"pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level,
|
"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)
|
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):
|
def generate_pricing_data(offering):
|
||||||
"""Generate pricing data for a specific offering and cloud provider"""
|
"""Generate pricing data for a specific offering and cloud provider"""
|
||||||
# Fetch compute plans for this 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
|
# Fetch pricing for this specific service
|
||||||
try:
|
try:
|
||||||
appcat_price = (
|
appcat_price = (
|
||||||
|
@ -225,6 +380,9 @@ def generate_pricing_data(offering):
|
||||||
"compute_plan_price": compute_plan_price,
|
"compute_plan_price": compute_plan_price,
|
||||||
"sla_price": sla_price,
|
"sla_price": sla_price,
|
||||||
"final_price": final_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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -14,8 +14,10 @@ dependencies = [
|
||||||
"django-schema-viewer>=0.5.2",
|
"django-schema-viewer>=0.5.2",
|
||||||
"djangorestframework>=3.15.2",
|
"djangorestframework>=3.15.2",
|
||||||
"environs[django]~=14.0",
|
"environs[django]~=14.0",
|
||||||
|
"markdownify>=1.1.0",
|
||||||
"odoorpc>=0.10.1",
|
"odoorpc>=0.10.1",
|
||||||
"pillow>=11.1.0",
|
"pillow>=11.1.0",
|
||||||
|
"pyyaml>=6.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
65
uv.lock
generated
65
uv.lock
generated
|
@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "diff-match-patch"
|
name = "diff-match-patch"
|
||||||
version = "20241021"
|
version = "20241021"
|
||||||
|
@ -202,6 +215,19 @@ django = [
|
||||||
{ name = "django-cache-url" },
|
{ 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]]
|
[[package]]
|
||||||
name = "marshmallow"
|
name = "marshmallow"
|
||||||
version = "3.26.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "servala-fe"
|
name = "servala-fe"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -322,8 +365,10 @@ dependencies = [
|
||||||
{ name = "django-schema-viewer" },
|
{ name = "django-schema-viewer" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
{ name = "environs", extra = ["django"] },
|
{ name = "environs", extra = ["django"] },
|
||||||
|
{ name = "markdownify" },
|
||||||
{ name = "odoorpc" },
|
{ name = "odoorpc" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
@ -343,11 +388,31 @@ requires-dist = [
|
||||||
{ name = "django-schema-viewer", specifier = ">=0.5.2" },
|
{ name = "django-schema-viewer", specifier = ">=0.5.2" },
|
||||||
{ name = "djangorestframework", specifier = ">=3.15.2" },
|
{ name = "djangorestframework", specifier = ">=3.15.2" },
|
||||||
{ name = "environs", extras = ["django"], specifier = "~=14.0" },
|
{ name = "environs", extras = ["django"], specifier = "~=14.0" },
|
||||||
|
{ name = "markdownify", specifier = ">=1.1.0" },
|
||||||
{ name = "odoorpc", specifier = ">=0.10.1" },
|
{ name = "odoorpc", specifier = ">=0.10.1" },
|
||||||
{ name = "pillow", specifier = ">=11.1.0" },
|
{ name = "pillow", specifier = ">=11.1.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
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]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue