add addons to services
This commit is contained in:
parent
b96b186875
commit
22e527bcd9
8 changed files with 1039 additions and 4 deletions
|
@ -25,6 +25,9 @@ from ..models import (
|
||||||
VSHNAppCatBaseFee,
|
VSHNAppCatBaseFee,
|
||||||
VSHNAppCatPrice,
|
VSHNAppCatPrice,
|
||||||
VSHNAppCatUnitRate,
|
VSHNAppCatUnitRate,
|
||||||
|
VSHNAppCatAddon,
|
||||||
|
VSHNAppCatAddonBaseFee,
|
||||||
|
VSHNAppCatAddonUnitRate,
|
||||||
ProgressiveDiscountModel,
|
ProgressiveDiscountModel,
|
||||||
DiscountTier,
|
DiscountTier,
|
||||||
ExternalPricePlans,
|
ExternalPricePlans,
|
||||||
|
@ -297,6 +300,15 @@ class VSHNAppCatUnitRateInline(admin.TabularInline):
|
||||||
fields = ("currency", "service_level", "amount")
|
fields = ("currency", "service_level", "amount")
|
||||||
|
|
||||||
|
|
||||||
|
class VSHNAppCatAddonInline(admin.TabularInline):
|
||||||
|
"""Inline admin for VSHNAppCatAddon model within the VSHNAppCatPrice admin"""
|
||||||
|
|
||||||
|
model = VSHNAppCatAddon
|
||||||
|
extra = 1
|
||||||
|
fields = ("name", "addon_type", "mandatory", "active")
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
class DiscountTierInline(admin.TabularInline):
|
class DiscountTierInline(admin.TabularInline):
|
||||||
"""Inline admin for DiscountTier model"""
|
"""Inline admin for DiscountTier model"""
|
||||||
|
|
||||||
|
@ -330,7 +342,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
|
||||||
)
|
)
|
||||||
list_filter = ("variable_unit", "service", "discount_model")
|
list_filter = ("variable_unit", "service", "discount_model")
|
||||||
search_fields = ("service__name",)
|
search_fields = ("service__name",)
|
||||||
inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline]
|
inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline, VSHNAppCatAddonInline]
|
||||||
|
|
||||||
def admin_display_base_fees(self, obj):
|
def admin_display_base_fees(self, obj):
|
||||||
"""Display base fees in admin list view"""
|
"""Display base fees in admin list view"""
|
||||||
|
@ -542,3 +554,84 @@ class ExternalPricePlansAdmin(ImportExportModelAdmin):
|
||||||
return f"{count} plan{'s' if count != 1 else ''}"
|
return f"{count} plan{'s' if count != 1 else ''}"
|
||||||
|
|
||||||
display_compare_to_count.short_description = "Compare To"
|
display_compare_to_count.short_description = "Compare To"
|
||||||
|
|
||||||
|
|
||||||
|
class VSHNAppCatAddonBaseFeeInline(admin.TabularInline):
|
||||||
|
"""Inline admin for VSHNAppCatAddonBaseFee model"""
|
||||||
|
|
||||||
|
model = VSHNAppCatAddonBaseFee
|
||||||
|
extra = 1
|
||||||
|
fields = ("currency", "amount")
|
||||||
|
|
||||||
|
|
||||||
|
class VSHNAppCatAddonUnitRateInline(admin.TabularInline):
|
||||||
|
"""Inline admin for VSHNAppCatAddonUnitRate model"""
|
||||||
|
|
||||||
|
model = VSHNAppCatAddonUnitRate
|
||||||
|
extra = 1
|
||||||
|
fields = ("currency", "service_level", "amount")
|
||||||
|
|
||||||
|
|
||||||
|
class VSHNAppCatAddonInline(admin.TabularInline):
|
||||||
|
"""Inline admin for VSHNAppCatAddon model within the VSHNAppCatPrice admin"""
|
||||||
|
|
||||||
|
model = VSHNAppCatAddon
|
||||||
|
extra = 1
|
||||||
|
fields = ("name", "addon_type", "mandatory", "active", "order")
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(VSHNAppCatAddon)
|
||||||
|
class VSHNAppCatAddonAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin configuration for VSHNAppCatAddon model"""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"vshn_appcat_price_config",
|
||||||
|
"addon_type",
|
||||||
|
"mandatory",
|
||||||
|
"active",
|
||||||
|
"display_pricing",
|
||||||
|
"order",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"addon_type",
|
||||||
|
"mandatory",
|
||||||
|
"active",
|
||||||
|
"vshn_appcat_price_config__service",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"commercial_description",
|
||||||
|
"vshn_appcat_price_config__service__name",
|
||||||
|
)
|
||||||
|
ordering = ("vshn_appcat_price_config__service__name", "order", "name")
|
||||||
|
|
||||||
|
# Different inlines based on addon type
|
||||||
|
inlines = [VSHNAppCatAddonBaseFeeInline, VSHNAppCatAddonUnitRateInline]
|
||||||
|
|
||||||
|
def display_pricing(self, obj):
|
||||||
|
"""Display pricing information based on addon type"""
|
||||||
|
if obj.addon_type == "BF": # Base Fee
|
||||||
|
fees = obj.base_fees.all()
|
||||||
|
if not fees:
|
||||||
|
return "No base fees set"
|
||||||
|
return format_html(
|
||||||
|
"<br>".join([f"{fee.amount} {fee.currency}" for fee in fees])
|
||||||
|
)
|
||||||
|
elif obj.addon_type == "UR": # Unit Rate
|
||||||
|
rates = obj.unit_rates.all()
|
||||||
|
if not rates:
|
||||||
|
return "No unit rates set"
|
||||||
|
return format_html(
|
||||||
|
"<br>".join(
|
||||||
|
[
|
||||||
|
f"{rate.amount} {rate.currency} ({rate.get_service_level_display()})"
|
||||||
|
for rate in rates
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "Unknown addon type"
|
||||||
|
|
||||||
|
display_pricing.short_description = "Pricing"
|
||||||
|
|
|
@ -0,0 +1,195 @@
|
||||||
|
# Generated by Django 5.2 on 2025-06-19 13:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0034_article"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="article",
|
||||||
|
name="image",
|
||||||
|
field=models.ImageField(
|
||||||
|
help_text="Title picture for the article", upload_to="article_images/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="VSHNAppCatAddon",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(help_text="Name of the addon", max_length=100),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Technical description of the addon"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"commercial_description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Commercial description displayed in the frontend",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"addon_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("BF", "Base Fee"), ("UR", "Unit Rate")],
|
||||||
|
help_text="Type of addon pricing (fixed fee or per-unit)",
|
||||||
|
max_length=2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mandatory",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False, help_text="Is this addon mandatory?"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Is this addon active and available for selection?",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.IntegerField(
|
||||||
|
default=0, help_text="Display order in the frontend"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("valid_from", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("valid_to", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"vshn_appcat_price_config",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="addons",
|
||||||
|
to="services.vshnappcatprice",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Service Addon",
|
||||||
|
"ordering": ["order", "name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="VSHNAppCatAddonBaseFee",
|
||||||
|
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="Base fee in the specified currency, excl. VAT",
|
||||||
|
max_digits=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"addon",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="base_fees",
|
||||||
|
to="services.vshnappcataddon",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Addon Base Fee",
|
||||||
|
"ordering": ["currency"],
|
||||||
|
"unique_together": {("addon", "currency")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="VSHNAppCatAddonUnitRate",
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"service_level",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("BE", "Best Effort"),
|
||||||
|
("GA", "Guaranteed Availability"),
|
||||||
|
],
|
||||||
|
max_length=2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount",
|
||||||
|
models.DecimalField(
|
||||||
|
decimal_places=4,
|
||||||
|
help_text="Price per unit in the specified currency and service level, excl. VAT",
|
||||||
|
max_digits=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"addon",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="unit_rates",
|
||||||
|
to="services.vshnappcataddon",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Addon Unit Rate",
|
||||||
|
"ordering": ["currency", "service_level"],
|
||||||
|
"unique_together": {("addon", "currency", "service_level")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,5 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from .base import Currency, Term, Unit
|
from .base import Currency, Term, Unit
|
||||||
from .providers import CloudProvider
|
from .providers import CloudProvider
|
||||||
|
@ -339,7 +340,11 @@ class VSHNAppCatPrice(models.Model):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def calculate_final_price(
|
def calculate_final_price(
|
||||||
self, currency_code: str, service_level: str, number_of_units: int
|
self,
|
||||||
|
currency_code: str,
|
||||||
|
service_level: str,
|
||||||
|
number_of_units: int,
|
||||||
|
addon_ids=None,
|
||||||
):
|
):
|
||||||
base_fee = self.get_base_fee(currency_code)
|
base_fee = self.get_base_fee(currency_code)
|
||||||
unit_rate = self.get_unit_rate(currency_code, service_level)
|
unit_rate = self.get_unit_rate(currency_code, service_level)
|
||||||
|
@ -359,7 +364,49 @@ class VSHNAppCatPrice(models.Model):
|
||||||
else:
|
else:
|
||||||
total_price = base_fee + (unit_rate * number_of_units)
|
total_price = base_fee + (unit_rate * number_of_units)
|
||||||
|
|
||||||
return total_price
|
# Add prices for mandatory addons and selected addons
|
||||||
|
addon_total = 0
|
||||||
|
addon_breakdown = []
|
||||||
|
|
||||||
|
# Query all active addons related to this price config
|
||||||
|
addons_query = self.addons.filter(active=True)
|
||||||
|
|
||||||
|
# Include mandatory addons and explicitly selected addons
|
||||||
|
if addon_ids:
|
||||||
|
addons = addons_query.filter(Q(mandatory=True) | Q(id__in=addon_ids))
|
||||||
|
else:
|
||||||
|
addons = addons_query.filter(mandatory=True)
|
||||||
|
|
||||||
|
for addon in addons:
|
||||||
|
addon_price = 0
|
||||||
|
if addon.addon_type == VSHNAppCatAddon.AddonType.BASE_FEE:
|
||||||
|
addon_price_value = addon.get_price(currency_code)
|
||||||
|
if addon_price_value:
|
||||||
|
addon_price = addon_price_value
|
||||||
|
elif addon.addon_type == VSHNAppCatAddon.AddonType.UNIT_RATE:
|
||||||
|
addon_price_value = addon.get_price(currency_code, service_level)
|
||||||
|
if addon_price_value:
|
||||||
|
addon_price = addon_price_value * number_of_units
|
||||||
|
|
||||||
|
addon_total += addon_price
|
||||||
|
addon_breakdown.append(
|
||||||
|
{
|
||||||
|
"id": addon.id,
|
||||||
|
"name": addon.name,
|
||||||
|
"description": addon.description,
|
||||||
|
"commercial_description": addon.commercial_description,
|
||||||
|
"mandatory": addon.mandatory,
|
||||||
|
"price": addon_price,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
total_price += addon_total
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_price": total_price,
|
||||||
|
"addon_total": addon_total,
|
||||||
|
"addon_breakdown": addon_breakdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VSHNAppCatUnitRate(models.Model):
|
class VSHNAppCatUnitRate(models.Model):
|
||||||
|
@ -389,6 +436,118 @@ class VSHNAppCatUnitRate(models.Model):
|
||||||
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 VSHNAppCatAddon(models.Model):
|
||||||
|
"""
|
||||||
|
Addon pricing model for VSHNAppCatPrice. Can be added to a service price configuration
|
||||||
|
to provide additional features or resources with their own pricing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class AddonType(models.TextChoices):
|
||||||
|
BASE_FEE = "BF", "Base Fee" # Fixed amount regardless of units
|
||||||
|
UNIT_RATE = "UR", "Unit Rate" # Price per unit
|
||||||
|
|
||||||
|
vshn_appcat_price_config = models.ForeignKey(
|
||||||
|
VSHNAppCatPrice, on_delete=models.CASCADE, related_name="addons"
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=100, help_text="Name of the addon")
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True, help_text="Technical description of the addon"
|
||||||
|
)
|
||||||
|
commercial_description = models.TextField(
|
||||||
|
blank=True, help_text="Commercial description displayed in the frontend"
|
||||||
|
)
|
||||||
|
addon_type = models.CharField(
|
||||||
|
max_length=2,
|
||||||
|
choices=AddonType.choices,
|
||||||
|
help_text="Type of addon pricing (fixed fee or per-unit)",
|
||||||
|
)
|
||||||
|
mandatory = models.BooleanField(default=False, help_text="Is this addon mandatory?")
|
||||||
|
active = models.BooleanField(
|
||||||
|
default=True, help_text="Is this addon active and available for selection?"
|
||||||
|
)
|
||||||
|
order = models.IntegerField(default=0, help_text="Display order in the frontend")
|
||||||
|
valid_from = models.DateTimeField(blank=True, null=True)
|
||||||
|
valid_to = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Service Addon"
|
||||||
|
ordering = ["order", "name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.vshn_appcat_price_config.service.name} - {self.name}"
|
||||||
|
|
||||||
|
def get_price(self, currency_code: str, service_level: str = None):
|
||||||
|
"""Get the price for this addon in the specified currency and service level"""
|
||||||
|
try:
|
||||||
|
if self.addon_type == self.AddonType.BASE_FEE:
|
||||||
|
return self.base_fees.get(currency=currency_code).amount
|
||||||
|
elif self.addon_type == self.AddonType.UNIT_RATE:
|
||||||
|
if not service_level:
|
||||||
|
raise ValueError("Service level is required for unit rate addons")
|
||||||
|
return self.unit_rates.get(
|
||||||
|
currency=currency_code, service_level=service_level
|
||||||
|
).amount
|
||||||
|
except (
|
||||||
|
VSHNAppCatAddonBaseFee.DoesNotExist,
|
||||||
|
VSHNAppCatAddonUnitRate.DoesNotExist,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class VSHNAppCatAddonBaseFee(models.Model):
|
||||||
|
"""Base fee for an addon (fixed amount regardless of units)"""
|
||||||
|
|
||||||
|
addon = models.ForeignKey(
|
||||||
|
VSHNAppCatAddon, on_delete=models.CASCADE, related_name="base_fees"
|
||||||
|
)
|
||||||
|
currency = models.CharField(
|
||||||
|
max_length=3,
|
||||||
|
choices=Currency.choices,
|
||||||
|
)
|
||||||
|
amount = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Base fee in the specified currency, excl. VAT",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Addon Base Fee"
|
||||||
|
unique_together = ("addon", "currency")
|
||||||
|
ordering = ["currency"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.addon.name} Base Fee - {self.amount} {self.currency}"
|
||||||
|
|
||||||
|
|
||||||
|
class VSHNAppCatAddonUnitRate(models.Model):
|
||||||
|
"""Unit rate for an addon (price per unit)"""
|
||||||
|
|
||||||
|
addon = models.ForeignKey(
|
||||||
|
VSHNAppCatAddon, on_delete=models.CASCADE, related_name="unit_rates"
|
||||||
|
)
|
||||||
|
currency = models.CharField(
|
||||||
|
max_length=3,
|
||||||
|
choices=Currency.choices,
|
||||||
|
)
|
||||||
|
service_level = models.CharField(
|
||||||
|
max_length=2,
|
||||||
|
choices=VSHNAppCatPrice.ServiceLevel.choices,
|
||||||
|
)
|
||||||
|
amount = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=4,
|
||||||
|
help_text="Price per unit in the specified currency and service level, excl. VAT",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Addon Unit Rate"
|
||||||
|
unique_together = ("addon", "currency", "service_level")
|
||||||
|
ordering = ["currency", "service_level"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.addon.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
|
||||||
|
|
||||||
|
|
||||||
class ExternalPricePlans(models.Model):
|
class ExternalPricePlans(models.Model):
|
||||||
plan_name = models.CharField()
|
plan_name = models.CharField()
|
||||||
description = models.CharField(max_length=200, blank=True, null=True)
|
description = models.CharField(max_length=200, blank=True, null=True)
|
||||||
|
|
|
@ -275,6 +275,14 @@
|
||||||
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
|
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Addons Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Add-ons (Optional)</label>
|
||||||
|
<div id="addonsContainer">
|
||||||
|
<!-- Add-ons will be dynamically populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Direct Plan Selection -->
|
<!-- Direct Plan Selection -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
@ -345,6 +353,12 @@
|
||||||
<span>Storage - <span id="storageAmount">20</span> GB</span>
|
<span>Storage - <span id="storageAmount">20</span> GB</span>
|
||||||
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
|
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Addons Pricing -->
|
||||||
|
<div id="addonPricingContainer">
|
||||||
|
<!-- Addon pricing will be dynamically added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span class="fs-5 fw-bold">Total Monthly Price</span>
|
<span class="fs-5 fw-bold">Total Monthly Price</span>
|
||||||
|
@ -447,5 +461,341 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- JavaScript for the price calculator with addons support -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Only run this script when the price calculator is present
|
||||||
|
if (!document.getElementById('cpuRange')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch pricing data from the server
|
||||||
|
fetch(window.location.href + '?pricing=json')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(pricingData => {
|
||||||
|
initializePriceCalculator(pricingData);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching pricing data:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializePriceCalculator(pricingData) {
|
||||||
|
// Store selected addons
|
||||||
|
let selectedAddons = [];
|
||||||
|
|
||||||
|
// UI Controls
|
||||||
|
const cpuRange = document.getElementById('cpuRange');
|
||||||
|
const memoryRange = document.getElementById('memoryRange');
|
||||||
|
const storageRange = document.getElementById('storageRange');
|
||||||
|
const instancesRange = document.getElementById('instancesRange');
|
||||||
|
const serviceLevelGroup = document.getElementById('serviceLevelGroup');
|
||||||
|
const planSelect = document.getElementById('planSelect');
|
||||||
|
const addonsContainer = document.getElementById('addonsContainer');
|
||||||
|
const addonPricingContainer = document.getElementById('addonPricingContainer');
|
||||||
|
|
||||||
|
// Results UI elements
|
||||||
|
const planMatchStatus = document.getElementById('planMatchStatus');
|
||||||
|
const selectedPlanDetails = document.getElementById('selectedPlanDetails');
|
||||||
|
const noMatchFound = document.getElementById('noMatchFound');
|
||||||
|
const planName = document.getElementById('planName');
|
||||||
|
const planGroup = document.getElementById('planGroup');
|
||||||
|
const planDescription = document.getElementById('planDescription');
|
||||||
|
const planCpus = document.getElementById('planCpus');
|
||||||
|
const planMemory = document.getElementById('planMemory');
|
||||||
|
const planInstances = document.getElementById('planInstances');
|
||||||
|
const planServiceLevel = document.getElementById('planServiceLevel');
|
||||||
|
const managedServicePrice = document.getElementById('managedServicePrice');
|
||||||
|
const storagePrice = document.getElementById('storagePrice');
|
||||||
|
const storageAmount = document.getElementById('storageAmount');
|
||||||
|
const totalPrice = document.getElementById('totalPrice');
|
||||||
|
|
||||||
|
// Find all plan options for the select dropdown
|
||||||
|
populatePlanOptions(pricingData);
|
||||||
|
|
||||||
|
// Populate optional addons
|
||||||
|
populateAddons(pricingData);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
cpuRange.addEventListener('input', updateCalculator);
|
||||||
|
memoryRange.addEventListener('input', updateCalculator);
|
||||||
|
storageRange.addEventListener('input', updateCalculator);
|
||||||
|
instancesRange.addEventListener('input', updateCalculator);
|
||||||
|
planSelect.addEventListener('change', handlePlanSelection);
|
||||||
|
|
||||||
|
// Add listeners for service level radio buttons
|
||||||
|
const serviceLevelRadios = document.querySelectorAll('input[name="serviceLevel"]');
|
||||||
|
serviceLevelRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', updateCalculator);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial calculation
|
||||||
|
updateCalculator();
|
||||||
|
|
||||||
|
function populatePlanOptions(pricingData) {
|
||||||
|
const planOption = document.createElement('option');
|
||||||
|
planOption.value = '';
|
||||||
|
planOption.textContent = 'Auto-select best matching plan';
|
||||||
|
planSelect.appendChild(planOption);
|
||||||
|
|
||||||
|
// Add all available plans to the dropdown
|
||||||
|
Object.keys(pricingData).forEach(groupName => {
|
||||||
|
const group = pricingData[groupName];
|
||||||
|
|
||||||
|
// Create optgroup for the plan group
|
||||||
|
const optgroup = document.createElement('optgroup');
|
||||||
|
optgroup.label = groupName;
|
||||||
|
|
||||||
|
// Add plans from each service level
|
||||||
|
Object.keys(group).forEach(serviceLevel => {
|
||||||
|
const plans = group[serviceLevel];
|
||||||
|
|
||||||
|
plans.forEach(plan => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = `${plan.compute_plan}|${serviceLevel}`;
|
||||||
|
option.textContent = `${plan.compute_plan} (${serviceLevel}) - ${plan.vcpus} vCPU, ${plan.ram} GB RAM`;
|
||||||
|
option.dataset.planData = JSON.stringify(plan);
|
||||||
|
optgroup.appendChild(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
planSelect.appendChild(optgroup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateAddons(pricingData) {
|
||||||
|
// Get a sample plan to extract addons (assuming all plans have the same addons)
|
||||||
|
let samplePlan = null;
|
||||||
|
for (const groupName in pricingData) {
|
||||||
|
for (const serviceLevel in pricingData[groupName]) {
|
||||||
|
if (pricingData[groupName][serviceLevel].length > 0) {
|
||||||
|
samplePlan = pricingData[groupName][serviceLevel][0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (samplePlan) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!samplePlan || !samplePlan.optional_addons) {
|
||||||
|
addonsContainer.innerHTML = '<p class="text-muted">No optional add-ons available</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UI for each optional addon
|
||||||
|
samplePlan.optional_addons.forEach(addon => {
|
||||||
|
const addonDiv = document.createElement('div');
|
||||||
|
addonDiv.className = 'form-check mb-2';
|
||||||
|
|
||||||
|
const addonCheckbox = document.createElement('input');
|
||||||
|
addonCheckbox.type = 'checkbox';
|
||||||
|
addonCheckbox.className = 'form-check-input addon-checkbox';
|
||||||
|
addonCheckbox.id = `addon-${addon.id}`;
|
||||||
|
addonCheckbox.dataset.addonId = addon.id;
|
||||||
|
addonCheckbox.addEventListener('change', function() {
|
||||||
|
if (this.checked) {
|
||||||
|
selectedAddons.push(addon.id);
|
||||||
|
} else {
|
||||||
|
selectedAddons = selectedAddons.filter(id => id !== addon.id);
|
||||||
|
}
|
||||||
|
updateCalculator();
|
||||||
|
});
|
||||||
|
|
||||||
|
const addonLabel = document.createElement('label');
|
||||||
|
addonLabel.className = 'form-check-label';
|
||||||
|
addonLabel.htmlFor = `addon-${addon.id}`;
|
||||||
|
addonLabel.innerHTML = `${addon.name} <small class="text-muted">${addon.commercial_description || addon.description || ''}</small>`;
|
||||||
|
|
||||||
|
addonDiv.appendChild(addonCheckbox);
|
||||||
|
addonDiv.appendChild(addonLabel);
|
||||||
|
addonsContainer.appendChild(addonDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (samplePlan.optional_addons.length === 0) {
|
||||||
|
addonsContainer.innerHTML = '<p class="text-muted">No optional add-ons available</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCalculator() {
|
||||||
|
// If a specific plan is selected, use that
|
||||||
|
if (planSelect.value) {
|
||||||
|
handlePlanSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current values from UI
|
||||||
|
const cpuValue = parseInt(cpuRange.value);
|
||||||
|
const memoryValue = parseInt(memoryRange.value);
|
||||||
|
const storageValue = parseInt(storageRange.value);
|
||||||
|
const instancesValue = parseInt(instancesRange.value);
|
||||||
|
const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked').value;
|
||||||
|
|
||||||
|
// Find the best matching plan
|
||||||
|
const bestPlan = findBestMatchingPlan(cpuValue, memoryValue, serviceLevel);
|
||||||
|
|
||||||
|
if (bestPlan) {
|
||||||
|
displayPlanDetails(bestPlan, storageValue, serviceLevel);
|
||||||
|
} else {
|
||||||
|
// No matching plan found
|
||||||
|
planMatchStatus.style.display = 'none';
|
||||||
|
selectedPlanDetails.style.display = 'none';
|
||||||
|
noMatchFound.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlanSelection() {
|
||||||
|
if (!planSelect.value) {
|
||||||
|
updateCalculator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOption = planSelect.options[planSelect.selectedIndex];
|
||||||
|
const planData = JSON.parse(selectedOption.dataset.planData);
|
||||||
|
|
||||||
|
// Get current values from UI
|
||||||
|
const storageValue = parseInt(storageRange.value);
|
||||||
|
const serviceLevel = planData.service_level;
|
||||||
|
|
||||||
|
// Update service level radio buttons
|
||||||
|
document.querySelectorAll('input[name="serviceLevel"]').forEach(radio => {
|
||||||
|
if (radio.value === serviceLevel) {
|
||||||
|
radio.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update sliders to match selected plan
|
||||||
|
cpuRange.value = planData.vcpus;
|
||||||
|
document.getElementById('cpuValue').textContent = planData.vcpus;
|
||||||
|
|
||||||
|
memoryRange.value = planData.ram;
|
||||||
|
document.getElementById('memoryValue').textContent = planData.ram;
|
||||||
|
|
||||||
|
displayPlanDetails(planData, storageValue, serviceLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestMatchingPlan(cpuValue, memoryValue, serviceLevel) {
|
||||||
|
let bestMatch = null;
|
||||||
|
let bestMatchScore = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// Search through all groups and plans
|
||||||
|
Object.keys(pricingData).forEach(groupName => {
|
||||||
|
const group = pricingData[groupName];
|
||||||
|
|
||||||
|
if (group[serviceLevel]) {
|
||||||
|
group[serviceLevel].forEach(plan => {
|
||||||
|
// Calculate how well this plan matches the requirements
|
||||||
|
const cpuDiff = Math.abs(plan.vcpus - cpuValue);
|
||||||
|
const memoryDiff = Math.abs(plan.ram - memoryValue);
|
||||||
|
|
||||||
|
// Simple scoring: sum of differences, lower is better
|
||||||
|
const score = cpuDiff + memoryDiff;
|
||||||
|
|
||||||
|
// Check if this plan meets minimum requirements
|
||||||
|
if (plan.vcpus >= cpuValue && plan.ram >= memoryValue) {
|
||||||
|
// If this is a better match than the current best
|
||||||
|
if (score < bestMatchScore) {
|
||||||
|
bestMatch = plan;
|
||||||
|
bestMatchScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPlanDetails(plan, storageValue, serviceLevel) {
|
||||||
|
// Update UI to show selected plan
|
||||||
|
planMatchStatus.style.display = 'none';
|
||||||
|
selectedPlanDetails.style.display = 'block';
|
||||||
|
noMatchFound.style.display = 'none';
|
||||||
|
|
||||||
|
// Set plan details
|
||||||
|
planName.textContent = plan.compute_plan;
|
||||||
|
planGroup.textContent = plan.compute_plan_group;
|
||||||
|
planDescription.textContent = plan.compute_plan_group_description || '';
|
||||||
|
planCpus.textContent = plan.vcpus;
|
||||||
|
planMemory.textContent = plan.ram + ' GB';
|
||||||
|
planInstances.textContent = '1'; // Default to 1 instance
|
||||||
|
planServiceLevel.textContent = serviceLevel;
|
||||||
|
|
||||||
|
// Calculate prices
|
||||||
|
const storageCost = (storageValue * plan.storage_price).toFixed(2);
|
||||||
|
const totalMonthlyCost = (plan.final_price + parseFloat(storageCost)).toFixed(2);
|
||||||
|
|
||||||
|
// Update price displays
|
||||||
|
managedServicePrice.textContent = plan.final_price.toFixed(2);
|
||||||
|
storagePrice.textContent = storageCost;
|
||||||
|
storageAmount.textContent = storageValue;
|
||||||
|
|
||||||
|
// Process addons
|
||||||
|
updateAddonPricing(plan, serviceLevel);
|
||||||
|
|
||||||
|
// Update total price after addons are processed
|
||||||
|
calculateTotalPrice(plan, storageCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAddonPricing(plan, serviceLevel) {
|
||||||
|
// Clear previous addon pricing
|
||||||
|
addonPricingContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Display mandatory addons first
|
||||||
|
if (plan.mandatory_addons && plan.mandatory_addons.length > 0) {
|
||||||
|
plan.mandatory_addons.forEach(addon => {
|
||||||
|
if (addon.price) {
|
||||||
|
const addonDiv = document.createElement('div');
|
||||||
|
addonDiv.className = 'd-flex justify-content-between mb-2';
|
||||||
|
addonDiv.innerHTML = `
|
||||||
|
<span>${addon.name} <small class="text-muted">(Required)</small></span>
|
||||||
|
<span class="fw-bold">CHF <span class="addon-price">${addon.price.toFixed(2)}</span></span>
|
||||||
|
`;
|
||||||
|
addonPricingContainer.appendChild(addonDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display selected optional addons
|
||||||
|
if (plan.optional_addons && plan.optional_addons.length > 0) {
|
||||||
|
plan.optional_addons.forEach(addon => {
|
||||||
|
if (selectedAddons.includes(addon.id) && addon.price) {
|
||||||
|
const addonDiv = document.createElement('div');
|
||||||
|
addonDiv.className = 'd-flex justify-content-between mb-2';
|
||||||
|
addonDiv.innerHTML = `
|
||||||
|
<span>${addon.name}</span>
|
||||||
|
<span class="fw-bold">CHF <span class="addon-price">${addon.price.toFixed(2)}</span></span>
|
||||||
|
`;
|
||||||
|
addonPricingContainer.appendChild(addonDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotalPrice(plan, storageCost) {
|
||||||
|
let addonTotal = 0;
|
||||||
|
|
||||||
|
// Add mandatory addons
|
||||||
|
if (plan.mandatory_addons) {
|
||||||
|
plan.mandatory_addons.forEach(addon => {
|
||||||
|
if (addon.price) {
|
||||||
|
addonTotal += addon.price;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected optional addons
|
||||||
|
if (plan.optional_addons) {
|
||||||
|
plan.optional_addons.forEach(addon => {
|
||||||
|
if (selectedAddons.includes(addon.id) && addon.price) {
|
||||||
|
addonTotal += addon.price;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate final price
|
||||||
|
const finalPrice = plan.final_price + parseFloat(storageCost) + addonTotal;
|
||||||
|
totalPrice.textContent = finalPrice.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -7,6 +7,53 @@
|
||||||
<script src="{% static "js/chart.js" %}"></script>
|
<script src="{% static "js/chart.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.addon-details {
|
||||||
|
max-width: 300px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-item {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-price {
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table th {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-price-header {
|
||||||
|
background-color: #28a745 !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-price-cell {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-row {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servala-row {
|
||||||
|
border-bottom: 2px solid #007bff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid mt-4">
|
<div class="container-fluid mt-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -71,6 +118,12 @@
|
||||||
Show discount details
|
Show discount details
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="addon_details" value="true" id="addon_details" {% if show_addon_details %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="addon_details">
|
||||||
|
Show addon details
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-check">
|
<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 %}>
|
<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">
|
<label class="form-check-label" for="price_comparison">
|
||||||
|
@ -87,13 +140,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Filters Display -->
|
<!-- Active Filters Display -->
|
||||||
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}
|
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level or show_discount_details or show_addon_details or show_price_comparison %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<strong>Active Filters:</strong>
|
<strong>Active Filters:</strong>
|
||||||
{% if filter_cloud_provider %}<span class="badge me-1">Cloud Provider: {{ filter_cloud_provider }}</span>{% endif %}
|
{% if filter_cloud_provider %}<span class="badge me-1">Cloud Provider: {{ filter_cloud_provider }}</span>{% endif %}
|
||||||
{% if filter_service %}<span class="badge me-1">Service: {{ filter_service }}</span>{% endif %}
|
{% if filter_service %}<span class="badge me-1">Service: {{ filter_service }}</span>{% endif %}
|
||||||
{% if filter_compute_plan_group %}<span class="badge me-1">Group: {{ filter_compute_plan_group }}</span>{% endif %}
|
{% if filter_compute_plan_group %}<span class="badge me-1">Group: {{ filter_compute_plan_group }}</span>{% endif %}
|
||||||
{% if filter_service_level %}<span class="badge me-1">Service Level: {{ filter_service_level }}</span>{% endif %}
|
{% if filter_service_level %}<span class="badge me-1">Service Level: {{ filter_service_level }}</span>{% endif %}
|
||||||
|
{% if show_discount_details %}<span class="badge bg-secondary me-1">Discount Details</span>{% endif %}
|
||||||
|
{% if show_addon_details %}<span class="badge bg-info me-1">Addon Details</span>{% endif %}
|
||||||
|
{% if show_price_comparison %}<span class="badge bg-warning me-1">Price Comparison</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -174,6 +230,52 @@
|
||||||
<strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}
|
<strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Display add-on summary #}
|
||||||
|
{% if show_addon_details and first_row.mandatory_addons or first_row.optional_addons %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Available Add-ons for {{ first_row.service }}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if first_row.mandatory_addons %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-success">Mandatory Add-ons (included in all plans):</h6>
|
||||||
|
<div class="row">
|
||||||
|
{% for addon in first_row.mandatory_addons %}
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<div class="border border-success rounded p-2">
|
||||||
|
<strong>{{ addon.name }}</strong>
|
||||||
|
<div class="text-muted small">{{ addon.commercial_description|default:addon.description }}</div>
|
||||||
|
<div class="text-success fw-bold">{{ addon.price|floatformat:2 }} {{ first_row.currency }}</div>
|
||||||
|
<small class="text-muted">{{ addon.addon_type }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if first_row.optional_addons %}
|
||||||
|
<div>
|
||||||
|
<h6 class="text-info">Optional Add-ons (can be added):</h6>
|
||||||
|
<div class="row">
|
||||||
|
{% for addon in first_row.optional_addons %}
|
||||||
|
<div class="col-md-4 mb-2">
|
||||||
|
<div class="border border-info rounded p-2">
|
||||||
|
<strong>{{ addon.name }}</strong>
|
||||||
|
<div class="text-muted small">{{ addon.commercial_description|default:addon.description }}</div>
|
||||||
|
<div class="text-info fw-bold">{{ addon.price|floatformat:2 }} {{ first_row.currency }}</div>
|
||||||
|
<small class="text-muted">{{ addon.addon_type }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
@ -191,6 +293,9 @@
|
||||||
<th>SLA Base</th>
|
<th>SLA Base</th>
|
||||||
<th>SLA Per Unit</th>
|
<th>SLA Per Unit</th>
|
||||||
<th>SLA Price</th>
|
<th>SLA Price</th>
|
||||||
|
{% if show_addon_details %}
|
||||||
|
<th>Add-ons</th>
|
||||||
|
{% endif %}
|
||||||
{% if show_discount_details %}
|
{% if show_discount_details %}
|
||||||
<th>Discount Model</th>
|
<th>Discount Model</th>
|
||||||
<th>Discount Details</th>
|
<th>Discount Details</th>
|
||||||
|
@ -215,6 +320,54 @@
|
||||||
<td>{{ row.sla_base|floatformat:2 }}</td>
|
<td>{{ row.sla_base|floatformat:2 }}</td>
|
||||||
<td>{{ row.sla_per_unit|floatformat:4 }}</td>
|
<td>{{ row.sla_per_unit|floatformat:4 }}</td>
|
||||||
<td>{{ row.sla_price|floatformat:2 }}</td>
|
<td>{{ row.sla_price|floatformat:2 }}</td>
|
||||||
|
{% if show_addon_details %}
|
||||||
|
<td>
|
||||||
|
{% if row.mandatory_addons or row.optional_addons %}
|
||||||
|
<div class="addon-details">
|
||||||
|
{% if row.mandatory_addons %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-success fw-bold">Mandatory Add-ons:</small>
|
||||||
|
{% for addon in row.mandatory_addons %}
|
||||||
|
<div class="addon-item border-start border-success ps-2 mb-1">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="addon-name">{{ addon.name }}</span>
|
||||||
|
<span class="addon-price fw-bold">{{ addon.price|floatformat:2 }} {{ row.currency }}</span>
|
||||||
|
</div>
|
||||||
|
{% if addon.commercial_description %}
|
||||||
|
<div class="text-muted small">{{ addon.commercial_description }}</div>
|
||||||
|
{% elif addon.description %}
|
||||||
|
<div class="text-muted small">{{ addon.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-muted small">Type: {{ addon.addon_type }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if row.optional_addons %}
|
||||||
|
<div>
|
||||||
|
<small class="text-info fw-bold">Optional Add-ons:</small>
|
||||||
|
{% for addon in row.optional_addons %}
|
||||||
|
<div class="addon-item border-start border-info ps-2 mb-1">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="addon-name">{{ addon.name }}</span>
|
||||||
|
<span class="addon-price">{{ addon.price|floatformat:2 }} {{ row.currency }}</span>
|
||||||
|
</div>
|
||||||
|
{% if addon.commercial_description %}
|
||||||
|
<div class="text-muted small">{{ addon.commercial_description }}</div>
|
||||||
|
{% elif addon.description %}
|
||||||
|
<div class="text-muted small">{{ addon.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-muted small">Type: {{ addon.addon_type }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No add-ons</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
{% if show_discount_details %}
|
{% if show_discount_details %}
|
||||||
<td>
|
<td>
|
||||||
{% if row.has_discount %}
|
{% if row.has_discount %}
|
||||||
|
@ -267,6 +420,9 @@
|
||||||
<td class="text-muted">-</td>
|
<td class="text-muted">-</td>
|
||||||
<td class="text-muted">-</td>
|
<td class="text-muted">-</td>
|
||||||
<td class="text-muted">-</td>
|
<td class="text-muted">-</td>
|
||||||
|
{% if show_addon_details %}
|
||||||
|
<td class="text-muted">-</td>
|
||||||
|
{% endif %}
|
||||||
{% if show_discount_details %}
|
{% if show_discount_details %}
|
||||||
<td class="text-muted">-</td>
|
<td class="text-muted">-</td>
|
||||||
<td class="text-muted">-</td>
|
<td class="text-muted">-</td>
|
||||||
|
@ -338,6 +494,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const filterForm = document.getElementById('filter-form');
|
const filterForm = document.getElementById('filter-form');
|
||||||
const filterSelects = document.querySelectorAll('.filter-select');
|
const filterSelects = document.querySelectorAll('.filter-select');
|
||||||
const discountCheckbox = document.getElementById('discount_details');
|
const discountCheckbox = document.getElementById('discount_details');
|
||||||
|
const addonCheckbox = document.getElementById('addon_details');
|
||||||
|
|
||||||
// Add change event listeners to all filter dropdowns
|
// Add change event listeners to all filter dropdowns
|
||||||
filterSelects.forEach(function(select) {
|
filterSelects.forEach(function(select) {
|
||||||
|
@ -351,6 +508,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
filterForm.submit();
|
filterForm.submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add change event listener to addon details checkbox
|
||||||
|
addonCheckbox.addEventListener('change', function() {
|
||||||
|
filterForm.submit();
|
||||||
|
});
|
||||||
|
|
||||||
// Add change event listener to price comparison checkbox
|
// Add change event listener to price comparison checkbox
|
||||||
const priceComparisonCheckbox = document.getElementById('price_comparison');
|
const priceComparisonCheckbox = document.getElementById('price_comparison');
|
||||||
priceComparisonCheckbox.addEventListener('change', function() {
|
priceComparisonCheckbox.addEventListener('change', function() {
|
||||||
|
|
|
@ -367,6 +367,41 @@ def generate_pricing_data(offering):
|
||||||
else:
|
else:
|
||||||
sla_price = standard_sla_price
|
sla_price = standard_sla_price
|
||||||
|
|
||||||
|
# Get addons information
|
||||||
|
addons = appcat_price.addons.filter(active=True)
|
||||||
|
mandatory_addons = []
|
||||||
|
optional_addons = []
|
||||||
|
|
||||||
|
# Calculate additional price from mandatory addons
|
||||||
|
addon_total = 0
|
||||||
|
|
||||||
|
for addon in addons:
|
||||||
|
addon_price = None
|
||||||
|
|
||||||
|
if addon.addon_type == "BF": # Base Fee
|
||||||
|
addon_price = addon.get_price(currency)
|
||||||
|
elif addon.addon_type == "UR": # Unit Rate
|
||||||
|
addon_price_per_unit = addon.get_price(currency, service_level)
|
||||||
|
if addon_price_per_unit:
|
||||||
|
addon_price = addon_price_per_unit * total_units
|
||||||
|
|
||||||
|
addon_info = {
|
||||||
|
"id": addon.id,
|
||||||
|
"name": addon.name,
|
||||||
|
"description": addon.description,
|
||||||
|
"commercial_description": addon.commercial_description,
|
||||||
|
"addon_type": addon.get_addon_type_display(),
|
||||||
|
"price": addon_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
if addon.mandatory:
|
||||||
|
mandatory_addons.append(addon_info)
|
||||||
|
if addon_price:
|
||||||
|
addon_total += addon_price
|
||||||
|
sla_price += addon_price
|
||||||
|
else:
|
||||||
|
optional_addons.append(addon_info)
|
||||||
|
|
||||||
final_price = compute_plan_price + sla_price
|
final_price = compute_plan_price + sla_price
|
||||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||||
service_level
|
service_level
|
||||||
|
@ -393,6 +428,8 @@ def generate_pricing_data(offering):
|
||||||
"storage_price": storage_price_data.get(currency, 0),
|
"storage_price": storage_price_data.get(currency, 0),
|
||||||
"ha_replica_min": appcat_price.ha_replica_min,
|
"ha_replica_min": appcat_price.ha_replica_min,
|
||||||
"ha_replica_max": appcat_price.ha_replica_max,
|
"ha_replica_max": appcat_price.ha_replica_max,
|
||||||
|
"mandatory_addons": mandatory_addons,
|
||||||
|
"optional_addons": optional_addons,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ 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_addon_details = request.GET.get("addon_details", "").lower() == "true"
|
||||||
show_price_comparison = request.GET.get("price_comparison", "").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", "")
|
||||||
|
@ -202,6 +203,40 @@ def pricelist(request):
|
||||||
discount_savings = 0
|
discount_savings = 0
|
||||||
discount_percentage = 0
|
discount_percentage = 0
|
||||||
|
|
||||||
|
# Get addon information
|
||||||
|
addons = appcat_price.addons.filter(active=True)
|
||||||
|
mandatory_addons = []
|
||||||
|
optional_addons = []
|
||||||
|
|
||||||
|
# Group addons by mandatory vs optional
|
||||||
|
for addon in addons:
|
||||||
|
addon_price = None
|
||||||
|
|
||||||
|
if addon.addon_type == "BF": # Base Fee
|
||||||
|
addon_price = addon.get_price(currency)
|
||||||
|
elif addon.addon_type == "UR": # Unit Rate
|
||||||
|
addon_price_per_unit = addon.get_price(
|
||||||
|
currency, service_level
|
||||||
|
)
|
||||||
|
if addon_price_per_unit:
|
||||||
|
addon_price = addon_price_per_unit * total_units
|
||||||
|
|
||||||
|
addon_info = {
|
||||||
|
"id": addon.id,
|
||||||
|
"name": addon.name,
|
||||||
|
"description": addon.description,
|
||||||
|
"commercial_description": addon.commercial_description,
|
||||||
|
"addon_type": addon.get_addon_type_display(),
|
||||||
|
"price": addon_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
if addon.mandatory:
|
||||||
|
mandatory_addons.append(addon_info)
|
||||||
|
if addon_price:
|
||||||
|
sla_price += addon_price
|
||||||
|
else:
|
||||||
|
optional_addons.append(addon_info)
|
||||||
|
|
||||||
final_price = compute_plan_price + sla_price
|
final_price = compute_plan_price + sla_price
|
||||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||||
service_level
|
service_level
|
||||||
|
@ -296,6 +331,8 @@ def pricelist(request):
|
||||||
and appcat_price.discount_model.active
|
and appcat_price.discount_model.active
|
||||||
),
|
),
|
||||||
"external_comparisons": external_comparisons,
|
"external_comparisons": external_comparisons,
|
||||||
|
"mandatory_addons": mandatory_addons,
|
||||||
|
"optional_addons": optional_addons,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -344,6 +381,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_addon_details": show_addon_details,
|
||||||
"show_price_comparison": show_price_comparison,
|
"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,
|
||||||
|
|
|
@ -254,6 +254,7 @@ JAZZMIN_SETTINGS = {
|
||||||
"changeform_format_overrides": {
|
"changeform_format_overrides": {
|
||||||
"services.ProgressiveDiscountModel": "single",
|
"services.ProgressiveDiscountModel": "single",
|
||||||
"services.VSHNAppCatPrice": "single",
|
"services.VSHNAppCatPrice": "single",
|
||||||
|
"services.VSHNAppCatAddon": "single",
|
||||||
},
|
},
|
||||||
"related_modal_active": True,
|
"related_modal_active": True,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue