diff --git a/hub/services/admin.py b/hub/services/admin.py index 030b053..1f46934 100644 --- a/hub/services/admin.py +++ b/hub/services/admin.py @@ -5,9 +5,34 @@ from .models import ( ConsultingPartner, Service, Category, + Currency, + Plan, + PlanPrice, ) +class PlanPriceInline(admin.TabularInline): + model = PlanPrice + extra = 1 + min_num = 1 + verbose_name = "Price" + verbose_name_plural = "Prices" + + +class PlanInline(admin.StackedInline): + model = Plan + extra = 1 + show_change_link = True + fields = ("name", "description", "is_default", "features", "order") + classes = ("collapse",) + inlines = [PlanPriceInline] + + def get_formset(self, request, obj=None, **kwargs): + formset = super().get_formset(request, obj, **kwargs) + formset.request = request + return formset + + @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display = ("name", "slug", "parent", "order") @@ -36,7 +61,6 @@ class ServiceAdmin(admin.ModelAdmin): list_display = ( "name", "cloud_provider", - "price", "logo_preview", "category_list", "partner_list", @@ -49,6 +73,7 @@ class ServiceAdmin(admin.ModelAdmin): filter_horizontal = ("categories", "consulting_partners") search_fields = ("name", "description", "slug") prepopulated_fields = {"slug": ("name",)} + inlines = [PlanInline] def logo_preview(self, obj): if obj.logo: @@ -66,6 +91,9 @@ class ServiceAdmin(admin.ModelAdmin): partner_list.short_description = "Consulting Partners" category_list.short_description = "Categories" + class Media: + css = {"all": ("admin/css/hide_inline_header.css",)} + @admin.register(ConsultingPartner) class ConsultingPartnerAdmin(admin.ModelAdmin): @@ -79,3 +107,19 @@ class ConsultingPartnerAdmin(admin.ModelAdmin): '', obj.logo.url ) return "No logo" + + +@admin.register(Currency) +class CurrencyAdmin(admin.ModelAdmin): + list_display = ("code", "name", "symbol") + search_fields = ("code", "name") + ordering = ("code",) + + +@admin.register(Plan) +class PlanAdmin(admin.ModelAdmin): + list_display = ("name", "service", "is_default", "order") + list_filter = ("service", "is_default") + search_fields = ("name", "description") + ordering = ("service", "order", "name") + inlines = [PlanPriceInline] diff --git a/hub/services/forms.py b/hub/services/forms.py index b91f764..1001f64 100644 --- a/hub/services/forms.py +++ b/hub/services/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Lead +from .models import Lead, Plan, PlanPrice class LeadForm(forms.ModelForm): @@ -12,3 +12,38 @@ class LeadForm(forms.ModelForm): "email": forms.EmailInput(attrs={"class": "form-control"}), "phone": forms.TextInput(attrs={"class": "form-control"}), } + + +class PlanForm(forms.ModelForm): + class Meta: + model = Plan + fields = ("name", "description", "is_default", "features", "order") + 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/0009_currency_plan_planprice.py b/hub/services/migrations/0009_currency_plan_planprice.py new file mode 100644 index 0000000..1fffa79 --- /dev/null +++ b/hub/services/migrations/0009_currency_plan_planprice.py @@ -0,0 +1,118 @@ +# Generated by Django 5.1.5 on 2025-01-28 09:19 + +import django.db.models.deletion +from django.db import migrations, models + + +def create_initial_currencies(apps, schema_editor): + Currency = apps.get_model("services", "Currency") + currencies = [ + {"code": "USD", "name": "US Dollar", "symbol": "$"}, + {"code": "CHF", "name": "Swiss Franc", "symbol": "CHF"}, + {"code": "EUR", "name": "Euro", "symbol": "€"}, + ] + for currency_data in currencies: + Currency.objects.create(**currency_data) + + +def remove_initial_currencies(apps, schema_editor): + Currency = apps.get_model("services", "Currency") + Currency.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0008_remove_service_countries_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Currency", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("code", models.CharField(max_length=3, unique=True)), + ("name", models.CharField(max_length=50)), + ("symbol", models.CharField(max_length=5)), + ], + options={ + "verbose_name_plural": "Currencies", + "ordering": ["code"], + }, + ), + migrations.CreateModel( + name="Plan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("description", models.TextField()), + ("is_default", models.BooleanField(default=False)), + ("features", models.TextField(blank=True)), + ("order", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "service", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plans", + to="services.service", + ), + ), + ], + options={ + "ordering": ["order", "name"], + "unique_together": {("service", "name")}, + }, + ), + migrations.CreateModel( + name="PlanPrice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("price", models.DecimalField(decimal_places=2, max_digits=10)), + ( + "currency", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="services.currency", + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="prices", + to="services.plan", + ), + ), + ], + options={ + "unique_together": {("plan", "currency")}, + }, + ), + migrations.RunPython(create_initial_currencies, remove_initial_currencies), + ] diff --git a/hub/services/migrations/0010_remove_service_price.py b/hub/services/migrations/0010_remove_service_price.py new file mode 100644 index 0000000..88bf271 --- /dev/null +++ b/hub/services/migrations/0010_remove_service_price.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.5 on 2025-01-28 09:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0009_currency_plan_planprice"), + ] + + operations = [ + migrations.RemoveField( + model_name="service", + name="price", + ), + ] diff --git a/hub/services/models.py b/hub/services/models.py index cf0cfb3..294e5f0 100644 --- a/hub/services/models.py +++ b/hub/services/models.py @@ -12,6 +12,63 @@ def validate_image_size(value): raise ValidationError("Maximum file size is 1MB") +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 Plan(models.Model): + name = models.CharField(max_length=100) + description = ProseEditorField() + service = models.ForeignKey( + "Service", 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"] + unique_together = [["service", "name"]] + + def __str__(self): + return f"{self.service.name} - {self.name}" + + def save(self, *args, **kwargs): + if self.is_default: + # Ensure only one default plan per service + Plan.objects.filter(service=self.service).update(is_default=False) + super().save(*args, **kwargs) + + def clean(self): + super().clean() + # If this is the only plan, make it default + if self.pk and self.service and self.service.plans.count() == 1: + self.is_default = True + + +class PlanPrice(models.Model): + plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices") + currency = models.ForeignKey(Currency, on_delete=models.PROTECT) + price = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + unique_together = [["plan", "currency"]] + + def __str__(self): + return f"{self.plan.name} - {self.currency.code} {self.price}" + + class Category(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) @@ -103,7 +160,6 @@ class Service(models.Model): ConsultingPartner, related_name="services", blank=True ) categories = models.ManyToManyField(Category, related_name="services") - price = models.DecimalField(max_digits=10, decimal_places=2) features = ProseEditorField() logo = models.ImageField( upload_to="service_logos/", @@ -133,6 +189,9 @@ class Service(models.Model): def get_absolute_url(self): return reverse("services:service_detail", kwargs={"slug": self.slug}) + def get_default_plan(self): + return self.plans.filter(is_default=True).first() or self.plans.first() + class Lead(models.Model): service = models.ForeignKey(Service, on_delete=models.CASCADE) diff --git a/hub/services/static/admin/css/hide_inline_header.css b/hub/services/static/admin/css/hide_inline_header.css new file mode 100644 index 0000000..317f623 --- /dev/null +++ b/hub/services/static/admin/css/hide_inline_header.css @@ -0,0 +1,7 @@ +.inline-group .tabular .has_original td:first-child { + display: none; +} + +.inline-group .tabular tr:not(.has_original) td:first-child { + display: none; +} \ No newline at end of file diff --git a/hub/services/templates/admin/services/service/change_form.html b/hub/services/templates/admin/services/service/change_form.html new file mode 100644 index 0000000..9f1f21a --- /dev/null +++ b/hub/services/templates/admin/services/service/change_form.html @@ -0,0 +1,23 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block admin_change_form_document_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/hub/services/templates/services/lead_form.html b/hub/services/templates/services/lead_form.html index 7ab8e09..0af638b 100644 --- a/hub/services/templates/services/lead_form.html +++ b/hub/services/templates/services/lead_form.html @@ -5,12 +5,12 @@
-

