Added stuffies to reports, like images and coordinate based geolocation

This commit is contained in:
2026-04-06 23:48:19 -06:00
parent 4395a81815
commit f812d4a664
10 changed files with 147 additions and 14 deletions

View File

@@ -7,3 +7,4 @@ pydantic-settings
pymongo pymongo
python-dotenv python-dotenv
pika pika
Pillow

View File

@@ -18,7 +18,8 @@ class CreateReport:
self.user_repo = user_repo self.user_repo = user_repo
def execute(self, id_usuario: int, tipo_reporte: int, descripcion: str, 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. Sends a create report message to RabbitMQ.
Valida previamente: Valida previamente:
@@ -68,6 +69,9 @@ class CreateReport:
tipo_reporte=tipo_reporte, tipo_reporte=tipo_reporte,
descripcion=descripcion.strip(), descripcion=descripcion.strip(),
ubicacion=ubicacion, ubicacion=ubicacion,
lat=lat,
lng=lng,
image_filename=image_filename,
visibilidad=50.0, # Visibilidad inicial neutral visibilidad=50.0, # Visibilidad inicial neutral
fecha_creacion=fecha_creacion.isoformat() fecha_creacion=fecha_creacion.isoformat()
) )

View File

@@ -3,6 +3,8 @@ import sys
import os import os
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path
from shutil import move
# Add src to path to import modules # Add src to path to import modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) 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.rabbitmq.messages import ReportMessage, ReportEventType
from infrastructure.adapters.persistence.report_repository_mongo import ReportRepositoryMongo from infrastructure.adapters.persistence.report_repository_mongo import ReportRepositoryMongo
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
from infrastructure.adapters.file_storage import image_storage
from domain.reports import Report from domain.reports import Report
from core.config import ConfSettings
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
@@ -64,6 +68,25 @@ class ReportConsumer:
# Parse datetime string # Parse datetime string
fecha_creacion = datetime.fromisoformat(message.fecha_creacion) 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 # Create Report domain object
report = Report( report = Report(
id_reporte=message.id_reporte, id_reporte=message.id_reporte,
@@ -71,6 +94,9 @@ class ReportConsumer:
tipo_reporte=message.tipo_reporte, tipo_reporte=message.tipo_reporte,
descripcion=message.descripcion, descripcion=message.descripcion,
ubicacion=message.ubicacion, ubicacion=message.ubicacion,
lat=message.lat,
lng=message.lng,
image_filename=final_image_filename,
visibilidad=message.visibilidad, visibilidad=message.visibilidad,
fecha_creacion=fecha_creacion fecha_creacion=fecha_creacion
) )
@@ -146,9 +172,21 @@ class ReportConsumer:
try: try:
logger.info(f"Deleting report: {message.id_reporte}") 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) success = self.repo.delete(message.id_reporte)
if success: 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: else:
logger.warning(f"Failed to delete report: {message.id_reporte}") logger.warning(f"Failed to delete report: {message.id_reporte}")

View File

@@ -35,6 +35,28 @@ class Settings(BaseSettings):
host: str = "0.0.0.0" host: str = "0.0.0.0"
log_level: str = "info" 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: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = False case_sensitive = False

View File

@@ -10,5 +10,8 @@ class Report:
tipo_reporte: int # Número que representa el tipo tipo_reporte: int # Número que representa el tipo
descripcion: str descripcion: str
ubicacion: Optional[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 fecha_creacion: Optional[datetime] = None

View File

@@ -19,6 +19,9 @@ class ReportRepositoryMongo(ReportRepository):
"tipo_reporte": report.tipo_reporte, "tipo_reporte": report.tipo_reporte,
"descripcion": report.descripcion, "descripcion": report.descripcion,
"ubicacion": report.ubicacion, "ubicacion": report.ubicacion,
"lat": report.lat,
"lng": report.lng,
"image_filename": report.image_filename,
"visibilidad": report.visibilidad, "visibilidad": report.visibilidad,
"fecha_creacion": report.fecha_creacion or datetime.utcnow() "fecha_creacion": report.fecha_creacion or datetime.utcnow()
} }
@@ -76,6 +79,9 @@ class ReportRepositoryMongo(ReportRepository):
tipo_reporte=doc.get("tipo_reporte"), tipo_reporte=doc.get("tipo_reporte"),
descripcion=doc.get("descripcion"), descripcion=doc.get("descripcion"),
ubicacion=doc.get("ubicacion"), ubicacion=doc.get("ubicacion"),
lat=doc.get("lat"),
lng=doc.get("lng"),
image_filename=doc.get("image_filename"),
visibilidad=doc.get("visibilidad"), visibilidad=doc.get("visibilidad"),
fecha_creacion=doc.get("fecha_creacion") fecha_creacion=doc.get("fecha_creacion")
) )

View File

