379 lines
14 KiB
Python
379 lines
14 KiB
Python
import re
|
|
import yaml
|
|
import json
|
|
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,
|
|
Category,
|
|
Service,
|
|
ComputePlan,
|
|
VSHNAppCatPrice,
|
|
)
|
|
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):
|
|
"""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
|
|
|
|
|
|
def offering_list(request):
|
|
offerings = (
|
|
ServiceOffering.objects.filter(disable_listing=False)
|
|
.order_by("service")
|
|
.select_related("service", "cloud_provider")
|
|
.prefetch_related(
|
|
"service__categories",
|
|
"plans",
|
|
)
|
|
)
|
|
|
|
cloud_providers = CloudProvider.objects.all()
|
|
categories = Category.objects.filter(parent=None).prefetch_related("children")
|
|
services = Service.objects.all().order_by("name")
|
|
|
|
# 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()
|
|
|
|
# Handle service filter
|
|
if request.GET.get("service"):
|
|
service_id = request.GET.get("service")
|
|
offerings = offerings.filter(service_id=service_id)
|
|
|
|
# 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,
|
|
"services": services,
|
|
}
|
|
return render(request, "services/offering_list.html", context)
|
|
|
|
|
|
def offering_detail(request, provider_slug, service_slug):
|
|
offering = get_object_or_404(
|
|
ServiceOffering.objects.select_related(
|
|
"service", "cloud_provider"
|
|
).prefetch_related("plans"),
|
|
cloud_provider__slug=provider_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
|
|
|
|
# Generate pricing data for VSHN offerings
|
|
if offering.msp == "VS":
|
|
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,
|
|
}
|
|
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
|
|
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
|