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

@ -1,6 +1,6 @@
This is a Django project which uses SQLite as the database. This is a Django project which uses SQLite as the database.
Follow the Django conventions and best practices, use the Django ORM and define the fields with appropriate types Follow the Django conventions and best practices, use the Django ORM and define the fields with appropriate types
Use class-based views and follow the Django conventions for naming and structuring views. Use function-based views and follow the Django conventions for naming and structuring views.
Templates use the Django template language and Bootstrap 5 CSS and JavaScript for styling. Templates use the Django template language and Bootstrap 5 CSS and JavaScript for styling.
The main Django app is in hub/services, ignore the app hub/broker. The main Django app is in hub/services, ignore the app hub/broker.
Docker specific code is in the folder docker/. Docker specific code is in the folder docker/.

View file

@ -19,6 +19,8 @@ from .models import (
ReusableText, ReusableText,
Service, Service,
ServiceOffering, ServiceOffering,
StoragePlan,
StoragePlanPrice,
VSHNAppCatBaseFee, VSHNAppCatBaseFee,
VSHNAppCatPrice, VSHNAppCatPrice,
VSHNAppCatUnitRate, VSHNAppCatUnitRate,
@ -208,6 +210,7 @@ class ComputePlanResource(resources.ModelResource):
attribute="cloud_provider", attribute="cloud_provider",
widget=ForeignKeyWidget(CloudProvider, "name"), widget=ForeignKeyWidget(CloudProvider, "name"),
) )
prices = Field(column_name="prices", attribute=None)
class Meta: class Meta:
model = ComputePlan model = ComputePlan
@ -221,10 +224,35 @@ class ComputePlanResource(resources.ModelResource):
"cpu_mem_ratio", "cpu_mem_ratio",
"cloud_provider", "cloud_provider",
"active", "active",
"term",
"valid_from", "valid_from",
"valid_to", "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) @admin.register(ComputePlan)
class ComputePlansAdmin(ImportExportModelAdmin): class ComputePlansAdmin(ImportExportModelAdmin):
@ -234,6 +262,7 @@ class ComputePlansAdmin(ImportExportModelAdmin):
"cloud_provider", "cloud_provider",
"vcpus", "vcpus",
"ram", "ram",
"term",
"display_prices", "display_prices",
"active", "active",
) )
@ -268,6 +297,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"service", "service",
"variable_unit", "variable_unit",
"term",
"admin_display_base_fees", "admin_display_base_fees",
"admin_display_unit_rates", "admin_display_unit_rates",
) )
@ -299,3 +329,80 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
) )
admin_display_unit_rates.short_description = "Unit Rates" 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" 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): class ComputePlanPrice(models.Model):
compute_plan = models.ForeignKey( compute_plan = models.ForeignKey(
"ComputePlan", on_delete=models.CASCADE, related_name="prices" "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?" 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?") 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( cloud_provider = models.ForeignKey(
CloudProvider, on_delete=models.CASCADE, related_name="compute_plans" CloudProvider, on_delete=models.CASCADE, related_name="compute_plans"
@ -413,6 +431,61 @@ class ComputePlan(models.Model):
return None 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): class VSHNAppCatBaseFee(models.Model):
vshn_appcat_price_config = models.ForeignKey( vshn_appcat_price_config = models.ForeignKey(
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees" "VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
@ -459,6 +532,11 @@ class VSHNAppCatPrice(models.Model):
ha_replica_max = models.IntegerField( ha_replica_max = models.IntegerField(
default=1, help_text="Maximum supported replicas" default=1, help_text="Maximum supported replicas"
) )
term = models.CharField(
max_length=3,
default=Term.MTH,
choices=Term.choices,
)
def __str__(self): def __str__(self):
return f"{self.service.name} - {self.get_variable_unit_display()} based pricing" 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/thank-you/", views.thank_you, name="thank_you"),
path("contact-form/", views.contact_form, name="contact_form"), path("contact-form/", views.contact_form, name="contact_form"),
path("subscribe/", views.subscribe, name="subscribe"), 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 .services import *
from .pages import * from .pages import *
from .subscriptions 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, "show_sidebar": True,
"navigation_expanded": True, "navigation_expanded": True,
"hide_apps": ["hub.broker"],
} }
IMPORT_EXPORT_FORMATS = [CSV] IMPORT_EXPORT_FORMATS = [CSV]