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/__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/articles.py b/hub/services/admin/articles.py
index e6dad8c..387ec30 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,18 +54,54 @@ 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",)
+
+ 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/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/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 c975884..b34cf97 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):
@@ -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/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/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/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):
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/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/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/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/__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/articles.py b/hub/services/models/articles.py
index 781c54c..8ea50c0 100644
--- a/hub/services/models/articles.py
+++ b/hub/services/models/articles.py
@@ -3,12 +3,14 @@ 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
+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(
@@ -18,11 +20,17 @@ 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(
+ default=timezone.now, help_text="Date of the article publishing"
+ )
# Relations to other models
related_service = models.ForeignKey(
@@ -82,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/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..3bf72f7
--- /dev/null
+++ b/hub/services/models/images.py
@@ -0,0 +1,225 @@
+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_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:
+ 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_library
+ except self.__class__.DoesNotExist:
+ pass
+
+ super().save(*args, **kwargs)
+
+ # Update usage counts
+ if old_image and old_image != self.image_library:
+ old_image.decrement_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_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/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/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/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 44af754..2f67b43 100644
--- a/hub/services/templates/services/article_detail.html
+++ b/hub/services/templates/services/article_detail.html
@@ -16,25 +16,13 @@
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" }}
-{% if article.image %}
-
-
-
-

-
-
-
-{% endif %}
-
@@ -55,7 +43,7 @@
Service
{% if article.related_service.logo %}
-
{% endif %}
@@ -72,7 +60,7 @@
Partner
{% if article.related_consulting_partner.logo %}
-
{% endif %}
@@ -89,7 +77,7 @@
Provider
{% if article.related_cloud_provider.logo %}
-
{% endif %}
@@ -112,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 edb0989..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 %}
@@ -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/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 %}