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 .base import *
|
||||
from .content import *
|
||||
from .images import *
|
||||
from .leads import *
|
||||
from .pricing 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 .base import *
|
||||
from .content import *
|
||||
from .images import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
|
|
|
@ -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):
|
||||
|
|
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,
|
||||
},
|
||||
{"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