diff --git a/hub/services/admin.py b/hub/services/admin.py
index 0b4dc4b..4481180 100644
--- a/hub/services/admin.py
+++ b/hub/services/admin.py
@@ -1,6 +1,15 @@
from django.contrib import admin
from django.utils.html import format_html
-from .models import CloudProvider, Country, ServiceLevel, Service
+from .models import CloudProvider, Country, ServiceLevel, Service, Category
+
+
+@admin.register(Category)
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ("name", "slug", "parent", "order")
+ list_filter = ("parent",)
+ search_fields = ("name", "description")
+ prepopulated_fields = {"slug": ("name",)}
+ ordering = ("order", "name")
@admin.register(CloudProvider)
@@ -30,10 +39,17 @@ class ServiceLevelAdmin(admin.ModelAdmin):
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
- list_display = ("name", "cloud_provider", "service_level", "price", "logo_preview")
- list_filter = ("cloud_provider", "service_level", "countries")
+ list_display = (
+ "name",
+ "cloud_provider",
+ "service_level",
+ "price",
+ "logo_preview",
+ "category_list",
+ )
+ list_filter = ("cloud_provider", "service_level", "countries", "categories")
search_fields = ("name", "description")
- filter_horizontal = ("countries",)
+ filter_horizontal = ("countries", "categories")
def logo_preview(self, obj):
if obj.logo:
@@ -41,3 +57,8 @@ class ServiceAdmin(admin.ModelAdmin):
'', obj.logo.url
)
return "No logo"
+
+ def category_list(self, obj):
+ return ", ".join([cat.name for cat in obj.categories.all()])
+
+ category_list.short_description = "Categories"
diff --git a/hub/services/migrations/0003_category_service_categories.py b/hub/services/migrations/0003_category_service_categories.py
new file mode 100644
index 0000000..0909d50
--- /dev/null
+++ b/hub/services/migrations/0003_category_service_categories.py
@@ -0,0 +1,53 @@
+# Generated by Django 5.1.5 on 2025-01-27 14:19
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0002_cloudprovider_logo_service_logo"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Category",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=100)),
+ ("slug", models.SlugField(unique=True)),
+ ("description", models.TextField(blank=True)),
+ ("order", models.IntegerField(default=0)),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="children",
+ to="services.category",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name_plural": "Categories",
+ "ordering": ["order", "name"],
+ },
+ ),
+ migrations.AddField(
+ model_name="service",
+ name="categories",
+ field=models.ManyToManyField(
+ related_name="services", to="services.category"
+ ),
+ ),
+ ]
diff --git a/hub/services/models.py b/hub/services/models.py
index 3f34064..43b2a88 100644
--- a/hub/services/models.py
+++ b/hub/services/models.py
@@ -1,5 +1,6 @@
from django.db import models
from django.core.exceptions import ValidationError
+from django.utils.text import slugify
def validate_image_size(value):
@@ -8,6 +9,39 @@ def validate_image_size(value):
raise ValidationError("Maximum file size is 1MB")
+class Category(models.Model):
+ name = models.CharField(max_length=100)
+ slug = models.SlugField(unique=True)
+ parent = models.ForeignKey(
+ "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
+ )
+ description = models.TextField(blank=True)
+ order = models.IntegerField(default=0)
+
+ class Meta:
+ verbose_name_plural = "Categories"
+ ordering = ["order", "name"]
+
+ def __str__(self):
+ if self.parent:
+ return f"{self.parent} > {self.name}"
+ return self.name
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ self.slug = slugify(self.name)
+ super().save(*args, **kwargs)
+
+ @property
+ def full_path(self):
+ path = [self.name]
+ parent = self.parent
+ while parent:
+ path.append(parent.name)
+ parent = parent.parent
+ return " > ".join(reversed(path))
+
+
class CloudProvider(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
@@ -47,6 +81,7 @@ class Service(models.Model):
description = models.TextField()
cloud_provider = models.ForeignKey(CloudProvider, on_delete=models.CASCADE)
service_level = models.ForeignKey(ServiceLevel, on_delete=models.CASCADE)
+ categories = models.ManyToManyField(Category, related_name="services")
countries = models.ManyToManyField(Country)
price = models.DecimalField(max_digits=10, decimal_places=2)
features = models.TextField()
diff --git a/hub/services/templates/services/service_detail.html b/hub/services/templates/services/service_detail.html
index 69bd185..01a1efb 100644
--- a/hub/services/templates/services/service_detail.html
+++ b/hub/services/templates/services/service_detail.html
@@ -3,6 +3,12 @@
{% block content %}
{{ service.description|truncatewords:30 }}
+
Service Level: {{ service.service_level.name }}
diff --git a/hub/services/views.py b/hub/services/views.py
index f2315d2..134cae3 100644
--- a/hub/services/views.py
+++ b/hub/services/views.py
@@ -1,6 +1,6 @@
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
-from .models import Service, CloudProvider, Country, ServiceLevel
+from .models import Service, CloudProvider, Country, ServiceLevel, Category
def service_list(request):
@@ -8,6 +8,7 @@ def service_list(request):
cloud_providers = CloudProvider.objects.all()
countries = Country.objects.all()
service_levels = ServiceLevel.objects.all()
+ categories = Category.objects.filter(parent=None) # Top-level categories
# Filter handling
if request.GET.get("cloud_provider"):
@@ -19,6 +20,16 @@ def service_list(request):
if request.GET.get("service_level"):
services = services.filter(service_level_id=request.GET.get("service_level"))
+ if request.GET.get("category"):
+ category_id = request.GET.get("category")
+ category = Category.objects.get(id=category_id)
+ # Get all subcategories
+ subcategories = Category.objects.filter(parent=category)
+ # Filter services in this category or its subcategories
+ services = services.filter(
+ Q(categories=category) | Q(categories__in=subcategories)
+ ).distinct()
+
if request.GET.get("search"):
query = request.GET.get("search")
services = services.filter(
@@ -30,6 +41,7 @@ def service_list(request):
"cloud_providers": cloud_providers,
"countries": countries,
"service_levels": service_levels,
+ "categories": categories,
}
return render(request, "services/service_list.html", context)