initial work on comparison

This commit is contained in:
Tobias Brunner 2025-05-27 17:07:55 +02:00
parent 06b4cba4bc
commit 4cffe5a9e3
No known key found for this signature in database
6 changed files with 358 additions and 2 deletions

View file

@ -22,6 +22,7 @@ from ..models import (
VSHNAppCatUnitRate,
ProgressiveDiscountModel,
DiscountTier,
ExternalPricePlans,
)
@ -308,3 +309,33 @@ class StoragePlanAdmin(ImportExportModelAdmin):
return format_html("<br>".join([f"{p.amount} {p.currency}" for p in prices]))
display_prices.short_description = "Prices (Amount Currency)"
@admin.register(ExternalPricePlans)
class ExternalPricePlansAdmin(admin.ModelAdmin):
"""Admin configuration for ExternalPricePlans model"""
list_display = (
"plan_name",
"cloud_provider",
"service",
"currency",
"amount",
"display_compare_to_count",
"date_retrieved",
)
list_filter = ("cloud_provider", "service", "currency", "term")
search_fields = ("plan_name", "cloud_provider__name", "service__name")
ordering = ("cloud_provider", "service", "plan_name")
# Configure many-to-many field display
filter_horizontal = ("compare_to",)
def display_compare_to_count(self, obj):
"""Display count of compute plans this external price compares to"""
count = obj.compare_to.count()
if count == 0:
return "No comparisons"
return f"{count} plan{'s' if count != 1 else ''}"
display_compare_to_count.short_description = "Compare To"

View file

@ -0,0 +1,127 @@
# Generated by Django 5.2 on 2025-05-27 14:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0030_serviceoffering_msp"),
]
operations = [
migrations.CreateModel(
name="ExternalPricePlans",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("plan_name", models.CharField()),
(
"description",
models.CharField(blank=True, max_length=200, null=True),
),
("source", models.URLField(blank=True, null=True)),
("date_retrieved", models.DateField(blank=True, null=True)),
(
"currency",
models.CharField(
choices=[
("CHF", "Swiss Franc"),
("EUR", "Euro"),
("USD", "US Dollar"),
],
default="CHF",
max_length=3,
),
),
(
"term",
models.CharField(
choices=[
("MTH", "per Month (30d)"),
("DAY", "per Day"),
("HR", "per Hour"),
("MIN", "per Minute"),
],
default="MTH",
max_length=3,
),
),
(
"amount",
models.DecimalField(
decimal_places=4,
help_text="Price per unit in the specified currency, excl. VAT",
max_digits=10,
),
),
(
"vcpus",
models.FloatField(
blank=True, help_text="Number of included vCPUs", null=True
),
),
(
"ram",
models.FloatField(
blank=True, help_text="Amount of GiB RAM included", null=True
),
),
(
"storage",
models.FloatField(
blank=True, help_text="Amount of GiB included", null=True
),
),
("competitor_sla", models.CharField(blank=True, null=True)),
("replicas", models.IntegerField(blank=True, null=True)),
(
"cloud_provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="external_price",
to="services.cloudprovider",
),
),
(
"compare_to",
models.ManyToManyField(
blank=True,
null=True,
related_name="external_prices",
to="services.computeplan",
),
),
(
"service",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="external_price",
to="services.service",
),
),
(
"vshn_appcat_price",
models.ForeignKey(
blank=True,
help_text="Specific VSHN AppCat price configuration to compare against",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="external_comparisons",
to="services.vshnappcatprice",
),
),
],
options={
"verbose_name": "External Price",
},
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.2 on 2025-05-27 15:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0031_externalpriceplans"),
]
operations = [
migrations.AddField(
model_name="externalpriceplans",
name="service_level",
field=models.CharField(
blank=True,
choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")],
help_text="Service level equivalent for comparison",
max_length=2,
null=True,
),
),
]

View file

@ -382,3 +382,72 @@ class VSHNAppCatUnitRate(models.Model):
def __str__(self):
return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
class ExternalPricePlans(models.Model):
plan_name = models.CharField()
description = models.CharField(max_length=200, blank=True, null=True)
source = models.URLField(blank=True, null=True)
date_retrieved = models.DateField(blank=True, null=True)
## Relations
cloud_provider = models.ForeignKey(
CloudProvider, on_delete=models.CASCADE, related_name="external_price"
)
service = models.ForeignKey(
Service, on_delete=models.CASCADE, related_name="external_price"
)
compare_to = models.ManyToManyField(
ComputePlan, related_name="external_prices", blank=True, null=True
)
vshn_appcat_price = models.ForeignKey(
VSHNAppCatPrice,
on_delete=models.CASCADE,
related_name="external_comparisons",
blank=True,
null=True,
help_text="Specific VSHN AppCat price configuration to compare against",
)
service_level = models.CharField(
max_length=2,
choices=VSHNAppCatPrice.ServiceLevel.choices,
blank=True,
null=True,
help_text="Service level equivalent for comparison",
)
## Money
currency = models.CharField(
max_length=3,
default=Currency.CHF,
choices=Currency.choices,
)
term = models.CharField(
max_length=3,
default=Term.MTH,
choices=Term.choices,
)
amount = models.DecimalField(
max_digits=10,
decimal_places=4,
help_text="Price per unit in the specified currency, excl. VAT",
)
## Offering
vcpus = models.FloatField(
help_text="Number of included vCPUs", blank=True, null=True
)
ram = models.FloatField(
help_text="Amount of GiB RAM included", blank=True, null=True
)
storage = models.FloatField(
help_text="Amount of GiB included", blank=True, null=True
)
competitor_sla = models.CharField(blank=True, null=True)
replicas = models.IntegerField(blank=True, null=True)
class Meta:
verbose_name = "External Price"
def __str__(self):
return f"{self.cloud_provider.name} - {self.service.name} - {self.plan_name}"

