Files
orion/app/modules/messaging/services/message_attachment_service.py
Samir Boulahtit 4e28d91a78 refactor: migrate templates and static files to self-contained modules
Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:34:16 +01:00

226 lines
6.9 KiB
Python

# 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.modules.core.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()