import uuid from django.db import models from django.utils import timezone from django.core.files.base import ContentFile from PIL import Image as PilImage from io import BytesIO import os class BaseModel(models.Model): """Abstract base model for all models in the application""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created_at = models.DateTimeField(default=timezone.now, editable=False) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True get_latest_by = 'created_at' class Contact(BaseModel): """Represents a contact person with their details""" first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) phone = models.CharField(max_length=20, blank=True, null=True, verbose_name="Phone Number") class Meta: abstract = True def __str__(self): return f"{self.first_name} {self.last_name}" @property def full_name(self): return f"{self.first_name} {self.last_name}" class Address(BaseModel): """Represents a physical address""" street_address = models.CharField(max_length=255) city = models.CharField(max_length=100) state = models.CharField(max_length=100) zip_code = models.CharField(max_length=20) class Meta: abstract = True def __str__(self): return f"{self.street_address}, {self.city}, {self.state} {self.zip_code}" def _default_image_upload_to(instance: 'Image', filename: str) -> str: """ Default upload path for original images. Override by setting the IMAGE_UPLOAD_TO staticmethod on subclass if needed. """ base, ext = os.path.splitext(filename) ext = ext.lower() or ".jpg" model_dir = instance._meta.model_name return f"uploads/{model_dir}/{instance.id}/{uuid.uuid4().hex}{ext}" def _default_thumb_upload_to(instance: 'Image', _filename: str) -> str: """ Default upload path for thumbnails. """ model_dir = instance._meta.model_name return f"uploads/{model_dir}/{instance.id}/thumb/{uuid.uuid4().hex}.jpg" class Note(BaseModel): """ Abstract base model for notes/comments. Use this as a base for model-specific note types (e.g., ServiceNote, ProjectNote). """ content = models.TextField() author = models.ForeignKey( 'TeamProfile', on_delete=models.SET_NULL, null=True, blank=True, related_name="%(class)s_notes" ) internal = models.BooleanField( default=True, help_text="Internal notes are only visible to team members, not customers" ) class Meta: abstract = True ordering = ('-created_at',) def __str__(self): preview = self.content[:50] + "..." if len(self.content) > 50 else self.content author_name = self.author.full_name if self.author else "Unknown" return f"{author_name}: {preview}" class Image(BaseModel): """ Abstract base for image-bearing models. Features: - Stores original image and auto-generated JPEG thumbnail - Captures width/height and content_type - Tracks the uploading team profile (optional) - Storage-agnostic (respects DEFAULT_FILE_STORAGE) Customize by overriding: - THUMBNAIL_SIZE - IMAGE_UPLOAD_TO / THUMB_UPLOAD_TO (callables like Django's upload_to) """ title = models.CharField(max_length=255, blank=True) image = models.ImageField(upload_to=_default_image_upload_to) thumbnail = models.ImageField(upload_to=_default_thumb_upload_to, blank=True, null=True) content_type = models.CharField(max_length=100, blank=True) width = models.PositiveIntegerField(default=0) height = models.PositiveIntegerField(default=0) uploaded_by_team_profile = models.ForeignKey( 'TeamProfile', on_delete=models.SET_NULL, null=True, blank=True, related_name="%(class)s_images" ) notes = models.TextField(blank=True) internal = models.BooleanField(default=True) # Optional: subclasses can override these constants THUMBNAIL_SIZE = (320, 320) THUMBNAIL_JPEG_QUALITY = 85 # Optional: subclasses can provide their own upload_to callables IMAGE_UPLOAD_TO = staticmethod(_default_image_upload_to) THUMB_UPLOAD_TO = staticmethod(_default_thumb_upload_to) class Meta: abstract = True ordering = ('-created_at',) def __str__(self) -> str: return self.title or str(self.id) def _make_thumbnail(self) -> None: """ Generate a JPEG thumbnail, update width/height from the original. No-op if Pillow is unavailable. """ if not PilImage or not self.image: return self.image.open() with PilImage.open(self.image) as img: img = img.convert('RGB') self.width, self.height = img.size thumb = img.copy() thumb.thumbnail(self.THUMBNAIL_SIZE) buf = BytesIO() thumb.save(buf, format='JPEG', quality=self.THUMBNAIL_JPEG_QUALITY) buf.seek(0) # Name the thumb deterministically by the original basename to aid caching, # but still safe to reuse upload_to which can rename. original_basename = os.path.basename(self.image.name) thumb_name = f"thumb_{original_basename}.jpg" # Respect custom THUMB_UPLOAD_TO if the subclass overrides the field's upload_to # When saving directly to FieldFile, the field's upload_to is applied only if the name has no path. # So we provide just a name; storage/backend will place it using field's configured upload_to. self.thumbnail.save( name=thumb_name, content=ContentFile(buf.read()), save=False, ) def save(self, *args, **kwargs): """ Save then ensure a thumbnail exists and dimensions are set. On creation or when the thumbnail is missing, attempt to generate a thumbnail. """ creating = self._state.adding super().save(*args, **kwargs) if creating or (self.image and not self.thumbnail): try: self._make_thumbnail() except (PilImage.UnidentifiedImageError, OSError, ValueError): # If thumbnail generation fails (invalid image or I/O), keep the original image return else: # Persist the derived fields on successful thumbnail generation super().save(update_fields=['thumbnail', 'width', 'height']) def delete(self, *args, **kwargs): """ Delete the model and its associated files from storage. """ # Store file names before delete (after delete, fields may be cleared) image_name = self.image.name if self.image else None thumbnail_name = self.thumbnail.name if self.thumbnail else None # Delete the model instance super().delete(*args, **kwargs) # Delete files from storage if image_name: try: self.image.storage.delete(image_name) except Exception: pass # File may already be deleted or inaccessible if thumbnail_name: try: self.thumbnail.storage.delete(thumbnail_name) except Exception: pass # File may already be deleted or inaccessible