refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories: - orders: 5 services, 4 models, order/invoice schemas - inventory: 3 services, 2 models, 30+ schemas - customers: 3 services, 2 models, customer schemas - messaging: 3 services, 2 models, message/notification schemas - monitoring: background_tasks_service - marketplace: 5+ services including letzshop submodule - dev_tools: code_quality_service, test_runner_service - billing: billing_service - contracts: definition.py Legacy files in app/services/, models/database/, models/schema/ now re-export from canonical module locations for backwards compatibility. Architecture validator passes with 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
225
app/modules/messaging/services/message_attachment_service.py
Normal file
225
app/modules/messaging/services/message_attachment_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# app/modules/messaging/services/message_attachment_service.py
|
||||
"""
|
||||
Attachment handling service for messaging system.
|
||||
|
||||
Handles file upload, validation, storage, and retrieval.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Allowed MIME types for attachments
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
# Documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
# Archives
|
||||
"application/zip",
|
||||
# Text
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
}
|
||||
|
||||
IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
|
||||
# Default max file size in MB
|
||||
DEFAULT_MAX_FILE_SIZE_MB = 10
|
||||
|
||||
|
||||
class MessageAttachmentService:
|
||||
"""Service for handling message attachments."""
|
||||
|
||||
def __init__(self, storage_base: str = "uploads/messages"):
|
||||
self.storage_base = storage_base
|
||||
|
||||
def get_max_file_size_bytes(self, db: Session) -> int:
|
||||
"""Get maximum file size from platform settings."""
|
||||
max_mb = admin_settings_service.get_setting_value(
|
||||
db,
|
||||
"message_attachment_max_size_mb",
|
||||
default=DEFAULT_MAX_FILE_SIZE_MB,
|
||||
)
|
||||
try:
|
||||
max_mb = int(max_mb)
|
||||
except (TypeError, ValueError):
|
||||
max_mb = DEFAULT_MAX_FILE_SIZE_MB
|
||||
return max_mb * 1024 * 1024 # Convert to bytes
|
||||
|
||||
def validate_file_type(self, mime_type: str) -> bool:
|
||||
"""Check if file type is allowed."""
|
||||
return mime_type in ALLOWED_MIME_TYPES
|
||||
|
||||
def is_image(self, mime_type: str) -> bool:
|
||||
"""Check if file is an image."""
|
||||
return mime_type in IMAGE_MIME_TYPES
|
||||
|
||||
async def validate_and_store(
|
||||
self,
|
||||
db: Session,
|
||||
file: UploadFile,
|
||||
conversation_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate and store an uploaded file.
|
||||
|
||||
Returns dict with file metadata for MessageAttachment creation.
|
||||
|
||||
Raises:
|
||||
ValueError: If file type or size is invalid
|
||||
"""
|
||||
# Validate MIME type
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
if not self.validate_file_type(content_type):
|
||||
raise ValueError(
|
||||
f"File type '{content_type}' not allowed. "
|
||||
"Allowed types: images (JPEG, PNG, GIF, WebP), "
|
||||
"PDF, Office documents, ZIP, text files."
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
max_size = self.get_max_file_size_bytes(db)
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"File size {file_size / 1024 / 1024:.1f}MB exceeds "
|
||||
f"maximum allowed size of {max_size / 1024 / 1024:.1f}MB"
|
||||
)
|
||||
|
||||
# Generate unique filename
|
||||
original_filename = file.filename or "attachment"
|
||||
ext = Path(original_filename).suffix.lower()
|
||||
unique_filename = f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
# Create storage path: uploads/messages/YYYY/MM/conversation_id/filename
|
||||
now = datetime.utcnow()
|
||||
relative_path = os.path.join(
|
||||
self.storage_base,
|
||||
str(now.year),
|
||||
f"{now.month:02d}",
|
||||
str(conversation_id),
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(relative_path, exist_ok=True)
|
||||
|
||||
# Full file path
|
||||
file_path = os.path.join(relative_path, unique_filename)
|
||||
|
||||
# Write file
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Prepare metadata
|
||||
is_image = self.is_image(content_type)
|
||||
metadata = {
|
||||
"filename": unique_filename,
|
||||
"original_filename": original_filename,
|
||||
"file_path": file_path,
|
||||
"file_size": file_size,
|
||||
"mime_type": content_type,
|
||||
"is_image": is_image,
|
||||
}
|
||||
|
||||
# Generate thumbnail for images
|
||||
if is_image:
|
||||
thumbnail_data = self._create_thumbnail(content, file_path)
|
||||
metadata.update(thumbnail_data)
|
||||
|
||||
logger.info(
|
||||
f"Stored attachment {unique_filename} for conversation {conversation_id} "
|
||||
f"({file_size} bytes, type: {content_type})"
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
def _create_thumbnail(self, content: bytes, original_path: str) -> dict:
|
||||
"""Create thumbnail for image attachments."""
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.open(io.BytesIO(content))
|
||||
width, height = img.size
|
||||
|
||||
# Create thumbnail
|
||||
img.thumbnail((200, 200))
|
||||
|
||||
thumb_path = original_path.replace(".", "_thumb.")
|
||||
img.save(thumb_path)
|
||||
|
||||
return {
|
||||
"image_width": width,
|
||||
"image_height": height,
|
||||
"thumbnail_path": thumb_path,
|
||||
}
|
||||
except ImportError:
|
||||
logger.warning("PIL not installed, skipping thumbnail generation")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create thumbnail: {e}")
|
||||
return {}
|
||||
|
||||
def delete_attachment(
|
||||
self, file_path: str, thumbnail_path: str | None = None
|
||||
) -> bool:
|
||||
"""Delete attachment files from storage."""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
logger.info(f"Deleted attachment file: {file_path}")
|
||||
|
||||
if thumbnail_path and os.path.exists(thumbnail_path):
|
||||
os.remove(thumbnail_path)
|
||||
logger.info(f"Deleted thumbnail: {thumbnail_path}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete attachment {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_download_url(self, file_path: str) -> str:
|
||||
"""
|
||||
Get download URL for an attachment.
|
||||
|
||||
For local storage, returns a relative path that can be served
|
||||
by the static file handler or a dedicated download endpoint.
|
||||
"""
|
||||
# Convert local path to URL path
|
||||
# Assumes files are served from /static/uploads or similar
|
||||
return f"/static/{file_path}"
|
||||
|
||||
def get_file_content(self, file_path: str) -> bytes | None:
|
||||
"""Read file content from storage."""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, "rb") as f:
|
||||
return f.read()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
message_attachment_service = MessageAttachmentService()
|
||||
Reference in New Issue
Block a user