From c93f8de717a8b5548dba2cb6660198e7441235ea Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 13:13:27 +0200 Subject: [PATCH] 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 %} +