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 @@
- {{ service.name }} + {% else %} +
+ {{ service.name }} +
+ {% endif %}
@@ -105,7 +111,7 @@
- {{ provider.name }} @@ -159,7 +165,7 @@
- {{ partner.name }} 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 %} -
-
-
- {{ article.title }} -
-
-
-{% endif %} -
@@ -55,7 +43,7 @@
Service
{% if article.related_service.logo %}
- {{ article.related_service.name }} logo
{% endif %} @@ -72,7 +60,7 @@
Partner
{% if article.related_consulting_partner.logo %}
- {{ article.related_consulting_partner.name }} logo
{% endif %} @@ -89,7 +77,7 @@
Provider
{% if article.related_cloud_provider.logo %}
- {{ article.related_cloud_provider.name }} logo
{% endif %} @@ -112,7 +100,7 @@
{% if related_article.image %} - {{ related_article.title }} + {{ related_article.title }} {% 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 %}
- {{ article.title }} + {{ article.title }}
{% 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 %} - Service Logo + Service Logo {% endif %}
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 7263024..2fe0eba 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -34,7 +34,7 @@
{% if offering.service.logo %} - {{ offering.service.name }} logo {% endif %} @@ -50,7 +50,7 @@

Runs on

- {{ offering.cloud_provider.name }} logo + {{ offering.cloud_provider.name }} logo
@@ -473,7 +473,7 @@
diff --git a/hub/services/templates/services/offering_list.html b/hub/services/templates/services/offering_list.html index 0f138e3..741352b 100644 --- a/hub/services/templates/services/offering_list.html +++ b/hub/services/templates/services/offering_list.html @@ -151,7 +151,7 @@
{% if offering.service.logo %} - {{ offering.service.name }} {% endif %} @@ -165,7 +165,7 @@
{% if offering.cloud_provider.logo %} - {{ offering.cloud_provider.name }} diff --git a/hub/services/templates/services/partner_detail.html b/hub/services/templates/services/partner_detail.html index 7e90b82..1e78144 100644 --- a/hub/services/templates/services/partner_detail.html +++ b/hub/services/templates/services/partner_detail.html @@ -24,7 +24,7 @@
{% if partner.logo %} - {{ partner.name }} logo + {{ partner.name }} logo {% endif %}
@@ -178,7 +178,7 @@ {% if service.logo %} {% endif %} diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index fb196dc..5f554ac 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -115,7 +115,7 @@
- {{ partner.name }} diff --git a/hub/services/templates/services/provider_detail.html b/hub/services/templates/services/provider_detail.html index 151c986..aa9c360 100644 --- a/hub/services/templates/services/provider_detail.html +++ b/hub/services/templates/services/provider_detail.html @@ -24,7 +24,7 @@
{% if provider.logo %} - {{ provider.name }} logo + {{ provider.name }} logo {% endif %}
@@ -178,7 +178,7 @@ {% if offering.service.logo %} {% endif %} diff --git a/hub/services/templates/services/provider_list.html b/hub/services/templates/services/provider_list.html index 5484b7e..ca27bab 100644 --- a/hub/services/templates/services/provider_list.html +++ b/hub/services/templates/services/provider_list.html @@ -99,7 +99,7 @@
{% if provider.logo %} - {{ provider.name }} diff --git a/hub/services/templates/services/service_detail.html b/hub/services/templates/services/service_detail.html index 5054d61..bdb3748 100644 --- a/hub/services/templates/services/service_detail.html +++ b/hub/services/templates/services/service_detail.html @@ -23,7 +23,7 @@
{% if service.logo %} - {{ service.name }} logo + {{ service.name }} logo {% endif %}
@@ -184,7 +184,7 @@
{% if offering.cloud_provider.logo %}
- {{ offering.cloud_provider.name }} logo
{% else %} diff --git a/hub/services/templates/services/service_list.html b/hub/services/templates/services/service_list.html index da16d3e..0420a74 100644 --- a/hub/services/templates/services/service_list.html +++ b/hub/services/templates/services/service_list.html @@ -156,7 +156,7 @@
{% if service.logo %}
- {{ service.name }} logo + {{ service.name }} logo
{% endif %} {% if service.is_featured %} 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( + 'Image not found', + 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/templatetags/json_ld_tags.py b/hub/services/templatetags/json_ld_tags.py index eb18007..c9225d5 100644 --- a/hub/services/templatetags/json_ld_tags.py +++ b/hub/services/templatetags/json_ld_tags.py @@ -119,8 +119,8 @@ def json_ld_structured_data(context): } # Add image if available - if hasattr(service, "logo") and service.logo: - data["image"] = request.build_absolute_uri(service.logo.url) + if hasattr(service, "get_logo") and service.get_logo: + data["image"] = request.build_absolute_uri(service.get_logo.url) # Add offerings if available if hasattr(service, "offerings") and service.offerings.exists(): @@ -143,8 +143,8 @@ def json_ld_structured_data(context): } # Add image if available - if hasattr(provider, "logo") and provider.logo: - data["logo"] = request.build_absolute_uri(provider.logo.url) + if hasattr(provider, "get_logo") and provider.get_logo: + data["logo"] = request.build_absolute_uri(provider.get_logo.url) # Add contact information if available contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"} @@ -179,8 +179,8 @@ def json_ld_structured_data(context): } # Add image if available - if hasattr(partner, "logo") and partner.logo: - data["logo"] = request.build_absolute_uri(partner.logo.url) + if hasattr(partner, "get_logo") and partner.get_logo: + data["logo"] = request.build_absolute_uri(partner.get_logo.url) # Add contact information if available contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"} @@ -219,8 +219,8 @@ def json_ld_structured_data(context): data["brand"] = {"@type": "Brand", "name": offering.service.name} # Add image if available - if hasattr(offering.service, "logo") and offering.service.logo: - data["image"] = request.build_absolute_uri(offering.service.logo.url) + if hasattr(offering.service, "get_logo") and offering.service.get_logo: + data["image"] = request.build_absolute_uri(offering.service.get_logo.url) # Add offers if available if hasattr(offering, "plans") and offering.plans.exists(): 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/services/views/articles.py b/hub/services/views/articles.py index 6589d6f..4b8117f 100644 --- a/hub/services/views/articles.py +++ b/hub/services/views/articles.py @@ -23,7 +23,7 @@ def article_list(request): # Apply filters based on request parameters if search_query: articles = articles.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -41,7 +41,7 @@ def article_list(request): # Order articles: featured first, then by creation date (newest first) articles = articles.order_by( "-is_featured", # Featured first (True before False) - "-created_at", # Newest first + "-article_date", # Newest first ) # Create base querysets for each filter type that apply all OTHER current filters @@ -51,7 +51,7 @@ def article_list(request): service_filter_base = all_articles if search_query: service_filter_base = service_filter_base.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -69,7 +69,7 @@ def article_list(request): cp_filter_base = all_articles if search_query: cp_filter_base = cp_filter_base.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -85,7 +85,7 @@ def article_list(request): cloud_filter_base = all_articles if search_query: cloud_filter_base = cloud_filter_base.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -136,16 +136,14 @@ def article_detail(request, slug): Article.objects.select_related( "author", "related_service", - "related_consulting_partner", - "related_cloud_provider" + "related_consulting_partner", + "related_cloud_provider", ).filter(is_published=True), slug=slug, ) # Get related articles (same service, partner, or provider) - related_articles = Article.objects.filter( - is_published=True - ).exclude(id=article.id) + related_articles = Article.objects.filter(is_published=True).exclude(id=article.id) if article.related_service: related_articles = related_articles.filter( @@ -164,13 +162,13 @@ def article_detail(request, slug): related_articles = related_articles.filter( related_service__isnull=True, related_consulting_partner__isnull=True, - related_cloud_provider__isnull=True + related_cloud_provider__isnull=True, ) - related_articles = related_articles.order_by("-created_at")[:3] + related_articles = related_articles.order_by("-article_date")[:3] context = { "article": article, "related_articles": related_articles, } - return render(request, "services/article_detail.html", context) \ No newline at end of file + return render(request, "services/article_detail.html", context) diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index e135aec..4016857 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -173,15 +173,28 @@ def generate_exoscale_marketplace_yaml(offering): ).strip() # Build YAML structure + service_name = offering.service.name + + # List of service names that should have "Enterprise" appended + # This concerns all services which are already available on Exoscale Marketplace or DBaaS for differentiation + # A workaround because we don't particularly have "Enterprise" services yet + enterprise_services = ["GitLab", "PostgreSQL"] + + if any( + enterprise_service in service_name for enterprise_service in enterprise_services + ): + service_name += " Enterprise" + + title = f"{service_name} by Servala" yaml_structure = { yaml_key: { "page_class": "tmpl-marketplace-product", - "html_title": f"Managed {offering.service.name} by VSHN via Servala", - "meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.", - "page_header_title": f"Managed {offering.service.name} by VSHN via Servala", + "html_title": title, + "meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.", + "page_header_title": title, "provider_key": "vshn", - "slug": f"servala-managed-{offering.service.slug}", - "title": f"Managed {offering.service.name} by VSHN via Servala", + "slug": f"{offering.service.slug}-by-servala", + "title": title, "logo": f"img/servala-{offering.service.slug}.svg", "list_display": [], "meta": [ 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, }