diff --git a/IMAGE_LIBRARY_MIGRATION_STATUS.md b/IMAGE_LIBRARY_MIGRATION_STATUS.md deleted file mode 100644 index d2e27de..0000000 --- a/IMAGE_LIBRARY_MIGRATION_STATUS.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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 fba7be9..3c79092 100644 --- a/hub/services/admin/__init__.py +++ b/hub/services/admin/__init__.py @@ -4,7 +4,6 @@ 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 387ec30..e6dad8c 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", - "article_date", + "created_at", ) list_filter = ( "is_published", @@ -54,54 +54,18 @@ class ArticleAdmin(admin.ModelAdmin): "related_service", "related_consulting_partner", "related_cloud_provider", - "article_date", + "created_at", ) 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""" - image = obj.get_image - if image: - return format_html('', image.url) + if obj.image: + return format_html( + '', obj.image.url + ) return "No image" image_preview.short_description = "Image" diff --git a/hub/services/admin/images.py b/hub/services/admin/images.py deleted file mode 100644 index 917d0db..0000000 --- a/hub/services/admin/images.py +++ /dev/null @@ -1,115 +0,0 @@ -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 d33e291..8ef3ad3 100644 --- a/hub/services/admin/providers.py +++ b/hub/services/admin/providers.py @@ -47,30 +47,12 @@ 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""" - logo = obj.get_logo - if logo: - return format_html('', logo.url) + if obj.logo: + return format_html( + '', obj.logo.url + ) return "No logo" logo_preview.short_description = "Logo" @@ -93,34 +75,12 @@ 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""" - logo = obj.get_logo - if logo: - return format_html('', logo.url) + if obj.logo: + return format_html( + '', obj.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 b34cf97..c975884 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", "order")}), + ("Display Options", {"fields": ("is_best",)}), ) - show_change_link = True + show_change_link = True # This allows clicking through to the Plan admin where prices can be managed class OfferingInline(admin.StackedInline): @@ -93,37 +93,12 @@ 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""" - logo = obj.get_logo - if logo: - return format_html('', logo.url) + if obj.logo: + return format_html( + '', obj.logo.url + ) return "No logo" logo_preview.short_description = "Logo" diff --git a/hub/services/forms/lead.py b/hub/services/forms.py similarity index 94% rename from hub/services/forms/lead.py rename to hub/services/forms.py index d02c876..a608d44 100644 --- a/hub/services/forms/lead.py +++ b/hub/services/forms.py @@ -1,5 +1,5 @@ from django import forms -from ..models import Lead +from .models import Lead, Plan class LeadForm(forms.ModelForm): diff --git a/hub/services/forms/__init__.py b/hub/services/forms/__init__.py deleted file mode 100644 index 920f500..0000000 --- a/hub/services/forms/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 1770456..0000000 --- a/hub/services/forms/image_library.py +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/hub/services/management/commands/__init__.py b/hub/services/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/hub/services/management/commands/migrate_images.py b/hub/services/management/commands/migrate_images.py deleted file mode 100644 index ca90dcb..0000000 --- a/hub/services/management/commands/migrate_images.py +++ /dev/null @@ -1,293 +0,0 @@ -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 deleted file mode 100644 index 69acc86..0000000 --- a/hub/services/migrations/0039_article_article_date.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index bc06e33..0000000 --- a/hub/services/migrations/0040_add_image_library.py +++ /dev/null @@ -1,144 +0,0 @@ -# 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 deleted file mode 100644 index 231520b..0000000 --- a/hub/services/migrations/0041_add_image_library_references.py +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index 0996f8b..0000000 --- a/hub/services/migrations/0042_fix_image_library_field_name.py +++ /dev/null @@ -1,74 +0,0 @@ -# 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 68159a4..b29a71e 100644 --- a/hub/services/models/__init__.py +++ b/hub/services/models/__init__.py @@ -1,7 +1,6 @@ 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 8ea50c0..781c54c 100644 --- a/hub/services/models/articles.py +++ b/hub/services/models/articles.py @@ -3,14 +3,12 @@ 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(ImageReference): +class Article(models.Model): title = models.CharField(max_length=200) slug = models.SlugField(max_length=250, unique=True) excerpt = models.TextField( @@ -20,17 +18,11 @@ class Article(ImageReference): 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( @@ -90,13 +82,6 @@ class Article(ImageReference): 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 a047330..9a7abce 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, mb=1): +def validate_image_size(value): filesize = value.size - if filesize > mb * 1024 * 1024: - raise ValidationError(f"Maximum file size is {mb} MB") + if filesize > 1 * 1024 * 1024: # 1MB + raise ValidationError("Maximum file size is 1MB") class Currency(models.TextChoices): diff --git a/hub/services/models/images.py b/hub/services/models/images.py deleted file mode 100644 index 3bf72f7..0000000 --- a/hub/services/models/images.py +++ /dev/null @@ -1,225 +0,0 @@ -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 5567cb9..e3257ea 100644 --- a/hub/services/models/providers.py +++ b/hub/services/models/providers.py @@ -4,10 +4,9 @@ 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(ImageReference): +class CloudProvider(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) description = ProseEditorField() @@ -16,7 +15,6 @@ class CloudProvider(ImageReference): 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], @@ -41,19 +39,11 @@ class CloudProvider(ImageReference): 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(ImageReference): +class ConsultingPartner(models.Model): 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], @@ -93,10 +83,3 @@ class ConsultingPartner(ImageReference): 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 af4c2e0..8b6984d 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -13,15 +13,13 @@ from .base import ( Currency, ) from .providers import CloudProvider -from .images import ImageReference -class Service(ImageReference): +class Service(models.Model): 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], @@ -60,13 +58,6 @@ class Service(ImageReference): 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 deleted file mode 100644 index 6d589bb..0000000 --- a/hub/services/static/admin/css/image_library.css +++ /dev/null @@ -1,79 +0,0 @@ -/* 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 a65d8cc..5efe5f6 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.151); - color: rgb(255, 255, 255); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); 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 a7507c6..f16da44 100644 --- a/hub/services/templates/pages/homepage.html +++ b/hub/services/templates/pages/homepage.html @@ -48,15 +48,9 @@
- {% if service.get_logo %} - {{ service.name }} - {% else %} -
- {{ service.name }} -
- {% endif %}
@@ -111,7 +105,7 @@
- {{ provider.name }} @@ -165,7 +159,7 @@
- {{ partner.name }} diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html index 2f67b43..44af754 100644 --- a/hub/services/templates/services/article_detail.html +++ b/hub/services/templates/services/article_detail.html @@ -16,13 +16,25 @@
By {{ article.author.get_full_name|default:article.author.username }} - {{ article.article_date|date:"M d, Y" }} + {{ article.created_at|date:"M d, Y" }} + {% if article.updated_at != article.created_at %} + {% endif %}
+{% if article.image %} +
+
+
+ {{ article.title }} +
+
+
+{% endif %} +
@@ -43,7 +55,7 @@
Service
{% if article.related_service.logo %}
- {{ article.related_service.name }} logo
{% endif %} @@ -60,7 +72,7 @@
Partner
{% if article.related_consulting_partner.logo %}
- {{ article.related_consulting_partner.name }} logo
{% endif %} @@ -77,7 +89,7 @@
Provider
{% if article.related_cloud_provider.logo %}
- {{ article.related_cloud_provider.name }} logo
{% endif %} @@ -100,7 +112,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 47ae5ae..edb0989 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.article_date|date:"M d, Y" }} + {{ article.created_at|date:"M d, Y" }}

diff --git a/hub/services/templates/services/lead_form.html b/hub/services/templates/services/lead_form.html index bcc26d6..73e0585 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 2fe0eba..7263024 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 741352b..0f138e3 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 1e78144..7e90b82 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 5f554ac..fb196dc 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 aa9c360..151c986 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 ca27bab..5484b7e 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 bdb3748..5054d61 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 0420a74..da16d3e 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 deleted file mode 100644 index 99339b7..0000000 --- a/hub/services/templatetags/image_library.py +++ /dev/null @@ -1,112 +0,0 @@ -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 c9225d5..eb18007 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, "get_logo") and service.get_logo: - data["image"] = request.build_absolute_uri(service.get_logo.url) + if hasattr(service, "logo") and service.logo: + data["image"] = request.build_absolute_uri(service.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, "get_logo") and provider.get_logo: - data["logo"] = request.build_absolute_uri(provider.get_logo.url) + if hasattr(provider, "logo") and provider.logo: + data["logo"] = request.build_absolute_uri(provider.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, "get_logo") and partner.get_logo: - data["logo"] = request.build_absolute_uri(partner.get_logo.url) + if hasattr(partner, "logo") and partner.logo: + data["logo"] = request.build_absolute_uri(partner.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, "get_logo") and offering.service.get_logo: - data["image"] = request.build_absolute_uri(offering.service.get_logo.url) + if hasattr(offering.service, "logo") and offering.service.logo: + data["image"] = request.build_absolute_uri(offering.service.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 deleted file mode 100644 index c0b72b2..0000000 --- a/hub/services/utils/image_library.py +++ /dev/null @@ -1,243 +0,0 @@ -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 4b8117f..6589d6f 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) - "-article_date", # Newest first + "-created_at", # 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,14 +136,16 @@ 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( @@ -162,13 +164,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("-article_date")[:3] + related_articles = related_articles.order_by("-created_at")[:3] context = { "article": article, "related_articles": related_articles, } - return render(request, "services/article_detail.html", context) + return render(request, "services/article_detail.html", context) \ No newline at end of file diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 4016857..e135aec 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -173,28 +173,15 @@ 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": 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, + "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", "provider_key": "vshn", - "slug": f"{offering.service.slug}-by-servala", - "title": title, + "slug": f"servala-managed-{offering.service.slug}", + "title": f"Managed {offering.service.name} by VSHN via Servala", "logo": f"img/servala-{offering.service.slug}.svg", "list_display": [], "meta": [ diff --git a/hub/settings.py b/hub/settings.py index 120d9cb..8e6f3e6 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -245,7 +245,6 @@ 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, @@ -258,7 +257,6 @@ JAZZMIN_SETTINGS = { "services.VSHNAppCatAddon": "single", "services.ServiceOffering": "single", "services.Plan": "single", - "services.ImageLibrary": "single", }, "related_modal_active": True, }