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 @@