View file

@ -71,6 +71,12 @@
Show discount details
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="price_comparison" value="true" id="price_comparison" {% if show_price_comparison %}checked{% endif %}>
<label class="form-check-label" for="price_comparison">
Show external price comparisons
</label>
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Apply Filters</button>
@ -153,6 +159,9 @@
<th>Discount Model</th>
<th>Discount Details</th>
{% endif %}
{% if show_price_comparison %}
<th>External Comparisons</th>
{% endif %}
<th class="final-price-header">Final Price</th>
</tr>
</thead>
@ -196,6 +205,39 @@
{% endif %}
</td>
{% endif %}
{% if show_price_comparison %}
<td>
{% if row.external_comparisons %}
<small>
{% for comparison in row.external_comparisons %}
<div class="mb-2 border-bottom pb-1">
<strong>{{ comparison.provider }}</strong>: {{ comparison.plan_name }}<br>
<span class="text-primary">{{ comparison.amount|floatformat:2 }} {{ row.currency }}</span>
{% if comparison.difference > 0 %}
<span class="badge ms-1">+{{ comparison.difference|floatformat:2 }}</span>
{% elif comparison.difference < 0 %}
<span class="badge ms-1">{{ comparison.difference|floatformat:2 }}</span>
{% endif %}
{% if comparison.ratio %}
<br><small class="text-muted">Price ratio: {{ comparison.ratio|floatformat:2 }}x</small>
{% endif %}
{% if comparison.description %}
<br><small class="text-muted">{{ comparison.description }}</small>
{% endif %}
{% if comparison.vcpus or comparison.ram %}
<br><small class="text-muted">
{% if comparison.vcpus %}{{ comparison.vcpus }} vCPU{% endif %}
{% if comparison.ram %}{{ comparison.ram }} GB RAM{% endif %}
</small>
{% endif %}
</div>
{% endfor %}
</small>
{% else %}
<small class="text-muted">No comparisons</small>
{% endif %}
</td>
{% endif %}
<td class="final-price-cell fw-bold">{{ row.final_price|floatformat:2 }}</td>
</tr>
{% endfor %}
@ -250,6 +292,12 @@ document.addEventListener('DOMContentLoaded', function() {
filterForm.submit();
});
// Add change event listener to price comparison checkbox
const priceComparisonCheckbox = document.getElementById('price_comparison');
priceComparisonCheckbox.addEventListener('change', function() {
filterForm.submit();
});
// Chart data for each service level
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
{% for service_level, pricing_data in service_levels.items %}

View file

@ -1,8 +1,10 @@
from django.shortcuts import render
import re
from django.shortcuts import render
from collections import defaultdict
from hub.services.models import ComputePlan, VSHNAppCatPrice
from hub.services.models import ComputePlan, VSHNAppCatPrice, ExternalPricePlans
from django.contrib.admin.views.decorators import staff_member_required
from django.db import models
def natural_sort_key(name):
@ -11,11 +13,32 @@ def natural_sort_key(name):
return int(match.group(1)) if match else 0
def get_external_price_comparisons(plan, appcat_price, currency, service_level):
"""Get external price comparisons for a specific compute plan and service"""
try:
# Filter by service level if external price has one set
external_prices = ExternalPricePlans.objects.filter(
compare_to=plan, service=appcat_price.service, currency=currency
).select_related("cloud_provider")
# Filter by service level if the external price has it configured
if service_level:
external_prices = external_prices.filter(
models.Q(service_level=service_level)
| models.Q(service_level__isnull=True)
)
return external_prices
except Exception:
return []
@staff_member_required
def pricelist(request):
"""Generate comprehensive price list grouped by compute plan groups and service levels"""
# Get filter parameters from request
show_discount_details = request.GET.get("discount_details", "").lower() == "true"
show_price_comparison = request.GET.get("price_comparison", "").lower() == "true"
filter_cloud_provider = request.GET.get("cloud_provider", "")
filter_service = request.GET.get("service", "")
filter_compute_plan_group = request.GET.get("compute_plan_group", "")
@ -179,6 +202,38 @@ def pricelist(request):
service_level
]
# Get external price comparisons if enabled
external_comparisons = []
if show_price_comparison:
external_prices = get_external_price_comparisons(
plan, appcat_price, currency, service_level
)
for ext_price in external_prices:
price_difference = float(ext_price.amount) - float(
final_price
)
price_ratio = (
float(ext_price.amount) / float(final_price)
if final_price > 0
else None
)
external_comparisons.append(
{
"provider": ext_price.cloud_provider.name,
"plan_name": ext_price.plan_name,
"amount": ext_price.amount,
"difference": price_difference,
"ratio": price_ratio,
"description": ext_price.description,
"vcpus": ext_price.vcpus,
"ram": ext_price.ram,
"storage": ext_price.storage,
"replicas": ext_price.replicas,
}
)
group_name = plan.group.name if plan.group else "No Group"
# Add pricing data to the grouped structure
@ -230,6 +285,7 @@ def pricelist(request):
appcat_price.discount_model
and appcat_price.discount_model.active
),
"external_comparisons": external_comparisons,
}
)
@ -278,6 +334,7 @@ def pricelist(request):
context = {
"pricing_data_by_group_and_service_level": final_context_data,
"show_discount_details": show_discount_details,
"show_price_comparison": show_price_comparison,
"filter_cloud_provider": filter_cloud_provider,
"filter_service": filter_service,
"filter_compute_plan_group": filter_compute_plan_group,