From 3b8eea9c14ae9051d5e729dd99c3b99dde78159b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 10:00:19 +0200 Subject: [PATCH 1/9] model for classic plan pricing --- .../0037_remove_plan_pricing_planprice.py | 63 +++++++++++++++++++ hub/services/models/services.py | 37 ++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 hub/services/migrations/0037_remove_plan_pricing_planprice.py diff --git a/hub/services/migrations/0037_remove_plan_pricing_planprice.py b/hub/services/migrations/0037_remove_plan_pricing_planprice.py new file mode 100644 index 0000000..5326161 --- /dev/null +++ b/hub/services/migrations/0037_remove_plan_pricing_planprice.py @@ -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")}, + }, + ), + ] diff --git a/hub/services/models/services.py b/hub/services/models/services.py index 5c155b8..e5b90cb 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -5,7 +5,13 @@ from django.urls import reverse from django.utils.text import slugify 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 @@ -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): name = models.CharField(max_length=100) description = ProseEditorField(blank=True, null=True) - pricing = ProseEditorField(blank=True, null=True) plan_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, @@ -122,6 +149,12 @@ class Plan(models.Model): def __str__(self): return f"{self.offering} - {self.name}" + 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): offering = models.ForeignKey( From 656b59904ca5591c5308fa6b6c64338f22f6d5a3 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 11:37:26 +0200 Subject: [PATCH 2/9] classic plan new styling --- .../templates/services/offering_detail.html | 137 +++++++++++++----- 1 file changed, 98 insertions(+), 39 deletions(-) diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 000d13d..b5c946f 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -7,6 +7,26 @@ {% block extra_js %} + {% endblock %} {% block content %} @@ -400,40 +420,90 @@ {% elif offering.plans.all %} -

Available Plans

-
- {% for plan in offering.plans.all %} -
-
-
-

{{ plan.name }}

- {% if plan.plan_description %} -
- {{ plan.plan_description.text|safe }} +

Choose your Plan

+
+
+ {% for plan in offering.plans.all %} +
+
+
+
+ {{ plan.name }} +
+ + + {% if plan.plan_description %} +
+ Description +
+ {{ plan.plan_description.text|safe }} +
+
+ {% endif %} + + {% if plan.description %} +
+ Details +
+ {{ plan.description|safe }} +
+
+ {% endif %} + + + {% if plan.plan_prices.exists %} +
+
+ Pricing +
+ {% for price in plan.plan_prices.all %} +
+ Monthly Price + {{ price.currency }} {{ price.amount }} +
+ {% endfor %} + + + Prices exclude VAT. Monthly pricing based on 30 days. + +
+ {% else %} +
+
+ Contact us for pricing details +
+
+ {% endif %} + + +
- {% endif %} - {% if plan.description %} -
- {{ plan.description|safe }} -
- {% endif %} - {% if plan.pricing %} -
- {{ plan.pricing|safe }} -
- {% endif %}
+ {% empty %} +
+
+

No plans available yet.

+

I'm interested in this offering

+ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} +
+
+ {% endfor %}
- {% empty %} -
-
-

No plans available yet.

-

I'm interested in this offering

- {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} +
+ + +
+

Order Your Plan

