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

{{ title }}

+
+
+

You are about to update {{ queryset.count }} compute plan(s). Please select the fields you want to update:

+ + + + + +
+ {% csrf_token %} + + + {% for obj in queryset %} + + {% endfor %} + + + + +
+ +
+ {{ form.active }} + {% if form.active.help_text %} +
{{ form.active.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.term }} + {% if form.term.help_text %} +
{{ form.term.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.group }} + {% if form.group.help_text %} +
{{ form.group.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.valid_from }} + {% if form.valid_from.help_text %} +
{{ form.valid_from.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.valid_to }} + {% if form.valid_to.help_text %} +
{{ form.valid_to.help_text }}
+ {% endif %} +
+
+ + +
+
+ + + Cancel + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/hub/services/templates/services/embedded_contact_form.html b/hub/services/templates/services/embedded_contact_form.html index 660be51..46a144f 100644 --- a/hub/services/templates/services/embedded_contact_form.html +++ b/hub/services/templates/services/embedded_contact_form.html @@ -73,14 +73,29 @@ {% endif %}
- + {{ form.message|addclass:"form-control" }} {% if form.message.errors %}
{{ form.message.errors }}
{% endif %} + {% if source == "Configuration Order" %} + Your selected configuration will be automatically filled here when you click "Order This Configuration". + {% endif %}
- + \ No newline at end of file diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 77b4413..134dc2f 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -4,6 +4,11 @@ {% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %} +{% block extra_js %} + + +{% endblock %} + {% block content %}
@@ -56,7 +61,7 @@ - + @@ -152,76 +157,188 @@
{% endif %} - +
- {% if offering.msp == "VS" and pricing_data_by_group_and_service_level %} - -

Service Plans

-
- {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %} -
-

- -

-
-
- {% 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 %} -

{{ representative_plan.compute_plan_group_description }}

- {% endif %} - {% endwith %} - {% endif %} - {% if forloop.first %} - {% comment %} Only show description for first service level {% endcomment %} - {% endif %} - {% endfor %} - - {% for service_level, pricing_data in service_levels.items %} -
-

{{ service_level }}

- {% if pricing_data %} -
- - - - - - - - - - - - - - {% for row in pricing_data %} - - - - - - - - - - {% endfor %} - -
Compute PlanvCPUsRAM (GB)CurrencyCompute PriceService PriceTotal Price
{{ row.compute_plan }}{{ row.vcpus }}{{ row.ram }}{{ row.currency }}{{ row.compute_plan_price|floatformat:2 }}{{ row.sla_price|floatformat:2 }}{{ row.final_price|floatformat:2 }}
-
- {% else %} -

No pricing data available for {{ service_level }}.

- {% endif %} + {% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %} + +

Choose your Plan

+
+ + + + + +
+

Order Your Configuration

+
+
+ {% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %} +
+
{% elif offering.plans.all %} @@ -269,8 +386,8 @@
{% endif %} - {% if offering.plans.exists %} -
+ {% if offering.plans.exists and not pricing_data_by_group_and_service_level %} +

I'm interested in a plan

diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index ca865ae..7173cdc 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -291,9 +291,9 @@ {{ comparison.amount|floatformat:2 }} {{ comparison.currency }} {% if comparison.difference > 0 %} - +{{ comparison.difference|floatformat:2 }} + +{{ comparison.difference|floatformat:2 }} {% elif comparison.difference < 0 %} - {{ comparison.difference|floatformat:2 }} + {{ comparison.difference|floatformat:2 }} {% endif %} diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index c6e13a9..4730b4a 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -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, } ) diff --git a/pyproject.toml b/pyproject.toml index c1cd69f..82adb9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/uv.lock b/uv.lock index 9f8cfae..5584e25 100644 --- a/uv.lock +++ b/uv.lock @@ -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"