nexus-5/core/models/base.py
2026-01-26 11:09:40 -05:00

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