initial work on comparison
This commit is contained in:
parent
06b4cba4bc
commit
4cffe5a9e3
6 changed files with 358 additions and 2 deletions
|
@ -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"
|
||||
|
|
127
hub/services/migrations/0031_externalpriceplans.py
Normal file
127
hub/services/migrations/0031_externalpriceplans.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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}"
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue