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)