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,
|
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"
|
||||||
|
|
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):
|
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}"
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue