continued work on price model

This commit is contained in:
Tobias Brunner 2025-05-22 16:34:15 +02:00
parent 6f41c8c344
commit a6a15150ea
No known key found for this signature in database
10 changed files with 500 additions and 1 deletions

View file

@ -19,6 +19,8 @@ from .models import (
ReusableText,
Service,
ServiceOffering,
StoragePlan,
StoragePlanPrice,
VSHNAppCatBaseFee,
VSHNAppCatPrice,
VSHNAppCatUnitRate,
@ -208,6 +210,7 @@ class ComputePlanResource(resources.ModelResource):
attribute="cloud_provider",
widget=ForeignKeyWidget(CloudProvider, "name"),
)
prices = Field(column_name="prices", attribute=None)
class Meta:
model = ComputePlan
@ -221,10 +224,35 @@ class ComputePlanResource(resources.ModelResource):
"cpu_mem_ratio",
"cloud_provider",
"active",
"term",
"valid_from",
"valid_to",
"prices",
)
def dehydrate_prices(self, compute_plan):
prices = compute_plan.prices.all()
if not prices:
return ""
return "|".join([f"{p.currency} {p.amount}" for p in prices])
def save_m2m(self, instance, row, *args, **kwargs):
super().save_m2m(instance, row, *args, **kwargs)
# Handle prices
if "prices" in row and row["prices"]:
# Clear existing prices first
instance.prices.all().delete()
# Create new prices
price_entries = row["prices"].split("|")
for entry in price_entries:
if " " in entry:
currency, amount = entry.split(" ")
ComputePlanPrice.objects.create(
compute_plan=instance, currency=currency, amount=amount
)
@admin.register(ComputePlan)
class ComputePlansAdmin(ImportExportModelAdmin):
@ -234,6 +262,7 @@ class ComputePlansAdmin(ImportExportModelAdmin):
"cloud_provider",
"vcpus",
"ram",
"term",
"display_prices",
"active",
)
@ -268,6 +297,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
list_display = (
"service",
"variable_unit",
"term",
"admin_display_base_fees",
"admin_display_unit_rates",
)
@ -299,3 +329,80 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
)
admin_display_unit_rates.short_description = "Unit Rates"
class StoragePlanPriceInline(admin.TabularInline):
model = StoragePlanPrice
extra = 1
fields = ("currency", "amount")
class StoragePlanResource(resources.ModelResource):
cloud_provider = Field(
column_name="cloud_provider",
attribute="cloud_provider",
widget=ForeignKeyWidget(CloudProvider, "name"),
)
prices = Field(column_name="prices", attribute=None)
class Meta:
model = StoragePlan
skip_unchanged = True
report_skipped = False
import_id_fields = ["name"]
fields = (
"name",
"cloud_provider",
"term",
"unit",
"valid_from",
"valid_to",
"prices",
)
def dehydrate_prices(self, storage_plan):
prices = storage_plan.prices.all()
if not prices:
return ""
return "|".join([f"{p.currency} {p.amount}" for p in prices])
def save_m2m(self, instance, row, *args, **kwargs):
super().save_m2m(instance, row, *args, **kwargs)
# Handle prices
if "prices" in row and row["prices"]:
# Clear existing prices first
instance.prices.all().delete()
# Create new prices
price_entries = row["prices"].split("|")
for entry in price_entries:
if " " in entry:
currency, amount = entry.split(" ")
StoragePlanPrice.objects.create(
storage_plan=instance, currency=currency, amount=amount
)
@admin.register(StoragePlan)
class StoragePlanAdmin(ImportExportModelAdmin):
resource_class = StoragePlanResource
list_display = (
"name",
"cloud_provider",
"term",
"unit",
"display_prices",
)
search_fields = ("name", "cloud_provider__name")
list_filter = ("cloud_provider",)
ordering = ("name",)
inlines = [StoragePlanPriceInline]
def display_prices(self, obj):
prices = obj.prices.all()
if not prices:
return "No prices set"
return format_html("<br>".join([f"{p.amount} {p.currency}" for p in prices]))
display_prices.short_description = "Prices (Amount Currency)"

View file

@ -0,0 +1,87 @@
# Generated by Django 5.2 on 2025-05-22 14:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0023_alter_computeplan_options_and_more"),
]
operations = [
migrations.CreateModel(
name="StoragePlan",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200)),
("valid_from", models.DateTimeField(blank=True, null=True)),
("valid_to", models.DateTimeField(blank=True, null=True)),
(
"cloud_provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="storage_plans",
to="services.cloudprovider",
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="StoragePlanPrice",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"currency",
models.CharField(
choices=[
("CHF", "Swiss Franc"),
("EUR", "Euro"),
("USD", "US Dollar"),
],
max_length=3,
),
),
(
"amount",
models.DecimalField(
decimal_places=2,
help_text="Price in the specified currency, excl. VAT",
max_digits=10,
),
),
(
"storage_plan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="prices",
to="services.storageplan",
),
),
],
options={
"ordering": ["currency"],
"unique_together": {("storage_plan", "currency")},
},
),
]

View file

@ -0,0 +1,70 @@
# Generated by Django 5.2 on 2025-05-22 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0024_storageplan_storageplanprice"),
]
operations = [
migrations.AddField(
model_name="computeplan",
name="term",
field=models.CharField(
choices=[
("MTH", "per Month (30d)"),
("DAY", "per Day"),
("HR", "per Hour"),
("MIN", "per Minute"),
],
default="MTH",
max_length=3,
),
),
migrations.AddField(
model_name="storageplan",
name="term",
field=models.CharField(
choices=[
("MTH", "per Month (30d)"),
("DAY", "per Day"),
("HR", "per Hour"),
("MIN", "per Minute"),
],
default="MTH",
max_length=3,
),
),
migrations.AddField(
model_name="storageplan",
name="unit",
field=models.CharField(
choices=[("GIB", "GiB"), ("MIB", "MiB"), ("CPU", "vCPU")],
default="GIB",
max_length=3,
),
),
migrations.AddField(
model_name="vshnappcatprice",
name="term",
field=models.CharField(
choices=[
("MTH", "per Month (30d)"),
("DAY", "per Day"),
("HR", "per Hour"),
("MIN", "per Minute"),
],
default="MTH",
max_length=3,
),
),
migrations.AlterUniqueTogether(
name="storageplan",
unique_together={
("cloud_provider", "term", "unit", "valid_from", "valid_to")
},
),
]

View file