@@ -61,6 +61,9 @@ class ReportMessage:
tipo_reporte: Optional[int] = None tipo_reporte: Optional[int] = None
descripcion: Optional[str] = None descripcion: Optional[str] = None
ubicacion: 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 visibilidad: Optional[float] = None
fecha_creacion: Optional[str] = None # ISO format datetime string fecha_creacion: Optional[str] = None # ISO format datetime string
penalize_author: Optional[bool] = None # For update_visibility event penalize_author: Optional[bool] = None # For update_visibility event

View File

@@ -1,4 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from core.config import ConfSettings from core.config import ConfSettings
from infrastructure.api.reports.router import router from infrastructure.api.reports.router import router
@@ -10,4 +12,10 @@ def create_app() -> FastAPI:
description="Microservicio de gestión de reportes comunitarios" description="Microservicio de gestión de reportes comunitarios"
) )
app.include_router(router) 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 return app

View File

@@ -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 infrastructure.api.reports.schemas import ReportCreateRequest, ReportUpdateVisibilityRequest, ReportResponse
from application.services.report_services import ( from application.services.report_services import (
CreateReport, GetReportById, GetReportsByUser, ListAllReports, 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.report_repository_mongo import ReportRepositoryMongo
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
from infrastructure.adapters.file_storage import image_storage
import logging import logging
from typing import Optional
router = APIRouter() router = APIRouter()
report_repo = ReportRepositoryMongo() report_repo = ReportRepositoryMongo()
user_repo = UserRepositorySQL() user_repo = UserRepositorySQL()
logger = logging.getLogger(__name__) 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) @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""" """Crea un nuevo reporte - envía a cola de procesamiento con validaciones previas"""
try: 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) create_use_case = CreateReport(report_repo, user_repo)
result = create_use_case.execute( result = create_use_case.execute(
id_usuario=report_data.id_usuario, id_usuario=id_usuario,
tipo_reporte=report_data.tipo_reporte, tipo_reporte=tipo_reporte,
descripcion=report_data.descripcion, descripcion=descripcion,
ubicacion=report_data.ubicacion ubicacion=ubicacion,
lat=lat,
lng=lng,
image_filename=image_filename
) )
if result["status"] == "error": if result["status"] == "error":
# Si hay error, eliminar imagen si fue guardada
if image_filename:
image_storage.delete_image(image_filename)
message = result["message"] message = result["message"]
if "no existe" in message: if "no existe" in message:
# 404 Not Found: usuario no existe # 404 Not Found: usuario no existe
@@ -63,7 +102,7 @@ async def get_report(report_id: str):
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Reporte con ID {report_id} no encontrado" detail=f"Reporte con ID {report_id} no encontrado"
) )
return report return _report_to_response(report)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -79,7 +118,7 @@ async def get_user_reports(user_id: int):
try: try:
get_use_case = GetReportsByUser(report_repo) get_use_case = GetReportsByUser(report_repo)
reports = get_use_case.execute(user_id) reports = get_use_case.execute(user_id)
return reports return [_report_to_response(report) for report in reports]
except Exception as e: except Exception as e:
logger.error(f"Error al obtener reportes del usuario {user_id}: {e}", exc_info=True) logger.error(f"Error al obtener reportes del usuario {user_id}: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
@@ -92,7 +131,8 @@ async def list_reports():
"""Obtiene todos los reportes - retorna lista vacía si no hay reportes""" """Obtiene todos los reportes - retorna lista vacía si no hay reportes"""
try: try:
list_use_case = ListAllReports(report_repo) 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: except Exception as e:
logger.error(f"Error al listar reportes: {e}", exc_info=True) logger.error(f"Error al listar reportes: {e}", exc_info=True)
raise HTTPException( 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""" """Obtiene reportes shadowbaneados (baja visibilidad) - retorna lista vacía si no hay"""
try: try:
get_use_case = GetShadowbannedReports(report_repo) 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: except Exception as e:
logger.error(f"Error al obtener reportes shadowbaneados: {e}", exc_info=True) logger.error(f"Error al obtener reportes shadowbaneados: {e}", exc_info=True)
raise HTTPException( raise HTTPException(

View File

@@ -1,13 +1,17 @@
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import Form, UploadFile, File
class ReportCreateRequest(BaseModel): class ReportCreateRequest(BaseModel):
"""Solicitud para crear un reporte""" """Solicitud para crear un reporte - usa multipart/form-data en FastAPI"""
id_usuario: int id_usuario: int
tipo_reporte: int tipo_reporte: int
descripcion: str descripcion: str
ubicacion: Optional[str] = None 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): class ReportUpdateVisibilityRequest(BaseModel):
"""Solicitud para actualizar la visibilidad de un reporte""" """Solicitud para actualizar la visibilidad de un reporte"""
@@ -21,6 +25,9 @@ class ReportResponse(BaseModel):
tipo_reporte: int tipo_reporte: int
descripcion: str descripcion: str
ubicacion: Optional[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 visibilidad: float
fecha_creacion: Optional[datetime] fecha_creacion: Optional[datetime]