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( - "" - ) - - for price in obj.prices.all(): - html.append( - f""" - - - - - - - """ - ) - - html.append("
CurrencyTermPriceActions
{price.currency}{price.term}{price.price} - Edit -
") - 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 %} - {{ offering.service.name }} logo + + {{ offering.service.name }} logo + {% endif %}
- - {% if offering.highlights.exists %} + + {% if offering.external_links.exists %}
-

Highlights

+

External Links

{% endif %} - - - {% if offering.service.external_links.exists %} -
-

External Links

- -
- {% endif %} @@ -90,12 +71,12 @@
{% if offering.cloud_provider.logo %} -

+

{{ offering.cloud_provider.name }} logo

{% endif %} -

{{ offering.service.name }}

+

{{ offering.service.name }}

{% 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 @@
-

Inquiry Received Successfully!

+

Inquiry received successfully!

-

Thank you for your interest in {{ service.name }}! We have received your inquiry and our team will contact you shortly. A confirmation email will be sent to your provided email address.

+

Thank you for your interest in {{ service.name }}. We have received your inquiry and our team will contact you shortly. A confirmation email will be sent to your provided email address.

-
- Browse More Services -
-
-
-
- {% if service.image %} - {{ service.name }} - {% endif %} -
-
-

{{ service.name }}

-

{{ service.category }}

-
-
- {% 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", ),