image library
created using VS Codey Copilot Agent with Claude Sonnet 4
This commit is contained in:
parent
bdf06863d2
commit
52dbe89582
14 changed files with 1366 additions and 3 deletions
|
@ -4,6 +4,7 @@
|
||||||
from .articles import *
|
from .articles import *
|
||||||
from .base import *
|
from .base import *
|
||||||
from .content import *
|
from .content import *
|
||||||
|
from .images import *
|
||||||
from .leads import *
|
from .leads import *
|
||||||
from .pricing import *
|
from .pricing import *
|
||||||
from .providers import *
|
from .providers import *
|
||||||
|
|
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",)}
|
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()})"
|
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)"
|
||||||
|
)
|
||||||
|
)
|
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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,6 +1,7 @@
|
||||||
from .articles import *
|
from .articles import *
|
||||||
from .base import *
|
from .base import *
|
||||||
from .content import *
|
from .content import *
|
||||||
|
from .images import *
|
||||||
from .leads import *
|
from .leads import *
|
||||||
from .pricing import *
|
from .pricing import *
|
||||||
from .providers import *
|
from .providers import *
|
||||||
|
|
|
@ -4,10 +4,10 @@ from django.utils.text import slugify
|
||||||
from django_prose_editor.fields import ProseEditorField
|
from django_prose_editor.fields import ProseEditorField
|
||||||
|
|
||||||
|
|
||||||
def validate_image_size(value):
|
def validate_image_size(value, mb=1):
|
||||||
filesize = value.size
|
filesize = value.size
|
||||||
if filesize > 1 * 1024 * 1024: # 1MB
|
if filesize > mb * 1024 * 1024:
|
||||||
raise ValidationError("Maximum file size is 1MB")
|
raise ValidationError(f"Maximum file size is {mb} MB")
|
||||||
|
|
||||||
|
|
||||||
class Currency(models.TextChoices):
|
class Currency(models.TextChoices):
|
||||||
|
|
224
hub/services/models/images.py
Normal file
224
hub/services/models/images.py
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
from .base import validate_image_size
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_upload_path(instance, filename):
|
||||||
|
"""
|
||||||
|
Generate upload path for images based on the image library structure.
|
||||||
|
"""
|
||||||
|
return f"image_library/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageLibrary(models.Model):
|
||||||
|
"""
|
||||||
|
Generic image library model that can be referenced by other models
|
||||||
|
to avoid duplicate uploads and provide centralized image management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Image metadata
|
||||||
|
name = models.CharField(max_length=200, help_text="Descriptive name for the image")
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=250, unique=True, help_text="URL-friendly version of the name"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True, help_text="Optional description of the image"
|
||||||
|
)
|
||||||
|
alt_text = models.CharField(
|
||||||
|
max_length=255, help_text="Alternative text for accessibility"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image file
|
||||||
|
image = models.ImageField(
|
||||||
|
upload_to=get_image_upload_path,
|
||||||
|
validators=[validate_image_size],
|
||||||
|
help_text="Upload image file (max 1MB)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image properties (automatically populated)
|
||||||
|
width = models.PositiveIntegerField(
|
||||||
|
null=True, blank=True, help_text="Image width in pixels"
|
||||||
|
)
|
||||||
|
height = models.PositiveIntegerField(
|
||||||
|
null=True, blank=True, help_text="Image height in pixels"
|
||||||
|
)
|
||||||
|
file_size = models.PositiveIntegerField(
|
||||||
|
null=True, blank=True, help_text="File size in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Categorization
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
("logo", "Logo"),
|
||||||
|
("article", "Article Image"),
|
||||||
|
("banner", "Banner"),
|
||||||
|
("icon", "Icon"),
|
||||||
|
("screenshot", "Screenshot"),
|
||||||
|
("photo", "Photo"),
|
||||||
|
("other", "Other"),
|
||||||
|
]
|
||||||
|
category = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=CATEGORY_CHOICES,
|
||||||
|
default="other",
|
||||||
|
help_text="Category of the image",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tags for easier searching
|
||||||
|
tags = models.CharField(
|
||||||
|
max_length=500, blank=True, help_text="Comma-separated tags for searching"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="User who uploaded the image",
|
||||||
|
)
|
||||||
|
uploaded_at = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="Date and time when image was uploaded"
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True, help_text="Date and time when image was last updated"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage tracking
|
||||||
|
usage_count = models.PositiveIntegerField(
|
||||||
|
default=0, help_text="Number of times this image is referenced"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-uploaded_at"]
|
||||||
|
verbose_name = "Image"
|
||||||
|
verbose_name_plural = "Image Library"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override save to automatically populate image properties and slug.
|
||||||
|
"""
|
||||||
|
# Generate slug if not provided
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
|
||||||
|
# Save the model first to get the image file
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Update image properties if image exists
|
||||||
|
if self.image:
|
||||||
|
self._update_image_properties()
|
||||||
|
|
||||||
|
def _update_image_properties(self):
|
||||||
|
"""
|
||||||
|
Update image properties like width, height, and file size.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get image dimensions
|
||||||
|
with PILImage.open(self.image.path) as img:
|
||||||
|
self.width = img.width
|
||||||
|
self.height = img.height
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
self.file_size = self.image.size
|
||||||
|
|
||||||
|
# Save without calling the full save method to avoid recursion
|
||||||
|
ImageLibrary.objects.filter(pk=self.pk).update(
|
||||||
|
width=self.width, height=self.height, file_size=self.file_size
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the save
|
||||||
|
print(f"Error updating image properties: {e}")
|
||||||
|
|
||||||
|
def get_file_size_display(self):
|
||||||
|
"""
|
||||||
|
Return human-readable file size.
|
||||||
|
"""
|
||||||
|
if not self.file_size:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
size = self.file_size
|
||||||
|
for unit in ["B", "KB", "MB", "GB"]:
|
||||||
|
if size < 1024.0:
|
||||||
|
return f"{size:.1f} {unit}"
|
||||||
|
size /= 1024.0
|
||||||
|
return f"{size:.1f} TB"
|
||||||
|
|
||||||
|
def get_tags_list(self):
|
||||||
|
"""
|
||||||
|
Return tags as a list.
|
||||||
|
"""
|
||||||
|
if not self.tags:
|
||||||
|
return []
|
||||||
|
return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
|
||||||
|
|
||||||
|
def increment_usage(self):
|
||||||
|
"""
|
||||||
|
Increment usage count when image is referenced.
|
||||||
|
"""
|
||||||
|
self.usage_count += 1
|
||||||
|
self.save(update_fields=["usage_count"])
|
||||||
|
|
||||||
|
def decrement_usage(self):
|
||||||
|
"""
|
||||||
|
Decrement usage count when reference is removed.
|
||||||
|
"""
|
||||||
|
if self.usage_count > 0:
|
||||||
|
self.usage_count -= 1
|
||||||
|
self.save(update_fields=["usage_count"])
|
||||||
|
|
||||||
|
|
||||||
|
class ImageReference(models.Model):
|
||||||
|
"""
|
||||||
|
Abstract base class for models that want to reference images from the library.
|
||||||
|
This helps track usage and provides a consistent interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
image = models.ForeignKey(
|
||||||
|
ImageLibrary,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Select an image from the library",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override save to update usage count.
|
||||||
|
"""
|
||||||
|
# Track if image changed
|
||||||
|
old_image = None
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old_instance = self.__class__.objects.get(pk=self.pk)
|
||||||
|
old_image = old_instance.image
|
||||||
|
except self.__class__.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Update usage counts
|
||||||
|
if old_image and old_image != self.image:
|
||||||
|
old_image.decrement_usage()
|
||||||
|
|
||||||
|
if self.image and self.image != old_image:
|
||||||
|
self.image.increment_usage()
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override delete to update usage count.
|
||||||
|
"""
|
||||||
|
if self.image:
|
||||||
|
self.image.decrement_usage()
|
||||||
|
super().delete(*args, **kwargs)
|
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;
|
||||||
|
}
|
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",
|
||||||
|
}
|
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
|
|
@ -245,6 +245,7 @@ JAZZMIN_SETTINGS = {
|
||||||
"new_window": True,
|
"new_window": True,
|
||||||
},
|
},
|
||||||
{"name": "Articles", "url": "/admin/services/article/"},
|
{"name": "Articles", "url": "/admin/services/article/"},
|
||||||
|
{"name": "Image Library", "url": "/admin/services/imagelibrary/"},
|
||||||
{"name": "FAQs", "url": "/admin/services/websitefaq/"},
|
{"name": "FAQs", "url": "/admin/services/websitefaq/"},
|
||||||
],
|
],
|
||||||
"show_sidebar": True,
|
"show_sidebar": True,
|
||||||
|
@ -257,6 +258,7 @@ JAZZMIN_SETTINGS = {
|
||||||
"services.VSHNAppCatAddon": "single",
|
"services.VSHNAppCatAddon": "single",
|
||||||
"services.ServiceOffering": "single",
|
"services.ServiceOffering": "single",
|
||||||
"services.Plan": "single",
|
"services.Plan": "single",
|
||||||
|
"services.ImageLibrary": "single",
|
||||||
},
|
},
|
||||||
"related_modal_active": True,
|
"related_modal_active": True,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue