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, VSHNAppCatUnitRate,
ProgressiveDiscountModel, ProgressiveDiscountModel,
DiscountTier, DiscountTier,
ExternalPricePlans,
) )
@ -308,3 +309,33 @@ class StoragePlanAdmin(ImportExportModelAdmin):
return format_html("<br>".join([f"{p.amount} {p.currency}" for p in prices])) return format_html("<br>".join([f"{p.amount} {p.currency}" for p in prices]))
display_prices.short_description = "Prices (Amount Currency)" 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): def __str__(self):
return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}" 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 Show discount details
</label> </label>
</div> </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>
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-primary">Apply Filters</button> <button type="submit" class="btn btn-primary">Apply Filters</button>
@ -153,6 +159,9 @@
<th>Discount Model</th> <th>Discount Model</th>
<th>Discount Details</th> <th>Discount Details</th>
{% endif %} {% endif %}
{% if show_price_comparison %}
<th>External Comparisons</th>
{% endif %}
<th class="final-price-header">Final Price</th> <th class="final-price-header">Final Price</th>
</tr> </tr>
</thead> </thead>
@ -196,6 +205,39 @@
{% endif %} {% endif %}
</td> </td>
{% endif %} {% 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> <td class="final-price-cell fw-bold">{{ row.final_price|floatformat:2 }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -250,6 +292,12 @@ document.addEventListener('DOMContentLoaded', function() {
filterForm.submit(); 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 // Chart data for each service level
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %} {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
{% for service_level, pricing_data in service_levels.items %} {% for service_level, pricing_data in service_levels.items %}

View file

@ -1,8 +1,10 @@
from django.shortcuts import render
import re import re
from django.shortcuts import render
from collections import defaultdict 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.contrib.admin.views.decorators import staff_member_required
from django.db import models
def natural_sort_key(name): def natural_sort_key(name):
@ -11,11 +13,32 @@ def natural_sort_key(name):
return int(match.group(1)) if match else 0 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 @staff_member_required
def pricelist(request): def pricelist(request):
"""Generate comprehensive price list grouped by compute plan groups and service levels""" """Generate comprehensive price list grouped by compute plan groups and service levels"""
# Get filter parameters from request # Get filter parameters from request
show_discount_details = request.GET.get("discount_details", "").lower() == "true" 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_cloud_provider = request.GET.get("cloud_provider", "")
filter_service = request.GET.get("service", "") filter_service = request.GET.get("service", "")
filter_compute_plan_group = request.GET.get("compute_plan_group", "") filter_compute_plan_group = request.GET.get("compute_plan_group", "")
@ -179,6 +202,38 @@ def pricelist(request):
service_level 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" group_name = plan.group.name if plan.group else "No Group"
# Add pricing data to the grouped structure # Add pricing data to the grouped structure
@ -230,6 +285,7 @@ def pricelist(request):
appcat_price.discount_model appcat_price.discount_model
and appcat_price.discount_model.active and appcat_price.discount_model.active
), ),
"external_comparisons": external_comparisons,
} }
) )
@ -278,6 +334,7 @@ def pricelist(request):
context = { context = {
"pricing_data_by_group_and_service_level": final_context_data, "pricing_data_by_group_and_service_level": final_context_data,
"show_discount_details": show_discount_details, "show_discount_details": show_discount_details,
"show_price_comparison": show_price_comparison,
"filter_cloud_provider": filter_cloud_provider, "filter_cloud_provider": filter_cloud_provider,
"filter_service": filter_service, "filter_service": filter_service,
"filter_compute_plan_group": filter_compute_plan_group, "filter_compute_plan_group": filter_compute_plan_group,