pricelist on offering detail
This commit is contained in:
parent
5b4392f838
commit
d9a04655ed
6 changed files with 496 additions and 38 deletions
23
hub/services/migrations/0030_serviceoffering_msp.py
Normal file
23
hub/services/migrations/0030_serviceoffering_msp.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 5.2 on 2025-05-23 15:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0029_alter_computeplangroup_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serviceoffering",
|
||||||
|
name="msp",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("VS", "VSHN")],
|
||||||
|
default="VS",
|
||||||
|
max_length=2,
|
||||||
|
verbose_name="Managed Service Provider",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -29,6 +29,11 @@ class Unit(models.TextChoices):
|
||||||
CPU = "CPU", "vCPU"
|
CPU = "CPU", "vCPU"
|
||||||
|
|
||||||
|
|
||||||
|
# This should be a relation, but for now this is good enough :TM:
|
||||||
|
class ManagedServiceProvider(models.TextChoices):
|
||||||
|
VS = "VS", "VSHN"
|
||||||
|
|
||||||
|
|
||||||
class ReusableText(models.Model):
|
class ReusableText(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
textsnippet = models.ForeignKey(
|
textsnippet = models.ForeignKey(
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django_prose_editor.fields import ProseEditorField
|
from django_prose_editor.fields import ProseEditorField
|
||||||
|
|
||||||
from .base import Category, ReusableText, validate_image_size
|
from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size
|
||||||
from .providers import CloudProvider
|
from .providers import CloudProvider
|
||||||
|
|
||||||
|
|
||||||
|
@ -57,6 +57,12 @@ class ServiceOffering(models.Model):
|
||||||
service = models.ForeignKey(
|
service = models.ForeignKey(
|
||||||
Service, on_delete=models.CASCADE, related_name="offerings"
|
Service, on_delete=models.CASCADE, related_name="offerings"
|
||||||
)
|
)
|
||||||
|
msp = models.CharField(
|
||||||
|
"Managed Service Provider",
|
||||||
|
max_length=2,
|
||||||
|
default=ManagedServiceProvider.VS,
|
||||||
|
choices=ManagedServiceProvider.choices,
|
||||||
|
)
|
||||||
cloud_provider = models.ForeignKey(
|
cloud_provider = models.ForeignKey(
|
||||||
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
||||||
)
|
)
|
||||||
|
|
|
@ -12339,3 +12339,174 @@ a.btn:focus {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accordion styles */
|
||||||
|
.accordion {
|
||||||
|
--bs-accordion-color: var(--bs-body-color);
|
||||||
|
--bs-accordion-bg: var(--bs-body-bg);
|
||||||
|
--bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease;
|
||||||
|
--bs-accordion-border-color: var(--bs-border-color);
|
||||||
|
--bs-accordion-border-width: var(--bs-border-width);
|
||||||
|
--bs-accordion-border-radius: var(--bs-border-radius);
|
||||||
|
--bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width));
|
||||||
|
--bs-accordion-btn-padding-x: 1.25rem;
|
||||||
|
--bs-accordion-btn-padding-y: 1rem;
|
||||||
|
--bs-accordion-btn-color: var(--bs-body-color);
|
||||||
|
--bs-accordion-btn-bg: var(--bs-accordion-bg);
|
||||||
|
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||||
|
--bs-accordion-btn-icon-width: 1.25rem;
|
||||||
|
--bs-accordion-btn-icon-transform: rotate(-180deg);
|
||||||
|
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
|
||||||
|
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23052c65'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||||
|
--bs-accordion-btn-focus-border-color: #86b7fe;
|
||||||
|
--bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-accordion-body-padding-x: 1.25rem;
|
||||||
|
--bs-accordion-body-padding-y: 1rem;
|
||||||
|
--bs-accordion-active-color: var(--bs-primary-text-emphasis);
|
||||||
|
--bs-accordion-active-bg: var(--bs-primary-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--bs-accordion-btn-color);
|
||||||
|
text-align: left;
|
||||||
|
background-color: var(--bs-accordion-btn-bg);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow-anchor: none;
|
||||||
|
transition: var(--bs-accordion-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.accordion-button {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed) {
|
||||||
|
color: var(--bs-accordion-active-color);
|
||||||
|
background-color: var(--bs-accordion-active-bg);
|
||||||
|
box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed):after {
|
||||||
|
background-image: var(--bs-accordion-btn-active-icon);
|
||||||
|
transform: var(--bs-accordion-btn-icon-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:after {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: var(--bs-accordion-btn-icon-width);
|
||||||
|
height: var(--bs-accordion-btn-icon-width);
|
||||||
|
margin-left: auto;
|
||||||
|
content: "";
|
||||||
|
background-image: var(--bs-accordion-btn-icon);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: var(--bs-accordion-btn-icon-width);
|
||||||
|
transition: var(--bs-accordion-btn-icon-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.accordion-button:after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:hover {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:focus {
|
||||||
|
z-index: 3;
|
||||||
|
border-color: var(--bs-accordion-btn-focus-border-color);
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: var(--bs-accordion-btn-focus-box-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
color: var(--bs-accordion-color);
|
||||||
|
background-color: var(--bs-accordion-bg);
|
||||||
|
border: var(--bs-accordion-border-width) solid var(--bs-accordion-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:first-of-type {
|
||||||
|
border-top-left-radius: var(--bs-accordion-border-radius);
|
||||||
|
border-top-right-radius: var(--bs-accordion-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:first-of-type .accordion-button {
|
||||||
|
border-top-left-radius: var(--bs-accordion-inner-border-radius);
|
||||||
|
border-top-right-radius: var(--bs-accordion-inner-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:not(:first-of-type) {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:last-of-type {
|
||||||
|
border-bottom-right-radius: var(--bs-accordion-border-radius);
|
||||||
|
border-bottom-left-radius: var(--bs-accordion-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:last-of-type .accordion-button.collapsed {
|
||||||
|
border-bottom-right-radius: var(--bs-accordion-inner-border-radius);
|
||||||
|
border-bottom-left-radius: var(--bs-accordion-inner-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item:last-of-type .accordion-collapse {
|
||||||
|
border-bottom-right-radius: var(--bs-accordion-border-radius);
|
||||||
|
border-bottom-left-radius: var(--bs-accordion-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-body {
|
||||||
|
padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-flush .accordion-collapse {
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-flush .accordion-item {
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-flush .accordion-item:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-flush .accordion-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-flush .accordion-item .accordion-button,
|
||||||
|
.accordion-flush .accordion-item .accordion-button.collapsed {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] .accordion-button:not(.collapsed) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] .accordion-button::after {
|
||||||
|
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||||
|
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed) {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed)::after {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ffffff'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||||
|
}
|
|
@ -152,50 +152,121 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Plans -->
|
<!-- Plans or Service Plans -->
|
||||||
{% if offering.plans.all %}
|
|
||||||
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
|
||||||
<div class="row">
|
<!-- Service Plans with Pricing Data -->
|
||||||
{% for plan in offering.plans.all %}
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Plans</h3>
|
||||||
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
|
<div class="accordion" id="servicePlansAccordion">
|
||||||
<div class="bg-purple-50 rounded-16 border-all p-24">
|
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
||||||
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
<div class="accordion-item">
|
||||||
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
<h2 class="accordion-header" id="heading{{ forloop.counter }}">
|
||||||
{% if plan.plan_description %}
|
<button class="accordion-button{% if not forloop.first %} collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="{% if forloop.first %}true{% else %}false{% endif %}" aria-controls="collapse{{ forloop.counter }}">
|
||||||
<div class="text-black mb-20">
|
<strong>{{ group_name }}</strong>
|
||||||
{{ plan.plan_description.text|safe }}
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#servicePlansAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
{% comment %} Display group description from first available plan {% endcomment %}
|
||||||
|
{% for service_level, pricing_data in service_levels.items %}
|
||||||
|
{% if pricing_data and forloop.first %}
|
||||||
|
{% with pricing_data.0 as representative_plan %}
|
||||||
|
{% if representative_plan.compute_plan_group_description %}
|
||||||
|
<p class="text-muted mb-3">{{ representative_plan.compute_plan_group_description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% if forloop.first %}
|
||||||
|
{% comment %} Only show description for first service level {% endcomment %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for service_level, pricing_data in service_levels.items %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="mb-3 text-primary">{{ service_level }}</h4>
|
||||||
|
{% if pricing_data %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Compute Plan</th>
|
||||||
|
<th>vCPUs</th>
|
||||||
|
<th>RAM (GB)</th>
|
||||||
|
<th>Currency</th>
|
||||||
|
<th>Compute Price</th>
|
||||||
|
<th>Service Price</th>
|
||||||
|
<th class="table-warning">Total Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in pricing_data %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.compute_plan }}</td>
|
||||||
|
<td>{{ row.vcpus }}</td>
|
||||||
|
<td>{{ row.ram }}</td>
|
||||||
|
<td>{{ row.currency }}</td>
|
||||||
|
<td>{{ row.compute_plan_price|floatformat:2 }}</td>
|
||||||
|
<td>{{ row.sla_price|floatformat:2 }}</td>
|
||||||
|
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No pricing data available for {{ service_level }}.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
{% if plan.description %}
|
{% endfor %}
|
||||||
<div class="text-black mb-20">
|
</div>
|
||||||
{{ plan.description|safe }}
|
{% elif offering.plans.all %}
|
||||||
|
<!-- Traditional Plans -->
|
||||||
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
||||||
|
<div class="row">
|
||||||
|
{% for plan in offering.plans.all %}
|
||||||
|
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
|
||||||
|
<div class="bg-purple-50 rounded-16 border-all p-24">
|
||||||
|
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
||||||
|
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
||||||
|
{% if plan.plan_description %}
|
||||||
|
<div class="text-black mb-20">
|
||||||
|
{{ plan.plan_description.text|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.description %}
|
||||||
|
<div class="text-black mb-20">
|
||||||
|
{{ plan.description|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.pricing %}
|
||||||
|
<div class="text-black mb-20">
|
||||||
|
{{ plan.pricing|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% if plan.pricing %}
|
|
||||||
<div class="text-black mb-20">
|
|
||||||
{{ plan.pricing|safe }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% empty %}
|
||||||
{% empty %}
|
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
<div class="alert alert-info">
|
||||||
<div class="alert alert-info">
|
<p>No plans available yet.</p>
|
||||||
<p>No plans available yet.</p>
|
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
<!-- No Plans Available -->
|
||||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||||
{% load contact_tags %}
|
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
{% load contact_tags %}
|
||||||
</div>
|
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if offering.plans.exists %}
|
{% if offering.plans.exists %}
|
||||||
|
|
|
@ -1,6 +1,21 @@
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from hub.services.models import ServiceOffering, CloudProvider, Category, Service
|
from hub.services.models import (
|
||||||
|
ServiceOffering,
|
||||||
|
CloudProvider,
|
||||||
|
Category,
|
||||||
|
Service,
|
||||||
|
ComputePlan,
|
||||||
|
VSHNAppCatPrice,
|
||||||
|
)
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
def natural_sort_key(name):
|
||||||
|
"""Extract numeric part from compute plan name for natural sorting"""
|
||||||
|
match = re.search(r"compute-std-(\d+)", name)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
|
||||||
def offering_list(request):
|
def offering_list(request):
|
||||||
|
@ -64,7 +79,174 @@ def offering_detail(request, provider_slug, service_slug):
|
||||||
service__slug=service_slug,
|
service__slug=service_slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pricing_data_by_group_and_service_level = None
|
||||||
|
|
||||||
|
# Generate pricing data for VSHN offerings
|
||||||
|
if offering.msp == "VS":
|
||||||
|
pricing_data_by_group_and_service_level = generate_pricing_data(offering)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"offering": offering,
|
"offering": offering,
|
||||||
|
"pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level,
|
||||||
}
|
}
|
||||||
return render(request, "services/offering_detail.html", context)
|
return render(request, "services/offering_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pricing_data(offering):
|
||||||
|
"""Generate pricing data for a specific offering and cloud provider"""
|
||||||
|
# Fetch compute plans for this cloud provider
|
||||||
|
compute_plans = (
|
||||||
|
ComputePlan.objects.filter(active=True, cloud_provider=offering.cloud_provider)
|
||||||
|
.select_related("cloud_provider", "group")
|
||||||
|
.prefetch_related("prices")
|
||||||
|
.order_by("group__order", "group__name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply natural sorting for compute plan names
|
||||||
|
compute_plans = sorted(
|
||||||
|
compute_plans,
|
||||||
|
key=lambda x: (
|
||||||
|
x.group.order if x.group else 999,
|
||||||
|
x.group.name if x.group else "ZZZ",
|
||||||
|
natural_sort_key(x.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch pricing for this specific service
|
||||||
|
try:
|
||||||
|
appcat_price = (
|
||||||
|
VSHNAppCatPrice.objects.select_related("service", "discount_model")
|
||||||
|
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
|
||||||
|
.get(service=offering.service)
|
||||||
|
)
|
||||||
|
except VSHNAppCatPrice.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
||||||
|
processed_combinations = set()
|
||||||
|
|
||||||
|
# Generate pricing combinations for each compute plan
|
||||||
|
for plan in compute_plans:
|
||||||
|
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||||
|
|
||||||
|
# Determine units based on variable unit type
|
||||||
|
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||||
|
units = int(plan.ram)
|
||||||
|
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
||||||
|
units = int(plan.vcpus)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_fee_currencies = set(
|
||||||
|
appcat_price.base_fees.values_list("currency", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
service_levels = appcat_price.unit_rates.values_list(
|
||||||
|
"service_level", flat=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
for service_level in service_levels:
|
||||||
|
unit_rate_currencies = set(
|
||||||
|
appcat_price.unit_rates.filter(service_level=service_level).values_list(
|
||||||
|
"currency", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find currencies that exist across all pricing components
|
||||||
|
matching_currencies = plan_currencies.intersection(
|
||||||
|
base_fee_currencies
|
||||||
|
).intersection(unit_rate_currencies)
|
||||||
|
|
||||||
|
if not matching_currencies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for currency in matching_currencies:
|
||||||
|
combination_key = (
|
||||||
|
plan.name,
|
||||||
|
service_level,
|
||||||
|
currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skip if combination already processed
|
||||||
|
if combination_key in processed_combinations:
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_combinations.add(combination_key)
|
||||||
|
|
||||||
|
# Get pricing components
|
||||||
|
compute_plan_price = plan.get_price(currency)
|
||||||
|
base_fee = appcat_price.get_base_fee(currency)
|
||||||
|
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||||
|
|
||||||
|
# Skip if any pricing component is missing
|
||||||
|
if any(
|
||||||
|
price is None for price in [compute_plan_price, base_fee, unit_rate]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate replica enforcement based on service level
|
||||||
|
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||||
|
replica_enforce = appcat_price.ha_replica_min
|
||||||
|
else:
|
||||||
|
replica_enforce = 1
|
||||||
|
|
||||||
|
total_units = units * replica_enforce
|
||||||
|
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||||
|
|
||||||
|
# Apply discount if available
|
||||||
|
if appcat_price.discount_model and appcat_price.discount_model.active:
|
||||||
|
discounted_price = appcat_price.discount_model.calculate_discount(
|
||||||
|
unit_rate, total_units
|
||||||
|
)
|
||||||
|
sla_price = base_fee + discounted_price
|
||||||
|
else:
|
||||||
|
sla_price = standard_sla_price
|
||||||
|
|
||||||
|
final_price = compute_plan_price + sla_price
|
||||||
|
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||||
|
service_level
|
||||||
|
]
|
||||||
|
|
||||||
|
group_name = plan.group.name if plan.group else "No Group"
|
||||||
|
|
||||||
|
# Add pricing data to the grouped structure
|
||||||
|
pricing_data_by_group_and_service_level[group_name][
|
||||||
|
service_level_display
|
||||||
|
].append(
|
||||||
|
{
|
||||||
|
"compute_plan": plan.name,
|
||||||
|
"compute_plan_group": group_name,
|
||||||
|
"compute_plan_group_description": (
|
||||||
|
plan.group.description if plan.group else ""
|
||||||
|
),
|
||||||
|
"vcpus": plan.vcpus,
|
||||||
|
"ram": plan.ram,
|
||||||
|
"currency": currency,
|
||||||
|
"compute_plan_price": compute_plan_price,
|
||||||
|
"sla_price": sla_price,
|
||||||
|
"final_price": final_price,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order groups correctly, placing "No Group" last
|
||||||
|
ordered_groups = {}
|
||||||
|
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
||||||
|
|
||||||
|
if "No Group" in all_group_names:
|
||||||
|
all_group_names.remove("No Group")
|
||||||
|
all_group_names.append("No Group")
|
||||||
|
|
||||||
|
for group_name_key in all_group_names:
|
||||||
|
ordered_groups[group_name_key] = pricing_data_by_group_and_service_level[
|
||||||
|
group_name_key
|
||||||
|
]
|
||||||
|
|
||||||
|
# Convert defaultdicts to regular dicts for the template
|
||||||
|
final_context_data = {}
|
||||||
|
for group_key, service_levels_dict in ordered_groups.items():
|
||||||
|
final_context_data[group_key] = {
|
||||||
|
sl_key: list(plans_list)
|
||||||
|
for sl_key, plans_list in service_levels_dict.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return final_context_data
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue