diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index c975884..44f848b 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -5,14 +5,7 @@ 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, - PlanPrice, -) +from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan class ExternalLinkInline(admin.TabularInline): @@ -33,25 +26,14 @@ 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 with sortable ordering""" + """Inline admin for Plan model""" model = Plan extra = 1 fieldsets = ( - (None, {"fields": ("name", "description", "plan_description")}), - ("Display Options", {"fields": ("is_best",)}), + (None, {"fields": ("name", "description", "pricing", "plan_description")}), ) - show_change_link = True # This allows clicking through to the Plan admin where prices can be managed class OfferingInline(admin.StackedInline): @@ -120,59 +102,7 @@ class ServiceAdmin(admin.ModelAdmin): class ServiceOfferingAdmin(admin.ModelAdmin): """Admin configuration for ServiceOffering model""" - list_display = ("service", "cloud_provider", "plan_count", "total_prices") + list_display = ("service", "cloud_provider") 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 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") diff --git a/hub/services/migrations/0038_add_plan_ordering_and_best.py b/hub/services/migrations/0038_add_plan_ordering_and_best.py deleted file mode 100644 index b02be24..0000000 --- a/hub/services/migrations/0038_add_plan_ordering_and_best.py +++ /dev/null @@ -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)", - ), - ), - ] diff --git a/hub/services/models/services.py b/hub/services/models/services.py index 8b6984d..e5b90cb 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -139,42 +139,16 @@ 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 = ["order", "name"] + ordering = ["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/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css index 5efe5f6..de8074e 100644 --- a/hub/services/static/css/price-calculator.css +++ b/hub/services/static/css/price-calculator.css @@ -33,37 +33,4 @@ to { 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; } \ No newline at end of file diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index 9ea66bb..54b2af9 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -1834,22 +1834,4 @@ document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('cpuRange')) { 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; - } - } - } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 7263024..000d13d 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -1,15 +1,12 @@ {% extends 'base.html' %} {% load static %} {% load contact_tags %} -{% load json_ld_tags %} {% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %} {% block extra_js %} - -{% json_ld_structured_data %} {% endblock %} {% block content %} @@ -403,102 +400,40 @@ {% elif offering.plans.all %} -

Choose your Plan

-
-
- {% for plan in offering.plans.all %} -
-
- {% if plan.is_best %} - -
- - Best Choice - +

Available Plans

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

{{ plan.name }}

+ {% if plan.plan_description %} +
+ {{ plan.plan_description.text|safe }}
{% endif %} -
-
- {{ 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 -
-
-
- Monthly Price -
- {% for price in plan.plan_prices.all %} -
{{ price.currency }} {{ price.amount }}
- {% endfor %} -
-
-
- - - Prices exclude VAT. Monthly pricing based on 30 days. - -
- {% else %} -
-
- Contact us for pricing details -
-
- {% 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 %}
-
- - -
-

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" %} + {% 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 %}
{% else %} @@ -508,6 +443,17 @@ {% 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 %}
diff --git a/hub/services/templatetags/json_ld_tags.py b/hub/services/templatetags/json_ld_tags.py index eb18007..12cc853 100644 --- a/hub/services/templatetags/json_ld_tags.py +++ b/hub/services/templatetags/json_ld_tags.py @@ -209,7 +209,7 @@ def json_ld_structured_data(context): data = { "@context": "https://schema.org", "@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, "url": offering_url, "category": "Cloud Service", @@ -224,69 +224,18 @@ 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() - - 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"] = { - "@type": "AggregateOffer", - "availability": "https://schema.org/InStock", - "offerCount": offering.plans.count(), - "seller": {"@type": "Organization", "name": "VSHN"}, - } + data["offers"] = { + "@type": "AggregateOffer", + "availability": "https://schema.org/InStock", + "offerCount": offering.plans.count(), + "seller": { + "@type": "Organization", + "name": offering.cloud_provider.name, + "url": request.build_absolute_uri( + offering.cloud_provider.get_absolute_url() + ), + }, + } else: # Default to organization data if no specific page type matches diff --git a/hub/settings.py b/hub/settings.py index 8e6f3e6..409b3d6 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -255,8 +255,6 @@ JAZZMIN_SETTINGS = { "services.ProgressiveDiscountModel": "single", "services.VSHNAppCatPrice": "single", "services.VSHNAppCatAddon": "single", - "services.ServiceOffering": "single", - "services.Plan": "single", }, "related_modal_active": True, }