From 7bbde80913997d610c5c1eede7e2c75ff6f4092a Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Mon, 23 Jun 2025 16:42:28 +0200
Subject: [PATCH 01/50] show order in inline
---
hub/services/admin/services.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py
index c975884..41bc97f 100644
--- a/hub/services/admin/services.py
+++ b/hub/services/admin/services.py
@@ -49,9 +49,9 @@ class PlanInline(admin.StackedInline):
extra = 1
fieldsets = (
(None, {"fields": ("name", "description", "plan_description")}),
- ("Display Options", {"fields": ("is_best",)}),
+ ("Display Options", {"fields": ("is_best", "order")}),
)
- show_change_link = True # This allows clicking through to the Plan admin where prices can be managed
+ show_change_link = True
class OfferingInline(admin.StackedInline):
From 60de2e547a15cb5498c344f208f57df6388bb3cd Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Mon, 23 Jun 2025 16:47:30 +0200
Subject: [PATCH 02/50] wording and color improvements
---
hub/services/static/css/price-calculator.css | 4 ++--
hub/services/templates/services/offering_detail.html | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/hub/services/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css
index 5efe5f6..a65d8cc 100644
--- a/hub/services/static/css/price-calculator.css
+++ b/hub/services/static/css/price-calculator.css
@@ -49,9 +49,9 @@
/* Best choice badge styling */
.badge.bg-success {
- background: linear-gradient(135deg, #198754 0%, #20c997 100%) !important;
border: 2px solid white;
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.151);
+ color: rgb(255, 255, 255);
white-space: nowrap;
font-size: 0.75rem;
padding: 0.5rem 0.75rem;
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html
index 7263024..e6682e3 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -473,7 +473,7 @@
From 470887c34ecda51f49d937d9330faf0fd88d8ecb Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Fri, 27 Jun 2025 15:18:03 +0200
Subject: [PATCH 03/50] exoscale marketplace listing tweaks
---
hub/services/views/offerings.py | 23 ++++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index e135aec..4016857 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -173,15 +173,28 @@ def generate_exoscale_marketplace_yaml(offering):
).strip()
# Build YAML structure
+ service_name = offering.service.name
+
+ # List of service names that should have "Enterprise" appended
+ # This concerns all services which are already available on Exoscale Marketplace or DBaaS for differentiation
+ # A workaround because we don't particularly have "Enterprise" services yet
+ enterprise_services = ["GitLab", "PostgreSQL"]
+
+ if any(
+ enterprise_service in service_name for enterprise_service in enterprise_services
+ ):
+ service_name += " Enterprise"
+
+ title = f"{service_name} by Servala"
yaml_structure = {
yaml_key: {
"page_class": "tmpl-marketplace-product",
- "html_title": f"Managed {offering.service.name} by VSHN via Servala",
- "meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
- "page_header_title": f"Managed {offering.service.name} by VSHN via Servala",
+ "html_title": title,
+ "meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
+ "page_header_title": title,
"provider_key": "vshn",
- "slug": f"servala-managed-{offering.service.slug}",
- "title": f"Managed {offering.service.name} by VSHN via Servala",
+ "slug": f"{offering.service.slug}-by-servala",
+ "title": title,
"logo": f"img/servala-{offering.service.slug}.svg",
"list_display": [],
"meta": [
From 6351da70ee3e6d0946d0c28c72a310ef6a0727f0 Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Fri, 4 Jul 2025 15:51:44 +0200
Subject: [PATCH 04/50] add article date field
---
hub/services/admin/articles.py | 5 ++--
.../migrations/0039_article_article_date.py | 22 +++++++++++++++++
hub/services/models/articles.py | 4 ++++
.../templates/services/article_detail.html | 4 +---
.../templates/services/article_list.html | 2 +-
hub/services/views/articles.py | 24 +++++++++----------
6 files changed, 42 insertions(+), 19 deletions(-)
create mode 100644 hub/services/migrations/0039_article_article_date.py
diff --git a/hub/services/admin/articles.py b/hub/services/admin/articles.py
index e6dad8c..90298a4 100644
--- a/hub/services/admin/articles.py
+++ b/hub/services/admin/articles.py
@@ -45,7 +45,7 @@ class ArticleAdmin(admin.ModelAdmin):
"image_preview",
"is_published",
"is_featured",
- "created_at",
+ "article_date",
)
list_filter = (
"is_published",
@@ -54,11 +54,12 @@ class ArticleAdmin(admin.ModelAdmin):
"related_service",
"related_consulting_partner",
"related_cloud_provider",
- "created_at",
+ "article_date",
)
search_fields = ("title", "excerpt", "content", "meta_keywords")
prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at")
+ ordering = ("-article_date",)
def image_preview(self, obj):
"""Display image preview in admin list view"""
diff --git a/hub/services/migrations/0039_article_article_date.py b/hub/services/migrations/0039_article_article_date.py
new file mode 100644
index 0000000..69acc86
--- /dev/null
+++ b/hub/services/migrations/0039_article_article_date.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2 on 2025-07-04 13:48
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0038_add_plan_ordering_and_best"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="article",
+ name="article_date",
+ field=models.DateField(
+ default=django.utils.timezone.now,
+ help_text="Date of the article publishing",
+ ),
+ ),
+ ]
diff --git a/hub/services/models/articles.py b/hub/services/models/articles.py
index 781c54c..b1b85e7 100644
--- a/hub/services/models/articles.py
+++ b/hub/services/models/articles.py
@@ -3,6 +3,7 @@ from django.urls import reverse
from django.utils.text import slugify
from django.contrib.auth.models import User
from django_prose_editor.fields import ProseEditorField
+from django.utils import timezone
from .base import validate_image_size
from .services import Service
from .providers import CloudProvider, ConsultingPartner
@@ -23,6 +24,9 @@ class Article(models.Model):
help_text="Title picture for the article",
)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
+ article_date = models.DateField(
+ default=timezone.now, help_text="Date of the article publishing"
+ )
# Relations to other models
related_service = models.ForeignKey(
diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html
index 44af754..9721513 100644
--- a/hub/services/templates/services/article_detail.html
+++ b/hub/services/templates/services/article_detail.html
@@ -16,9 +16,7 @@
By {{ article.author.get_full_name|default:article.author.username }}
•
- {{ article.created_at|date:"M d, Y" }}
- {% if article.updated_at != article.created_at %}
- {% endif %}
+ {{ article.article_date|date:"M d, Y" }}
diff --git a/hub/services/templates/services/article_list.html b/hub/services/templates/services/article_list.html
index edb0989..ac86df4 100644
--- a/hub/services/templates/services/article_list.html
+++ b/hub/services/templates/services/article_list.html
@@ -169,7 +169,7 @@
By {{ article.author.get_full_name|default:article.author.username }}
- {{ article.created_at|date:"M d, Y" }}
+ {{ article.article_date|date:"M d, Y" }}
diff --git a/hub/services/views/articles.py b/hub/services/views/articles.py
index 6589d6f..4b8117f 100644
--- a/hub/services/views/articles.py
+++ b/hub/services/views/articles.py
@@ -23,7 +23,7 @@ def article_list(request):
# Apply filters based on request parameters
if search_query:
articles = articles.filter(
- Q(title__icontains=search_query)
+ Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@@ -41,7 +41,7 @@ def article_list(request):
# Order articles: featured first, then by creation date (newest first)
articles = articles.order_by(
"-is_featured", # Featured first (True before False)
- "-created_at", # Newest first
+ "-article_date", # Newest first
)
# Create base querysets for each filter type that apply all OTHER current filters
@@ -51,7 +51,7 @@ def article_list(request):
service_filter_base = all_articles
if search_query:
service_filter_base = service_filter_base.filter(
- Q(title__icontains=search_query)
+ Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@@ -69,7 +69,7 @@ def article_list(request):
cp_filter_base = all_articles
if search_query:
cp_filter_base = cp_filter_base.filter(
- Q(title__icontains=search_query)
+ Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@@ -85,7 +85,7 @@ def article_list(request):
cloud_filter_base = all_articles
if search_query:
cloud_filter_base = cloud_filter_base.filter(
- Q(title__icontains=search_query)
+ Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@@ -136,16 +136,14 @@ def article_detail(request, slug):
Article.objects.select_related(
"author",
"related_service",
- "related_consulting_partner",
- "related_cloud_provider"
+ "related_consulting_partner",
+ "related_cloud_provider",
).filter(is_published=True),
slug=slug,
)
# Get related articles (same service, partner, or provider)
- related_articles = Article.objects.filter(
- is_published=True
- ).exclude(id=article.id)
+ related_articles = Article.objects.filter(is_published=True).exclude(id=article.id)
if article.related_service:
related_articles = related_articles.filter(
@@ -164,13 +162,13 @@ def article_detail(request, slug):
related_articles = related_articles.filter(
related_service__isnull=True,
related_consulting_partner__isnull=True,
- related_cloud_provider__isnull=True
+ related_cloud_provider__isnull=True,
)
- related_articles = related_articles.order_by("-created_at")[:3]
+ related_articles = related_articles.order_by("-article_date")[:3]
context = {
"article": article,
"related_articles": related_articles,
}
- return render(request, "services/article_detail.html", context)
\ No newline at end of file
+ return render(request, "services/article_detail.html", context)
From bdf06863d2fcd39fe57534a4ee36c3849e401690 Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Fri, 4 Jul 2025 15:53:00 +0200
Subject: [PATCH 05/50] do not show header image in article detail view
---
hub/services/templates/services/article_detail.html | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html
index 9721513..b3264e5 100644
--- a/hub/services/templates/services/article_detail.html
+++ b/hub/services/templates/services/article_detail.html
@@ -23,16 +23,6 @@
-{% if article.image %}
-
-
-
-

-
-
-
-{% endif %}
-
From 52dbe895825af65ca9d9ff31e1d08c96cfeba400 Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Fri, 4 Jul 2025 16:28:13 +0200
Subject: [PATCH 06/50] image library
created using VS Codey Copilot Agent with Claude Sonnet 4
---
hub/services/admin/__init__.py | 1 +
hub/services/admin/images.py | 115 +++++++
hub/services/forms/image_library.py | 149 +++++++++
hub/services/management/__init__.py | 0
hub/services/management/commands/__init__.py | 0
.../management/commands/migrate_images.py | 293 ++++++++++++++++++
.../migrations/0040_add_image_library.py | 144 +++++++++
hub/services/models/__init__.py | 1 +
hub/services/models/base.py | 6 +-
hub/services/models/images.py | 224 +++++++++++++
.../static/admin/css/image_library.css | 79 +++++
hub/services/templatetags/image_library.py | 112 +++++++
hub/services/utils/image_library.py | 243 +++++++++++++++
hub/settings.py | 2 +
14 files changed, 1366 insertions(+), 3 deletions(-)
create mode 100644 hub/services/admin/images.py
create mode 100644 hub/services/forms/image_library.py
create mode 100644 hub/services/management/__init__.py
create mode 100644 hub/services/management/commands/__init__.py
create mode 100644 hub/services/management/commands/migrate_images.py
create mode 100644 hub/services/migrations/0040_add_image_library.py
create mode 100644 hub/services/models/images.py
create mode 100644 hub/services/static/admin/css/image_library.css
create mode 100644 hub/services/templatetags/image_library.py
create mode 100644 hub/services/utils/image_library.py
diff --git a/hub/services/admin/__init__.py b/hub/services/admin/__init__.py
index 3c79092..fba7be9 100644
--- a/hub/services/admin/__init__.py
+++ b/hub/services/admin/__init__.py
@@ -4,6 +4,7 @@
from .articles import *
from .base import *
from .content import *
+from .images import *
from .leads import *
from .pricing import *
from .providers import *
diff --git a/hub/services/admin/images.py b/hub/services/admin/images.py
new file mode 100644
index 0000000..917d0db
--- /dev/null
+++ b/hub/services/admin/images.py
@@ -0,0 +1,115 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+from ..models.images import ImageLibrary
+
+
+@admin.register(ImageLibrary)
+class ImageLibraryAdmin(admin.ModelAdmin):
+ """
+ Admin interface for the Image Library.
+ """
+
+ list_display = [
+ "image_thumbnail",
+ "name",
+ "category",
+ "get_dimensions",
+ "get_file_size_display",
+ "usage_count",
+ "uploaded_by",
+ "uploaded_at",
+ ]
+
+ list_filter = [
+ "category",
+ "uploaded_at",
+ "uploaded_by",
+ ]
+
+ search_fields = [
+ "name",
+ "description",
+ "alt_text",
+ "tags",
+ ]
+
+ readonly_fields = [
+ "width",
+ "height",
+ "file_size",
+ "usage_count",
+ "uploaded_at",
+ "updated_at",
+ "image_preview",
+ ]
+
+ prepopulated_fields = {"slug": ("name",)}
+
+ fieldsets = (
+ ("Image Information", {"fields": ("name", "slug", "description", "alt_text")}),
+ ("Image File", {"fields": ("image", "image_preview")}),
+ ("Categorization", {"fields": ("category", "tags")}),
+ (
+ "Metadata",
+ {
+ "fields": ("width", "height", "file_size", "usage_count"),
+ "classes": ("collapse",),
+ },
+ ),
+ (
+ "Timestamps",
+ {
+ "fields": ("uploaded_by", "uploaded_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ def image_thumbnail(self, obj):
+ """
+ Display small thumbnail in list view.
+ """
+ if obj.image:
+ return format_html(
+ '
',
+ obj.image.url,
+ )
+ return "No Image"
+
+ image_thumbnail.short_description = "Thumbnail"
+
+ def image_preview(self, obj):
+ """
+ Display larger preview in detail view.
+ """
+ if obj.image:
+ return format_html(
+ '
',
+ obj.image.url,
+ )
+ return "No Image"
+
+ image_preview.short_description = "Preview"
+
+ def get_dimensions(self, obj):
+ """
+ Display image dimensions.
+ """
+ if obj.width and obj.height:
+ return f"{obj.width} × {obj.height}"
+ return "Unknown"
+
+ get_dimensions.short_description = "Dimensions"
+
+ def save_model(self, request, obj, form, change):
+ """
+ Set uploaded_by field to current user if not already set.
+ """
+ if not change: # Only set on creation
+ obj.uploaded_by = request.user
+ super().save_model(request, obj, form, change)
+
+ class Media:
+ css = {"all": ("admin/css/image_library.css",)}
diff --git a/hub/services/forms/image_library.py b/hub/services/forms/image_library.py
new file mode 100644
index 0000000..1770456
--- /dev/null
+++ b/hub/services/forms/image_library.py
@@ -0,0 +1,149 @@
+from django import forms
+from django.utils.safestring import mark_safe
+from django.utils.html import format_html
+from django.urls import reverse
+from ..models.images import ImageLibrary
+
+
+class ImageLibraryWidget(forms.Select):
+ """
+ Custom widget for selecting images from the library with thumbnails.
+ """
+
+ def __init__(self, attrs=None, choices=(), show_thumbnails=True):
+ self.show_thumbnails = show_thumbnails
+ super().__init__(attrs, choices)
+
+ def format_value(self, value):
+ """
+ Format the selected value for display.
+ """
+ if value is None:
+ return ""
+ return str(value)
+
+ def render(self, name, value, attrs=None, renderer=None):
+ """
+ Render the widget with thumbnails.
+ """
+ if attrs is None:
+ attrs = {}
+
+ # Add CSS class for styling
+ attrs["class"] = attrs.get("class", "") + " image-library-select"
+
+ # Get all images for the select options
+ images = ImageLibrary.objects.all().order_by("name")
+
+ # Build choices with thumbnails
+ choices = [("", "--- Select an image ---")]
+ for image in images:
+ thumbnail_html = ""
+ if self.show_thumbnails and image.image:
+ thumbnail_html = format_html(
+ '
',
+ image.image.url,
+ )
+
+ choice_text = (
+ f"{image.name} ({image.get_category_display()}){thumbnail_html}"
+ )
+ choices.append((image.pk, choice_text))
+
+ # Build the select element
+ select_html = format_html(
+ '',
+ name,
+ attrs.get("id", ""),
+ self._build_attrs_string(attrs),
+ self._build_options(choices, value),
+ )
+
+ # Add preview area
+ preview_html = ""
+ if value:
+ try:
+ image = ImageLibrary.objects.get(pk=value)
+ preview_html = format_html(
+ ''
+ '

'
+ '
{} - {}x{} - {}
'
+ "
",
+ image.image.url,
+ image.name,
+ image.width or "?",
+ image.height or "?",
+ image.get_file_size_display(),
+ )
+ except ImageLibrary.DoesNotExist:
+ pass
+
+ # Add JavaScript for preview updates
+ js_html = format_html(
+ "",
+ attrs.get("id", ""),
+ )
+
+ return mark_safe(select_html + preview_html + js_html)
+
+ def _build_attrs_string(self, attrs):
+ """
+ Build HTML attributes string.
+ """
+ attr_parts = []
+ for key, value in attrs.items():
+ if key != "id": # id is handled separately
+ attr_parts.append(f'{key}="{value}"')
+ return " " + " ".join(attr_parts) if attr_parts else ""
+
+ def _build_options(self, choices, selected_value):
+ """
+ Build option elements for the select.
+ """
+ options = []
+ for value, text in choices:
+ selected = "selected" if str(value) == str(selected_value) else ""
+ options.append(f'')
+ return "".join(options)
+
+
+class ImageLibraryField(forms.ModelChoiceField):
+ """
+ Custom form field for selecting images from the library.
+ """
+
+ def __init__(self, queryset=None, widget=None, show_thumbnails=True, **kwargs):
+ if queryset is None:
+ queryset = ImageLibrary.objects.all()
+
+ if widget is None:
+ widget = ImageLibraryWidget(show_thumbnails=show_thumbnails)
+
+ super().__init__(queryset=queryset, widget=widget, **kwargs)
+
+ def label_from_instance(self, obj):
+ """
+ Return the label for an image instance.
+ """
+ return f"{obj.name} ({obj.get_category_display()})"
diff --git a/hub/services/management/__init__.py b/hub/services/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hub/services/management/commands/__init__.py b/hub/services/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hub/services/management/commands/migrate_images.py b/hub/services/management/commands/migrate_images.py
new file mode 100644
index 0000000..ca90dcb
--- /dev/null
+++ b/hub/services/management/commands/migrate_images.py
@@ -0,0 +1,293 @@
+from django.core.management.base import BaseCommand
+from django.core.files.base import ContentFile
+from django.utils.text import slugify
+from hub.services.models import (
+ ImageLibrary,
+ Service,
+ CloudProvider,
+ ConsultingPartner,
+ Article,
+)
+import os
+import shutil
+
+
+class Command(BaseCommand):
+ help = "Migrate existing images to the Image Library"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Show what would be migrated without actually doing it",
+ )
+ parser.add_argument(
+ "--force",
+ action="store_true",
+ help="Force migration even if images already exist in library",
+ )
+
+ def handle(self, *args, **options):
+ """
+ Main command handler to migrate existing images to the library.
+ """
+ dry_run = options["dry_run"]
+ force = options["force"]
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'Starting image migration {"(DRY RUN)" if dry_run else ""}'
+ )
+ )
+
+ # Migrate different types of images
+ self.migrate_service_logos(dry_run, force)
+ self.migrate_cloud_provider_logos(dry_run, force)
+ self.migrate_partner_logos(dry_run, force)
+ self.migrate_article_images(dry_run, force)
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'Image migration completed {"(DRY RUN)" if dry_run else ""}'
+ )
+ )
+
+ def migrate_service_logos(self, dry_run, force):
+ """
+ Migrate service logos to the image library.
+ """
+ self.stdout.write("Migrating service logos...")
+
+ services = Service.objects.filter(logo__isnull=False).exclude(logo="")
+
+ for service in services:
+ if not service.logo:
+ continue
+
+ # Check if image already exists in library
+ existing_image = ImageLibrary.objects.filter(
+ name=f"{service.name} Logo"
+ ).first()
+
+ if existing_image and not force:
+ self.stdout.write(
+ self.style.WARNING(
+ f" - Skipping {service.name} logo (already exists)"
+ )
+ )
+ continue
+
+ if dry_run:
+ self.stdout.write(
+ self.style.SUCCESS(f" - Would migrate: {service.name} logo")
+ )
+ continue
+
+ # Create image library entry
+ image_lib = ImageLibrary(
+ name=f"{service.name} Logo",
+ slug=slugify(f"{service.name}-logo"),
+ description=f"Logo for {service.name} service",
+ alt_text=f"{service.name} logo",
+ category="logo",
+ tags=f"service, logo, {service.name.lower()}",
+ )
+
+ # Copy the image file
+ if service.logo and os.path.exists(service.logo.path):
+ with open(service.logo.path, "rb") as f:
+ image_lib.image.save(
+ os.path.basename(service.logo.name),
+ ContentFile(f.read()),
+ save=True,
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(f" - Migrated: {service.name} logo")
+ )
+ else:
+ self.stdout.write(
+ self.style.ERROR(
+ f" - Failed to migrate: {service.name} logo (file not found)"
+ )
+ )
+
+ def migrate_cloud_provider_logos(self, dry_run, force):
+ """
+ Migrate cloud provider logos to the image library.
+ """
+ self.stdout.write("Migrating cloud provider logos...")
+
+ providers = CloudProvider.objects.filter(logo__isnull=False).exclude(logo="")
+
+ for provider in providers:
+ if not provider.logo:
+ continue
+
+ # Check if image already exists in library
+ existing_image = ImageLibrary.objects.filter(
+ name=f"{provider.name} Logo"
+ ).first()
+
+ if existing_image and not force:
+ self.stdout.write(
+ self.style.WARNING(
+ f" - Skipping {provider.name} logo (already exists)"
+ )
+ )
+ continue
+
+ if dry_run:
+ self.stdout.write(
+ self.style.SUCCESS(f" - Would migrate: {provider.name} logo")
+ )
+ continue
+
+ # Create image library entry
+ image_lib = ImageLibrary(
+ name=f"{provider.name} Logo",
+ slug=slugify(f"{provider.name}-logo"),
+ description=f"Logo for {provider.name} cloud provider",
+ alt_text=f"{provider.name} logo",
+ category="logo",
+ tags=f"cloud, provider, logo, {provider.name.lower()}",
+ )
+
+ # Copy the image file
+ if provider.logo and os.path.exists(provider.logo.path):
+ with open(provider.logo.path, "rb") as f:
+ image_lib.image.save(
+ os.path.basename(provider.logo.name),
+ ContentFile(f.read()),
+ save=True,
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(f" - Migrated: {provider.name} logo")
+ )
+ else:
+ self.stdout.write(
+ self.style.ERROR(
+ f" - Failed to migrate: {provider.name} logo (file not found)"
+ )
+ )
+
+ def migrate_partner_logos(self, dry_run, force):
+ """
+ Migrate consulting partner logos to the image library.
+ """
+ self.stdout.write("Migrating consulting partner logos...")
+
+ partners = ConsultingPartner.objects.filter(logo__isnull=False).exclude(logo="")
+
+ for partner in partners:
+ if not partner.logo:
+ continue
+
+ # Check if image already exists in library
+ existing_image = ImageLibrary.objects.filter(
+ name=f"{partner.name} Logo"
+ ).first()
+
+ if existing_image and not force:
+ self.stdout.write(
+ self.style.WARNING(
+ f" - Skipping {partner.name} logo (already exists)"
+ )
+ )
+ continue
+
+ if dry_run:
+ self.stdout.write(
+ self.style.SUCCESS(f" - Would migrate: {partner.name} logo")
+ )
+ continue
+
+ # Create image library entry
+ image_lib = ImageLibrary(
+ name=f"{partner.name} Logo",
+ slug=slugify(f"{partner.name}-logo"),
+ description=f"Logo for {partner.name} consulting partner",
+ alt_text=f"{partner.name} logo",
+ category="logo",
+ tags=f"consulting, partner, logo, {partner.name.lower()}",
+ )
+
+ # Copy the image file
+ if partner.logo and os.path.exists(partner.logo.path):
+ with open(partner.logo.path, "rb") as f:
+ image_lib.image.save(
+ os.path.basename(partner.logo.name),
+ ContentFile(f.read()),
+ save=True,
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(f" - Migrated: {partner.name} logo")
+ )
+ else:
+ self.stdout.write(
+ self.style.ERROR(
+ f" - Failed to migrate: {partner.name} logo (file not found)"
+ )
+ )
+
+ def migrate_article_images(self, dry_run, force):
+ """
+ Migrate article images to the image library.
+ """
+ self.stdout.write("Migrating article images...")
+
+ articles = Article.objects.filter(image__isnull=False).exclude(image="")
+
+ for article in articles:
+ if not article.image:
+ continue
+
+ # Check if image already exists in library
+ existing_image = ImageLibrary.objects.filter(
+ name=f"{article.title} Image"
+ ).first()
+
+ if existing_image and not force:
+ self.stdout.write(
+ self.style.WARNING(
+ f" - Skipping {article.title} image (already exists)"
+ )
+ )
+ continue
+
+ if dry_run:
+ self.stdout.write(
+ self.style.SUCCESS(f" - Would migrate: {article.title} image")
+ )
+ continue
+
+ # Create image library entry
+ image_lib = ImageLibrary(
+ name=f"{article.title} Image",
+ slug=slugify(f"{article.title}-image"),
+ description=f"Feature image for article: {article.title}",
+ alt_text=f"{article.title} feature image",
+ category="article",
+ tags=f"article, {article.title.lower()}",
+ )
+
+ # Copy the image file
+ if article.image and os.path.exists(article.image.path):
+ with open(article.image.path, "rb") as f:
+ image_lib.image.save(
+ os.path.basename(article.image.name),
+ ContentFile(f.read()),
+ save=True,
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(f" - Migrated: {article.title} image")
+ )
+ else:
+ self.stdout.write(
+ self.style.ERROR(
+ f" - Failed to migrate: {article.title} image (file not found)"
+ )
+ )
diff --git a/hub/services/migrations/0040_add_image_library.py b/hub/services/migrations/0040_add_image_library.py
new file mode 100644
index 0000000..bc06e33
--- /dev/null
+++ b/hub/services/migrations/0040_add_image_library.py
@@ -0,0 +1,144 @@
+# Generated by Django 5.2 on 2025-07-04 14:19
+
+import django.db.models.deletion
+import hub.services.models.base
+import hub.services.models.images
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0039_article_article_date"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ImageLibrary",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ help_text="Descriptive name for the image", max_length=200
+ ),
+ ),
+ (
+ "slug",
+ models.SlugField(
+ help_text="URL-friendly version of the name",
+ max_length=250,
+ unique=True,
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True, help_text="Optional description of the image"
+ ),
+ ),
+ (
+ "alt_text",
+ models.CharField(
+ help_text="Alternative text for accessibility", max_length=255
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ help_text="Upload image file (max 1MB)",
+ upload_to=hub.services.models.images.get_image_upload_path,
+ validators=[hub.services.models.base.validate_image_size],
+ ),
+ ),
+ (
+ "width",
+ models.PositiveIntegerField(
+ blank=True, help_text="Image width in pixels", null=True
+ ),
+ ),
+ (
+ "height",
+ models.PositiveIntegerField(
+ blank=True, help_text="Image height in pixels", null=True
+ ),
+ ),
+ (
+ "file_size",
+ models.PositiveIntegerField(
+ blank=True, help_text="File size in bytes", null=True
+ ),
+ ),
+ (
+ "category",
+ models.CharField(
+ choices=[
+ ("logo", "Logo"),
+ ("article", "Article Image"),
+ ("banner", "Banner"),
+ ("icon", "Icon"),
+ ("screenshot", "Screenshot"),
+ ("photo", "Photo"),
+ ("other", "Other"),
+ ],
+ default="other",
+ help_text="Category of the image",
+ max_length=20,
+ ),
+ ),
+ (
+ "tags",
+ models.CharField(
+ blank=True,
+ help_text="Comma-separated tags for searching",
+ max_length=500,
+ ),
+ ),
+ (
+ "uploaded_at",
+ models.DateTimeField(
+ auto_now_add=True,
+ help_text="Date and time when image was uploaded",
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True,
+ help_text="Date and time when image was last updated",
+ ),
+ ),
+ (
+ "usage_count",
+ models.PositiveIntegerField(
+ default=0, help_text="Number of times this image is referenced"
+ ),
+ ),
+ (
+ "uploaded_by",
+ models.ForeignKey(
+ blank=True,
+ help_text="User who uploaded the image",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Image",
+ "verbose_name_plural": "Image Library",
+ "ordering": ["-uploaded_at"],
+ },
+ ),
+ ]
diff --git a/hub/services/models/__init__.py b/hub/services/models/__init__.py
index b29a71e..68159a4 100644
--- a/hub/services/models/__init__.py
+++ b/hub/services/models/__init__.py
@@ -1,6 +1,7 @@
from .articles import *
from .base import *
from .content import *
+from .images import *
from .leads import *
from .pricing import *
from .providers import *
diff --git a/hub/services/models/base.py b/hub/services/models/base.py
index 9a7abce..a047330 100644
--- a/hub/services/models/base.py
+++ b/hub/services/models/base.py
@@ -4,10 +4,10 @@ from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
-def validate_image_size(value):
+def validate_image_size(value, mb=1):
filesize = value.size
- if filesize > 1 * 1024 * 1024: # 1MB
- raise ValidationError("Maximum file size is 1MB")
+ if filesize > mb * 1024 * 1024:
+ raise ValidationError(f"Maximum file size is {mb} MB")
class Currency(models.TextChoices):
diff --git a/hub/services/models/images.py b/hub/services/models/images.py
new file mode 100644
index 0000000..90052ab
--- /dev/null
+++ b/hub/services/models/images.py
@@ -0,0 +1,224 @@
+import os
+
+from django.db import models
+from django.utils import timezone
+from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
+from django.utils.text import slugify
+from PIL import Image as PILImage
+from .base import validate_image_size
+
+
+def get_image_upload_path(instance, filename):
+ """
+ Generate upload path for images based on the image library structure.
+ """
+ return f"image_library/{filename}"
+
+
+class ImageLibrary(models.Model):
+ """
+ Generic image library model that can be referenced by other models
+ to avoid duplicate uploads and provide centralized image management.
+ """
+
+ # Image metadata
+ name = models.CharField(max_length=200, help_text="Descriptive name for the image")
+ slug = models.SlugField(
+ max_length=250, unique=True, help_text="URL-friendly version of the name"
+ )
+ description = models.TextField(
+ blank=True, help_text="Optional description of the image"
+ )
+ alt_text = models.CharField(
+ max_length=255, help_text="Alternative text for accessibility"
+ )
+
+ # Image file
+ image = models.ImageField(
+ upload_to=get_image_upload_path,
+ validators=[validate_image_size],
+ help_text="Upload image file (max 1MB)",
+ )
+
+ # Image properties (automatically populated)
+ width = models.PositiveIntegerField(
+ null=True, blank=True, help_text="Image width in pixels"
+ )
+ height = models.PositiveIntegerField(
+ null=True, blank=True, help_text="Image height in pixels"
+ )
+ file_size = models.PositiveIntegerField(
+ null=True, blank=True, help_text="File size in bytes"
+ )
+
+ # Categorization
+ CATEGORY_CHOICES = [
+ ("logo", "Logo"),
+ ("article", "Article Image"),
+ ("banner", "Banner"),
+ ("icon", "Icon"),
+ ("screenshot", "Screenshot"),
+ ("photo", "Photo"),
+ ("other", "Other"),
+ ]
+ category = models.CharField(
+ max_length=20,
+ choices=CATEGORY_CHOICES,
+ default="other",
+ help_text="Category of the image",
+ )
+
+ # Tags for easier searching
+ tags = models.CharField(
+ max_length=500, blank=True, help_text="Comma-separated tags for searching"
+ )
+
+ # Metadata
+ uploaded_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ help_text="User who uploaded the image",
+ )
+ uploaded_at = models.DateTimeField(
+ auto_now_add=True, help_text="Date and time when image was uploaded"
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True, help_text="Date and time when image was last updated"
+ )
+
+ # Usage tracking
+ usage_count = models.PositiveIntegerField(
+ default=0, help_text="Number of times this image is referenced"
+ )
+
+ class Meta:
+ ordering = ["-uploaded_at"]
+ verbose_name = "Image"
+ verbose_name_plural = "Image Library"
+
+ def __str__(self):
+ return self.name
+
+ def save(self, *args, **kwargs):
+ """
+ Override save to automatically populate image properties and slug.
+ """
+ # Generate slug if not provided
+ if not self.slug:
+ self.slug = slugify(self.name)
+
+ # Save the model first to get the image file
+ super().save(*args, **kwargs)
+
+ # Update image properties if image exists
+ if self.image:
+ self._update_image_properties()
+
+ def _update_image_properties(self):
+ """
+ Update image properties like width, height, and file size.
+ """
+ try:
+ # Get image dimensions
+ with PILImage.open(self.image.path) as img:
+ self.width = img.width
+ self.height = img.height
+
+ # Get file size
+ self.file_size = self.image.size
+
+ # Save without calling the full save method to avoid recursion
+ ImageLibrary.objects.filter(pk=self.pk).update(
+ width=self.width, height=self.height, file_size=self.file_size
+ )
+ except Exception as e:
+ # Log error but don't fail the save
+ print(f"Error updating image properties: {e}")
+
+ def get_file_size_display(self):
+ """
+ Return human-readable file size.
+ """
+ if not self.file_size:
+ return "Unknown"
+
+ size = self.file_size
+ for unit in ["B", "KB", "MB", "GB"]:
+ if size < 1024.0:
+ return f"{size:.1f} {unit}"
+ size /= 1024.0
+ return f"{size:.1f} TB"
+
+ def get_tags_list(self):
+ """
+ Return tags as a list.
+ """
+ if not self.tags:
+ return []
+ return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
+
+ def increment_usage(self):
+ """
+ Increment usage count when image is referenced.
+ """
+ self.usage_count += 1
+ self.save(update_fields=["usage_count"])
+
+ def decrement_usage(self):
+ """
+ Decrement usage count when reference is removed.
+ """
+ if self.usage_count > 0:
+ self.usage_count -= 1
+ self.save(update_fields=["usage_count"])
+
+
+class ImageReference(models.Model):
+ """
+ Abstract base class for models that want to reference images from the library.
+ This helps track usage and provides a consistent interface.
+ """
+
+ image = models.ForeignKey(
+ ImageLibrary,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ help_text="Select an image from the library",
+ )
+
+ class Meta:
+ abstract = True
+
+ def save(self, *args, **kwargs):
+ """
+ Override save to update usage count.
+ """
+ # Track if image changed
+ old_image = None
+ if self.pk:
+ try:
+ old_instance = self.__class__.objects.get(pk=self.pk)
+ old_image = old_instance.image
+ except self.__class__.DoesNotExist:
+ pass
+
+ super().save(*args, **kwargs)
+
+ # Update usage counts
+ if old_image and old_image != self.image:
+ old_image.decrement_usage()
+
+ if self.image and self.image != old_image:
+ self.image.increment_usage()
+
+ def delete(self, *args, **kwargs):
+ """
+ Override delete to update usage count.
+ """
+ if self.image:
+ self.image.decrement_usage()
+ super().delete(*args, **kwargs)
diff --git a/hub/services/static/admin/css/image_library.css b/hub/services/static/admin/css/image_library.css
new file mode 100644
index 0000000..6d589bb
--- /dev/null
+++ b/hub/services/static/admin/css/image_library.css
@@ -0,0 +1,79 @@
+/* CSS for Image Library Admin */
+
+/* Thumbnail styling in list view */
+.image-thumbnail {
+ border-radius: 4px;
+ object-fit: cover;
+}
+
+/* Preview styling in detail view */
+.image-preview {
+ border-radius: 4px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+/* Form styling */
+.image-library-form .form-row {
+ margin-bottom: 15px;
+}
+
+.image-library-form .help {
+ font-size: 11px;
+ color: #666;
+ margin-top: 5px;
+}
+
+/* Usage count styling */
+.usage-count {
+ font-weight: bold;
+ color: #0066cc;
+}
+
+.usage-count.high {
+ color: #cc0000;
+}
+
+/* Category badges */
+.category-badge {
+ display: inline-block;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.category-badge.logo {
+ background-color: #e8f4f8;
+ color: #2c6e92;
+}
+
+.category-badge.article {
+ background-color: #f0f8e8;
+ color: #5a7c3a;
+}
+
+.category-badge.banner {
+ background-color: #fef4e8;
+ color: #d2691e;
+}
+
+.category-badge.icon {
+ background-color: #f8e8f8;
+ color: #8b4c8b;
+}
+
+.category-badge.screenshot {
+ background-color: #e8f8f4;
+ color: #3a7c5a;
+}
+
+.category-badge.photo {
+ background-color: #f4e8f8;
+ color: #923c92;
+}
+
+.category-badge.other {
+ background-color: #f0f0f0;
+ color: #666;
+}
\ No newline at end of file
diff --git a/hub/services/templatetags/image_library.py b/hub/services/templatetags/image_library.py
new file mode 100644
index 0000000..99339b7
--- /dev/null
+++ b/hub/services/templatetags/image_library.py
@@ -0,0 +1,112 @@
+from django import template
+from django.utils.safestring import mark_safe
+from django.utils.html import format_html
+from ..models.images import ImageLibrary
+
+register = template.Library()
+
+
+@register.simple_tag
+def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None):
+ """
+ Render an image from the image library by slug or ID.
+
+ Usage:
+ {% image_library_img "my-image-slug" css_class="img-fluid" %}
+ {% image_library_img image_id css_class="logo" width="100" height="100" %}
+ """
+ try:
+ # Try to get by slug first, then by ID
+ if isinstance(slug_or_id, str):
+ image = ImageLibrary.objects.get(slug=slug_or_id)
+ else:
+ image = ImageLibrary.objects.get(pk=slug_or_id)
+
+ # Use provided alt_text or fall back to image's alt_text
+ final_alt_text = alt_text or image.alt_text
+
+ # Build HTML attributes
+ attrs = {
+ "src": image.image.url,
+ "alt": final_alt_text,
+ }
+
+ if css_class:
+ attrs["class"] = css_class
+
+ if width:
+ attrs["width"] = width
+
+ if height:
+ attrs["height"] = height
+
+ # Build the HTML
+ attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
+ return format_html("
", attr_string)
+
+ except ImageLibrary.DoesNotExist:
+ # Return empty string or placeholder if image not found
+ return format_html(
+ '
',
+ css_class,
+ )
+
+
+@register.simple_tag
+def image_library_url(slug_or_id):
+ """
+ Get the URL of an image from the image library.
+
+ Usage:
+ {% image_library_url "my-image-slug" %}
+ {% image_library_url image_id %}
+ """
+ try:
+ if isinstance(slug_or_id, str):
+ image = ImageLibrary.objects.get(slug=slug_or_id)
+ else:
+ image = ImageLibrary.objects.get(pk=slug_or_id)
+
+ return image.image.url
+
+ except ImageLibrary.DoesNotExist:
+ return "/static/images/placeholder.png"
+
+
+@register.simple_tag
+def image_library_info(slug_or_id):
+ """
+ Get information about an image from the image library.
+
+ Usage:
+ {% image_library_info "my-image-slug" as img_info %}
+ {{ img_info.name }} - {{ img_info.width }}x{{ img_info.height }}
+ """
+ try:
+ if isinstance(slug_or_id, str):
+ image = ImageLibrary.objects.get(slug=slug_or_id)
+ else:
+ image = ImageLibrary.objects.get(pk=slug_or_id)
+
+ return {
+ "name": image.name,
+ "alt_text": image.alt_text,
+ "width": image.width,
+ "height": image.height,
+ "file_size": image.get_file_size_display(),
+ "category": image.get_category_display(),
+ "tags": image.get_tags_list(),
+ "url": image.image.url,
+ }
+
+ except ImageLibrary.DoesNotExist:
+ return {
+ "name": "Image not found",
+ "alt_text": "Image not found",
+ "width": None,
+ "height": None,
+ "file_size": "Unknown",
+ "category": "Unknown",
+ "tags": [],
+ "url": "/static/images/placeholder.png",
+ }
diff --git a/hub/services/utils/image_library.py b/hub/services/utils/image_library.py
new file mode 100644
index 0000000..c0b72b2
--- /dev/null
+++ b/hub/services/utils/image_library.py
@@ -0,0 +1,243 @@
+from django.core.files.base import ContentFile
+from django.utils.text import slugify
+from ..models.images import ImageLibrary
+import os
+
+try:
+ import requests
+except ImportError:
+ requests = None
+from PIL import Image as PILImage
+
+
+def create_image_from_file(
+ file_path, name, description="", alt_text="", category="other", tags=""
+):
+ """
+ Create an ImageLibrary entry from a local file.
+
+ Args:
+ file_path: Path to the image file
+ name: Name for the image
+ description: Optional description
+ alt_text: Alternative text for accessibility
+ category: Image category
+ tags: Comma-separated tags
+
+ Returns:
+ ImageLibrary instance or None if failed
+ """
+ try:
+ if not os.path.exists(file_path):
+ print(f"File not found: {file_path}")
+ return None
+
+ # Generate slug
+ slug = slugify(name)
+
+ # Check if image already exists
+ if ImageLibrary.objects.filter(slug=slug).exists():
+ print(f"Image with slug '{slug}' already exists")
+ return ImageLibrary.objects.get(slug=slug)
+
+ # Create image library entry
+ image_lib = ImageLibrary(
+ name=name,
+ slug=slug,
+ description=description,
+ alt_text=alt_text or name,
+ category=category,
+ tags=tags,
+ )
+
+ # Read and save the image file
+ with open(file_path, "rb") as f:
+ image_lib.image.save(
+ os.path.basename(file_path), ContentFile(f.read()), save=True
+ )
+
+ print(f"Created image library entry: {name}")
+ return image_lib
+
+ except Exception as e:
+ print(f"Error creating image library entry: {e}")
+ return None
+
+
+def create_image_from_url(
+ url, name, description="", alt_text="", category="other", tags=""
+):
+ """
+ Create an ImageLibrary entry from a URL.
+
+ Args:
+ url: URL to the image
+ name: Name for the image
+ description: Optional description
+ alt_text: Alternative text for accessibility
+ category: Image category
+ tags: Comma-separated tags
+
+ Returns:
+ ImageLibrary instance or None if failed
+ """
+ if requests is None:
+ print("requests library is not installed. Cannot download from URL.")
+ return None
+
+ try:
+ # Generate slug
+ slug = slugify(name)
+
+ # Check if image already exists
+ if ImageLibrary.objects.filter(slug=slug).exists():
+ print(f"Image with slug '{slug}' already exists")
+ return ImageLibrary.objects.get(slug=slug)
+
+ # Download the image
+ response = requests.get(url)
+ response.raise_for_status()
+
+ # Create image library entry
+ image_lib = ImageLibrary(
+ name=name,
+ slug=slug,
+ description=description,
+ alt_text=alt_text or name,
+ category=category,
+ tags=tags,
+ )
+
+ # Save the image
+ filename = url.split("/")[-1]
+ if "?" in filename:
+ filename = filename.split("?")[0]
+
+ image_lib.image.save(filename, ContentFile(response.content), save=True)
+
+ print(f"Created image library entry from URL: {name}")
+ return image_lib
+
+ except Exception as e:
+ print(f"Error creating image library entry from URL: {e}")
+ return None
+
+
+def get_image_by_slug(slug):
+ """
+ Get an image from the library by slug.
+
+ Args:
+ slug: Slug of the image
+
+ Returns:
+ ImageLibrary instance or None if not found
+ """
+ try:
+ return ImageLibrary.objects.get(slug=slug)
+ except ImageLibrary.DoesNotExist:
+ return None
+
+
+def get_images_by_category(category):
+ """
+ Get all images from a specific category.
+
+ Args:
+ category: Category name
+
+ Returns:
+ QuerySet of ImageLibrary instances
+ """
+ return ImageLibrary.objects.filter(category=category)
+
+
+def get_images_by_tags(tags):
+ """
+ Get images that contain any of the specified tags.
+
+ Args:
+ tags: List of tags or comma-separated string
+
+ Returns:
+ QuerySet of ImageLibrary instances
+ """
+ if isinstance(tags, str):
+ tags = [tag.strip() for tag in tags.split(",")]
+
+ from django.db.models import Q
+
+ query = Q()
+ for tag in tags:
+ query |= Q(tags__icontains=tag)
+
+ return ImageLibrary.objects.filter(query).distinct()
+
+
+def cleanup_unused_images():
+ """
+ Find and optionally clean up unused images from the library.
+
+ Returns:
+ List of ImageLibrary instances with usage_count = 0
+ """
+ unused_images = ImageLibrary.objects.filter(usage_count=0)
+
+ print(f"Found {unused_images.count()} unused images:")
+ for image in unused_images:
+ print(f" - {image.name} ({image.slug})")
+
+ return unused_images
+
+
+def optimize_image(image_library_instance, max_width=1920, max_height=1080, quality=85):
+ """
+ Optimize an image in the library by resizing and compressing.
+
+ Args:
+ image_library_instance: ImageLibrary instance
+ max_width: Maximum width in pixels
+ max_height: Maximum height in pixels
+ quality: JPEG quality (1-100)
+
+ Returns:
+ bool: True if optimization was successful
+ """
+ try:
+ if not image_library_instance.image:
+ return False
+
+ # Open the image
+ with PILImage.open(image_library_instance.image.path) as img:
+ # Calculate new dimensions while maintaining aspect ratio
+ ratio = min(max_width / img.width, max_height / img.height)
+
+ if ratio < 1: # Only resize if image is larger than max dimensions
+ new_width = int(img.width * ratio)
+ new_height = int(img.height * ratio)
+
+ # Resize the image
+ img_resized = img.resize(
+ (new_width, new_height), PILImage.Resampling.LANCZOS
+ )
+
+ # Save the optimized image
+ img_resized.save(
+ image_library_instance.image.path,
+ format="JPEG",
+ quality=quality,
+ optimize=True,
+ )
+
+ # Update the image properties
+ image_library_instance._update_image_properties()
+
+ print(f"Optimized image: {image_library_instance.name}")
+ return True
+ else:
+ print(f"Image already optimal: {image_library_instance.name}")
+ return True
+
+ except Exception as e:
+ print(f"Error optimizing image {image_library_instance.name}: {e}")
+ return False
diff --git a/hub/settings.py b/hub/settings.py
index 8e6f3e6..120d9cb 100644
--- a/hub/settings.py
+++ b/hub/settings.py
@@ -245,6 +245,7 @@ JAZZMIN_SETTINGS = {
"new_window": True,
},
{"name": "Articles", "url": "/admin/services/article/"},
+ {"name": "Image Library", "url": "/admin/services/imagelibrary/"},
{"name": "FAQs", "url": "/admin/services/websitefaq/"},
],
"show_sidebar": True,
@@ -257,6 +258,7 @@ JAZZMIN_SETTINGS = {
"services.VSHNAppCatAddon": "single",
"services.ServiceOffering": "single",
"services.Plan": "single",
+ "services.ImageLibrary": "single",
},
"related_modal_active": True,
}
From 07bea333bc95d8859b58d3dc1d0ebef952b92561 Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Fri, 4 Jul 2025 16:49:29 +0200
Subject: [PATCH 07/50] move form to folder
---
hub/services/forms/__init__.py | 2 ++
hub/services/{forms.py => forms/lead.py} | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
create mode 100644 hub/services/forms/__init__.py
rename hub/services/{forms.py => forms/lead.py} (94%)
diff --git a/hub/services/forms/__init__.py b/hub/services/forms/__init__.py
new file mode 100644
index 0000000..920f500
--- /dev/null
+++ b/hub/services/forms/__init__.py
@@ -0,0 +1,2 @@
+from .lead import LeadForm
+from .image_library import ImageLibraryField, ImageLibraryWidget
diff --git a/hub/services/forms.py b/hub/services/forms/lead.py
similarity index 94%
rename from hub/services/forms.py
rename to hub/services/forms/lead.py
index a608d44..d02c876 100644
--- a/hub/services/forms.py
+++ b/hub/services/forms/lead.py
@@ -1,5 +1,5 @@
from django import forms
-from .models import Lead, Plan
+from ..models import Lead
class LeadForm(forms.ModelForm):
From 1a2bbb1c3522a08edfc55c2ab37204abfba17aee Mon Sep 17 00:00:00 2001
From: Tobias Brunner
Date: Fri, 4 Jul 2025 17:26:09 +0200
Subject: [PATCH 08/50] image library migration step 1
---
IMAGE_LIBRARY_MIGRATION_STATUS.md | 81 +++++++++++++++++++
hub/services/admin/articles.py | 43 +++++++++-
hub/services/admin/providers.py | 56 +++++++++++--
hub/services/admin/services.py | 33 +++++++-
.../0041_add_image_library_references.py | 57 +++++++++++++
.../0042_fix_image_library_field_name.py | 74 +++++++++++++++++
hub/services/models/articles.py | 13 ++-
hub/services/models/images.py | 15 ++--
hub/services/models/providers.py | 21 ++++-
hub/services/models/services.py | 11 ++-
hub/services/templates/pages/homepage.html | 12 ++-
.../templates/services/article_detail.html | 8 +-
.../templates/services/article_list.html | 2 +-
.../templates/services/lead_form.html | 2 +-
.../templates/services/offering_detail.html | 4 +-
.../templates/services/offering_list.html | 4 +-
.../templates/services/partner_detail.html | 4 +-
.../templates/services/partner_list.html | 2 +-
.../templates/services/provider_detail.html | 4 +-
.../templates/services/provider_list.html | 2 +-
.../templates/services/service_detail.html | 4 +-
.../templates/services/service_list.html | 2 +-
hub/services/templatetags/json_ld_tags.py | 16 ++--
23 files changed, 413 insertions(+), 57 deletions(-)
create mode 100644 IMAGE_LIBRARY_MIGRATION_STATUS.md
create mode 100644 hub/services/migrations/0041_add_image_library_references.py
create mode 100644 hub/services/migrations/0042_fix_image_library_field_name.py
diff --git a/IMAGE_LIBRARY_MIGRATION_STATUS.md b/IMAGE_LIBRARY_MIGRATION_STATUS.md
new file mode 100644
index 0000000..d2e27de
--- /dev/null
+++ b/IMAGE_LIBRARY_MIGRATION_STATUS.md
@@ -0,0 +1,81 @@
+# Image Library Migration Status
+
+## ✅ COMPLETED (First Production Rollout) - UPDATED
+
+### Models Updated
+- **Article**: Now inherits from `ImageReference`, with `image_library` field for new images and original `image` field temporarily
+- **CloudProvider**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
+- **ConsultingPartner**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
+- **Service**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
+
+### New Properties Added
+- `Article.get_image()` - Returns image from library or falls back to original field
+- `CloudProvider.get_logo()` - Returns logo from library or falls back to original field
+- `ConsultingPartner.get_logo()` - Returns logo from library or falls back to original field
+- `Service.get_logo()` - Returns logo from library or falls back to original field
+
+### Templates Updated
+- ✅ `pages/homepage.html` - Updated service, provider, and partner image references
+- ✅ `services/article_list.html` - Updated article image references
+- ✅ `services/article_detail.html` - Updated related service/provider/partner logos
+- ✅ `services/offering_list.html` - Updated service and provider logos
+- ✅ `services/offering_detail.html` - Updated service and provider logos
+- ✅ `services/lead_form.html` - Updated service logo
+- ✅ `services/partner_detail.html` - Updated partner and service logos
+- ✅ `services/partner_list.html` - Updated partner logos
+- ✅ `services/provider_list.html` - Updated provider logos
+- ✅ `services/provider_detail.html` - Updated provider and service logos
+- ✅ `services/service_detail.html` - Updated service and provider logos
+
+### Admin Interface Updated
+- ✅ `ArticleAdmin` - Updated image_preview to use get_image property
+- ✅ `ServiceAdmin` - Updated logo_preview to use get_logo property
+- ✅ `CloudProviderAdmin` - Updated logo_preview to use get_logo property
+- ✅ `ConsultingPartnerAdmin` - Updated logo_preview to use get_logo property
+
+### JSON-LD Template Tags Updated
+- ✅ Updated structured data generation to use new image properties
+- ✅ Updated logo references for services, providers, and partners
+
+### Database Migration
+- ✅ Migration `0041_add_image_library_references` successfully applied
+- ✅ Migration `0042_fix_image_library_field_name` successfully applied
+- ✅ All models now have `image_library` foreign key fields to ImageLibrary
+- ✅ Original image fields preserved for backward compatibility
+- ✅ Fixed field name conflicts using `%(class)s_references` related_name pattern
+
+### Admin Interface Enhanced
+- ✅ **ArticleAdmin**: Added fieldsets with `image_library` field visible in "Images" section
+- ✅ **ServiceAdmin**: Added fieldsets with `image_library` field visible in "Images" section
+- ✅ **CloudProviderAdmin**: Added fieldsets with `image_library` field visible in "Images" section
+- ✅ **ConsultingPartnerAdmin**: Added fieldsets with `image_library` field visible in "Images" section
+- ✅ All admin interfaces show both new and legacy fields during transition
+- ✅ Clear descriptions guide users to use Image Library for new images
+
+## Current Status
+The system is now ready for production with dual image support:
+- **New images**: Can be added through the Image Library
+- **Legacy images**: Still work through the original fields
+- **Templates**: Use the new `get_image/get_logo` properties that automatically fall back
+
+## Next Steps (Future Cleanup)
+1. **Data Migration**: Create script to migrate existing images to ImageLibrary
+2. **Admin Updates**: Update admin interfaces to use ImageLibrary selection
+3. **Template Validation**: Add null checks to remaining templates
+4. **Field Removal**: Remove legacy image fields after migration is complete
+5. **Storage Cleanup**: Remove old image files from media directories
+
+## Benefits Achieved
+- ✅ Centralized image management through ImageLibrary
+- ✅ Usage tracking for images
+- ✅ Backward compatibility maintained
+- ✅ Enhanced admin experience ready
+- ✅ Consistent image handling across all models
+- ✅ Proper fallback mechanisms in place
+
+## Safety Measures
+- ✅ Original image fields preserved
+- ✅ Gradual migration approach
+- ✅ Fallback properties ensure no broken images
+- ✅ Database migration tested and applied
+- ✅ Admin interface maintains functionality
diff --git a/hub/services/admin/articles.py b/hub/services/admin/articles.py
index 90298a4..387ec30 100644
--- a/hub/services/admin/articles.py
+++ b/hub/services/admin/articles.py
@@ -61,12 +61,47 @@ class ArticleAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at")
ordering = ("-article_date",)
+ fieldsets = (
+ (None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}),
+ (
+ "Images",
+ {
+ "fields": (
+ "image_library",
+ "image",
+ ), # New image library field and legacy field
+ "description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
+ },
+ ),
+ (
+ "Publishing",
+ {"fields": ("author", "article_date", "is_published", "is_featured")},
+ ),
+ (
+ "Relations",
+ {
+ "fields": (
+ "related_service",
+ "related_consulting_partner",
+ "related_cloud_provider",
+ ),
+ "classes": ("collapse",),
+ },
+ ),
+ (
+ "Metadata",
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
def image_preview(self, obj):
"""Display image preview in admin list view"""
- if obj.image:
- return format_html(
- '
', obj.image.url
- )
+ image = obj.get_image
+ if image:
+ return format_html('
', image.url)
return "No image"
image_preview.short_description = "Image"
diff --git a/hub/services/admin/providers.py b/hub/services/admin/providers.py
index 8ef3ad3..d33e291 100644
--- a/hub/services/admin/providers.py
+++ b/hub/services/admin/providers.py
@@ -47,12 +47,30 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
inlines = [OfferingInline]
ordering = ("order",)
+ fieldsets = (
+ (None, {"fields": ("name", "slug", "description", "order")}),
+ (
+ "Images",
+ {
+ "fields": (
+ "image_library",
+ "logo",
+ ), # New image library field and legacy field
+ "description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
+ },
+ ),
+ (
+ "Contact Information",
+ {"fields": ("website", "linkedin", "phone", "email", "address")},
+ ),
+ ("Settings", {"fields": ("is_featured", "disable_listing")}),
+ )
+
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
- if obj.logo:
- return format_html(
- '
', obj.logo.url
- )
+ logo = obj.get_logo
+ if logo:
+ return format_html('
', logo.url)
return "No logo"
logo_preview.short_description = "Logo"
@@ -75,12 +93,34 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
filter_horizontal = ("services", "cloud_providers")
ordering = ("order",)
+ fieldsets = (
+ (None, {"fields": ("name", "slug", "description", "order")}),
+ (
+ "Images",
+ {
+ "fields": (
+ "image_library",
+ "logo",
+ ), # New image library field and legacy field
+ "description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
+ },
+ ),
+ (
+ "Contact Information",
+ {"fields": ("website", "linkedin", "phone", "email", "address")},
+ ),
+ (
+ "Relations",
+ {"fields": ("services", "cloud_providers"), "classes": ("collapse",)},
+ ),
+ ("Settings", {"fields": ("is_featured", "disable_listing")}),
+ )
+
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
- if obj.logo:
- return format_html(
- '
', obj.logo.url
- )
+ logo = obj.get_logo
+ if logo:
+ return format_html('
', logo.url)
return "No logo"
logo_preview.short_description = "Logo"
diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py
index 41bc97f..b34cf97 100644
--- a/hub/services/admin/services.py
+++ b/hub/services/admin/services.py
@@ -93,12 +93,37 @@ class ServiceAdmin(admin.ModelAdmin):
filter_horizontal = ("categories",)
inlines = [ExternalLinkInline, OfferingInline]
+ fieldsets = (
+ (None, {"fields": ("name", "slug", "description", "tagline")}),
+ (
+ "Images",
+ {
+ "fields": (
+ "image_library",
+ "logo",
+ ), # New image library field and legacy field
+ "description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
+ },
+ ),
+ (
+ "Configuration",
+ {
+ "fields": (
+ "categories",
+ "features",
+ "is_featured",
+ "is_coming_soon",
+ "disable_listing",
+ )
+ },
+ ),
+ )
+
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
- if obj.logo:
- return format_html(
- '
', obj.logo.url
- )
+ logo = obj.get_logo
+ if logo:
+ return format_html('
', logo.url)
return "No logo"
logo_preview.short_description = "Logo"
diff --git a/hub/services/migrations/0041_add_image_library_references.py b/hub/services/migrations/0041_add_image_library_references.py
new file mode 100644
index 0000000..231520b
--- /dev/null
+++ b/hub/services/migrations/0041_add_image_library_references.py
@@ -0,0 +1,57 @@
+# Generated by Django 5.2 on 2025-07-04 15:04
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0040_add_image_library"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="cloudprovider",
+ name="image",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="services.imagelibrary",
+ ),
+ ),
+ migrations.AddField(
+ model_name="consultingpartner",
+ name="image",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="services.imagelibrary",
+ ),
+ ),
+ migrations.AddField(
+ model_name="service",
+ name="image",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="services.imagelibrary",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="article",
+ name="image",
+ field=models.ImageField(
+ blank=True,
+ help_text="Title picture for the article",
+ null=True,
+ upload_to="article_images/",
+ ),
+ ),
+ ]
diff --git a/hub/services/migrations/0042_fix_image_library_field_name.py b/hub/services/migrations/0042_fix_image_library_field_name.py
new file mode 100644
index 0000000..0996f8b
--- /dev/null
+++ b/hub/services/migrations/0042_fix_image_library_field_name.py
@@ -0,0 +1,74 @@
+# Generated by Django 5.2 on 2025-07-04 15:22
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0041_add_image_library_references"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="cloudprovider",
+ name="image",
+ ),
+ migrations.RemoveField(
+ model_name="consultingpartner",
+ name="image",
+ ),
+ migrations.RemoveField(
+ model_name="service",
+ name="image",
+ ),
+ migrations.AddField(
+ model_name="article",
+ name="image_library",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_references",
+ to="services.imagelibrary",
+ ),
+ ),
+ migrations.AddField(
+ model_name="cloudprovider",
+ name="image_library",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_references",
+ to="services.imagelibrary",
+ ),
+ ),
+ migrations.AddField(
+ model_name="consultingpartner",
+ name="image_library",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_references",
+ to="services.imagelibrary",
+ ),
+ ),
+ migrations.AddField(
+ model_name="service",
+ name="image_library",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Select an image from the library",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_references",
+ to="services.imagelibrary",
+ ),
+ ),
+ ]
diff --git a/hub/services/models/articles.py b/hub/services/models/articles.py
index b1b85e7..8ea50c0 100644
--- a/hub/services/models/articles.py
+++ b/hub/services/models/articles.py
@@ -7,9 +7,10 @@ from django.utils import timezone
from .base import validate_image_size
from .services import Service
from .providers import CloudProvider, ConsultingPartner
+from .images import ImageReference
-class Article(models.Model):
+class Article(ImageReference):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
excerpt = models.TextField(
@@ -19,9 +20,12 @@ class Article(models.Model):
meta_keywords = models.CharField(
max_length=255, blank=True, help_text="SEO keywords separated by commas"
)
+ # Original image field - keep temporarily for migration
image = models.ImageField(
upload_to="article_images/",
help_text="Title picture for the article",
+ null=True,
+ blank=True,
)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
article_date = models.DateField(
@@ -86,6 +90,13 @@ class Article(models.Model):
def get_absolute_url(self):
return reverse("services:article_detail", kwargs={"slug": self.slug})
+ @property
+ def get_image(self):
+ """Returns the image from library or falls back to legacy image"""
+ if self.image_library and self.image_library.image:
+ return self.image_library.image
+ return self.image
+
@property
def related_to(self):
"""Returns a string describing what this article is related to"""
diff --git a/hub/services/models/images.py b/hub/services/models/images.py
index 90052ab..3bf72f7 100644
--- a/hub/services/models/images.py
+++ b/hub/services/models/images.py
@@ -182,12 +182,13 @@ class ImageReference(models.Model):
This helps track usage and provides a consistent interface.
"""
- image = models.ForeignKey(
+ image_library = models.ForeignKey(
ImageLibrary,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Select an image from the library",
+ related_name="%(class)s_references",
)
class Meta:
@@ -202,23 +203,23 @@ class ImageReference(models.Model):
if self.pk:
try:
old_instance = self.__class__.objects.get(pk=self.pk)
- old_image = old_instance.image
+ old_image = old_instance.image_library
except self.__class__.DoesNotExist:
pass
super().save(*args, **kwargs)
# Update usage counts
- if old_image and old_image != self.image:
+ if old_image and old_image != self.image_library:
old_image.decrement_usage()
- if self.image and self.image != old_image:
- self.image.increment_usage()
+ if self.image_library and self.image_library != old_image:
+ self.image_library.increment_usage()
def delete(self, *args, **kwargs):
"""
Override delete to update usage count.
"""
- if self.image:
- self.image.decrement_usage()
+ if self.image_library:
+ self.image_library.decrement_usage()
super().delete(*args, **kwargs)
diff --git a/hub/services/models/providers.py b/hub/services/models/providers.py
index e3257ea..5567cb9 100644
--- a/hub/services/models/providers.py
+++ b/hub/services/models/providers.py
@@ -4,9 +4,10 @@ from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
from .base import validate_image_size
+from .images import ImageReference
-class CloudProvider(models.Model):
+class CloudProvider(ImageReference):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = ProseEditorField()
@@ -15,6 +16,7 @@ class CloudProvider(models.Model):
phone = models.CharField(max_length=25, blank=True, null=True)
email = models.EmailField(max_length=254, blank=True, null=True)
address = models.TextField(max_length=250, blank=True, null=True)
+ # Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="cloud_provider_logos/",
validators=[validate_image_size],
@@ -39,11 +41,19 @@ class CloudProvider(models.Model):
def get_absolute_url(self):
return reverse("services:provider_detail", kwargs={"slug": self.slug})
+ @property
+ def get_logo(self):
+ """Returns the logo from library or falls back to legacy logo"""
+ if self.image_library and self.image_library.image:
+ return self.image_library.image
+ return self.logo
-class ConsultingPartner(models.Model):
+
+class ConsultingPartner(ImageReference):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = ProseEditorField()
+ # Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="partner_logos/",
validators=[validate_image_size],
@@ -83,3 +93,10 @@ class ConsultingPartner(models.Model):
def get_absolute_url(self):
return reverse("services:partner_detail", kwargs={"slug": self.slug})
+
+ @property
+ def get_logo(self):
+ """Returns the logo from library or falls back to legacy logo"""
+ if self.image_library and self.image_library.image:
+ return self.image_library.image
+ return self.logo
diff --git a/hub/services/models/services.py b/hub/services/models/services.py
index 8b6984d..af4c2e0 100644
--- a/hub/services/models/services.py
+++ b/hub/services/models/services.py
@@ -13,13 +13,15 @@ from .base import (
Currency,
)
from .providers import CloudProvider
+from .images import ImageReference
-class Service(models.Model):
+class Service(ImageReference):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
description = ProseEditorField()
tagline = models.TextField(max_length=500, blank=True, null=True)
+ # Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="service_logos/",
validators=[validate_image_size],
@@ -58,6 +60,13 @@ class Service(models.Model):
def get_absolute_url(self):
return reverse("services:service_detail", kwargs={"slug": self.slug})
+ @property
+ def get_logo(self):
+ """Returns the logo from library or falls back to legacy logo"""
+ if self.image_library and self.image_library.image:
+ return self.image_library.image
+ return self.logo
+
class ServiceOffering(models.Model):
service = models.ForeignKey(
diff --git a/hub/services/templates/pages/homepage.html b/hub/services/templates/pages/homepage.html
index f16da44..a7507c6 100644
--- a/hub/services/templates/pages/homepage.html
+++ b/hub/services/templates/pages/homepage.html
@@ -48,9 +48,15 @@
@@ -105,7 +111,7 @@
-
@@ -159,7 +165,7 @@
-
diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html
index b3264e5..2f67b43 100644
--- a/hub/services/templates/services/article_detail.html
+++ b/hub/services/templates/services/article_detail.html
@@ -43,7 +43,7 @@
Service
{% if article.related_service.logo %}
-
{% endif %}
@@ -60,7 +60,7 @@
Partner
{% if article.related_consulting_partner.logo %}
-
{% endif %}
@@ -77,7 +77,7 @@
Provider
{% if article.related_cloud_provider.logo %}
-
{% endif %}
@@ -100,7 +100,7 @@
{% if related_article.image %}
-

+

{% endif %}
{{ related_article.title }}
diff --git a/hub/services/templates/services/article_list.html b/hub/services/templates/services/article_list.html
index ac86df4..47ae5ae 100644
--- a/hub/services/templates/services/article_list.html
+++ b/hub/services/templates/services/article_list.html
@@ -149,7 +149,7 @@
{% if article.image %}
-

+
{% endif %}
{% if article.is_featured %}
diff --git a/hub/services/templates/services/lead_form.html b/hub/services/templates/services/lead_form.html
index 73e0585..bcc26d6 100644
--- a/hub/services/templates/services/lead_form.html
+++ b/hub/services/templates/services/lead_form.html
@@ -78,7 +78,7 @@
{% if selected_offering.service.logo %}
-

+

{% endif %}