diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 6da4852..1077b68 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -2,13 +2,8 @@ Admin classes for pricing models including compute plans, storage plans, and VSHN AppCat pricing """ -import re -from django.contrib import admin, messages -from django.contrib.admin import helpers +from django.contrib import admin 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 @@ -31,14 +26,6 @@ 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""" @@ -129,43 +116,8 @@ 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 ComputePlanAdmin(ImportExportModelAdmin): +class ComputePlansAdmin(ImportExportModelAdmin): """Admin configuration for ComputePlan model with import/export functionality""" resource_class = ComputePlanResource @@ -181,21 +133,8 @@ class ComputePlanAdmin(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""" @@ -206,80 +145,6 @@ class ComputePlanAdmin(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""" @@ -326,7 +191,6 @@ 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 deleted file mode 100644 index 6089f11..0000000 --- a/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# 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 42b2778..dc557ea 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -310,11 +310,6 @@ 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 deleted file mode 100644 index de8074e..0000000 --- a/hub/services/static/css/price-calculator.css +++ /dev/null @@ -1,36 +0,0 @@ -.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 deleted file mode 100644 index a65a9f2..0000000 --- a/hub/services/static/js/price-calculator.js +++ /dev/null @@ -1,612 +0,0 @@ -/** - * 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 deleted file mode 100644 index df2c558..0000000 --- a/hub/services/templates/admin/mass_update_compute_plans.html +++ /dev/null @@ -1,126 +0,0 @@ -{% 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 46a144f..660be51 100644 --- a/hub/services/templates/services/embedded_contact_form.html +++ b/hub/services/templates/services/embedded_contact_form.html @@ -73,29 +73,14 @@ {% 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 134dc2f..77b4413 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -4,11 +4,6 @@ {% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %} -{% block extra_js %} - - -{% endblock %} - {% block content %}
@@ -61,7 +56,7 @@ - + @@ -157,188 +152,76 @@
{% endif %} - +
- {% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %} - -

Choose your Plan

-
-
- - - - -
-
-
-
Your Plan
+ {% 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 %} - -
- - Finding best matching plan... -
- - -
-
-
- - - - - -
-

Order Your Configuration

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

I'm interested in a plan

diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 7173cdc..ca865ae 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 4730b4a..c6e13a9 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,11 +1,5 @@ -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, @@ -13,21 +7,9 @@ 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): @@ -97,140 +79,19 @@ 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": - 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 + pricing_data_by_group_and_service_level = generate_pricing_data(offering) 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 @@ -251,22 +112,6 @@ 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 = ( @@ -380,9 +225,6 @@ 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 82adb9b..c1cd69f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,8 @@ 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 5584e25..9f8cfae 100644 --- a/uv.lock +++ b/uv.lock @@ -11,19 +11,6 @@ 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" @@ -215,19 +202,6 @@ 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" @@ -334,23 +308,6 @@ 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" @@ -365,10 +322,8 @@ dependencies = [ { name = "django-schema-viewer" }, { name = "djangorestframework" }, { name = "environs", extra = ["django"] }, - { name = "markdownify" }, { name = "odoorpc" }, { name = "pillow" }, - { name = "pyyaml" }, ] [package.optional-dependencies] @@ -388,31 +343,11 @@ 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"