From 6083ab34ca284eec12533734483c95cbf46b9f80 Mon Sep 17 00:00:00 2001 From: "Juan M. Ley" Date: Mon, 6 Apr 2026 23:53:39 -0600 Subject: [PATCH] checking stuff --- src/infrastructure/adapters/file_storage.py | 154 ++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/infrastructure/adapters/file_storage.py diff --git a/src/infrastructure/adapters/file_storage.py b/src/infrastructure/adapters/file_storage.py new file mode 100644 index 0000000..3b97c7e --- /dev/null +++ b/src/infrastructure/adapters/file_storage.py @@ -0,0 +1,154 @@ +"""File storage utilities for report images""" +import os +import logging +from pathlib import Path +from fastapi import UploadFile, HTTPException, status +from PIL import Image +from io import BytesIO +from core.config import ConfSettings + +logger = logging.getLogger(__name__) + + +class ImageStorageManager: + """Maneja almacenamiento, compresión y eliminación de imágenes de reportes""" + + def __init__(self): + self.storage_path = Path(ConfSettings.storage_base_path) / ConfSettings.images_dir + self.max_size_bytes = ConfSettings.images_max_size_mb * 1024 * 1024 + self.allowed_types = ConfSettings.images_allowed_types + self.compression_quality = ConfSettings.images_compression_quality + + # Crear directorio si no existe + self.storage_path.mkdir(parents=True, exist_ok=True) + logger.info(f"ImageStorageManager initialized with path: {self.storage_path}") + + def validate_and_save_image(self, file: UploadFile, report_id: str) -> str: + """ + Valida y guarda una imagen, comprimiendo a WebP. + + Args: + file: Archivo subido (UploadFile) + report_id: ID del reporte para nombrado del archivo + + Returns: + Nombre del archivo guardado (sin ruta) + + Raises: + HTTPException: Si hay error en validación o guardado + """ + try: + # Validar tipo MIME + if file.content_type not in self.allowed_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Tipo de archivo no permitido. Permitidos: {', '.join(self.allowed_types)}" + ) + + # Leer contenido + content = file.file.read() + + # Validar tamaño + if len(content) > self.max_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Archivo demasiado grande. Máximo: {ConfSettings.images_max_size_mb}MB" + ) + + # Abrir imagen con Pillow + try: + image = Image.open(BytesIO(content)) + image.verify() # Verificar que sea una imagen válida + + # Reabrir después de verify() que la cierra + image = Image.open(BytesIO(content)) + + # Convertir a RGB si tiene canal alpha (RGBA) + if image.mode in ('RGBA', 'LA', 'P'): + rgb_image = Image.new('RGB', image.size, (255, 255, 255)) + rgb_image.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + image = rgb_image + + except Exception as e: + logger.error(f"Error validating image: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Archivo no es una imagen válida" + ) + + # Guardar como WebP comprimido + filename = f"{report_id}.webp" + filepath = self.storage_path / filename + + try: + image.save( + filepath, + "WEBP", + quality=self.compression_quality, + method=6 + ) + logger.info(f"Image saved: {filename} ({len(open(filepath, 'rb').read())} bytes)") + return filename + + except Exception as e: + logger.error(f"Error saving image to disk: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error al guardar la imagen" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected error processing image: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error procesando la imagen" + ) + + def get_image_path(self, filename: str) -> Path: + """Obtiene la ruta completa de una imagen""" + return self.storage_path / filename + + def image_exists(self, filename: str) -> bool: + """Verifica si una imagen existe""" + if not filename: + return False + filepath = self.get_image_path(filename) + return filepath.exists() + + def delete_image(self, filename: str) -> bool: + """ + Elimina una imagen del almacenamiento. + + Args: + filename: Nombre del archivo a eliminar + + Returns: + True si se eliminó, False si no existía + """ + if not filename: + return False + + filepath = self.get_image_path(filename) + try: + if filepath.exists(): + filepath.unlink() # Eliminar archivo + logger.info(f"Image deleted: {filename}") + return True + else: + logger.warning(f"Image not found for deletion: {filename}") + return False + except Exception as e: + logger.error(f"Error deleting image {filename}: {e}") + return False + + def get_image_url(self, filename: str) -> str: + """Genera la URL pública para una imagen""" + if not filename: + return None + return f"/images/{filename}" + + +# Instancia global +image_storage = ImageStorageManager()