csp detail page
1
.gitignore
vendored
|
@ -12,3 +12,4 @@ wheels/
|
||||||
# Project specifics
|
# Project specifics
|
||||||
.env
|
.env
|
||||||
hub/db.sqlite3
|
hub/db.sqlite3
|
||||||
|
hub/media/
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
BIN
hub/media_blubb/cloud_provider_logos/cloudscale_oZ4GYnx.png
Normal file
After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
@ -14,8 +14,9 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
@admin.register(CloudProvider)
|
@admin.register(CloudProvider)
|
||||||
class CloudProviderAdmin(admin.ModelAdmin):
|
class CloudProviderAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "logo_preview")
|
list_display = ("name", "slug", "logo_preview")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
def logo_preview(self, obj):
|
def logo_preview(self, obj):
|
||||||
if obj.logo:
|
if obj.logo:
|
||||||
|
|
40
hub/services/migrations/0004_cloudprovider_slug.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-01-27 14:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
|
||||||
|
def generate_provider_slugs(apps, schema_editor):
|
||||||
|
CloudProvider = apps.get_model("services", "CloudProvider")
|
||||||
|
for provider in CloudProvider.objects.all():
|
||||||
|
provider.slug = slugify(provider.name)
|
||||||
|
counter = 1
|
||||||
|
while CloudProvider.objects.filter(slug=provider.slug).exists():
|
||||||
|
provider.slug = f"{slugify(provider.name)}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
provider.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0003_category_service_categories"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="cloudprovider",
|
||||||
|
name="slug",
|
||||||
|
field=models.SlugField(unique=True, null=True),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
generate_provider_slugs, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="cloudprovider",
|
||||||
|
name="slug",
|
||||||
|
field=models.SlugField(unique=True),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
|
@ -46,6 +46,7 @@ class Category(models.Model):
|
||||||
|
|
||||||
class CloudProvider(models.Model):
|
class CloudProvider(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
description = ProseEditorField(blank=True)
|
description = ProseEditorField(blank=True)
|
||||||
logo = models.ImageField(
|
logo = models.ImageField(
|
||||||
upload_to="cloud_provider_logos/",
|
upload_to="cloud_provider_logos/",
|
||||||
|
@ -57,6 +58,14 @@ class CloudProvider(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
|
|
||||||
class Country(models.Model):
|
class Country(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
|
|
|
@ -11,7 +11,18 @@
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="{% url 'services:service_list' %}">Servala - The Cloud Native Services Hub</a>
|
<a class="navbar-brand" href="{% url 'services:service_list' %}">Services Marketplace</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.view_name == 'services:service_list' %}active{% endif %}"
|
||||||
|
href="{% url 'services:service_list' %}">Services</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@ -28,10 +39,12 @@
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-content img {
|
.rich-text-content img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-preview img {
|
.description-preview img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
62
hub/services/templates/services/provider_detail.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends 'services/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start mb-4">
|
||||||
|
{% if provider.logo %}
|
||||||
|
<img src="{{ provider.logo.url }}" alt="{{ provider.name }} logo" class="me-4"
|
||||||
|
style="max-height: 120px; max-width: 240px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title mb-3">{{ provider.name }}</h2>
|
||||||
|
<div class="rich-text-content">
|
||||||
|
{{ provider.description|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-4">Available Services</h3>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||||
|
{% for service in services %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
{% if service.logo %}
|
||||||
|
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo"
|
||||||
|
class="me-3" style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title mb-0">{{ service.name }}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-text description-preview mb-3">
|
||||||
|
{{ service.description|safe|truncatewords_html:30 }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{% for category in service.categories.all %}
|
||||||
|
<span class="badge bg-secondary me-1">{{ category.full_path }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="card-text">
|
||||||
|
<small class="text-muted">
|
||||||
|
Service Level: {{ service.service_level.name }}<br>
|
||||||
|
Price: ${{ service.price }}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'services:service_detail' service.pk %}" class="btn btn-primary">View Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No services available from this provider yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -17,9 +17,16 @@
|
||||||
<h2 class="card-title">{{ service.name }}</h2>
|
<h2 class="card-title">{{ service.name }}</h2>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
{% if service.cloud_provider.logo %}
|
{% if service.cloud_provider.logo %}
|
||||||
<img src="{{ service.cloud_provider.logo.url }}" alt="{{ service.cloud_provider.name }} logo" class="me-2" style="max-height: 30px; max-width: 60px; object-fit: contain;">
|
<a href="{% url 'services:provider_detail' service.cloud_provider.slug %}">
|
||||||
|
<img src="{{ service.cloud_provider.logo.url }}" alt="{{ service.cloud_provider.name }} logo"
|
||||||
|
class="me-2" style="max-height: 25px; max-width: 200px; object-fit: contain;">
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<h6 class="card-subtitle text-muted mb-0">
|
||||||
|
<a href="{% url 'services:provider_detail' service.cloud_provider.pk %}"
|
||||||
|
class="text-decoration-none text-muted">{{ service.cloud_provider.name }}</a>
|
||||||
|
</h6>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h6 class="card-subtitle text-muted mb-0">{{ service.cloud_provider.name }}</h6>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -86,18 +86,26 @@
|
||||||
<h5 class="card-title mb-0">{{ service.name }}</h5>
|
<h5 class="card-title mb-0">{{ service.name }}</h5>
|
||||||
<div class="d-flex align-items-center mt-2">
|
<div class="d-flex align-items-center mt-2">
|
||||||
{% if service.cloud_provider.logo %}
|
{% if service.cloud_provider.logo %}
|
||||||
<img src="{{ service.cloud_provider.logo.url }}" alt="{{ service.cloud_provider.name }} logo" class="me-2" style="max-height: 25px; max-width: 50px; object-fit: contain;">
|
<a href="{% url 'services:provider_detail' service.cloud_provider.slug %}">
|
||||||
|
<img src="{{ service.cloud_provider.logo.url }}" alt="{{ service.cloud_provider.name }} logo"
|
||||||
|
class="me-2" style="max-height: 25px; max-width: 200px; object-fit: contain;">
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<h6 class="card-subtitle text-muted mb-0">
|
||||||
|
<a href="{% url 'services:provider_detail' service.cloud_provider.pk %}"
|
||||||
|
class="text-decoration-none text-muted">{{ service.cloud_provider.name }}</a>
|
||||||
|
</h6>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h6 class="card-subtitle text-muted mb-0">{{ service.cloud_provider.name }}</h6>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-text">{{ service.description|safe|truncatewords:30 }}</p>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{% for category in service.categories.all %}
|
{% for category in service.categories.all %}
|
||||||
<span class="badge bg-secondary me-1">{{ category.full_path }}</span>
|
<span class="badge bg-secondary me-1">{{ category.full_path }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="card-text">{{ service.description|safe|truncatewords:30 }}</p>
|
||||||
|
|
||||||
<p class="card-text">
|
<p class="card-text">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Service Level: {{ service.service_level.name }}<br>
|
Service Level: {{ service.service_level.name }}<br>
|
||||||
|
|
|
@ -6,4 +6,5 @@ app_name = "services"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.service_list, name="service_list"),
|
path("", views.service_list, name="service_list"),
|
||||||
path("service/<int:pk>/", views.service_detail, name="service_detail"),
|
path("service/<int:pk>/", views.service_detail, name="service_detail"),
|
||||||
|
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -49,3 +49,13 @@ def service_list(request):
|
||||||
def service_detail(request, pk):
|
def service_detail(request, pk):
|
||||||
service = get_object_or_404(Service, pk=pk)
|
service = get_object_or_404(Service, pk=pk)
|
||||||
return render(request, "services/service_detail.html", {"service": service})
|
return render(request, "services/service_detail.html", {"service": service})
|
||||||
|
|
||||||
|
|
||||||
|
def provider_detail(request, slug):
|
||||||
|
provider = get_object_or_404(CloudProvider, slug=slug)
|
||||||
|
services = Service.objects.filter(cloud_provider=provider)
|
||||||
|
context = {
|
||||||
|
"provider": provider,
|
||||||
|
"services": services,
|
||||||
|
}
|
||||||
|
return render(request, "services/provider_detail.html", context)
|
||||||
|
|