añadido los estados de reporte

This commit is contained in:
2026-04-19 19:08:43 -06:00
parent 6083ab34ca
commit 30efa0e098
17 changed files with 1173 additions and 1047 deletions

View File

@@ -1,154 +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()
"""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()

View File

@@ -23,6 +23,7 @@ class ReportRepositoryMongo(ReportRepository):
"lng": report.lng,
"image_filename": report.image_filename,
"visibilidad": report.visibilidad,
"estado": report.estado,
"fecha_creacion": report.fecha_creacion or datetime.utcnow()
}
result = self.collection.insert_one(report_dict)
@@ -59,6 +60,13 @@ class ReportRepositoryMongo(ReportRepository):
{"$set": {"visibilidad": new_visibility}}
)
def update_estado(self, report_id: str, new_estado: str) -> None:
"""Actualiza el estado de un reporte"""
self.collection.update_one(
{"id_reporte": report_id},
{"$set": {"estado": new_estado}}
)
def delete(self, report_id: str) -> bool:
"""Elimina un reporte"""
result = self.collection.delete_one({"id_reporte": report_id})
@@ -83,5 +91,6 @@ class ReportRepositoryMongo(ReportRepository):
lng=doc.get("lng"),
image_filename=doc.get("image_filename"),
visibilidad=doc.get("visibilidad"),
estado=doc.get("estado", "en proceso"),
fecha_creacion=doc.get("fecha_creacion")
)

View File

@@ -1 +1 @@
"""RabbitMQ adapters for message publishing and consuming"""
"""RabbitMQ adapters for message publishing and consuming"""

View File

@@ -1,58 +1,58 @@
import pika
import json
from typing import Callable, Dict, Any
import logging
from sqlalchemy.exc import IntegrityError
logger = logging.getLogger(__name__)
class RabbitMQConsumer:
def __init__(self, queue_name: str, host: str = 'localhost', port: int = 5672):
self.queue_name = queue_name
self.host = host
self.port = port
self.callback = None
def set_callback(self, callback: Callable[[Dict[str, Any]], None]) -> None:
self.callback = callback
def start_consuming(self) -> None:
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=self.host, port=self.port)
)
channel = connection.channel()
channel.queue_declare(queue=self.queue_name, durable=True)
def callback_wrapper(ch, method, properties, body):
try:
message = json.loads(body.decode('utf-8'))
logger.info(f"Received message from queue '{self.queue_name}': {message}")
if self.callback:
self.callback(message)
ch.basic_ack(delivery_tag=method.delivery_tag)
except IntegrityError as e:
# Error de negocio: no tiene sentido reintentar
logger.warning(f"Business error, discarding message: {e}")
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
except Exception as e:
# Error transitorio (red, DB caída): sí puede resolverse solo
logger.error(f"Transient error processing message: {e}")
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
channel.basic_consume(
queue=self.queue_name,
on_message_callback=callback_wrapper,
auto_ack=False
)
logger.info(f"[*] Waiting for messages in queue '{self.queue_name}'. Ctrl+C to exit.")
channel.start_consuming()
except Exception as e:
logger.error(f"Error in consumer: {e}")
import pika
import json
from typing import Callable, Dict, Any
import logging
from sqlalchemy.exc import IntegrityError
logger = logging.getLogger(__name__)
class RabbitMQConsumer:
def __init__(self, queue_name: str, host: str = 'localhost', port: int = 5672):
self.queue_name = queue_name
self.host = host
self.port = port
self.callback = None
def set_callback(self, callback: Callable[[Dict[str, Any]], None]) -> None:
self.callback = callback
def start_consuming(self) -> None:
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=self.host, port=self.port)
)
channel = connection.channel()
channel.queue_declare(queue=self.queue_name, durable=True)
def callback_wrapper(ch, method, properties, body):
try:
message = json.loads(body.decode('utf-8'))
logger.info(f"Received message from queue '{self.queue_name}': {message}")
if self.callback:
self.callback(message)
ch.basic_ack(delivery_tag=method.delivery_tag)
except IntegrityError as e:
# Error de negocio: no tiene sentido reintentar
logger.warning(f"Business error, discarding message: {e}")
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
except Exception as e:
# Error transitorio (red, DB caída): sí puede resolverse solo
logger.error(f"Transient error processing message: {e}")
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
channel.basic_consume(
queue=self.queue_name,
on_message_callback=callback_wrapper,
auto_ack=False
)
logger.info(f"[*] Waiting for messages in queue '{self.queue_name}'. Ctrl+C to exit.")
channel.start_consuming()
except Exception as e:
logger.error(f"Error in consumer: {e}")
raise

View File

@@ -1,85 +1,86 @@
"""Message schemas for RabbitMQ communication"""
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Optional
from enum import Enum
import json
class UserEventType(str, Enum):
"""Types of user events"""
CREATE = "user.create"
UPDATE = "user.update"
DELETE = "user.delete"
class ReportEventType(str, Enum):
"""Types of report events"""
CREATE = "report.create"
UPDATE_VISIBILITY = "report.update_visibility"
DELETE = "report.delete"
@dataclass
class UserMessage:
"""Message for user events"""
event_type: UserEventType
user_id: Optional[int] = None
nombre: Optional[str] = None
apellido: Optional[str] = None
email: Optional[str] = None
fecha_nacimiento: Optional[str] = None # ISO format datetime string
fecha_creacion: Optional[str] = None # ISO format datetime string
calificacion: Optional[float] = None
numero_reportes: Optional[int] = None
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None
def to_dict(self):
"""Convert to dictionary"""
data = asdict(self)
data['event_type'] = self.event_type.value
return data
def to_json(self) -> str:
"""Convert to JSON string"""
return json.dumps(self.to_dict())
@staticmethod
def from_dict(data: dict) -> 'UserMessage':
"""Create from dictionary"""
data['event_type'] = UserEventType(data['event_type'])
return UserMessage(**data)
@dataclass
class ReportMessage:
"""Message for report events"""
event_type: ReportEventType
id_reporte: Optional[str] = None
id_usuario: Optional[int] = None
tipo_reporte: Optional[int] = None
descripcion: Optional[str] = None
ubicacion: Optional[str] = None
lat: Optional[float] = None
lng: Optional[float] = None
image_filename: Optional[str] = None # Nombre del archivo de imagen guardado
visibilidad: Optional[float] = None
fecha_creacion: Optional[str] = None # ISO format datetime string
penalize_author: Optional[bool] = None # For update_visibility event
def to_dict(self):
"""Convert to dictionary"""
data = asdict(self)
data['event_type'] = self.event_type.value
return data
def to_json(self) -> str:
"""Convert to JSON string"""
return json.dumps(self.to_dict())
@staticmethod
def from_dict(data: dict) -> 'ReportMessage':
"""Create from dictionary"""
data['event_type'] = ReportEventType(data['event_type'])
return ReportMessage(**data)
"""Message schemas for RabbitMQ communication"""
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Optional
from enum import Enum
import json
class UserEventType(str, Enum):
"""Types of user events"""
CREATE = "user.create"
UPDATE = "user.update"
DELETE = "user.delete"
class ReportEventType(str, Enum):
"""Types of report events"""
CREATE = "report.create"
UPDATE_VISIBILITY = "report.update_visibility"
DELETE = "report.delete"
@dataclass
class UserMessage:
"""Message for user events"""
event_type: UserEventType
user_id: Optional[int] = None
nombre: Optional[str] = None
apellido: Optional[str] = None
email: Optional[str] = None
fecha_nacimiento: Optional[str] = None # ISO format datetime string
fecha_creacion: Optional[str] = None # ISO format datetime string
calificacion: Optional[float] = None
numero_reportes: Optional[int] = None
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None
def to_dict(self):
"""Convert to dictionary"""
data = asdict(self)
data['event_type'] = self.event_type.value
return data
def to_json(self) -> str:
"""Convert to JSON string"""
return json.dumps(self.to_dict())
@staticmethod
def from_dict(data: dict) -> 'UserMessage':
"""Create from dictionary"""
data['event_type'] = UserEventType(data['event_type'])
return UserMessage(**data)
@dataclass
class ReportMessage:
"""Message for report events"""
event_type: ReportEventType
id_reporte: Optional[str] = None
id_usuario: Optional[int] = None
tipo_reporte: Optional[int] = None
descripcion: Optional[str] = None
ubicacion: Optional[str] = None
lat: Optional[float] = None
lng: Optional[float] = None
image_filename: Optional[str] = None # Nombre del archivo de imagen guardado
visibilidad: Optional[float] = None
estado: Optional[str] = None # Estado del reporte: "en proceso", "no resuelto", "resuelto"
fecha_creacion: Optional[str] = None # ISO format datetime string
penalize_author: Optional[bool] = None # For update_visibility event
def to_dict(self):
"""Convert to dictionary"""
data = asdict(self)
data['event_type'] = self.event_type.value
return data
def to_json(self) -> str:
"""Convert to JSON string"""
return json.dumps(self.to_dict())
@staticmethod
def from_dict(data: dict) -> 'ReportMessage':
"""Create from dictionary"""
data['event_type'] = ReportEventType(data['event_type'])
return ReportMessage(**data)

View File

@@ -1,74 +1,74 @@
"""RabbitMQ message sender"""
import pika
import json
from typing import Any, Dict
import logging
logger = logging.getLogger(__name__)
class RabbitMQSender:
"""Generic RabbitMQ sender for publishing messages to queues"""
def __init__(self, host: str = 'localhost', port: int = 5672):
self.host = host
self.port = port
def send_message(self, queue_name: str, message: Dict[str, Any]) -> bool:
"""
Sends a message to a RabbitMQ queue
Args:
queue_name: Name of the queue to send to
message: Dictionary containing the message data
Returns:
True if successful, False otherwise
"""
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=self.host, port=self.port)
)
channel = connection.channel()
# Declare queue to ensure it exists
channel.queue_declare(queue=queue_name, durable=True)
# Convert message to JSON
message_json = json.dumps(message)
# Publish the message
channel.basic_publish(
exchange='',
routing_key=queue_name,
body=message_json,
properties=pika.BasicProperties(
delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE
)
)
connection.close()
logger.info(f"Message sent to queue '{queue_name}': {message_json}")
return True
except Exception as e:
logger.error(f"Error sending message to RabbitMQ: {e}")
return False
def send_to_queue(queue_name: str, message: Dict[str, Any],
host: str = 'localhost', port: int = 5672) -> bool:
"""
Convenience function to send a message to RabbitMQ
Args:
queue_name: Name of the queue
message: Message dictionary
host: RabbitMQ host
port: RabbitMQ port
Returns:
True if successful, False otherwise
"""
sender = RabbitMQSender(host=host, port=port)
return sender.send_message(queue_name, message)
"""RabbitMQ message sender"""
import pika
import json
from typing import Any, Dict
import logging
logger = logging.getLogger(__name__)
class RabbitMQSender:
"""Generic RabbitMQ sender for publishing messages to queues"""
def __init__(self, host: str = 'localhost', port: int = 5672):
self.host = host
self.port = port
def send_message(self, queue_name: str, message: Dict[str, Any]) -> bool:
"""
Sends a message to a RabbitMQ queue
Args:
queue_name: Name of the queue to send to
message: Dictionary containing the message data
Returns:
True if successful, False otherwise
"""
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=self.host, port=self.port)
)
channel = connection.channel()
# Declare queue to ensure it exists
channel.queue_declare(queue=queue_name, durable=True)
# Convert message to JSON
message_json = json.dumps(message)
# Publish the message
channel.basic_publish(
exchange='',
routing_key=queue_name,
body=message_json,
properties=pika.BasicProperties(
delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE
)
)
connection.close()
logger.info(f"Message sent to queue '{queue_name}': {message_json}")
return True
except Exception as e:
logger.error(f"Error sending message to RabbitMQ: {e}")
return False
def send_to_queue(queue_name: str, message: Dict[str, Any],
host: str = 'localhost', port: int = 5672) -> bool:
"""
Convenience function to send a message to RabbitMQ
Args:
queue_name: Name of the queue
message: Message dictionary
host: RabbitMQ host
port: RabbitMQ port
Returns:
True if successful, False otherwise
"""
sender = RabbitMQSender(host=host, port=port)
return sender.send_message(queue_name, message)