+
+
+ {% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
- {% endfor %}
{% else %} @@ -443,17 +513,6 @@ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
{% endif %} - - {% if offering.plans.exists and not pricing_data_by_group_and_service_level %} -
-

I'm interested in a plan

-
-
- {% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %} -
-
-
- {% endif %}
From 9e0ccb6025eb4df3721c84487b8fb3465b4a7747 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 11:58:09 +0200 Subject: [PATCH 3/9] plan price admin --- hub/services/admin/services.py | 72 +++++++++++++++++++++++++++++++--- hub/settings.py | 2 + 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 44f848b..8d86c08 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -5,7 +5,14 @@ Admin classes for services and service offerings from django.contrib import admin 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): @@ -26,14 +33,22 @@ class ExternalLinkOfferingInline(admin.TabularInline): 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): """Inline admin for Plan model""" model = Plan extra = 1 - fieldsets = ( - (None, {"fields": ("name", "description", "pricing", "plan_description")}), - ) + fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),) + show_change_link = True # This allows clicking through to the Plan admin where prices can be managed class OfferingInline(admin.StackedInline): @@ -102,7 +117,54 @@ class ServiceAdmin(admin.ModelAdmin): class ServiceOfferingAdmin(admin.ModelAdmin): """Admin configuration for ServiceOffering model""" - list_display = ("service", "cloud_provider") + list_display = ("service", "cloud_provider", "plan_count", "total_prices") list_filter = ("service", "cloud_provider") search_fields = ("service__name", "cloud_provider__name", "description") 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""" + + list_display = ("name", "offering", "price_summary") + list_filter = ("offering__service", "offering__cloud_provider") + search_fields = ("name", "description", "offering__service__name") + inlines = [PlanPriceInline] + + 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") diff --git a/hub/settings.py b/hub/settings.py index 409b3d6..8e6f3e6 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -255,6 +255,8 @@ JAZZMIN_SETTINGS = { "services.ProgressiveDiscountModel": "single", "services.VSHNAppCatPrice": "single", "services.VSHNAppCatAddon": "single", + "services.ServiceOffering": "single", + "services.Plan": "single", }, "related_modal_active": True, } From c93f8de717a8b5548dba2cb6660198e7441235ea Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 13:13:27 +0200 Subject: [PATCH 4/9] introduce plan ordering and marking as best --- hub/services/admin/services.py | 18 +++-- .../0038_add_plan_ordering_and_best.py | 32 ++++++++ hub/services/models/services.py | 28 ++++++- .../templates/services/offering_detail.html | 75 +++++++++++++++---- 4 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 hub/services/migrations/0038_add_plan_ordering_and_best.py diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 8d86c08..c975884 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -43,11 +43,14 @@ class PlanPriceInline(admin.TabularInline): class PlanInline(admin.StackedInline): - """Inline admin for Plan model""" + """Inline admin for Plan model with sortable ordering""" model = Plan extra = 1 - fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),) + fieldsets = ( + (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 @@ -138,12 +141,17 @@ class ServiceOfferingAdmin(admin.ModelAdmin): @admin.register(Plan) class PlanAdmin(admin.ModelAdmin): - """Admin configuration for Plan model""" + """Admin configuration for Plan model with sortable ordering""" - list_display = ("name", "offering", "price_summary") - list_filter = ("offering__service", "offering__cloud_provider") + 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""" diff --git a/hub/services/migrations/0038_add_plan_ordering_and_best.py b/hub/services/migrations/0038_add_plan_ordering_and_best.py new file mode 100644 index 0000000..b02be24 --- /dev/null +++ b/hub/services/migrations/0038_add_plan_ordering_and_best.py @@ -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)", + ), + ), + ] diff --git a/hub/services/models/services.py b/hub/services/models/services.py index e5b90cb..8b6984d 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -139,16 +139,42 @@ class Plan(models.Model): 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) updated_at = models.DateTimeField(auto_now=True) class Meta: - ordering = ["name"] + ordering = ["order", "name"] unique_together = [["offering", "name"]] def __str__(self): 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: diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index b5c946f..ee4f684 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -7,6 +7,39 @@ {% block extra_js %} + + + +{% json_ld_structured_data %} + - {% endblock %} {% block content %} diff --git a/hub/services/templatetags/json_ld_tags.py b/hub/services/templatetags/json_ld_tags.py index e95ba70..eb18007 100644 --- a/hub/services/templatetags/json_ld_tags.py +++ b/hub/services/templatetags/json_ld_tags.py @@ -225,19 +225,21 @@ def json_ld_structured_data(context): # Add offers if available if hasattr(offering, "plans") and offering.plans.exists(): # Get all plans with pricing - plans_with_prices = offering.plans.filter(plan_prices__isnull=False).distinct() - + 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, @@ -245,25 +247,19 @@ def json_ld_structured_data(context): "priceCurrency": first_price.currency, "availability": "https://schema.org/InStock", "url": offering_url + "#plan-order-form", - "seller": { - "@type": "Organization", - "name": "VSHN" - } + "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" - } + "seller": {"@type": "Organization", "name": "VSHN"}, } - + # Add lowPrice, highPrice and priceCurrency if we have prices if all_prices: data["offers"]["lowPrice"] = str(min(all_prices)) @@ -272,7 +268,7 @@ def json_ld_structured_data(context): 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. @@ -289,10 +285,7 @@ def json_ld_structured_data(context): "@type": "AggregateOffer", "availability": "https://schema.org/InStock", "offerCount": offering.plans.count(), - "seller": { - "@type": "Organization", - "name": "VSHN" - } + "seller": {"@type": "Organization", "name": "VSHN"}, } else: From 25d4164bae8f51821779131bb4e41a5f6f4cd40b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 14:22:33 +0200 Subject: [PATCH 8/9] only run tests on main and PRs --- .forgejo/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index a0cb310..1bc048c 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -2,7 +2,7 @@ name: Django Tests on: push: - branches: ["*"] + branches: [main] pull_request: jobs: @@ -31,4 +31,4 @@ jobs: -w /app \ -e SECRET_KEY=dummysecretkey \ website:test \ - sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2' \ No newline at end of file + sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2' From 0a3837d1e197c37062e5f16b94fbb1c251054932 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 14:22:52 +0200 Subject: [PATCH 9/9] set service level for test --- hub/services/tests/test_pricing_edge_cases.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/hub/services/tests/test_pricing_edge_cases.py b/hub/services/tests/test_pricing_edge_cases.py index 8a59104..0363ea8 100644 --- a/hub/services/tests/test_pricing_edge_cases.py +++ b/hub/services/tests/test_pricing_edge_cases.py @@ -1,6 +1,5 @@ from decimal import Decimal from django.test import TestCase -from django.core.exceptions import ValidationError from django.utils import timezone from datetime import timedelta @@ -10,16 +9,12 @@ from ..models.services import Service from ..models.pricing import ( ComputePlan, ComputePlanPrice, - StoragePlan, - StoragePlanPrice, ProgressiveDiscountModel, DiscountTier, VSHNAppCatPrice, VSHNAppCatBaseFee, VSHNAppCatUnitRate, VSHNAppCatAddon, - VSHNAppCatAddonBaseFee, - VSHNAppCatAddonUnitRate, ExternalPricePlans, ) @@ -163,7 +158,8 @@ class PricingEdgeCasesTestCase(TestCase): ) # 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) def test_compute_plan_with_validity_dates(self):