diff --git a/hub/broker/views.py b/hub/broker/views.py
index 4f7cf53..8affe30 100644
--- a/hub/broker/views.py
+++ b/hub/broker/views.py
@@ -30,9 +30,6 @@ class ServiceBrokerViewSet(viewsets.ViewSet):
broker_user = get_object_or_404(ServiceBrokerUser, user=request.user)
offerings = broker_user.allowed_offerings.prefetch_related(
"plans",
- "plans__prices",
- "plans__prices__currency",
- "plans__prices__term",
"service",
"cloud_provider",
).all()
diff --git a/hub/services/admin.py b/hub/services/admin.py
index 574a65f..f3a889d 100644
--- a/hub/services/admin.py
+++ b/hub/services/admin.py
@@ -4,17 +4,23 @@ from .models import (
Category,
CloudProvider,
ConsultingPartner,
- Currency,
ExternalLink,
+ ExternalLinkOffering,
+ Lead,
Plan,
- PlanPrice,
+ ReusableText,
Service,
ServiceOffering,
- Term,
- Lead,
)
+@admin.register(ReusableText)
+class ReusableTextAdmin(admin.ModelAdmin):
+ list_display = ("name",)
+ search_fields = ("name", "text")
+ ordering = ("name",)
+
+
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "parent", "order")
@@ -24,20 +30,6 @@ class CategoryAdmin(admin.ModelAdmin):
ordering = ("order", "name")
-@admin.register(Currency)
-class CurrencyAdmin(admin.ModelAdmin):
- list_display = ("code", "name", "symbol")
- search_fields = ("code", "name")
- ordering = ("code",)
-
-
-@admin.register(Term)
-class TermAdmin(admin.ModelAdmin):
- list_display = ("name", "order")
- ordering = ("order", "name")
- search_fields = ("name", "description")
-
-
@admin.register(CloudProvider)
class CloudProviderAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "logo_preview")
@@ -90,66 +82,25 @@ class ServiceAdmin(admin.ModelAdmin):
partner_list.short_description = "Consulting Partners"
-@admin.register(PlanPrice)
-class PlanPriceAdmin(admin.ModelAdmin):
- model = PlanPrice
-
-
class PlanInline(admin.StackedInline):
model = Plan
extra = 1
- fieldsets = (
- (None, {"fields": ("name", "description", "is_default", "features", "order")}),
- (
- "Pricing",
- {
- "fields": ("get_prices",),
- },
- ),
- )
- readonly_fields = ("get_prices",)
+ fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),)
- def get_prices(self, obj):
- if not obj:
- return "Save the plan first to add prices"
- html = ['
']
- html.append(
- "Currency | Term | Price | Actions |
"
- )
-
- for price in obj.prices.all():
- html.append(
- f"""
-
- {price.currency} |
- {price.term} |
- {price.price} |
-
- Edit
- |
-
- """
- )
-
- html.append("
")
- html.append(
- f'Add Price'
- )
- return format_html("".join(html))
-
- get_prices.short_description = "Plan Prices"
+class ExternalLinkOfferingInline(admin.TabularInline):
+ model = ExternalLinkOffering
+ extra = 1
+ fields = ("description", "url", "order")
+ ordering = ("order", "description")
@admin.register(ServiceOffering)
class ServiceOfferingAdmin(admin.ModelAdmin):
- list_display = ("service", "cloud_provider", "status")
+ list_display = ("service", "slug", "cloud_provider")
list_filter = ("service", "cloud_provider")
search_fields = ("service__name", "cloud_provider__name", "description")
- inlines = [PlanInline]
-
- class Media:
- css = {"all": ("admin/css/service_offering.css",)}
+ inlines = [ExternalLinkOfferingInline, PlanInline]
@admin.register(ConsultingPartner)
diff --git a/hub/services/forms.py b/hub/services/forms.py
index 25b8187..59af925 100644
--- a/hub/services/forms.py
+++ b/hub/services/forms.py
@@ -1,5 +1,5 @@
from django import forms
-from .models import Lead, Plan, PlanPrice
+from .models import Lead, Plan
class LeadForm(forms.ModelForm):
@@ -18,33 +18,8 @@ class LeadForm(forms.ModelForm):
class PlanForm(forms.ModelForm):
class Meta:
model = Plan
- fields = ("name", "description", "is_default", "features", "order")
+ fields = ("name", "description")
widgets = {
"description": forms.Textarea(attrs={"rows": 3}),
"features": forms.Textarea(attrs={"rows": 4}),
}
-
- def clean(self):
- cleaned_data = super().clean()
- # If this is set as default, ensure no other plan for this service is default
- if cleaned_data.get("is_default"):
- service = self.instance.service
- if service:
- Plan.objects.filter(service=service).exclude(
- pk=self.instance.pk
- ).update(is_default=False)
- return cleaned_data
-
-
-class PlanPriceForm(forms.ModelForm):
- class Meta:
- model = PlanPrice
- fields = ("currency", "price")
-
- def clean(self):
- cleaned_data = super().clean()
- currency = cleaned_data.get("currency")
- price = cleaned_data.get("price")
- if price and price < 0:
- raise forms.ValidationError("Price cannot be negative")
- return cleaned_data
diff --git a/hub/services/migrations/0010_reusabletext_remove_planprice_currency_and_more.py b/hub/services/migrations/0010_reusabletext_remove_planprice_currency_and_more.py
new file mode 100644
index 0000000..9cbe211
--- /dev/null
+++ b/hub/services/migrations/0010_reusabletext_remove_planprice_currency_and_more.py
@@ -0,0 +1,142 @@
+# Generated by Django 5.1.5 on 2025-02-28 10:46
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0009_consultingpartner_address_consultingpartner_email_and_more"),
+ ]
+
+ operations = [
+ # 1. First create new models
+ migrations.CreateModel(
+ name="ReusableText",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=100)),
+ ("text", models.TextField()),
+ ],
+ options={
+ "ordering": ["name"],
+ },
+ ),
+ # 2. Modify models that will be kept
+ migrations.AlterModelOptions(
+ name="plan",
+ options={"ordering": ["name"]},
+ ),
+ migrations.RemoveField(
+ model_name="plan",
+ name="features",
+ ),
+ migrations.RemoveField(
+ model_name="plan",
+ name="is_default",
+ ),
+ migrations.RemoveField(
+ model_name="plan",
+ name="order",
+ ),
+ migrations.RemoveField(
+ model_name="serviceoffering",
+ name="status",
+ ),
+ migrations.AlterField(
+ model_name="serviceoffering",
+ name="description",
+ field=models.TextField(blank=True, null=True),
+ ),
+ # 3. Add new models and relationships
+ migrations.CreateModel(
+ name="ExternalLinkOffering",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("url", models.URLField()),
+ ("description", models.CharField(max_length=200)),
+ ("order", models.IntegerField(default=0)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "offering",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="external_links",
+ to="services.serviceoffering",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "External Link",
+ "verbose_name_plural": "External Links",
+ "ordering": ["order", "description"],
+ },
+ ),
+ migrations.AddField(
+ model_name="plan",
+ name="plan_description",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="plan_descriptions",
+ to="services.reusabletext",
+ ),
+ ),
+ migrations.AddField(
+ model_name="serviceoffering",
+ name="offer_description",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="offer_descriptions",
+ to="services.reusabletext",
+ ),
+ ),
+ # 4. Handle PlanPrice modifications and deletion
+ migrations.AlterUniqueTogether(
+ name="planprice",
+ unique_together=None,
+ ),
+ migrations.RemoveField(
+ model_name="planprice",
+ name="plan",
+ ),
+ migrations.RemoveField(
+ model_name="planprice",
+ name="term",
+ ),
+ migrations.RemoveField(
+ model_name="planprice",
+ name="currency",
+ ),
+ # 5. Delete models in the right order (dependent models first)
+ migrations.DeleteModel(
+ name="PlanPrice",
+ ),
+ migrations.DeleteModel(
+ name="Currency",
+ ),
+ migrations.DeleteModel(
+ name="Term",
+ ),
+ ]
diff --git a/hub/services/migrations/0011_reusabletext_textsnippet.py b/hub/services/migrations/0011_reusabletext_textsnippet.py
new file mode 100644
index 0000000..7fdd382
--- /dev/null
+++ b/hub/services/migrations/0011_reusabletext_textsnippet.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.1.5 on 2025-02-28 13:07
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0010_reusabletext_remove_planprice_currency_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="reusabletext",
+ name="textsnippet",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="children",
+ to="services.reusabletext",
+ ),
+ ),
+ ]
diff --git a/hub/services/models.py b/hub/services/models.py
index af02664..506c6c4 100644
--- a/hub/services/models.py
+++ b/hub/services/models.py
@@ -11,6 +11,44 @@ def validate_image_size(value):
raise ValidationError("Maximum file size is 1MB")
+class ReusableText(models.Model):
+ name = models.CharField(max_length=100)
+ textsnippet = models.ForeignKey(
+ "self",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="children",
+ )
+ text = ProseEditorField()
+
+ class Meta:
+ ordering = ["name"]
+
+ def __str__(self):
+ return self.name
+
+ def get_full_text(self):
+ """Returns the text with all nested textsnippet content recursively included in reverse order"""
+ text_parts = []
+
+ # Recursively collect all text parts
+ def collect_text(snippet):
+ if snippet is None:
+ return
+ collect_text(snippet.textsnippet) # Collect deepest snippets first
+ text_parts.append(snippet.text)
+
+ # Start collection with the deepest snippets
+ collect_text(self.textsnippet)
+
+ # Add the main text at the end
+ text_parts.append(self.text)
+
+ # Join all text parts
+ return "".join(text_parts)
+
+
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
@@ -69,31 +107,6 @@ class CloudProvider(models.Model):
return reverse("services:provider_detail", kwargs={"slug": self.slug})
-class Currency(models.Model):
- code = models.CharField(max_length=3, unique=True) # ISO 4217 currency code
- name = models.CharField(max_length=50)
- symbol = models.CharField(max_length=5)
-
- class Meta:
- verbose_name_plural = "Currencies"
- ordering = ["code"]
-
- def __str__(self):
- return f"{self.code} ({self.name})"
-
-
-class Term(models.Model):
- name = models.CharField(max_length=100)
- description = models.TextField(blank=True)
- order = models.IntegerField(default=0)
-
- class Meta:
- ordering = ["order", "name"]
-
- def __str__(self):
- return self.name
-
-
class Service(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
@@ -182,19 +195,18 @@ class ServiceOffering(models.Model):
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
)
slug = models.SlugField(max_length=250, unique=True)
- description = ProseEditorField(help_text="Provider-specific details and features")
+ description = ProseEditorField(blank=True, null=True)
+ offer_description = models.ForeignKey(
+ ReusableText,
+ on_delete=models.PROTECT,
+ related_name="offer_descriptions",
+ blank=True,
+ null=True,
+ )
+
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
- STATUS_CHOICES = [
- ("available", "Available"),
- ("planned", "Planned"),
- ("on_request", "On Request"),
- ]
- status = models.CharField(
- max_length=20, choices=STATUS_CHOICES, default="available"
- )
-
class Meta:
unique_together = ["service", "cloud_provider"]
ordering = ["service__name", "cloud_provider__name"]
@@ -219,40 +231,54 @@ class ServiceOffering(models.Model):
class Plan(models.Model):
name = models.CharField(max_length=100)
description = ProseEditorField()
+ plan_description = models.ForeignKey(
+ ReusableText,
+ on_delete=models.PROTECT,
+ related_name="plan_descriptions",
+ blank=True,
+ null=True,
+ )
offering = models.ForeignKey(
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
)
- is_default = models.BooleanField(default=False)
- features = ProseEditorField(blank=True)
- order = models.IntegerField(default=0)
+
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 save(self, *args, **kwargs):
- if self.is_default:
- # Ensure only one default plan per offering
- Plan.objects.filter(offering=self.offering).update(is_default=False)
- super().save(*args, **kwargs)
-
-class PlanPrice(models.Model):
- plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices")
- currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
- term = models.ForeignKey(Term, on_delete=models.PROTECT)
- price = models.DecimalField(max_digits=10, decimal_places=2)
+class ExternalLinkOffering(models.Model):
+ offering = models.ForeignKey(
+ ServiceOffering, on_delete=models.CASCADE, related_name="external_links"
+ )
+ url = models.URLField()
+ description = models.CharField(max_length=200)
+ order = models.IntegerField(default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
class Meta:
- unique_together = [["plan", "currency", "term"]]
+ ordering = ["order", "description"]
+ verbose_name = "External Link"
+ verbose_name_plural = "External Links"
def __str__(self):
- return f"{self.plan.name} - {self.currency.code} {self.price}"
+ return f"{self.description} ({self.url})"
+
+ def clean(self):
+ from django.core.validators import URLValidator
+
+ validate = URLValidator()
+ try:
+ validate(self.url)
+ except ValidationError:
+ raise ValidationError({"url": "Enter a valid URL."})
class ExternalLink(models.Model):
diff --git a/hub/services/static/admin/css/service_offering.css b/hub/services/static/admin/css/service_offering.css
deleted file mode 100644
index b847673..0000000
--- a/hub/services/static/admin/css/service_offering.css
+++ /dev/null
@@ -1,29 +0,0 @@
-.price-table {
- width: 100%;
- border-collapse: collapse;
- margin: 1em 0;
-}
-
-.price-table th,
-.price-table td {
- padding: 8px;
- border: 1px solid #ddd;
-}
-
-.price-table th {
- background-color: #f5f5f5;
-}
-
-.button {
- display: inline-block;
- padding: 4px 8px;
- background: #79aec8;
- color: white;
- text-decoration: none;
- border-radius: 4px;
- margin: 2px;
-}
-
-.button:hover {
- background: #609ab6;
-}
\ No newline at end of file
diff --git a/hub/services/templates/services/base.html b/hub/services/templates/services/base.html
index f99a9bb..d5a0607 100644
--- a/hub/services/templates/services/base.html
+++ b/hub/services/templates/services/base.html
@@ -33,7 +33,6 @@
@@ -125,7 +124,6 @@
diff --git a/hub/services/templates/services/lead_form.html b/hub/services/templates/services/lead_form.html
index 797f97a..5739d06 100644
--- a/hub/services/templates/services/lead_form.html
+++ b/hub/services/templates/services/lead_form.html
@@ -91,15 +91,6 @@
{{ selected_plan.name }}
- {% if selected_plan.prices.exists %}
-
-
- {% for price in selected_plan.prices.all %}
- - {{ price.currency.symbol }}{{ price.price }} {{ price.currency.code }} per {{ price.term.name }}
- {% endfor %}
-
-
- {% endif %}
{% endif %}
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html
index c2544a1..85d36e3 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -12,25 +12,28 @@
{% if offering.service.logo %}
-

+
+
+
{% endif %}
-
- {% if offering.highlights.exists %}
+
+ {% if offering.external_links.exists %}
-
Highlights
+
External Links
{% endif %}
-
-
- {% if offering.service.external_links.exists %}
-
- {% endif %}
@@ -90,12 +71,12 @@
{% for category in offering.service.categories.all %}
@@ -107,12 +88,29 @@
-
+
+ {% if offering.service.description %}
+
+
Service Overview
+
+ {{ offering.service.description|safe }}
+
+
+ {% endif %}
+
+
-
Overview
+
Offering
+ {% if offering.description %}
{{ offering.description|safe }}
+ {% endif %}
+ {% if offering.offer_description %}
+
+ {{ offering.offer_description.get_full_text|safe }}
+
+ {% endif %}
@@ -122,18 +120,18 @@
{% for plan in offering.plans.all %}
-
-
{{ plan.description|safe }}
-
{{ plan.name }}
-
-
- {% for price in plan.prices.all %}
- - {{ price.currency.symbol }}{{ price.price }} {{ price.currency.code }} per {{ price.term.name }}
- {% endfor %}
-
+ {% if plan.plan_description %}
+
+ {{ plan.plan_description.text|safe }}
+ {% endif %}
+ {% if plan.description %}
+
+ {{ plan.description|safe }}
+
+ {% endif %}
-
-
- {{ offering.description|safe|truncatewords_html:30 }}
-
diff --git a/hub/services/templates/services/thank_you.html b/hub/services/templates/services/thank_you.html
index f0b771e..1f576e1 100644
--- a/hub/services/templates/services/thank_you.html
+++ b/hub/services/templates/services/thank_you.html
@@ -30,53 +30,14 @@
-
-
-
-
- {% if service.image %}
-

- {% endif %}
-
-
-
- {% if service.description %}
-
-
-
{{ service.description|safe }}
-
-
- {% endif %}
- {% if service.pricing_plans %}
-
- {% for plan in service.pricing_plans %}
-
-
{{ plan.name }}
-
-
- {% for price in plan.prices %}
- - {{ price.amount }} {{ price.currency }} per {{ price.interval }}
- {% endfor %}
-
-
-
- {% endfor %}
-
- {% endif %}
-
diff --git a/hub/services/views/leads.py b/hub/services/views/leads.py
index 9b05645..582ba57 100644
--- a/hub/services/views/leads.py
+++ b/hub/services/views/leads.py
@@ -43,9 +43,7 @@ def create_lead(request, slug):
# Get the selected plan
selected_plan = get_object_or_404(
- Plan.objects.prefetch_related(
- "prices", "prices__currency", "prices__term"
- ),
+ Plan,
id=request.GET.get("plan"),
offering=selected_offering,
)
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index 883a2be..194d6dc 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -11,9 +11,6 @@ def offering_list(request):
.prefetch_related(
"service__categories",
"plans",
- "plans__prices",
- "plans__prices__currency",
- "plans__prices__term",
)
)
@@ -62,9 +59,7 @@ def offering_detail(request, slug):
offering = get_object_or_404(
ServiceOffering.objects.select_related(
"service", "cloud_provider"
- ).prefetch_related(
- "plans", "plans__prices", "plans__prices__currency", "plans__prices__term"
- ),
+ ).prefetch_related("plans"),
slug=slug,
)
diff --git a/hub/services/views/providers.py b/hub/services/views/providers.py
index b92f3ae..61d2021 100644
--- a/hub/services/views/providers.py
+++ b/hub/services/views/providers.py
@@ -32,7 +32,6 @@ def provider_detail(request, slug):
"offerings",
"offerings__service",
"offerings__plans",
- "offerings__plans__prices",
"consulting_partners",
),
slug=slug,
diff --git a/hub/services/views/services.py b/hub/services/views/services.py
index 1676635..970b3b5 100644
--- a/hub/services/views/services.py
+++ b/hub/services/views/services.py
@@ -17,7 +17,6 @@ def service_list(request):
"offerings",
"offerings__cloud_provider",
"offerings__plans",
- "offerings__plans__prices",
"consulting_partners",
"external_links",
)
@@ -69,9 +68,6 @@ def service_detail(request, slug):
"offerings",
"offerings__cloud_provider",
"offerings__plans",
- "offerings__plans__prices",
- "offerings__plans__prices__currency",
- "offerings__plans__prices__term",
"consulting_partners",
"external_links",
),