From d9a04655edd9fe98438ecec001423f3ea2ecc001 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 23 May 2025 17:43:29 +0200 Subject: [PATCH] pricelist on offering detail --- .../migrations/0030_serviceoffering_msp.py | 23 +++ hub/services/models/base.py | 5 + hub/services/models/services.py | 8 +- hub/services/static/css/servala-main.css | 171 ++++++++++++++++ .../templates/services/offering_detail.html | 143 ++++++++++---- hub/services/views/offerings.py | 184 +++++++++++++++++- 6 files changed, 496 insertions(+), 38 deletions(-) create mode 100644 hub/services/migrations/0030_serviceoffering_msp.py diff --git a/hub/services/migrations/0030_serviceoffering_msp.py b/hub/services/migrations/0030_serviceoffering_msp.py new file mode 100644 index 0000000..7c6c2d4 --- /dev/null +++ b/hub/services/migrations/0030_serviceoffering_msp.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2025-05-23 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0029_alter_computeplangroup_options"), + ] + + operations = [ + migrations.AddField( + model_name="serviceoffering", + name="msp", + field=models.CharField( + choices=[("VS", "VSHN")], + default="VS", + max_length=2, + verbose_name="Managed Service Provider", + ), + ), + ] diff --git a/hub/services/models/base.py b/hub/services/models/base.py index 04d9b26..9a7abce 100644 --- a/hub/services/models/base.py +++ b/hub/services/models/base.py @@ -29,6 +29,11 @@ class Unit(models.TextChoices): CPU = "CPU", "vCPU" +# This should be a relation, but for now this is good enough :TM: +class ManagedServiceProvider(models.TextChoices): + VS = "VS", "VSHN" + + class ReusableText(models.Model): name = models.CharField(max_length=100) textsnippet = models.ForeignKey( diff --git a/hub/services/models/services.py b/hub/services/models/services.py index 4f4b873..5c155b8 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField -from .base import Category, ReusableText, validate_image_size +from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size from .providers import CloudProvider @@ -57,6 +57,12 @@ class ServiceOffering(models.Model): service = models.ForeignKey( Service, on_delete=models.CASCADE, related_name="offerings" ) + msp = models.CharField( + "Managed Service Provider", + max_length=2, + default=ManagedServiceProvider.VS, + choices=ManagedServiceProvider.choices, + ) cloud_provider = models.ForeignKey( CloudProvider, on_delete=models.CASCADE, related_name="offerings" ) diff --git a/hub/services/static/css/servala-main.css b/hub/services/static/css/servala-main.css index dc240b5..784acab 100644 --- a/hub/services/static/css/servala-main.css +++ b/hub/services/static/css/servala-main.css @@ -12338,4 +12338,175 @@ a.btn:focus { .clickable-button { position: relative; z-index: 2; +} + +/* Accordion styles */ +.accordion { + --bs-accordion-color: var(--bs-body-color); + --bs-accordion-bg: var(--bs-body-bg); + --bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease; + --bs-accordion-border-color: var(--bs-border-color); + --bs-accordion-border-width: var(--bs-border-width); + --bs-accordion-border-radius: var(--bs-border-radius); + --bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width)); + --bs-accordion-btn-padding-x: 1.25rem; + --bs-accordion-btn-padding-y: 1rem; + --bs-accordion-btn-color: var(--bs-body-color); + --bs-accordion-btn-bg: var(--bs-accordion-bg); + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-icon-width: 1.25rem; + --bs-accordion-btn-icon-transform: rotate(-180deg); + --bs-accordion-btn-icon-transition: transform 0.2s ease-in-out; + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23052c65'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-focus-border-color: #86b7fe; + --bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + --bs-accordion-body-padding-x: 1.25rem; + --bs-accordion-body-padding-y: 1rem; + --bs-accordion-active-color: var(--bs-primary-text-emphasis); + --bs-accordion-active-bg: var(--bs-primary-bg-subtle); +} + +.accordion-button { + position: relative; + display: flex; + align-items: center; + width: 100%; + padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x); + font-size: 1rem; + color: var(--bs-accordion-btn-color); + text-align: left; + background-color: var(--bs-accordion-btn-bg); + border: 0; + border-radius: 0; + overflow-anchor: none; + transition: var(--bs-accordion-transition); +} + +@media (prefers-reduced-motion: reduce) { + .accordion-button { + transition: none; + } +} + +.accordion-button:not(.collapsed) { + color: var(--bs-accordion-active-color); + background-color: var(--bs-accordion-active-bg); + box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color); +} + +.accordion-button:not(.collapsed):after { + background-image: var(--bs-accordion-btn-active-icon); + transform: var(--bs-accordion-btn-icon-transform); +} + +.accordion-button:after { + flex-shrink: 0; + width: var(--bs-accordion-btn-icon-width); + height: var(--bs-accordion-btn-icon-width); + margin-left: auto; + content: ""; + background-image: var(--bs-accordion-btn-icon); + background-repeat: no-repeat; + background-size: var(--bs-accordion-btn-icon-width); + transition: var(--bs-accordion-btn-icon-transition); +} + +@media (prefers-reduced-motion: reduce) { + .accordion-button:after { + transition: none; + } +} + +.accordion-button:hover { + z-index: 2; +} + +.accordion-button:focus { + z-index: 3; + border-color: var(--bs-accordion-btn-focus-border-color); + outline: 0; + box-shadow: var(--bs-accordion-btn-focus-box-shadow); +} + +.accordion-header { + margin-bottom: 0; +} + +.accordion-item { + color: var(--bs-accordion-color); + background-color: var(--bs-accordion-bg); + border: var(--bs-accordion-border-width) solid var(--bs-accordion-border-color); +} + +.accordion-item:first-of-type { + border-top-left-radius: var(--bs-accordion-border-radius); + border-top-right-radius: var(--bs-accordion-border-radius); +} + +.accordion-item:first-of-type .accordion-button { + border-top-left-radius: var(--bs-accordion-inner-border-radius); + border-top-right-radius: var(--bs-accordion-inner-border-radius); +} + +.accordion-item:not(:first-of-type) { + border-top: 0; +} + +.accordion-item:last-of-type { + border-bottom-right-radius: var(--bs-accordion-border-radius); + border-bottom-left-radius: var(--bs-accordion-border-radius); +} + +.accordion-item:last-of-type .accordion-button.collapsed { + border-bottom-right-radius: var(--bs-accordion-inner-border-radius); + border-bottom-left-radius: var(--bs-accordion-inner-border-radius); +} + +.accordion-item:last-of-type .accordion-collapse { + border-bottom-right-radius: var(--bs-accordion-border-radius); + border-bottom-left-radius: var(--bs-accordion-border-radius); +} + +.accordion-body { + padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x); +} + +.accordion-flush .accordion-collapse { + border-width: 0; +} + +.accordion-flush .accordion-item { + border-right: 0; + border-left: 0; + border-radius: 0; +} + +.accordion-flush .accordion-item:first-child { + border-top: 0; +} + +.accordion-flush .accordion-item:last-child { + border-bottom: 0; +} + +.accordion-flush .accordion-item .accordion-button, +.accordion-flush .accordion-item .accordion-button.collapsed { + border-radius: 0; +} + +[data-bs-theme=dark] .accordion-button:not(.collapsed) { + box-shadow: none; +} + +[data-bs-theme=dark] .accordion-button::after { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); +} + +.accordion-button:not(.collapsed) { + color: white !important; +} + +.accordion-button:not(.collapsed)::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ffffff'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); } \ 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 5d71aa8..77b4413 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -152,50 +152,121 @@ {% endif %} - - {% if offering.plans.all %} +
-

