Compare commits
No commits in common. "0e88a8d13ea600da025face7a99c61daa279ea09" and "3b8eea9c14ae9051d5e729dd99c3b99dde78159b" have entirely different histories.
0e88a8d13e
...
3b8eea9c14
8 changed files with 56 additions and 342 deletions
|
@ -5,14 +5,7 @@ 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 (
|
from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan
|
||||||
Service,
|
|
||||||
ServiceOffering,
|
|
||||||
ExternalLink,
|
|
||||||
ExternalLinkOffering,
|
|
||||||
Plan,
|
|
||||||
PlanPrice,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalLinkInline(admin.TabularInline):
|
class ExternalLinkInline(admin.TabularInline):
|
||||||
|
@ -33,25 +26,14 @@ 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 with sortable ordering"""
|
"""Inline admin for Plan model"""
|
||||||
|
|
||||||
model = Plan
|
model = Plan
|
||||||
extra = 1
|
extra = 1
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "description", "plan_description")}),
|
(None, {"fields": ("name", "description", "pricing", "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):
|
||||||
|
@ -120,59 +102,7 @@ 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", "plan_count", "total_prices")
|
list_display = ("service", "cloud_provider")
|
||||||
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")
|
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
# 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)",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -139,42 +139,16 @@ 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 = ["order", "name"]
|
ordering = ["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):
|
def get_price(self, currency_code: str):
|
||||||
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
||||||
if price_obj:
|
if price_obj:
|
||||||
|
|
|
@ -34,36 +34,3 @@
|
||||||
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,21 +1835,3 @@ 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,15 +1,12 @@
|
||||||
{% 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 %}
|
||||||
|
@ -403,102 +400,40 @@
|
||||||
</div>
|
</div>
|
||||||
{% elif offering.plans.all %}
|
{% elif offering.plans.all %}
|
||||||
<!-- Traditional Plans -->
|
<!-- Traditional Plans -->
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</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 -->
|
{% if plan.plan_description %}
|
||||||
<div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
|
<div class="text-black mb-20">
|
||||||
<span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
|
{{ plan.plan_description.text|safe }}
|
||||||
<i class="bi bi-star-fill me-1"></i>Best Choice
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-body pt-3 d-flex flex-column">
|
{% if plan.description %}
|
||||||
<h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
|
<div class="text-black mb-20">
|
||||||
<i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
|
{{ plan.description|safe }}
|
||||||
</h5>
|
|
||||||
|
|
||||||
<!-- Plan Description -->
|
|
||||||
{% if plan.plan_description %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<small class="text-muted">Description</small>
|
|
||||||
<div class="text-dark">
|
|
||||||
{{ plan.plan_description.text|safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if plan.description %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<small class="text-muted">Details</small>
|
|
||||||
<div class="text-dark">
|
|
||||||
{{ plan.description|safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.pricing %}
|
||||||
|
<div class="text-black mb-20">
|
||||||
|
{{ plan.pricing|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
|
||||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<p>No plans available yet.</p>
|
|
||||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
|
||||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% empty %}
|
||||||
|
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||||
<!-- Plan Order Forms -->
|
<div class="alert alert-info">
|
||||||
<div id="plan-order-form" class="pt-40" style="scroll-margin-top: 30px;">
|
<p>No plans available yet.</p>
|
||||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Plan</h4>
|
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||||
<div class="row">
|
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||||
<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>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- No Plans Available -->
|
<!-- No Plans Available -->
|
||||||
|
@ -508,6 +443,17 @@
|
||||||
{% 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"Managed {offering.service.name} on {offering.cloud_provider.name}",
|
"name": f"{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,69 +224,18 @@ 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
|
data["offers"] = {
|
||||||
plans_with_prices = offering.plans.filter(
|
"@type": "AggregateOffer",
|
||||||
plan_prices__isnull=False
|
"availability": "https://schema.org/InStock",
|
||||||
).distinct()
|
"offerCount": offering.plans.count(),
|
||||||
|
"seller": {
|
||||||
if plans_with_prices.exists():
|
"@type": "Organization",
|
||||||
# Create individual offers for each plan
|
"name": offering.cloud_provider.name,
|
||||||
offers = []
|
"url": request.build_absolute_uri(
|
||||||
all_prices = []
|
offering.cloud_provider.get_absolute_url()
|
||||||
|
),
|
||||||
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"] = {
|
|
||||||
"@type": "AggregateOffer",
|
|
||||||
"availability": "https://schema.org/InStock",
|
|
||||||
"offerCount": offering.plans.count(),
|
|
||||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default to organization data if no specific page type matches
|
# Default to organization data if no specific page type matches
|
||||||
|
|
|
@ -255,8 +255,6 @@ 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