compute plan grouping

This commit is contained in:
Tobias Brunner 2025-05-23 17:09:02 +02:00
parent 3896636f9b
commit 19b36b9a2c
No known key found for this signature in database
7 changed files with 284 additions and 93 deletions

View file

@ -10,4 +10,6 @@ GitLab CI is used as the main CI/CD system.
The project uses Astral uv to manage the Pythong project, dependencies and the venv. The project uses Astral uv to manage the Pythong project, dependencies and the venv.
Execute Django with `uv run --extra dev manage.py`. Execute Django with `uv run --extra dev manage.py`.
Always add comments to the code to describe what's happening.
Answers should be short and concise, and should not include any unnecessary comments or explanations, but be clear on which file a code block should be placed in. Answers should be short and concise, and should not include any unnecessary comments or explanations, but be clear on which file a code block should be placed in.

View file

@ -10,6 +10,7 @@ from .models import (
Category, Category,
CloudProvider, CloudProvider,
ComputePlan, ComputePlan,
ComputePlanGroup,
ComputePlanPrice, ComputePlanPrice,
ConsultingPartner, ConsultingPartner,
ExternalLink, ExternalLink,
@ -206,12 +207,29 @@ class ComputePlanPriceInline(admin.TabularInline):
fields = ("currency", "amount") fields = ("currency", "amount")
@admin.register(ComputePlanGroup)
class ComputePlanGroupAdmin(SortableAdminMixin, admin.ModelAdmin):
list_display = ("name", "node_label", "compute_plans_count")
search_fields = ("name", "description", "node_label")
ordering = ("order",)
def compute_plans_count(self, obj):
return obj.compute_plans.count()
compute_plans_count.short_description = "Compute Plans"
class ComputePlanResource(resources.ModelResource): class ComputePlanResource(resources.ModelResource):
cloud_provider = Field( cloud_provider = Field(
column_name="cloud_provider", column_name="cloud_provider",
attribute="cloud_provider", attribute="cloud_provider",
widget=ForeignKeyWidget(CloudProvider, "name"), widget=ForeignKeyWidget(CloudProvider, "name"),
) )
group = Field(
column_name="group",
attribute="group",
widget=ForeignKeyWidget(ComputePlanGroup, "name"),
)
prices = Field(column_name="prices", attribute=None) prices = Field(column_name="prices", attribute=None)
class Meta: class Meta:
@ -225,6 +243,7 @@ class ComputePlanResource(resources.ModelResource):
"ram", "ram",
"cpu_mem_ratio", "cpu_mem_ratio",
"cloud_provider", "cloud_provider",
"group",
"active", "active",
"term", "term",
"valid_from", "valid_from",
@ -262,14 +281,15 @@ class ComputePlansAdmin(ImportExportModelAdmin):
list_display = ( list_display = (
"name", "name",
"cloud_provider", "cloud_provider",
"group",
"vcpus", "vcpus",
"ram", "ram",
"term", "term",
"display_prices", "display_prices",
"active", "active",
) )
search_fields = ("name", "cloud_provider__name") search_fields = ("name", "cloud_provider__name", "group__name")
list_filter = ("active", "cloud_provider") list_filter = ("active", "cloud_provider", "group")
ordering = ("name",) ordering = ("name",)
inlines = [ComputePlanPriceInline] inlines = [ComputePlanPriceInline]

View file

@ -0,0 +1,52 @@
# Generated by Django 5.2 on 2025-05-23 14:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0027_alter_category_options_alter_discounttier_options_and_more"),
]
operations = [
migrations.CreateModel(
name="ComputePlanGroup",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200)),
("description", models.TextField(blank=True)),
(
"node_label",
models.CharField(
help_text="Kubernetes node label for this group", max_length=100
),
),
("order", models.IntegerField(default=0)),
],
options={
"ordering": ["name"],
},
),
migrations.AddField(
model_name="computeplan",
name="group",
field=models.ForeignKey(
blank=True,
help_text="Group this compute plan belongs to",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="compute_plans",
to="services.computeplangroup",
),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-05-23 14:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("services", "0028_computeplangroup_computeplan_group"),
]
operations = [
migrations.AlterModelOptions(
name="computeplangroup",
options={"ordering": ["order", "name"]},
),
]

View file