Available Plans

-
- {% for plan in offering.plans.all %} -
-
-
-

{{ plan.name }}

- {% if plan.plan_description %} -
- {{ plan.plan_description.text|safe }} + {% 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 %} +
+ {% endfor %} +
- {% endif %} - {% if plan.description %} -
- {{ plan.description|safe }} +
+ {% endfor %} +
+ {% elif offering.plans.all %} + +

Available Plans

+
+ {% for plan in offering.plans.all %} +
+
+
+

{{ plan.name }}

+ {% if plan.plan_description %} +
+ {{ plan.plan_description.text|safe }} +
+ {% endif %} + {% if plan.description %} +
+ {{ plan.description|safe }} +
+ {% endif %} + {% if plan.pricing %} +
+ {{ plan.pricing|safe }} +
+ {% endif %}
- {% endif %} - {% if plan.pricing %} -
- {{ plan.pricing|safe }} -
- {% endif %}
-
- {% empty %} -
-
-

No plans available yet.

-

I'm interested in this offering

- {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} + {% empty %} +
+
+

No plans available yet.

+

I'm interested in this offering

+ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} +
+ {% endfor %}
- {% endfor %} -
{% else %} -
-

I'm interested in this offering

- {% load contact_tags %} - {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} -
+ +
+

I'm interested in this offering

+ {% load contact_tags %} + {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} +
{% endif %} {% if offering.plans.exists %} diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 2414c51..c6e13a9 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,6 +1,21 @@ from django.shortcuts import render, get_object_or_404 from django.db.models import Q -from hub.services.models import ServiceOffering, CloudProvider, Category, Service +from hub.services.models import ( + ServiceOffering, + CloudProvider, + Category, + Service, + ComputePlan, + VSHNAppCatPrice, +) +import re +from collections import defaultdict + + +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): @@ -64,7 +79,174 @@ def offering_detail(request, provider_slug, service_slug): service__slug=service_slug, ) + 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_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