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 @@
Provider: {{ service.cloud_provider.name }}
-Price: ${{ service.price }}
+Plan: {{ selected_plan.name }}
{{ service.features|safe }}
Price: ${{ service.price }}
-We have received your inquiry and our team will contact you shortly.
Provider: {{ service.cloud_provider.name }}
-A confirmation email will be sent to your provided email address.