215 lines
7.3 KiB
Python
215 lines
7.3 KiB
Python
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
|