@ -5,6 +5,21 @@ from .providers import CloudProvider
from .services import Service from .services import Service
class ComputePlanGroup(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
node_label = models.CharField(
max_length=100, help_text="Kubernetes node label for this group"
)
order = models.IntegerField(default=0)
class Meta:
ordering = ["order", "name"]
def __str__(self):
return self.name
class ComputePlanPrice(models.Model): class ComputePlanPrice(models.Model):
compute_plan = models.ForeignKey( compute_plan = models.ForeignKey(
"ComputePlan", on_delete=models.CASCADE, related_name="prices" "ComputePlan", on_delete=models.CASCADE, related_name="prices"
@ -45,6 +60,15 @@ class ComputePlan(models.Model):
CloudProvider, on_delete=models.CASCADE, related_name="compute_plans" CloudProvider, on_delete=models.CASCADE, related_name="compute_plans"
) )
group = models.ForeignKey(
ComputePlanGroup,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="compute_plans",
help_text="Group this compute plan belongs to",
)
valid_from = models.DateTimeField(blank=True, null=True) valid_from = models.DateTimeField(blank=True, null=True)
valid_to = models.DateTimeField(blank=True, null=True) valid_to = models.DateTimeField(blank=True, null=True)

View file

@ -8,84 +8,110 @@
<div class="col-12"> <div class="col-12">
<h1 class="mb-4">Complete Price List - All Service Variants</h1> <h1 class="mb-4">Complete Price List - All Service Variants</h1>
{% if pricing_data_by_service_level %} {% if pricing_data_by_group_and_service_level %}
{% for service_level, pricing_data in pricing_data_by_service_level.items %} {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
<div class="mb-5"> <div class="mb-5 border rounded p-3">
<h2 class="mb-3">{{ service_level }}</h2> <h2 class="mb-3 text-primary">{{ group_name }}</h2>
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm"> {# Display group description and node_label from first available plan #}
<thead class="table-dark"> {% for service_level, pricing_data in service_levels.items %}
<tr> {% if pricing_data and forloop.first %}
<th>Cloud Provider</th> {% with pricing_data.0 as representative_plan %}
<th>Service</th> {% if representative_plan.compute_plan_group_description %}
<th>Compute Plan</th> <p class="text-muted mb-2"><strong>Description:</strong> {{ representative_plan.compute_plan_group_description }}</p>
<th>vCPUs</th> {% endif %}
<th>RAM (GB)</th> {% if representative_plan.compute_plan_group_node_label %}
<th>CPU/Memory Ratio</th> <p class="text-muted mb-3"><strong>Node Label:</strong> <code>{{ representative_plan.compute_plan_group_node_label }}</code></p>
<th>Term</th> {% endif %}
<th>Currency</th> {% endwith %}
<th>Compute Plan Price</th> {% endif %}
<th>Variable Unit</th> {% endfor %}
<th>Units</th>
<th>Replica Enforce</th> {% for service_level, pricing_data in service_levels.items %}
<th>SLA Base</th> <div class="mb-4">
<th>SLA Per Unit</th> <h3 class="mb-3 text-secondary">{{ service_level }}</h3>
<th>SLA Price</th> {% if pricing_data %}
<th>Discount Model</th> <div class="table-responsive">
<th>Discount Details</th> <table class="table table-striped table-bordered table-sm">
<th class="table-warning">Final Price</th> <thead class="table-dark">
</tr> <tr>
</thead> <th>Cloud Provider</th>
<tbody> <th>Service</th>
{% for row in pricing_data %} <th>Compute Plan</th>
<tr> <th>vCPUs</th>
<td>{{ row.cloud_provider }}</td> <th>RAM (GB)</th>
<td>{{ row.service }}</td> <th>CPU/Memory Ratio</th>
<td>{{ row.compute_plan }}</td> <th>Term</th>
<td>{{ row.vcpus }}</td> <th>Currency</th>
<td>{{ row.ram }}</td> <th>Compute Plan Price</th>
<td>{{ row.cpu_mem_ratio }}</td> <th>Variable Unit</th>
<td>{{ row.term }}</td> <th>Units</th>
<td>{{ row.currency }}</td> <th>Replica Enforce</th>
<td>{{ row.compute_plan_price|floatformat:2 }}</td> <th>SLA Base</th>
<td>{{ row.variable_unit }}</td> <th>SLA Per Unit</th>
<td>{{ row.units }}</td> <th>SLA Price</th>
<td>{{ row.replica_enforce }}</td> <th>Discount Model</th>
<td>{{ row.sla_base|floatformat:2 }}</td> <th>Discount Details</th>
<td>{{ row.sla_per_unit|floatformat:4 }}</td> <th class="table-warning">Final Price</th>
<td>{{ row.sla_price|floatformat:2 }}</td> </tr>
<td> </thead>
{% if row.has_discount %} <tbody>
{{ row.discount_model }} {% for row in pricing_data %}
{% else %} <tr>
None <td>{{ row.cloud_provider }}</td>
{% endif %} <td>{{ row.service }}</td>
</td> <td>{{ row.compute_plan }}</td>
<td> <td>{{ row.vcpus }}</td>
{% if row.has_discount %} <td>{{ row.ram }}</td>
<small class="text-muted"> <td>{{ row.cpu_mem_ratio }}</td>
<strong>Total Units:</strong> {{ row.total_units }}<br> <td>{{ row.term }}</td>
<strong>Standard Price:</strong> {{ row.standard_sla_price|floatformat:2 }}<br> <td>{{ row.currency }}</td>
<strong>Discounted Price:</strong> {{ row.discounted_sla_price|floatformat:2 }}<br> <td>{{ row.compute_plan_price|floatformat:2 }}</td>
<strong>Savings:</strong> {{ row.discount_savings|floatformat:2 }} ({{ row.discount_percentage|floatformat:1 }}%)<br> <td>{{ row.variable_unit }}</td>
{% if row.discount_breakdown %} <td>{{ row.units }}</td>
<strong>Breakdown:</strong><br> <td>{{ row.replica_enforce }}</td>
{% for tier in row.discount_breakdown %} <td>{{ row.sla_base|floatformat:2 }}</td>
{{ tier.tier_range }} units: {{ tier.units }} × {{ tier.rate|floatformat:4 }} = {{ tier.subtotal|floatformat:2 }}<br> <td>{{ row.sla_per_unit|floatformat:4 }}</td>
{% endfor %} <td>{{ row.sla_price|floatformat:2 }}</td>
{% endif %} <td>
</small> {% if row.has_discount %}
{% else %} {{ row.discount_model }}
<small class="text-muted">No discount applied</small> {% else %}
{% endif %} None
</td> {% endif %}
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td> </td>
</tr> <td>
{% endfor %} {% if row.has_discount %}
</tbody> <small class="text-muted">
</table> <strong>Total Units:</strong> {{ row.total_units }}<br>
</div> <strong>Standard Price:</strong> {{ row.standard_sla_price|floatformat:2 }}<br>
<p class="text-muted"><strong>{{ pricing_data|length }}</strong> variants for {{ service_level }}</p> <strong>Discounted Price:</strong> {{ row.discounted_sla_price|floatformat:2 }}<br>
<strong>Savings:</strong> {{ row.discount_savings|floatformat:2 }} ({{ row.discount_percentage|floatformat:1 }}%)<br>
{% if row.discount_breakdown %}
<strong>Breakdown:</strong><br>
{% for tier in row.discount_breakdown %}
{{ tier.tier_range }} units: {{ tier.units }} × {{ tier.rate|floatformat:4 }} = {{ tier.subtotal|floatformat:2 }}<br>
{% endfor %}
{% endif %}
</small>
{% else %}
<small class="text-muted">No discount applied</small>
{% endif %}
</td>
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="text-muted"><strong>{{ pricing_data|length }}</strong> variants for {{ service_level }} in {{ group_name }}</p>
{% else %}
<p class="text-muted">No pricing variants available for {{ service_level }} in {{ group_name }}.</p>
{% endif %}
</div>
{% empty %}
<p class="text-muted">No service levels with pricing data found for group: {{ group_name }}.</p>
{% endfor %}
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}

View file

@ -5,22 +5,33 @@ from hub.services.models import ComputePlan, VSHNAppCatPrice
def natural_sort_key(name): def natural_sort_key(name):
"""Extract numeric part from compute plan name for natural sorting"""
match = re.search(r"compute-std-(\d+)", name) match = re.search(r"compute-std-(\d+)", name)
return int(match.group(1)) if match else 0 return int(match.group(1)) if match else 0
def pricelist(request): def pricelist(request):
"""Generate comprehensive price list grouped by compute plan groups and service levels"""
# Fetch all active compute plans with related data
compute_plans = ( compute_plans = (
ComputePlan.objects.filter(active=True) ComputePlan.objects.filter(active=True)
.select_related("cloud_provider") .select_related("cloud_provider", "group")
.prefetch_related("prices") .prefetch_related("prices")
.order_by("cloud_provider__name") .order_by("group__order", "group__name", "cloud_provider__name")
) )
# Apply natural sorting for compute plan names
compute_plans = sorted( compute_plans = sorted(
compute_plans, key=lambda x: (x.cloud_provider.name, natural_sort_key(x.name)) compute_plans,
key=lambda x: (
x.group.order if x.group else 999, # No group plans at the end
x.group.name if x.group else "ZZZ",
x.cloud_provider.name,
natural_sort_key(x.name),
),
) )
# Fetch all appcat price configurations
appcat_prices = ( appcat_prices = (
VSHNAppCatPrice.objects.all() VSHNAppCatPrice.objects.all()
.select_related("service", "discount_model") .select_related("service", "discount_model")
@ -28,13 +39,15 @@ def pricelist(request):
.order_by("service__name") .order_by("service__name")
) )
pricing_data_by_service_level = defaultdict(list) pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
processed_combinations = set() processed_combinations = set()
# Generate pricing combinations for each compute plan and service
for plan in compute_plans: for plan in compute_plans:
plan_currencies = set(plan.prices.values_list("currency", flat=True)) plan_currencies = set(plan.prices.values_list("currency", flat=True))
for appcat_price in appcat_prices: for appcat_price in appcat_prices:
# Determine units based on variable unit type
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM: if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
units = int(plan.ram) units = int(plan.ram)
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU: elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
@ -57,6 +70,7 @@ def pricelist(request):
).values_list("currency", flat=True) ).values_list("currency", flat=True)
) )
# Find currencies that exist across all pricing components
matching_currencies = plan_currencies.intersection( matching_currencies = plan_currencies.intersection(
base_fee_currencies base_fee_currencies
).intersection(unit_rate_currencies) ).intersection(unit_rate_currencies)
@ -73,23 +87,25 @@ def pricelist(request):
currency, currency,
) )
# Skip if combination already processed
if combination_key in processed_combinations: if combination_key in processed_combinations:
continue continue
processed_combinations.add(combination_key) processed_combinations.add(combination_key)
# Get pricing components
compute_plan_price = plan.get_price(currency) compute_plan_price = plan.get_price(currency)
if compute_plan_price is None:
continue
base_fee = appcat_price.get_base_fee(currency) base_fee = appcat_price.get_base_fee(currency)
if base_fee is None:
continue
unit_rate = appcat_price.get_unit_rate(currency, service_level) unit_rate = appcat_price.get_unit_rate(currency, service_level)
if unit_rate is None:
# Skip if any pricing component is missing
if any(
price is None
for price in [compute_plan_price, base_fee, unit_rate]
):
continue continue
# Calculate replica enforcement based on service level
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED: if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
replica_enforce = appcat_price.ha_replica_min replica_enforce = appcat_price.ha_replica_min
else: else:
@ -98,6 +114,7 @@ def pricelist(request):
total_units = units * replica_enforce total_units = units * replica_enforce
standard_sla_price = base_fee + (total_units * unit_rate) standard_sla_price = base_fee + (total_units * unit_rate)
# Apply discount if available
discount_breakdown = None discount_breakdown = None
if ( if (
appcat_price.discount_model appcat_price.discount_model
@ -131,11 +148,23 @@ def pricelist(request):
service_level service_level
] ]
pricing_data_by_service_level[service_level_display].append( 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(
{ {
"cloud_provider": plan.cloud_provider.name, "cloud_provider": plan.cloud_provider.name,
"service": appcat_price.service.name, "service": appcat_price.service.name,
"compute_plan": plan.name, "compute_plan": plan.name,
"compute_plan_group": group_name,
"compute_plan_group_description": (
plan.group.description if plan.group else ""
),
"compute_plan_group_node_label": (
plan.group.node_label if plan.group else ""
),
"vcpus": plan.vcpus, "vcpus": plan.vcpus,
"ram": plan.ram, "ram": plan.ram,
"cpu_mem_ratio": plan.cpu_mem_ratio, "cpu_mem_ratio": plan.cpu_mem_ratio,
@ -173,5 +202,26 @@ def pricelist(request):
} }
) )
context = {"pricing_data_by_service_level": dict(pricing_data_by_service_level)} # Order groups correctly, placing "No Group" last
ordered_groups_intermediate = {}
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_intermediate[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_intermediate.items():
final_context_data[group_key] = {
sl_key: list(plans_list)
for sl_key, plans_list in service_levels_dict.items()
}
context = {"pricing_data_by_group_and_service_level": final_context_data}
return render(request, "services/pricelist.html", context) return render(request, "services/pricelist.html", context)