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.
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.

View file

@ -10,6 +10,7 @@ from .models import (
Category,
CloudProvider,
ComputePlan,
ComputePlanGroup,
ComputePlanPrice,
ConsultingPartner,
ExternalLink,
@ -206,12 +207,29 @@ class ComputePlanPriceInline(admin.TabularInline):
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):
cloud_provider = Field(
column_name="cloud_provider",
attribute="cloud_provider",
widget=ForeignKeyWidget(CloudProvider, "name"),
)
group = Field(
column_name="group",
attribute="group",
widget=ForeignKeyWidget(ComputePlanGroup, "name"),
)
prices = Field(column_name="prices", attribute=None)
class Meta:
@ -225,6 +243,7 @@ class ComputePlanResource(resources.ModelResource):
"ram",
"cpu_mem_ratio",
"cloud_provider",
"group",
"active",
"term",
"valid_from",
@ -262,14 +281,15 @@ class ComputePlansAdmin(ImportExportModelAdmin):
list_display = (
"name",
"cloud_provider",
"group",
"vcpus",
"ram",
"term",
"display_prices",
"active",
)
search_fields = ("name", "cloud_provider__name")
list_filter = ("active", "cloud_provider")
search_fields = ("name", "cloud_provider__name", "group__name")
list_filter = ("active", "cloud_provider", "group")
ordering = ("name",)
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
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):
compute_plan = models.ForeignKey(
"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"
)
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_to = models.DateTimeField(blank=True, null=True)

View file

@ -8,10 +8,29 @@
<div class="col-12">
<h1 class="mb-4">Complete Price List - All Service Variants</h1>
{% if pricing_data_by_service_level %}
{% for service_level, pricing_data in pricing_data_by_service_level.items %}
<div class="mb-5">
<h2 class="mb-3">{{ service_level }}</h2>
{% if pricing_data_by_group_and_service_level %}
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
<div class="mb-5 border rounded p-3">
<h2 class="mb-3 text-primary">{{ group_name }}</h2>
{# Display group description and node_label from first available plan #}
{% 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 %}
<p class="text-muted mb-2"><strong>Description:</strong> {{ representative_plan.compute_plan_group_description }}</p>
{% endif %}
{% if representative_plan.compute_plan_group_node_label %}
<p class="text-muted mb-3"><strong>Node Label:</strong> <code>{{ representative_plan.compute_plan_group_node_label }}</code></p>
{% endif %}
{% endwith %}
{% endif %}
{% endfor %}
{% for service_level, pricing_data in service_levels.items %}
<div class="mb-4">
<h3 class="mb-3 text-secondary">{{ service_level }}</h3>
{% if pricing_data %}
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm">
<thead class="table-dark">
@ -85,7 +104,14 @@
</tbody>
</table>
</div>
<p class="text-muted"><strong>{{ pricing_data|length }}</strong> variants for {{ service_level }}</p>
<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>
{% endfor %}
{% else %}

View file

@ -5,22 +5,33 @@ from hub.services.models import ComputePlan, VSHNAppCatPrice
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 pricelist(request):
"""Generate comprehensive price list grouped by compute plan groups and service levels"""
# Fetch all active compute plans with related data
compute_plans = (
ComputePlan.objects.filter(active=True)
.select_related("cloud_provider")
.select_related("cloud_provider", "group")
.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, 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 = (
VSHNAppCatPrice.objects.all()
.select_related("service", "discount_model")
@ -28,13 +39,15 @@ def pricelist(request):
.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()
# Generate pricing combinations for each compute plan and service
for plan in compute_plans:
plan_currencies = set(plan.prices.values_list("currency", flat=True))
for appcat_price in appcat_prices:
# 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:
@ -57,6 +70,7 @@ def pricelist(request):
).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)
@ -73,23 +87,25 @@ def pricelist(request):
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)
if compute_plan_price is None:
continue
base_fee = appcat_price.get_base_fee(currency)
if base_fee is None:
continue
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
# Calculate replica enforcement based on service level
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
replica_enforce = appcat_price.ha_replica_min
else:
@ -98,6 +114,7 @@ def pricelist(request):
total_units = units * replica_enforce
standard_sla_price = base_fee + (total_units * unit_rate)
# Apply discount if available
discount_breakdown = None
if (
appcat_price.discount_model
@ -131,11 +148,23 @@ def pricelist(request):
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,
"service": appcat_price.service.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,
"ram": plan.ram,
"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)