@ -362,6 +362,19 @@ class Currency(models.TextChoices):
USD = "USD", "US Dollar"
class Term(models.TextChoices):
MTH = "MTH", "per Month (30d)"
DAY = "DAY", "per Day"
HR = "HR", "per Hour"
MIN = "MIN", "per Minute"
class Unit(models.TextChoices):
GIB = "GIB", "GiB"
MIB = "MIB", "MiB"
CPU = "CPU", "vCPU"
class ComputePlanPrice(models.Model):
compute_plan = models.ForeignKey(
"ComputePlan", on_delete=models.CASCADE, related_name="prices"
@ -392,6 +405,11 @@ class ComputePlan(models.Model):
help_text="vCPU to Memory ratio. How much vCPU per GiB RAM is available?"
)
active = models.BooleanField(default=True, help_text="Is the plan active?")
term = models.CharField(
max_length=3,
default=Term.MTH,
choices=Term.choices,
)
cloud_provider = models.ForeignKey(
CloudProvider, on_delete=models.CASCADE, related_name="compute_plans"
@ -413,6 +431,61 @@ class ComputePlan(models.Model):
return None
class StoragePlanPrice(models.Model):
storage_plan = models.ForeignKey(
"StoragePlan", on_delete=models.CASCADE, related_name="prices"
)
currency = models.CharField(
max_length=3,
choices=Currency.choices,
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
help_text="Price in the specified currency, excl. VAT",
)
class Meta:
unique_together = ("storage_plan", "currency")
ordering = ["currency"]
def __str__(self):
return f"{self.storage_plan.name} - {self.amount} {self.currency}"
class StoragePlan(models.Model):
name = models.CharField(max_length=200)
cloud_provider = models.ForeignKey(
CloudProvider, on_delete=models.CASCADE, related_name="storage_plans"
)
term = models.CharField(
max_length=3,
default=Term.MTH,
choices=Term.choices,
)
unit = models.CharField(
max_length=3,
default=Unit.GIB,
choices=Unit.choices,
)
valid_from = models.DateTimeField(blank=True, null=True)
valid_to = models.DateTimeField(blank=True, null=True)
class Meta:
unique_together = ("cloud_provider", "term", "unit", "valid_from", "valid_to")
ordering = ["name"]
def __str__(self):
return self.name
def get_price(self, currency_code: str):
try:
return self.prices.get(currency=currency_code).amount
except ComputePlanPrice.DoesNotExist:
return None
class VSHNAppCatBaseFee(models.Model):
vshn_appcat_price_config = models.ForeignKey(
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
@ -459,6 +532,11 @@ class VSHNAppCatPrice(models.Model):
ha_replica_max = models.IntegerField(
default=1, help_text="Maximum supported replicas"
)
term = models.CharField(
max_length=3,
default=Term.MTH,
choices=Term.choices,
)
def __str__(self):
return f"{self.service.name} - {self.get_variable_unit_display()} based pricing"

View file

@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% block content %}
<h1>Compute Plan Price Comparison</h1>
{% for plan_data in plans_data %}
<div class="card mb-4">
<div class="card-header">
<h2>{{ plan_data.plan.name }}</h2>
</div>
<div class="card-body">
<h3>Plan Details</h3>
<table class="table table-striped">
<tr>
<th>Cloud Provider:</th>
<td>{{ plan_data.plan.cloud_provider.name }}</td>
</tr>
<tr>
<th>vCPUs:</th>
<td>{{ plan_data.plan.vcpus }}</td>
</tr>
<tr>
<th>RAM:</th>
<td>{{ plan_data.plan.ram }} GB</td>
</tr>
<tr>
<th>CPU/Memory Ratio:</th>
<td>{{ plan_data.plan.cpu_mem_ratio }}</td>
</tr>
<tr>
<th>Compute Plan Prices:</th>
<td>
{% for price in plan_data.plan.prices.all %}
{{ price.amount }} {{ price.currency }}<br>
{% empty %}
No prices set
{% endfor %}
</td>
</tr>
</table>
<h3>Calculated AppCat Prices</h3>
{% if plan_data.calculated_prices %}
<table class="table table-bordered">
<thead>
<tr>
<th>Service</th>
<th>Variable Unit</th>
<th>Service Level</th>
<th>Units</th>
<th>Currency</th>
<th>Final Price</th>
</tr>
</thead>
<tbody>
{% for price in plan_data.calculated_prices %}
<tr>
<td>{{ price.service }}</td>
<td>{{ price.variable_unit }}</td>
<td>{{ price.service_level }}</td>
<td>{{ price.units }}</td>
<td>{{ price.currency }}</td>
<td>{{ price.price }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No AppCat prices calculated for this plan.</p>
{% endif %}
</div>
</div>
{% empty %}
<div class="alert alert-info">
No compute plans found.
</div>
{% endfor %}
{% endblock %}

View file

@ -22,4 +22,9 @@ urlpatterns = [
path("contact/thank-you/", views.thank_you, name="thank_you"),
path("contact-form/", views.contact_form, name="contact_form"),
path("subscribe/", views.subscribe, name="subscribe"),
path(
"pricelist/",
views.compute_plan_price_comparison,
name="pricelist",
),
]

View file

@ -5,3 +5,4 @@ from .providers import *
from .services import *
from .pages import *
from .subscriptions import *
from .pricelist import *

View file

@ -0,0 +1,72 @@
from django.shortcuts import render
from hub.services.models import ComputePlan, VSHNAppCatPrice, VSHNAppCatUnitRate
def compute_plan_price_comparison(request):
# Get all compute plans and app catalog prices
compute_plans = (
ComputePlan.objects.all()
.select_related("cloud_provider")
.prefetch_related("prices")
)
appcat_prices = (
VSHNAppCatPrice.objects.all()
.select_related("service")
.prefetch_related("base_fees", "unit_rates")
)
plans_data = []
for plan in compute_plans:
plan_data = {"plan": plan, "calculated_prices": []}
for price_config in appcat_prices:
# Get all service levels for this price config
service_levels = (
VSHNAppCatUnitRate.objects.filter(vshn_appcat_price_config=price_config)
.values_list("service_level", flat=True)
.distinct()
)
# Determine number of units based on variable_unit
if price_config.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
units = int(plan.ram)
elif price_config.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
units = int(plan.vcpus)
else:
continue # Skip other unit type as we don't know yet how to handle them
# Get all currencies used in base fees
currencies = price_config.base_fees.values_list(
"currency", flat=True
).distinct()
# Calculate prices for all combinations
for service_level in service_levels:
for currency in currencies:
final_price = price_config.calculate_final_price(
currency_code=currency,
service_level=service_level,
number_of_units=units,
)
if final_price is not None:
service_level_display = dict(
VSHNAppCatPrice.ServiceLevel.choices
)[service_level]
plan_data["calculated_prices"].append(
{
"service": price_config.service.name,
"variable_unit": price_config.get_variable_unit_display(),
"service_level": service_level_display,
"units": units,
"currency": currency,
"price": final_price,
}
)
plans_data.append(plan_data)
context = {"plans_data": plans_data}
return render(request, "services/pricelist.html", context)

View file

@ -246,6 +246,7 @@ JAZZMIN_SETTINGS = {
],
"show_sidebar": True,
"navigation_expanded": True,
"hide_apps": ["hub.broker"],
}
IMPORT_EXPORT_FORMATS = [CSV]