From f812d4a664fa8b5ee60cabb5591e44d28de06e74 Mon Sep 17 00:00:00 2001 From: "Juan M. Ley" Date: Mon, 6 Apr 2026 23:48:19 -0600 Subject: [PATCH] Added stuffies to reports, like images and coordinate based geolocation --- requirements.txt | 1 + src/application/services/report_services.py | 6 +- src/consumers/report_consumer.py | 40 +++++++++++- src/core/config.py | 22 +++++++ src/domain/reports.py | 5 +- .../persistence/report_repository_mongo.py | 6 ++ .../adapters/rabbitmq/messages.py | 3 + src/infrastructure/api/reports/app.py | 8 +++ src/infrastructure/api/reports/reports.py | 61 ++++++++++++++++--- src/infrastructure/api/reports/schemas.py | 9 ++- 10 files changed, 147 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index c76ee35..2ae592f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pydantic-settings pymongo python-dotenv pika +Pillow diff --git a/src/application/services/report_services.py b/src/application/services/report_services.py index ad892d7..67c91fa 100644 --- a/src/application/services/report_services.py +++ b/src/application/services/report_services.py @@ -18,7 +18,8 @@ class CreateReport: self.user_repo = user_repo def execute(self, id_usuario: int, tipo_reporte: int, descripcion: str, - ubicacion: Optional[str] = None) -> Dict[str, Any]: + ubicacion: Optional[str] = None, lat: Optional[float] = None, + lng: Optional[float] = None, image_filename: Optional[str] = None) -> Dict[str, Any]: """ Sends a create report message to RabbitMQ. Valida previamente: @@ -68,6 +69,9 @@ class CreateReport: tipo_reporte=tipo_reporte, descripcion=descripcion.strip(), ubicacion=ubicacion, + lat=lat, + lng=lng, + image_filename=image_filename, visibilidad=50.0, # Visibilidad inicial neutral fecha_creacion=fecha_creacion.isoformat() ) diff --git a/src/consumers/report_consumer.py b/src/consumers/report_consumer.py index 318fb38..149aaf3 100644 --- a/src/consumers/report_consumer.py +++ b/src/consumers/report_consumer.py @@ -3,6 +3,8 @@ import sys import os import logging from datetime import datetime +from pathlib import Path +from shutil import move # Add src to path to import modules sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) @@ -11,7 +13,9 @@ from infrastructure.adapters.rabbitmq.consumer import RabbitMQConsumer from infrastructure.adapters.rabbitmq.messages import ReportMessage, ReportEventType from infrastructure.adapters.persistence.report_repository_mongo import ReportRepositoryMongo from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL +from infrastructure.adapters.file_storage import image_storage from domain.reports import Report +from core.config import ConfSettings # Set up logging logging.basicConfig( @@ -64,6 +68,25 @@ class ReportConsumer: # Parse datetime string fecha_creacion = datetime.fromisoformat(message.fecha_creacion) + # Renombrar imagen temporal si existe + final_image_filename = None + if message.image_filename: + try: + # Renombrar de temp_userid_type a report_id + temp_path = image_storage.get_image_path(message.image_filename) + final_filename = f"{message.id_reporte}.webp" + final_path = image_storage.get_image_path(final_filename) + + if temp_path.exists(): + move(str(temp_path), str(final_path)) + final_image_filename = final_filename + logger.info(f"Image renamed from {message.image_filename} to {final_filename}") + else: + logger.warning(f"Temporary image not found: {message.image_filename}") + except Exception as img_error: + logger.error(f"Error renaming image: {img_error}", exc_info=True) + # Continuar sin imagen si falla el renombramiento + # Create Report domain object report = Report( id_reporte=message.id_reporte, @@ -71,6 +94,9 @@ class ReportConsumer: tipo_reporte=message.tipo_reporte, descripcion=message.descripcion, ubicacion=message.ubicacion, + lat=message.lat, + lng=message.lng, + image_filename=final_image_filename, visibilidad=message.visibilidad, fecha_creacion=fecha_creacion ) @@ -146,9 +172,21 @@ class ReportConsumer: try: logger.info(f"Deleting report: {message.id_reporte}") + # Obtener reportepara acceder a image_filename antes de eliminarlo + report = self.repo.find_by_id(message.id_reporte) + + # Eliminar del MongoDB success = self.repo.delete(message.id_reporte) if success: - logger.info(f"Report deleted successfully: {message.id_reporte}") + logger.info(f"Report deleted successfully from MongoDB: {message.id_reporte}") + + # Eliminar imagen del almacenamiento + if report and report.image_filename: + deleted = image_storage.delete_image(report.image_filename) + if deleted: + logger.info(f"Image deleted: {report.image_filename}") + else: + logger.warning(f"Could not delete image: {report.image_filename}") else: logger.warning(f"Failed to delete report: {message.id_reporte}") diff --git a/src/core/config.py b/src/core/config.py index 06c7386..bb1ee3e 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -35,6 +35,28 @@ class Settings(BaseSettings): host: str = "0.0.0.0" log_level: str = "info" + # Almacenamiento de archivos + storage_base_path: str = Field( + default=os.getenv("STORAGE_BASE_PATH", os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "storage")), + description="Ruta base para almacenamiento de archivos" + ) + images_dir: str = Field( + default="reports_images", + description="Directorio para imágenes de reportes (dentro de storage_base_path)" + ) + images_max_size_mb: int = Field( + default=int(os.getenv("IMAGES_MAX_SIZE_MB", 4)), + description="Tamaño máximo de imagen en MB" + ) + images_allowed_types: list = Field( + default=["image/jpeg", "image/png", "image/webp"], + description="Tipos MIME permitidos para imágenes" + ) + images_compression_quality: int = Field( + default=int(os.getenv("IMAGES_COMPRESSION_QUALITY", 80)), + description="Calidad de compresión WebP (0-100)" + ) + class Config: env_file = ".env" case_sensitive = False diff --git a/src/domain/reports.py b/src/domain/reports.py index 5b21ea6..5e460ae 100644 --- a/src/domain/reports.py +++ b/src/domain/reports.py @@ -10,5 +10,8 @@ class Report: tipo_reporte: int # Número que representa el tipo descripcion: str ubicacion: Optional[str] - visibilidad: float # 0-100 (puntuación comunitaria) + lat: Optional[float] = None + lng: Optional[float] = None + image_filename: Optional[str] = None # Nombre del archivo de imagen WebP + visibilidad: float = 0.0 # 0-100 (puntuación comunitaria) fecha_creacion: Optional[datetime] = None diff --git a/src/infrastructure/adapters/persistence/report_repository_mongo.py b/src/infrastructure/adapters/persistence/report_repository_mongo.py index 56b6f1c..9be65ac 100644 --- a/src/infrastructure/adapters/persistence/report_repository_mongo.py +++ b/src/infrastructure/adapters/persistence/report_repository_mongo.py @@ -19,6 +19,9 @@ class ReportRepositoryMongo(ReportRepository): "tipo_reporte": report.tipo_reporte, "descripcion": report.descripcion, "ubicacion": report.ubicacion, + "lat": report.lat, + "lng": report.lng, + "image_filename": report.image_filename, "visibilidad": report.visibilidad, "fecha_creacion": report.fecha_creacion or datetime.utcnow() } @@ -76,6 +79,9 @@ class ReportRepositoryMongo(ReportRepository): tipo_reporte=doc.get("tipo_reporte"), descripcion=doc.get("descripcion"), ubicacion=doc.get("ubicacion"), + lat=doc.get("lat"), + lng=doc.get("lng"), + image_filename=doc.get("image_filename"), visibilidad=doc.get("visibilidad"), fecha_creacion=doc.get("fecha_creacion") ) diff --git a/src/infrastructure/adapters/rabbitmq/messages.py b/src/infrastructure/adapters/rabbitmq/messages.py index 21775ae..e5d2420 100644 --- a/src/infrastructure/adapters/rabbitmq/messages.py +++ b/src/infrastructure/adapters/rabbitmq/messages.py @@ -61,6 +61,9 @@ class ReportMessage: 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 diff --git a/src/infrastructure/api/reports/app.py b/src/infrastructure/api/reports/app.py index e3ed332..1ac53c2 100644 --- a/src/infrastructure/api/reports/app.py +++ b/src/infrastructure/api/reports/app.py @@ -1,4 +1,6 @@ from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from pathlib import Path from core.config import ConfSettings from infrastructure.api.reports.router import router @@ -10,4 +12,10 @@ def create_app() -> FastAPI: description="Microservicio de gestión de reportes comunitarios" ) app.include_router(router) + + # Montar directorio de almacenamiento de imágenes como rutas estáticas + images_path = Path(ConfSettings.storage_base_path) / ConfSettings.images_dir + images_path.mkdir(parents=True, exist_ok=True) + app.mount("/images", StaticFiles(directory=str(images_path)), name="images") + return app diff --git a/src/infrastructure/api/reports/reports.py b/src/infrastructure/api/reports/reports.py index 3b1c266..125ca97 100644 --- a/src/infrastructure/api/reports/reports.py +++ b/src/infrastructure/api/reports/reports.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, status, UploadFile, File, Form from infrastructure.api.reports.schemas import ReportCreateRequest, ReportUpdateVisibilityRequest, ReportResponse from application.services.report_services import ( CreateReport, GetReportById, GetReportsByUser, ListAllReports, @@ -6,26 +6,65 @@ from application.services.report_services import ( ) from infrastructure.adapters.persistence.report_repository_mongo import ReportRepositoryMongo from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL +from infrastructure.adapters.file_storage import image_storage import logging +from typing import Optional router = APIRouter() report_repo = ReportRepositoryMongo() user_repo = UserRepositorySQL() logger = logging.getLogger(__name__) + +def _report_to_response(report) -> dict: + """Convierte un objeto Report a dict con image_url""" + return { + "id_reporte": report.id_reporte, + "id_usuario": report.id_usuario, + "tipo_reporte": report.tipo_reporte, + "descripcion": report.descripcion, + "ubicacion": report.ubicacion, + "lat": report.lat, + "lng": report.lng, + "image_url": image_storage.get_image_url(report.image_filename) if report.image_filename else None, + "visibilidad": report.visibilidad, + "fecha_creacion": report.fecha_creacion + } + @router.post("/", status_code=status.HTTP_202_ACCEPTED) -async def create_report(report_data: ReportCreateRequest): +async def create_report( + id_usuario: int = Form(...), + tipo_reporte: int = Form(...), + descripcion: str = Form(...), + ubicacion: Optional[str] = Form(None), + lat: Optional[float] = Form(None), + lng: Optional[float] = Form(None), + file: Optional[UploadFile] = File(None) +): """Crea un nuevo reporte - envía a cola de procesamiento con validaciones previas""" try: + # Procesar imagen si fue proporcionada + image_filename = None + if file: + logger.info(f"Processing image file: {file.filename} ({file.content_type})") + image_filename = image_storage.validate_and_save_image(file, f"temp_{id_usuario}_{tipo_reporte}") + create_use_case = CreateReport(report_repo, user_repo) result = create_use_case.execute( - id_usuario=report_data.id_usuario, - tipo_reporte=report_data.tipo_reporte, - descripcion=report_data.descripcion, - ubicacion=report_data.ubicacion + id_usuario=id_usuario, + tipo_reporte=tipo_reporte, + descripcion=descripcion, + ubicacion=ubicacion, + lat=lat, + lng=lng, + image_filename=image_filename ) if result["status"] == "error": + # Si hay error, eliminar imagen si fue guardada + if image_filename: + image_storage.delete_image(image_filename) + message = result["message"] if "no existe" in message: # 404 Not Found: usuario no existe @@ -63,7 +102,7 @@ async def get_report(report_id: str): status_code=status.HTTP_404_NOT_FOUND, detail=f"Reporte con ID {report_id} no encontrado" ) - return report + return _report_to_response(report) except HTTPException: raise except Exception as e: @@ -79,7 +118,7 @@ async def get_user_reports(user_id: int): try: get_use_case = GetReportsByUser(report_repo) reports = get_use_case.execute(user_id) - return reports + return [_report_to_response(report) for report in reports] except Exception as e: logger.error(f"Error al obtener reportes del usuario {user_id}: {e}", exc_info=True) raise HTTPException( @@ -92,7 +131,8 @@ async def list_reports(): """Obtiene todos los reportes - retorna lista vacía si no hay reportes""" try: list_use_case = ListAllReports(report_repo) - return list_use_case.execute() + reports = list_use_case.execute() + return [_report_to_response(report) for report in reports] except Exception as e: logger.error(f"Error al listar reportes: {e}", exc_info=True) raise HTTPException( @@ -105,7 +145,8 @@ async def get_shadowbanned_reports(threshold: float = 20): """Obtiene reportes shadowbaneados (baja visibilidad) - retorna lista vacía si no hay""" try: get_use_case = GetShadowbannedReports(report_repo) - return get_use_case.execute(threshold) + reports = get_use_case.execute(threshold) + return [_report_to_response(report) for report in reports] except Exception as e: logger.error(f"Error al obtener reportes shadowbaneados: {e}", exc_info=True) raise HTTPException( diff --git a/src/infrastructure/api/reports/schemas.py b/src/infrastructure/api/reports/schemas.py index 1e05ca2..d35f3a3 100644 --- a/src/infrastructure/api/reports/schemas.py +++ b/src/infrastructure/api/reports/schemas.py @@ -1,13 +1,17 @@ from pydantic import BaseModel from datetime import datetime from typing import Optional +from fastapi import Form, UploadFile, File class ReportCreateRequest(BaseModel): - """Solicitud para crear un reporte""" + """Solicitud para crear un reporte - usa multipart/form-data en FastAPI""" id_usuario: int tipo_reporte: int descripcion: str ubicacion: Optional[str] = None + lat: Optional[float] = None + lng: Optional[float] = None + # file se recibe como UploadFile en el endpoint, no en el modelo class ReportUpdateVisibilityRequest(BaseModel): """Solicitud para actualizar la visibilidad de un reporte""" @@ -21,6 +25,9 @@ class ReportResponse(BaseModel): tipo_reporte: int descripcion: str ubicacion: Optional[str] + lat: Optional[float] + lng: Optional[float] + image_url: Optional[str] = None # URL pública para acceder a la imagen visibilidad: float fecha_creacion: Optional[datetime]