Classic Plan Pricing #18
11 changed files with 444 additions and 66 deletions
|
@ -2,7 +2,7 @@ name: Django Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["*"]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
@ -5,7 +5,14 @@ Admin classes for services and service offerings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan
|
from ..models import (
|
||||||
|
Service,
|
||||||
|
ServiceOffering,
|
||||||
|
ExternalLink,
|
||||||
|
ExternalLinkOffering,
|
||||||
|
Plan,
|
||||||
|
PlanPrice,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExternalLinkInline(admin.TabularInline):
|
class ExternalLinkInline(admin.TabularInline):
|
||||||
|
@ -26,14 +33,25 @@ class ExternalLinkOfferingInline(admin.TabularInline):
|
||||||
ordering = ("order", "description")
|
ordering = ("order", "description")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanPriceInline(admin.TabularInline):
|
||||||
|
"""Inline admin for PlanPrice model"""
|
||||||
|
|
||||||
|
model = PlanPrice
|
||||||
|
extra = 1
|
||||||
|
fields = ("currency", "amount")
|
||||||
|
ordering = ("currency",)
|
||||||
|
|
||||||
|
|
||||||
class PlanInline(admin.StackedInline):
|
class PlanInline(admin.StackedInline):
|
||||||
"""Inline admin for Plan model"""
|
"""Inline admin for Plan model with sortable ordering"""
|
||||||
|
|
||||||
model = Plan
|
model = Plan
|
||||||
extra = 1
|
extra = 1
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
(None, {"fields": ("name", "description", "plan_description")}),
|
||||||
|
("Display Options", {"fields": ("is_best",)}),
|
||||||
)
|
)
|
||||||
|
show_change_link = True # This allows clicking through to the Plan admin where prices can be managed
|
||||||
|
|
||||||
|
|
||||||
class OfferingInline(admin.StackedInline):
|
class OfferingInline(admin.StackedInline):
|
||||||
|
@ -102,7 +120,59 @@ class ServiceAdmin(admin.ModelAdmin):
|
||||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for ServiceOffering model"""
|
"""Admin configuration for ServiceOffering model"""
|
||||||
|
|
||||||
list_display = ("service", "cloud_provider")
|
list_display = ("service", "cloud_provider", "plan_count", "total_prices")
|
||||||
list_filter = ("service", "cloud_provider")
|
list_filter = ("service", "cloud_provider")
|
||||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||||
inlines = [ExternalLinkOfferingInline, PlanInline]
|
inlines = [ExternalLinkOfferingInline, PlanInline]
|
||||||
|
|
||||||
|
def plan_count(self, obj):
|
||||||
|
"""Display number of plans for this offering"""
|
||||||
|
return obj.plans.count()
|
||||||
|
|
||||||
|
plan_count.short_description = "Plans"
|
||||||
|
|
||||||
|
def total_prices(self, obj):
|
||||||
|
"""Display total number of plan prices for this offering"""
|
||||||
|
total = sum(plan.plan_prices.count() for plan in obj.plans.all())
|
||||||
|
return f"{total} prices"
|
||||||
|
|
||||||
|
total_prices.short_description = "Total Prices"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Plan)
|
||||||
|
class PlanAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin configuration for Plan model with sortable ordering"""
|
||||||
|
|
||||||
|
list_display = ("name", "offering", "is_best", "price_summary", "order")
|
||||||
|
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
|
||||||
|
search_fields = ("name", "description", "offering__service__name")
|
||||||
|
list_editable = ("is_best",)
|
||||||
|
inlines = [PlanPriceInline]
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("name", "offering", "description", "plan_description")}),
|
||||||
|
("Display Options", {"fields": ("is_best", "order")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def price_summary(self, obj):
|
||||||
|
"""Display a summary of prices for this plan"""
|
||||||
|
prices = obj.plan_prices.all()
|
||||||
|
if prices:
|
||||||
|
price_strs = [f"{price.amount} {price.currency}" for price in prices]
|
||||||
|
return ", ".join(price_strs)
|
||||||
|
return "No prices set"
|
||||||
|
|
||||||
|
price_summary.short_description = "Prices"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanPrice)
|
||||||
|
class PlanPriceAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin configuration for PlanPrice model"""
|
||||||
|
|
||||||
|
list_display = ("plan", "currency", "amount")
|
||||||
|
list_filter = (
|
||||||
|
"currency",
|
||||||
|
"plan__offering__service",
|
||||||
|
"plan__offering__cloud_provider",
|
||||||
|
)
|
||||||
|
search_fields = ("plan__name", "plan__offering__service__name")
|
||||||
|
ordering = ("plan__offering__service__name", "plan__name", "currency")
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Generated by Django 5.2 on 2025-06-23 07:58
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0036_alter_vshnappcataddonbasefee_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="plan",
|
||||||
|
name="pricing",
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlanPrice",
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plan",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="plan_prices",
|
||||||
|
to="services.plan",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["currency"],
|
||||||
|
"unique_together": {("plan", "currency")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
32
hub/services/migrations/0038_add_plan_ordering_and_best.py
Normal file
32
hub/services/migrations/0038_add_plan_ordering_and_best.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 5.2 on 2025-06-23 10:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0037_remove_plan_pricing_planprice"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="plan",
|
||||||
|
options={"ordering": ["order", "name"]},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="plan",
|
||||||
|
name="is_best",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Mark this plan as the best/recommended option"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="plan",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Order of this plan in the offering (lower numbers appear first)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -5,7 +5,13 @@ 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, ManagedServiceProvider, validate_image_size
|
from .base import (
|
||||||
|
Category,
|
||||||
|
ReusableText,
|
||||||
|
ManagedServiceProvider,
|
||||||
|
validate_image_size,
|
||||||
|
Currency,
|
||||||
|
)
|
||||||
from .providers import CloudProvider
|
from .providers import CloudProvider
|
||||||
|
|
||||||
|
|
||||||
|
@ -97,10 +103,31 @@ class ServiceOffering(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanPrice(models.Model):
|
||||||
|
plan = models.ForeignKey(
|
||||||
|
"Plan", on_delete=models.CASCADE, related_name="plan_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 = ("plan", "currency")
|
||||||
|
ordering = ["currency"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.plan.name} - {self.amount} {self.currency}"
|
||||||
|
|
||||||
|
|
||||||
class Plan(models.Model):
|
class Plan(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = ProseEditorField(blank=True, null=True)
|
description = ProseEditorField(blank=True, null=True)
|
||||||
pricing = ProseEditorField(blank=True, null=True)
|
|
||||||
plan_description = models.ForeignKey(
|
plan_description = models.ForeignKey(
|
||||||
ReusableText,
|
ReusableText,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -112,16 +139,48 @@ class Plan(models.Model):
|
||||||
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ordering and highlighting fields
|
||||||
|
order = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Order of this plan in the offering (lower numbers appear first)",
|
||||||
|
)
|
||||||
|
is_best = models.BooleanField(
|
||||||
|
default=False, help_text="Mark this plan as the best/recommended option"
|
||||||
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["order", "name"]
|
||||||
unique_together = [["offering", "name"]]
|
unique_together = [["offering", "name"]]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.offering} - {self.name}"
|
return f"{self.offering} - {self.name}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
# Ensure only one plan per offering can be marked as "best"
|
||||||
|
if self.is_best:
|
||||||
|
existing_best = Plan.objects.filter(
|
||||||
|
offering=self.offering, is_best=True
|
||||||
|
).exclude(pk=self.pk)
|
||||||
|
if existing_best.exists():
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
raise ValidationError(
|
||||||
|
"Only one plan per offering can be marked as the best option."
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_price(self, currency_code: str):
|
||||||
|
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
||||||
|
if price_obj:
|
||||||
|
return price_obj.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ExternalLinkOffering(models.Model):
|
class ExternalLinkOffering(models.Model):
|
||||||
offering = models.ForeignKey(
|
offering = models.ForeignKey(
|
||||||
|
|
|
@ -34,3 +34,36 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Subtle styling for the best plan */
|
||||||
|
.card.border-success.border-2 {
|
||||||
|
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.1) !important;
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.border-success.border-2:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(25, 135, 84, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Best choice badge styling */
|
||||||
|
.badge.bg-success {
|
||||||
|
background: linear-gradient(135deg, #198754 0%, #20c997 100%) !important;
|
||||||
|
border: 2px solid white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle enhancement for best plan button */
|
||||||
|
.btn-success.shadow {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success.shadow:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
|
||||||
|
}
|
|
@ -1835,3 +1835,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
new PriceCalculator();
|
new PriceCalculator();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function selectPlan(element) {
|
||||||
|
const planId = element.getAttribute('data-plan-id');
|
||||||
|
const planName = element.getAttribute('data-plan-name');
|
||||||
|
|
||||||
|
// Find the plan dropdown in the contact form
|
||||||
|
const planDropdown = document.getElementById('id_choice');
|
||||||
|
if (planDropdown) {
|
||||||
|
// Find the option with matching plan id and select it
|
||||||
|
for (let i = 0; i < planDropdown.options.length; i++) {
|
||||||
|
const optionValue = planDropdown.options[i].value;
|
||||||
|
if (optionValue.startsWith(planId + '|')) {
|
||||||
|
planDropdown.selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,15 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load contact_tags %}
|
{% load contact_tags %}
|
||||||
|
{% load json_ld_tags %}
|
||||||
|
|
||||||
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
|
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
|
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
|
||||||
|
|
||||||
|
{% json_ld_structured_data %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
@ -400,28 +403,79 @@
|
||||||
</div>
|
</div>
|
||||||
{% elif offering.plans.all %}
|
{% elif offering.plans.all %}
|
||||||
<!-- Traditional Plans -->
|
<!-- Traditional Plans -->
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
||||||
|
<div class="bg-light rounded-4 p-4 mb-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for plan in offering.plans.all %}
|
{% 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="col-12 {% if offering.plans.all|length == 1 %}col-lg-8 mx-auto{% elif offering.plans.all|length == 2 %}col-lg-6{% else %}col-lg-4{% endif %} mb-4">
|
||||||
<div class="bg-purple-50 rounded-16 border-all p-24">
|
<div class="card h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
|
||||||
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
{% if plan.is_best %}
|
||||||
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
<!-- Best Plan Badge -->
|
||||||
|
<div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
|
||||||
|
<span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
|
||||||
|
<i class="bi bi-star-fill me-1"></i>Best Choice
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body pt-3 d-flex flex-column">
|
||||||
|
<h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
|
||||||
|
<i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<!-- Plan Description -->
|
||||||
{% if plan.plan_description %}
|
{% if plan.plan_description %}
|
||||||
<div class="text-black mb-20">
|
<div class="mb-3">
|
||||||
|
<small class="text-muted">Description</small>
|
||||||
|
<div class="text-dark">
|
||||||
{{ plan.plan_description.text|safe }}
|
{{ plan.plan_description.text|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if plan.description %}
|
{% if plan.description %}
|
||||||
<div class="text-black mb-20">
|
<div class="mb-3">
|
||||||
|
<small class="text-muted">Details</small>
|
||||||
|
<div class="text-dark">
|
||||||
{{ plan.description|safe }}
|
{{ plan.description|safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% if plan.pricing %}
|
|
||||||
<div class="text-black mb-20">
|
|
||||||
{{ plan.pricing|safe }}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pricing Information -->
|
||||||
|
{% if plan.plan_prices.exists %}
|
||||||
|
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex flex-column">
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="{% if plan.is_best %}text-success fw-semibold{% else %}text-muted{% endif %}">Pricing</small>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>Monthly Price</span>
|
||||||
|
<div class="text-end">
|
||||||
|
{% for price in plan.plan_prices.all %}
|
||||||
|
<div class="fs-5 fw-bold {% if plan.is_best %}text-success{% else %}text-primary{% endif %}">{{ price.currency }} {{ price.amount }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted mt-2 d-block">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Prices exclude VAT. Monthly pricing based on 30 days.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex align-items-center justify-content-center">
|
||||||
|
<div class="text-center text-muted">
|
||||||
|
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Plan Action Button -->
|
||||||
|
<div class="text-center mt-auto pt-3">
|
||||||
|
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
|
||||||
|
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>{% if plan.is_best %}Select Best Plan{% else %}Select This Plan{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -435,6 +489,17 @@
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan Order Forms -->
|
||||||
|
<div id="plan-order-form" class="pt-40" style="scroll-margin-top: 30px;">
|
||||||
|
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Plan</h4>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- No Plans Available -->
|
<!-- No Plans Available -->
|
||||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||||
|
@ -443,17 +508,6 @@
|
||||||
{% 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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
|
|
||||||
<div id="form" class="pt-40">
|
|
||||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -209,7 +209,7 @@ def json_ld_structured_data(context):
|
||||||
data = {
|
data = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Product",
|
"@type": "Product",
|
||||||
"name": f"{offering.service.name} on {offering.cloud_provider.name}",
|
"name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
|
||||||
"description": offering.description or offering.service.description,
|
"description": offering.description or offering.service.description,
|
||||||
"url": offering_url,
|
"url": offering_url,
|
||||||
"category": "Cloud Service",
|
"category": "Cloud Service",
|
||||||
|
@ -224,17 +224,68 @@ def json_ld_structured_data(context):
|
||||||
|
|
||||||
# Add offers if available
|
# Add offers if available
|
||||||
if hasattr(offering, "plans") and offering.plans.exists():
|
if hasattr(offering, "plans") and offering.plans.exists():
|
||||||
|
# Get all plans with pricing
|
||||||
|
plans_with_prices = offering.plans.filter(
|
||||||
|
plan_prices__isnull=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
if plans_with_prices.exists():
|
||||||
|
# Create individual offers for each plan
|
||||||
|
offers = []
|
||||||
|
all_prices = []
|
||||||
|
|
||||||
|
for plan in plans_with_prices:
|
||||||
|
plan_prices = plan.plan_prices.all()
|
||||||
|
if plan_prices.exists():
|
||||||
|
first_price = plan_prices.first()
|
||||||
|
all_prices.extend([p.amount for p in plan_prices])
|
||||||
|
|
||||||
|
offer = {
|
||||||
|
"@type": "Offer",
|
||||||
|
"name": plan.name,
|
||||||
|
"price": str(first_price.amount),
|
||||||
|
"priceCurrency": first_price.currency,
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"url": offering_url + "#plan-order-form",
|
||||||
|
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||||
|
}
|
||||||
|
offers.append(offer)
|
||||||
|
|
||||||
|
# Add aggregate offer with all individual offers
|
||||||
|
data["offers"] = {
|
||||||
|
"@type": "AggregateOffer",
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"offerCount": len(offers),
|
||||||
|
"offers": offers,
|
||||||
|
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add lowPrice, highPrice and priceCurrency if we have prices
|
||||||
|
if all_prices:
|
||||||
|
data["offers"]["lowPrice"] = str(min(all_prices))
|
||||||
|
data["offers"]["highPrice"] = str(max(all_prices))
|
||||||
|
# Use the currency from the first plan's first price
|
||||||
|
first_plan_with_prices = plans_with_prices.first()
|
||||||
|
first_currency = first_plan_with_prices.plan_prices.first().currency
|
||||||
|
data["offers"]["priceCurrency"] = first_currency
|
||||||
|
|
||||||
|
# Note: aggregateRating and review fields are not included as this is a B2B
|
||||||
|
# service marketplace without a review system. These could be added in the future
|
||||||
|
# if customer reviews/ratings are implemented.
|
||||||
|
# Example structure for future implementation:
|
||||||
|
# if hasattr(offering, 'reviews') and offering.reviews.exists():
|
||||||
|
# data["aggregateRating"] = {
|
||||||
|
# "@type": "AggregateRating",
|
||||||
|
# "ratingValue": "4.5",
|
||||||
|
# "reviewCount": "10"
|
||||||
|
# }
|
||||||
|
else:
|
||||||
|
# No pricing available, just basic offer info
|
||||||
data["offers"] = {
|
data["offers"] = {
|
||||||
"@type": "AggregateOffer",
|
"@type": "AggregateOffer",
|
||||||
"availability": "https://schema.org/InStock",
|
"availability": "https://schema.org/InStock",
|
||||||
"offerCount": offering.plans.count(),
|
"offerCount": offering.plans.count(),
|
||||||
"seller": {
|
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||||
"@type": "Organization",
|
|
||||||
"name": offering.cloud_provider.name,
|
|
||||||
"url": request.build_absolute_uri(
|
|
||||||
offering.cloud_provider.get_absolute_url()
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
@ -10,16 +9,12 @@ from ..models.services import Service
|
||||||
from ..models.pricing import (
|
from ..models.pricing import (
|
||||||
ComputePlan,
|
ComputePlan,
|
||||||
ComputePlanPrice,
|
ComputePlanPrice,
|
||||||
StoragePlan,
|
|
||||||
StoragePlanPrice,
|
|
||||||
ProgressiveDiscountModel,
|
ProgressiveDiscountModel,
|
||||||
DiscountTier,
|
DiscountTier,
|
||||||
VSHNAppCatPrice,
|
VSHNAppCatPrice,
|
||||||
VSHNAppCatBaseFee,
|
VSHNAppCatBaseFee,
|
||||||
VSHNAppCatUnitRate,
|
VSHNAppCatUnitRate,
|
||||||
VSHNAppCatAddon,
|
VSHNAppCatAddon,
|
||||||
VSHNAppCatAddonBaseFee,
|
|
||||||
VSHNAppCatAddonUnitRate,
|
|
||||||
ExternalPricePlans,
|
ExternalPricePlans,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -163,7 +158,8 @@ class PricingEdgeCasesTestCase(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should return None when price doesn't exist
|
# Should return None when price doesn't exist
|
||||||
price = addon.get_price(Currency.CHF)
|
# For BASE_FEE addons, service_level is required
|
||||||
|
price = addon.get_price(Currency.CHF, service_level="standard")
|
||||||
self.assertIsNone(price)
|
self.assertIsNone(price)
|
||||||
|
|
||||||
def test_compute_plan_with_validity_dates(self):
|
def test_compute_plan_with_validity_dates(self):
|
||||||
|
|
|
@ -255,6 +255,8 @@ JAZZMIN_SETTINGS = {
|
||||||
"services.ProgressiveDiscountModel": "single",
|
"services.ProgressiveDiscountModel": "single",
|
||||||
"services.VSHNAppCatPrice": "single",
|
"services.VSHNAppCatPrice": "single",
|
||||||
"services.VSHNAppCatAddon": "single",
|
"services.VSHNAppCatAddon": "single",
|
||||||
|
"services.ServiceOffering": "single",
|
||||||
|
"services.Plan": "single",
|
||||||
},
|
},
|
||||||
"related_modal_active": True,
|
"related_modal_active": True,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue