Compare commits
8 commits
fef37c8ec3
...
1a2bbb1c35
Author | SHA1 | Date | |
---|---|---|---|
1a2bbb1c35 | |||
07bea333bc | |||
52dbe89582 | |||
bdf06863d2 | |||
6351da70ee | |||
470887c34e | |||
60de2e547a | |||
7bbde80913 |
42 changed files with 1840 additions and 93 deletions
81
IMAGE_LIBRARY_MIGRATION_STATUS.md
Normal file
81
IMAGE_LIBRARY_MIGRATION_STATUS.md
Normal file
|
@ -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
|
|
@ -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 *
|
||||
|
|
|
@ -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(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.image.url
|
||||
)
|
||||
image = obj.get_image
|
||||
if image:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', image.url)
|
||||
return "No image"
|
||||
|
||||
image_preview.short_description = "Image"
|
||||
|
|
115
hub/services/admin/images.py
Normal file
115
hub/services/admin/images.py
Normal file
|
@ -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(
|
||||
'<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
|
||||
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(
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />',
|
||||
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",)}
|
|
@ -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(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', 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(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
|
|
@ -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(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
|
2
hub/services/forms/__init__.py
Normal file
2
hub/services/forms/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .lead import LeadForm
|
||||
from .image_library import ImageLibraryField, ImageLibraryWidget
|
149
hub/services/forms/image_library.py
Normal file
149
hub/services/forms/image_library.py
Normal file
|
@ -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(
|
||||
' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />',
|
||||
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(
|
||||
'<select name="{}" id="{}"{}>{}</select>',
|
||||
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(
|
||||
'<div class="image-preview" style="margin-top: 10px;">'
|
||||
'<img src="{}" style="max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px;" />'
|
||||
'<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}</p>'
|
||||
"</div>",
|
||||
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(
|
||||
"<script>"
|
||||
'document.addEventListener("DOMContentLoaded", function() {{'
|
||||
' const select = document.getElementById("{}");\n'
|
||||
' const previewDiv = select.parentNode.querySelector(".image-preview");\n'
|
||||
' select.addEventListener("change", function() {{'
|
||||
" const imageId = this.value;\n"
|
||||
" if (imageId) {{"
|
||||
' fetch("/admin/services/imagelibrary/" + imageId + "/preview/")'
|
||||
" .then(response => response.json())"
|
||||
" .then(data => {{"
|
||||
" if (previewDiv) {{"
|
||||
" previewDiv.innerHTML = data.html;\n"
|
||||
" }}"
|
||||
" }});\n"
|
||||
" }} else {{"
|
||||
" if (previewDiv) {{"
|
||||
' previewDiv.innerHTML = "";\n'
|
||||
" }}"
|
||||
" }}"
|
||||
" }});\n"
|
||||
"}});\n"
|
||||
"</script>",
|
||||
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'<option value="{value}" {selected}>{text}</option>')
|
||||
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()})"
|
|
@ -1,5 +1,5 @@
|
|||
from django import forms
|
||||
from .models import Lead, Plan
|
||||
from ..models import Lead
|
||||
|
||||
|
||||
class LeadForm(forms.ModelForm):
|
0
hub/services/management/__init__.py
Normal file
0
hub/services/management/__init__.py
Normal file
0
hub/services/management/commands/__init__.py
Normal file
0
hub/services/management/commands/__init__.py
Normal file
293
hub/services/management/commands/migrate_images.py
Normal file
293
hub/services/management/commands/migrate_images.py
Normal file
|
@ -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)"
|
||||
)
|
||||
)
|
22
hub/services/migrations/0039_article_article_date.py
Normal file
22
hub/services/migrations/0039_article_article_date.py
Normal file
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
144
hub/services/migrations/0040_add_image_library.py
Normal file
144
hub/services/migrations/0040_add_image_library.py
Normal file
|
@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
57
hub/services/migrations/0041_add_image_library_references.py
Normal file
57
hub/services/migrations/0041_add_image_library_references.py
Normal file
|
@ -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/",
|
||||
),
|
||||
),
|
||||
]
|
74
hub/services/migrations/0042_fix_image_library_field_name.py
Normal file
74
hub/services/migrations/0042_fix_image_library_field_name.py
Normal file
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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 *
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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):
|
||||
|
|
225
hub/services/models/images.py
Normal file
225
hub/services/models/images.py
Normal file
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
79
hub/services/static/admin/css/image_library.css
Normal file
79
hub/services/static/admin/css/image_library.css
Normal file
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -48,9 +48,15 @@
|
|||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ service.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ service.logo.url }}"
|
||||
{% if service.get_logo %}
|
||||
<img src="{{ service.get_logo.url }}"
|
||||
alt="{{ service.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
{% else %}
|
||||
<div class="text-muted" style="height: 100px; width: 250px; display: flex; align-items: center; justify-content: center; border: 1px solid #dee2e6; border-radius: 0.375rem;">
|
||||
{{ service.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -105,7 +111,7 @@
|
|||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ provider.logo.url }}"
|
||||
<img src="{{ provider.get_logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
|
@ -159,7 +165,7 @@
|
|||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ partner.logo.url }}"
|
||||
<img src="{{ partner.get_logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
@ -16,25 +16,13 @@
|
|||
<div class="d-flex justify-content-center align-items-center gap-3 text-sm">
|
||||
<span>By {{ article.author.get_full_name|default:article.author.username }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ article.created_at|date:"M d, Y" }}</span>
|
||||
{% if article.updated_at != article.created_at %}
|
||||
{% endif %}
|
||||
<span>{{ article.article_date|date:"M d, Y" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if article.image %}
|
||||
<section class="section py-0">
|
||||
<div class="container-xl mx-auto">
|
||||
<div class="article-hero-image">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid w-100" style="max-height: 400px; object-fit: cover;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="section">
|
||||
<div class="container-xl mx-auto px-3 px-lg-0 pt-60 pt-lg-80 pb-40">
|
||||
<div class="row">
|
||||
|
@ -55,7 +43,7 @@
|
|||
<h5 class="card-title">Service</h5>
|
||||
{% if article.related_service.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_service.logo.url }}" alt="{{ article.related_service.name }} logo"
|
||||
<img src="{{ article.related_service.get_logo.url }}" alt="{{ article.related_service.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -72,7 +60,7 @@
|
|||
<h5 class="card-title">Partner</h5>
|
||||
{% if article.related_consulting_partner.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_consulting_partner.logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
|
||||
<img src="{{ article.related_consulting_partner.get_logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -89,7 +77,7 @@
|
|||
<h5 class="card-title">Provider</h5>
|
||||
{% if article.related_cloud_provider.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_cloud_provider.logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
|
||||
<img src="{{ article.related_cloud_provider.get_logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -112,7 +100,7 @@
|
|||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card h-100 clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
|
||||
{% if related_article.image %}
|
||||
<img src="{{ related_article.image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
|
||||
<img src="{{ related_article.get_image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
|
||||
{% endif %}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title">{{ related_article.title }}</h5>
|
||||
|
|
|
@ -149,7 +149,7 @@
|
|||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if article.image %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid">
|
||||
<img src="{{ article.get_image.url }}" alt="{{ article.title }}" class="img-fluid">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if article.is_featured %}
|
||||
|
@ -169,7 +169,7 @@
|
|||
By {{ article.author.get_full_name|default:article.author.username }}
|
||||
</span>
|
||||
<span class="text-muted ms-2">
|
||||
{{ article.created_at|date:"M d, Y" }}
|
||||
{{ article.article_date|date:"M d, Y" }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
<div class="d-flex align-items-center mb-24">
|
||||
<div class="card__image mb-0">
|
||||
{% if selected_offering.service.logo %}
|
||||
<img class="img-fluid" src="{{ selected_offering.service.logo.url }}" alt="Service Logo">
|
||||
<img class="img-fluid" src="{{ selected_offering.service.get_logo.url }}" alt="Service Logo">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__header ps-16">
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if offering.service.logo %}
|
||||
<a href="{{ offering.service.get_absolute_url }}">
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.get_logo.url }}"
|
||||
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -50,7 +50,7 @@
|
|||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Runs on</h3>
|
||||
<a href="{{ offering.cloud_provider.get_absolute_url }}">
|
||||
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
|
||||
<img class="img-fluid" src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
@ -473,7 +473,7 @@
|
|||
<!-- Plan Action Button -->
|
||||
<div class="text-center mt-auto pt-3">
|
||||
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
|
||||
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>{% if plan.is_best %}Select Best Plan{% else %}Select This Plan{% endif %}
|
||||
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>Select Plan
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -151,7 +151,7 @@
|
|||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
{% if offering.service.logo %}
|
||||
<img src="{{ offering.service.logo.url }}"
|
||||
<img src="{{ offering.service.get_logo.url }}"
|
||||
alt="{{ offering.service.name }}"
|
||||
style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||
{% endif %}
|
||||
|
@ -165,7 +165,7 @@
|
|||
<div class="d-flex align-items-center">
|
||||
{% if offering.cloud_provider.logo %}
|
||||
<a href="{{ offering.get_absolute_url }}" class="me-2">
|
||||
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||
<img src="{{ offering.cloud_provider.get_logo.url }}"
|
||||
alt="{{ offering.cloud_provider.name }}"
|
||||
style="max-height: 30px; max-width: 100px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if partner.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -178,7 +178,7 @@
|
|||
{% if service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<a href="{{ service.get_absolute_url }}">
|
||||
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -115,7 +115,7 @@
|
|||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3">
|
||||
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ partner.logo.url }}"
|
||||
<img src="{{ partner.get_logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if provider.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.get_logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -178,7 +178,7 @@
|
|||
{% if offering.service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<a href="{{ offering.get_absolute_url }}">
|
||||
<img src="{{ offering.service.logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
|
||||
<img src="{{ offering.service.get_logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
|
||||
{% if provider.logo %}
|
||||
<img src="{{ provider.logo.url }}"
|
||||
<img src="{{ provider.get_logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if service.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ service.logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -184,7 +184,7 @@
|
|||
<div class="card-body text-center">
|
||||
{% if offering.cloud_provider.logo %}
|
||||
<div class="mb-3 d-flex align-items-center justify-content-center" style="height: 80px;">
|
||||
<img src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
|
||||
<img src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
|
||||
class="img-fluid" style="max-height: 60px; object-fit: contain;">
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -156,7 +156,7 @@
|
|||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if service.is_featured %}
|
||||
|
|
112
hub/services/templatetags/image_library.py
Normal file
112
hub/services/templatetags/image_library.py
Normal file
|
@ -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("<img {}/>", attr_string)
|
||||
|
||||
except ImageLibrary.DoesNotExist:
|
||||
# Return empty string or placeholder if image not found
|
||||
return format_html(
|
||||
'<img src="/static/images/placeholder.png" alt="Image not found" class="{}"/>',
|
||||
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",
|
||||
}
|
|
@ -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():
|
||||
|
|
243
hub/services/utils/image_library.py
Normal file
243
hub/services/utils/image_library.py
Normal file
|
@ -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
|
|
@ -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)
|
||||
return render(request, "services/article_detail.html", context)
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue