website/hub/services/models/images.py

320 lines
11 KiB
Python

import os
import mimetypes
import xml.etree.ElementTree as ET
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_or_svg
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.FileField(
upload_to=get_image_upload_path,
validators=[validate_image_or_svg],
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
)
# 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 file size
self.file_size = self.image.size
# Check if it's an SVG file
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
if filename.endswith(".svg") or mime_type == "image/svg+xml":
# For SVG files, try to extract dimensions from the SVG content
try:
with open(self.image.path, "r", encoding="utf-8") as f:
content = f.read()
# Parse the SVG to extract width and height
root = ET.fromstring(content)
# Get width and height attributes
width = root.get("width")
height = root.get("height")
# Extract numeric values if they exist
if width and height:
# Remove units like 'px', 'em', etc. and convert to int
try:
width_val = int(
float(
width.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
height_val = int(
float(
height.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
self.width = width_val
self.height = height_val
except (ValueError, TypeError):
# If we can't parse dimensions, try viewBox
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
# Default SVG dimensions if we can't parse
self.width = 100
self.height = 100
else:
# Check for viewBox if width/height attributes don't exist
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
self.width = 100
self.height = 100
else:
# Default SVG dimensions
self.width = 100
self.height = 100
except (ET.ParseError, FileNotFoundError, UnicodeDecodeError):
# If SVG parsing fails, set default dimensions
self.width = 100
self.height = 100
else:
# For raster images, use PIL
with PILImage.open(self.image.path) as img:
self.width = img.width
self.height = img.height
# 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"])
def is_svg(self):
"""
Check if the uploaded file is an SVG.
"""
if not self.image:
return False
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
return filename.endswith(".svg") or mime_type == "image/svg+xml"
def get_mime_type(self):
"""
Return the MIME type of the image file.
"""
if not self.image:
return None
mime_type, _ = mimetypes.guess_type(self.image.name)
return mime_type
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)