Added stuffies to reports, like images and coordinate based geolocation
This commit is contained in:
@@ -7,3 +7,4 @@ pydantic-settings
|
|||||||
pymongo
|
pymongo
|
||||||
python-dotenv
|
python-dotenv
|
||||||
pika
|
pika
|
||||||
|
Pillow
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user