Show Interest in {{ service.name }}

+

Order {{ service.name }}

Service Details

Provider: {{ service.cloud_provider.name }}

-

Price: ${{ service.price }}

+

Plan: {{ selected_plan.name }}

{% if messages %} diff --git a/hub/services/templates/services/service_detail.html b/hub/services/templates/services/service_detail.html index edc5d3e..ae2fb1a 100644 --- a/hub/services/templates/services/service_detail.html +++ b/hub/services/templates/services/service_detail.html @@ -33,17 +33,49 @@
Features

{{ service.features|safe }}

- -
-
-
-
Service Details
-

Price: ${{ service.price }}

-
+
+ +
+
+

Plans

+
+ {% for plan in service.plans.all %} +
+
+ {% if plan.is_default %} +
+ Recommended +
+ {% endif %} +
+
{{ plan.name }}
+
{{ plan.description|safe }}
+ +
+ {% for price in plan.prices.all %} +
+ {{ price.currency.symbol }} {{ price.price }} + {{ price.currency.code }} +
+ {% endfor %} +
+ + {% if plan.features %} +
+ {{ plan.features|safe }} +
+ {% endif %} + + Order +
+
+
+ {% endfor %}
- + Order Back to Services
diff --git a/hub/services/templates/services/thank_you.html b/hub/services/templates/services/thank_you.html index 0f34a6a..d0d9282 100644 --- a/hub/services/templates/services/thank_you.html +++ b/hub/services/templates/services/thank_you.html @@ -12,17 +12,12 @@

We have received your inquiry and our team will contact you shortly.

-
-
Service Details
-

Provider: {{ service.cloud_provider.name }}

-
-

A confirmation email will be sent to your provided email address.

- + Back to Service Details diff --git a/hub/services/views.py b/hub/services/views.py index 9b2719a..d686b2d 100644 --- a/hub/services/views.py +++ b/hub/services/views.py @@ -9,6 +9,7 @@ from .models import ( CloudProvider, ConsultingPartner, Category, + Plan, ) from .forms import LeadForm @@ -86,12 +87,21 @@ def thank_you(request, slug): def create_lead(request, slug): service = get_object_or_404(Service, slug=slug) + selected_plan = None + + if request.GET.get("plan"): + selected_plan = get_object_or_404( + Plan, id=request.GET.get("plan"), service=service + ) + else: + selected_plan = service.get_default_plan() if request.method == "POST": form = LeadForm(request.POST) if form.is_valid(): lead = form.save(commit=False) lead.service = service + lead.plan = selected_plan try: logger.info(f"Attempting to create lead for service: {service.name}") @@ -115,5 +125,7 @@ def create_lead(request, slug): form = LeadForm() return render( - request, "services/lead_form.html", {"form": form, "service": service} + request, + "services/lead_form.html", + {"form": form, "service": service, "selected_plan": selected_plan}, )