2025-05-30 15:44:37 +02:00
import re
2025-05-30 16:07:38 +02:00
import yaml
2025-06-02 16:22:54 +02:00
import json
from decimal import Decimal
2025-05-30 15:44:37 +02:00
2025-01-30 09:49:27 +01:00
from django . shortcuts import render , get_object_or_404
from django . db . models import Q
2025-06-02 16:22:54 +02:00
from django . http import HttpResponse , JsonResponse
2025-05-30 15:44:37 +02:00
from django . template . loader import render_to_string
2025-05-23 17:43:29 +02:00
from hub . services . models import (
ServiceOffering ,
CloudProvider ,
Category ,
Service ,
ComputePlan ,
VSHNAppCatPrice ,
)
from collections import defaultdict
2025-05-30 15:44:37 +02:00
from markdownify import markdownify
2025-05-23 17:43:29 +02:00
2025-06-02 16:22:54 +02:00
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
2025-05-23 17:43:29 +02:00
def natural_sort_key ( name ) :
""" Extract numeric part from compute plan name for natural sorting """
match = re . search ( r " compute-std-( \ d+) " , name )
return int ( match . group ( 1 ) ) if match else 0
2025-01-30 09:49:27 +01:00
def offering_list ( request ) :
offerings = (
2025-03-03 11:34:27 +01:00
ServiceOffering . objects . filter ( disable_listing = False )
2025-02-26 10:39:23 +01:00
. order_by ( " service " )
2025-01-30 09:49:27 +01:00
. select_related ( " service " , " cloud_provider " )
. prefetch_related (
" service__categories " ,
" plans " ,
)
)
cloud_providers = CloudProvider . objects . all ( )
categories = Category . objects . filter ( parent = None ) . prefetch_related ( " children " )
2025-02-25 13:43:28 +01:00
services = Service . objects . all ( ) . order_by ( " name " )
2025-01-30 09:49:27 +01:00
# Handle cloud provider filter
if request . GET . get ( " cloud_provider " ) :
provider_id = request . GET . get ( " cloud_provider " )
offerings = offerings . filter ( cloud_provider_id = provider_id )
# Handle category filter
if request . GET . get ( " category " ) :
category_id = request . GET . get ( " category " )
category = get_object_or_404 ( Category , id = category_id )
subcategories = Category . objects . filter ( parent = category )
offerings = offerings . filter (
Q ( service__categories = category ) | Q ( service__categories__in = subcategories )
) . distinct ( )
2025-02-25 13:43:28 +01:00
# Handle service filter
2025-02-25 10:45:03 +01:00
if request . GET . get ( " service " ) :
service_id = request . GET . get ( " service " )
offerings = offerings . filter ( service_id = service_id )
2025-01-30 09:49:27 +01:00
# Handle search
if request . GET . get ( " search " ) :
query = request . GET . get ( " search " )
offerings = offerings . filter (
Q ( service__name__icontains = query )
| Q ( description__icontains = query )
| Q ( cloud_provider__name__icontains = query )
) . distinct ( )
context = {
" offerings " : offerings ,
" cloud_providers " : cloud_providers ,
" categories " : categories ,
2025-02-25 10:45:03 +01:00
" services " : services ,
2025-01-30 09:49:27 +01:00
}
return render ( request , " services/offering_list.html " , context )
2025-02-28 14:25:35 +01:00
def offering_detail ( request , provider_slug , service_slug ) :
2025-01-30 09:49:27 +01:00
offering = get_object_or_404 (
ServiceOffering . objects . select_related (
" service " , " cloud_provider "
2025-02-28 14:13:51 +01:00
) . prefetch_related ( " plans " ) ,
2025-02-28 14:25:35 +01:00
cloud_provider__slug = provider_slug ,
service__slug = service_slug ,
2025-01-30 09:49:27 +01:00
)
2025-06-02 16:22:54 +02:00
# 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 { } )
2025-05-30 15:44:37 +02:00
# Check if Exoscale marketplace YAML is requested
if request . GET . get ( " exo_marketplace " ) == " true " :
return generate_exoscale_marketplace_yaml ( offering )
2025-05-23 17:43:29 +02:00
pricing_data_by_group_and_service_level = None
# Generate pricing data for VSHN offerings
if offering . msp == " VS " :
pricing_data_by_group_and_service_level = generate_pricing_data ( offering )
2025-01-30 09:49:27 +01:00
context = {
" offering " : offering ,
2025-05-23 17:43:29 +02:00
" pricing_data_by_group_and_service_level " : pricing_data_by_group_and_service_level ,
2025-01-30 09:49:27 +01:00
}
return render ( request , " services/offering_detail.html " , context )
2025-05-23 17:43:29 +02:00
2025-05-30 15:44:37 +02:00
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 "
2025-05-30 16:07:38 +02:00
) . strip ( )
2025-05-30 15:44:37 +02:00
# Generate highlights content from offering description and offer_description (convert HTML to Markdown)
highlights = " "
if offering . description :
2025-05-30 16:07:38 +02:00
highlights + = markdownify ( offering . description , heading_style = " ATX " ) . strip ( )
2025-05-30 15:44:37 +02:00
if offering . offer_description :
if highlights :
highlights + = " \n \n "
highlights + = markdownify (
offering . offer_description . get_full_text ( ) , heading_style = " ATX "
2025-05-30 16:07:38 +02:00
) . strip ( )
2025-05-30 15:44:37 +02:00
# 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 \n Several SLAs are available on request, offering support 24/7. \n \n More 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
2025-05-30 16:07:38 +02:00
yaml_content = yaml . dump (
yaml_structure ,
default_flow_style = False ,
allow_unicode = True ,
indent = 2 ,
width = 120 ,
sort_keys = False ,
default_style = None ,
)
2025-05-30 15:44:37 +02:00
# Return as plain text for browser display
response = HttpResponse ( yaml_content , content_type = " text/plain " )
return response
2025-05-23 17:43:29 +02:00
def generate_pricing_data ( offering ) :
""" Generate pricing data for a specific offering and cloud provider """
# Fetch compute plans for this cloud provider
compute_plans = (
ComputePlan . objects . filter ( active = True , cloud_provider = offering . cloud_provider )
. select_related ( " cloud_provider " , " group " )
. prefetch_related ( " prices " )
. order_by ( " group__order " , " group__name " )
)
# Apply natural sorting for compute plan names
compute_plans = sorted (
compute_plans ,
key = lambda x : (
x . group . order if x . group else 999 ,
x . group . name if x . group else " ZZZ " ,
natural_sort_key ( x . name ) ,
) ,
)
# Fetch pricing for this specific service
try :
appcat_price = (
VSHNAppCatPrice . objects . select_related ( " service " , " discount_model " )
. prefetch_related ( " base_fees " , " unit_rates " , " discount_model__tiers " )
. get ( service = offering . service )
)
except VSHNAppCatPrice . DoesNotExist :
return None
pricing_data_by_group_and_service_level = defaultdict ( lambda : defaultdict ( list ) )
processed_combinations = set ( )
# Generate pricing combinations for each compute plan
for plan in compute_plans :
plan_currencies = set ( plan . prices . values_list ( " currency " , flat = True ) )
# Determine units based on variable unit type
if appcat_price . variable_unit == VSHNAppCatPrice . VariableUnit . RAM :
units = int ( plan . ram )
elif appcat_price . variable_unit == VSHNAppCatPrice . VariableUnit . CPU :
units = int ( plan . vcpus )
else :
continue
base_fee_currencies = set (
appcat_price . base_fees . values_list ( " currency " , flat = True )
)
service_levels = appcat_price . unit_rates . values_list (
" service_level " , flat = True
) . distinct ( )
for service_level in service_levels :
unit_rate_currencies = set (
appcat_price . unit_rates . filter ( service_level = service_level ) . values_list (
" currency " , flat = True
)
)
# Find currencies that exist across all pricing components
matching_currencies = plan_currencies . intersection (
base_fee_currencies
) . intersection ( unit_rate_currencies )
if not matching_currencies :
continue
for currency in matching_currencies :
combination_key = (
plan . name ,
service_level ,
currency ,
)
# Skip if combination already processed
if combination_key in processed_combinations :
continue
processed_combinations . add ( combination_key )
# Get pricing components
compute_plan_price = plan . get_price ( currency )
base_fee = appcat_price . get_base_fee ( currency )
unit_rate = appcat_price . get_unit_rate ( currency , service_level )
# Skip if any pricing component is missing
if any (
price is None for price in [ compute_plan_price , base_fee , unit_rate ]
) :
continue
# Calculate replica enforcement based on service level
if service_level == VSHNAppCatPrice . ServiceLevel . GUARANTEED :
replica_enforce = appcat_price . ha_replica_min
else :
replica_enforce = 1
total_units = units * replica_enforce
standard_sla_price = base_fee + ( total_units * unit_rate )
# Apply discount if available
if appcat_price . discount_model and appcat_price . discount_model . active :
discounted_price = appcat_price . discount_model . calculate_discount (
unit_rate , total_units
)
sla_price = base_fee + discounted_price
else :
sla_price = standard_sla_price
final_price = compute_plan_price + sla_price
service_level_display = dict ( VSHNAppCatPrice . ServiceLevel . choices ) [
service_level
]
group_name = plan . group . name if plan . group else " No Group "
# Add pricing data to the grouped structure
pricing_data_by_group_and_service_level [ group_name ] [
service_level_display
] . append (
{
" compute_plan " : plan . name ,
" compute_plan_group " : group_name ,
" compute_plan_group_description " : (
plan . group . description if plan . group else " "
) ,
" vcpus " : plan . vcpus ,
" ram " : plan . ram ,
" currency " : currency ,
" compute_plan_price " : compute_plan_price ,
" sla_price " : sla_price ,
" final_price " : final_price ,
}
)
# Order groups correctly, placing "No Group" last
ordered_groups = { }
all_group_names = list ( pricing_data_by_group_and_service_level . keys ( ) )
if " No Group " in all_group_names :
all_group_names . remove ( " No Group " )
all_group_names . append ( " No Group " )
for group_name_key in all_group_names :
ordered_groups [ group_name_key ] = pricing_data_by_group_and_service_level [
group_name_key
]
# Convert defaultdicts to regular dicts for the template
final_context_data = { }
for group_key , service_levels_dict in ordered_groups . items ( ) :
final_context_data [ group_key ] = {
sl_key : list ( plans_list )
for sl_key , plans_list in service_levels_dict . items ( )
}
return final_context_data