continued work on price model
This commit is contained in:
parent
6f41c8c344
commit
a6a15150ea
10 changed files with 500 additions and 1 deletions
|
@ -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)"
|
||||
|
|
87
hub/services/migrations/0024_storageplan_storageplanprice.py
Normal file
87
hub/services/migrations/0024_storageplan_storageplanprice.py
Normal 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")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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")
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
|
|
78
hub/services/templates/services/pricelist.html
Normal file
78
hub/services/templates/services/pricelist.html
Normal 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 %}
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -5,3 +5,4 @@ from .providers import *
|
|||
from .services import *
|
||||
from .pages import *
|
||||
from .subscriptions import *
|
||||
from .pricelist import *
|
||||
|
|
72
hub/services/views/pricelist.py
Normal file
72
hub/services/views/pricelist.py
Normal 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)
|
|
@ -246,6 +246,7 @@ JAZZMIN_SETTINGS = {
|
|||
],
|
||||
"show_sidebar": True,
|
||||
"navigation_expanded": True,
|
||||
"hide_apps": ["hub.broker"],
|
||||
}
|
||||
|
||||
IMPORT_EXPORT_FORMATS = [CSV]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue