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,
}