Added notifications!!

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 12:28:11 -06:00
parent 9e3cc3a03f
commit fef8ab225d
24 changed files with 3596 additions and 20 deletions

View File

@@ -0,0 +1,100 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from domain.notifications import Notification
class NotificationRepository(ABC):
"""Puerto/Interfaz para el repositorio de notificaciones"""
@abstractmethod
def create(self, notification: Notification) -> str:
"""
Crea una nueva notificación
Args:
notification: Objeto Notification a crear
Returns:
ID de la notificación creada
"""
pass
@abstractmethod
def get_by_id(self, notification_id: str) -> Optional[Notification]:
"""
Obtiene una notificación por ID
Args:
notification_id: ID de la notificación
Returns:
Objeto Notification o None si no existe
"""
pass
@abstractmethod
def get_by_user(self, user_id: int, limit: int = 50, offset: int = 0) -> List[Notification]:
"""
Obtiene notificaciones de un usuario
Args:
user_id: ID del usuario
limit: Número máximo de notificaciones
offset: Desplazamiento de registros
Returns:
Lista de notificaciones del usuario
"""
pass
@abstractmethod
def mark_as_read(self, notification_id: str) -> bool:
"""
Marca una notificación como leída
Args:
notification_id: ID de la notificación
Returns:
True si se actualizó exitosamente
"""
pass
@abstractmethod
def mark_all_as_read(self, user_id: int) -> int:
"""
Marca todas las notificaciones de un usuario como leídas
Args:
user_id: ID del usuario
Returns:
Número de notificaciones actualizadas
"""
pass
@abstractmethod
def delete(self, notification_id: str) -> bool:
"""
Elimina una notificación
Args:
notification_id: ID de la notificación
Returns:
True si se eliminó exitosamente
"""
pass
@abstractmethod
def get_unread_count(self, user_id: int) -> int:
"""
Obtiene el número de notificaciones no leídas para un usuario
Args:
user_id: ID del usuario
Returns:
Número de notificaciones no leídas
"""
pass

View File

@@ -0,0 +1,99 @@
from typing import List, Optional
from domain.notifications import Notification
from infrastructure.adapters.persistence.notification_repository_mongo import NotificationRepositoryMongo
from datetime import datetime
class NotificationService:
"""Servicio de negocio para notificaciones"""
def __init__(self):
self.repository = NotificationRepositoryMongo()
def create_notification(
self,
id_usuario: int,
id_reporte: str,
message: str
) -> str:
"""
Crea una nueva notificación
Args:
id_usuario: ID del usuario
id_reporte: ID del reporte relacionado
message: Mensaje de la notificación
Returns:
ID de la notificación creada
"""
notification = Notification(
id_usuario=id_usuario,
id_reporte=id_reporte,
message=message,
fecha=datetime.utcnow(),
read=False
)
return self.repository.create(notification)
def get_notification(self, notification_id: str) -> Optional[Notification]:
"""Obtiene una notificación por ID"""
return self.repository.get_by_id(notification_id)
def get_user_notifications(
self,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[Notification]:
"""Obtiene notificaciones de un usuario"""
return self.repository.get_by_user(user_id, limit, offset)
def mark_as_read(self, notification_id: str) -> bool:
"""Marca una notificación como leída"""
return self.repository.mark_as_read(notification_id)
def mark_all_as_read(self, user_id: int) -> int:
"""Marca todas las notificaciones de un usuario como leídas"""
return self.repository.mark_all_as_read(user_id)
def delete_notification(self, notification_id: str) -> bool:
"""Elimina una notificación"""
return self.repository.delete(notification_id)
def get_unread_count(self, user_id: int) -> int:
"""Obtiene el número de notificaciones no leídas"""
return self.repository.get_unread_count(user_id)
def send_report_status_notification(
self,
id_usuario: int,
id_reporte: str,
old_status: str,
new_status: str
) -> str:
"""
Envía una notificación cuando cambia el estado de un reporte
Args:
id_usuario: ID del usuario propietario del reporte
id_reporte: ID del reporte
old_status: Estado anterior
new_status: Nuevo estado
Returns:
ID de la notificación creada
"""
status_messages = {
("en proceso", "resuelto"): f"¡Tu reporte #{id_reporte} ha sido resuelto!",
("en proceso", "no resuelto"): f"Tu reporte #{id_reporte} fue marcado como no resuelto.",
("no resuelto", "resuelto"): f"¡Tu reporte #{id_reporte} ha sido resuelto!",
("resuelto", "en proceso"): f"Tu reporte #{id_reporte} ha sido reabierto.",
}
message = status_messages.get(
(old_status, new_status),
f"El estado de tu reporte #{id_reporte} ha cambiado a {new_status}"
)
return self.create_notification(id_usuario, id_reporte, message)

View File

@@ -256,7 +256,7 @@ class DeleteReport:
}
class UpdateReportStatus:
"""Use case para actualizar el estado de un reporte"""
"""Use case para actualizar el estado de un reporte - envía evento a RabbitMQ"""
def __init__(self, repo: ReportRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
@@ -264,7 +264,7 @@ class UpdateReportStatus:
def execute(self, report_id: str, new_estado: str) -> Dict[str, Any]:
"""
Actualiza el estado de un reporte.
Actualiza el estado de un reporte y envía notificación vía RabbitMQ.
Valida previamente:
- Reporte existe
- Estado es válido
@@ -294,9 +294,32 @@ class UpdateReportStatus:
"message": f"Error al buscar reporte: {str(e)}"
}
# Guardar estado anterior para notificación
old_estado = report.estado
# Actualizar estado
try:
self.repo.update_estado(report_id, new_estado)
# Enviar evento a RabbitMQ solo si el estado cambió
if old_estado != new_estado:
message = ReportMessage(
event_type=ReportEventType.UPDATE_STATUS,
id_reporte=report_id,
id_usuario=report.id_usuario,
old_estado=old_estado,
new_estado=new_estado,
estado=new_estado,
tipo_reporte=report.tipo_reporte,
descripcion=report.descripcion,
ubicacion=report.ubicacion,
lat=report.lat,
lng=report.lng,
visibilidad=report.visibilidad,
fecha_creacion=report.fecha_creacion.isoformat() if report.fecha_creacion else None
)
send_to_queue("notifications_queue", message.to_dict())
return {
"status": "success",
"message": f"Estado del reporte actualizado a '{new_estado}'",

View File

@@ -0,0 +1,98 @@
"""Notifications RabbitMQ Consumer - Processes report status change events"""
import sys
import os
import logging
from datetime import datetime
# Add src to path to import modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from infrastructure.adapters.rabbitmq.consumer import RabbitMQConsumer
from infrastructure.adapters.rabbitmq.messages import ReportMessage, ReportEventType
from application.services.notification_services import NotificationService
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('logs/notifications_consumer.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class NotificationConsumer:
"""Consumer para eventos de cambio de estado de reportes desde RabbitMQ"""
def __init__(self):
self.notification_service = NotificationService()
self.consumer = RabbitMQConsumer(queue_name='notifications_queue')
self.consumer.set_callback(self.process_message)
def start(self):
"""Inicia el consumidor"""
logger.info("Notifications Consumer started")
self.consumer.start_consuming()
def process_message(self, message_dict: dict):
"""
Procesa un evento de cambio de estado de reporte desde RabbitMQ
Args:
message_dict: Diccionario con los datos del mensaje
"""
try:
# Reconstruir el objeto ReportMessage
message = ReportMessage.from_dict(message_dict)
# Solo procesar eventos de actualización de estado
if message.event_type == ReportEventType.UPDATE_STATUS:
self._handle_status_update(message)
elif message.event_type == ReportEventType.UPDATE_VISIBILITY:
self._handle_visibility_update(message)
else:
logger.debug(f"Ignoring event type: {message.event_type}")
except Exception as e:
logger.error(f"Error processing notification message: {e}", exc_info=True)
raise
def _handle_status_update(self, message: ReportMessage):
"""Maneja la actualización de estado de un reporte"""
try:
logger.info(
f"Creating notification for report {message.id_reporte} "
f"status change from {message.old_estado} to {message.new_estado}"
)
# Crear notificación para el usuario propietario del reporte
self.notification_service.send_report_status_notification(
id_usuario=message.id_usuario,
id_reporte=message.id_reporte,
old_status=message.old_estado,
new_status=message.new_estado
)
logger.info(f"Notification created for user {message.id_usuario}")
except Exception as e:
logger.error(f"Error creating status notification: {e}", exc_info=True)
raise
def _handle_visibility_update(self, message: ReportMessage):
"""Maneja la actualización de visibilidad de un reporte"""
try:
# Notificar si la visibilidad cambió significativamente
if hasattr(message, 'old_visibility') and hasattr(message, 'new_visibility'):
logger.info(
f"Report {message.id_reporte} visibility changed "
f"from {message.old_visibility} to {message.new_visibility}"
)
# Puedes agregar lógica para notificar sobre cambios de visibilidad
# Por ahora solo registramos el evento
except Exception as e:
logger.error(f"Error handling visibility update: {e}", exc_info=True)

View File

@@ -11,19 +11,29 @@ class Settings(BaseSettings):
description="URL de conexión a MySQL para API de Usuarios"
)
# Base de datos MongoDB
mongodb_url: str = Field(
default=os.getenv("MONGODB_URL", "mongodb://localhost:27017"),
description="URL de conexión a MongoDB para API de Reportes"
# Base de datos MongoDB - Reportes (Instancia Separada)
mongodb_reports_url: str = Field(
default=os.getenv("MONGODB_REPORTS_URL", "mongodb://admin:admin_password@localhost:27017"),
description="URL de conexión a MongoDB para API de Reportes (Instancia 1)"
)
mongodb_db: str = Field(
mongodb_reports_db: str = Field(
default="voxpopuli_reports",
description="Base de datos MongoDB"
description="Base de datos MongoDB para Reportes"
)
rabbitmq: str = Field (
default=os.getenv("RABBITMQ_URI", "localhost")
# Base de datos MongoDB - Notificaciones (Instancia Separada)
mongodb_notifications_url: str = Field(
default=os.getenv("MONGODB_NOTIFICATIONS_URL", "mongodb://admin:admin_password@localhost:27018"),
description="URL de conexión a MongoDB para Notificaciones (Instancia 2)"
)
mongodb_notifications_db: str = Field(
default="voxpopuli_notifications",
description="Base de datos MongoDB para Notificaciones"
)
rabbitmq: str = Field(
default=os.getenv("RABBITMQ_URI", "localhost"),
description="URL de conexión a RabbitMQ"
)
# JWT Configuration

View File

@@ -0,0 +1,14 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Notification:
"""Modelo de dominio para Notificación"""
id_notificacion: Optional[str] = None
id_usuario: int = None
id_reporte: str = None
message: str = None
fecha: Optional[datetime] = None
read: bool = False

View File

@@ -2,10 +2,18 @@ from pymongo import MongoClient
from pymongo.collection import Collection
from core.config import ConfSettings
# Conexión a MongoDB para Reportes
mongo_client = MongoClient(ConfSettings.mongodb_url)
mongodb = mongo_client[ConfSettings.mongodb_db]
# Conexión a MongoDB para Reportes (Instancia Separada - Puerto 27017)
mongo_client_reports = MongoClient(ConfSettings.mongodb_reports_url)
mongodb_reports = mongo_client_reports[ConfSettings.mongodb_reports_db]
# Conexión a MongoDB para Notificaciones (Instancia Separada - Puerto 27018)
mongo_client_notifications = MongoClient(ConfSettings.mongodb_notifications_url)
mongodb_notifications = mongo_client_notifications[ConfSettings.mongodb_notifications_db]
def get_reports_collection() -> Collection:
"""Obtiene la colección de reportes desde MongoDB"""
return mongodb["reportes"]
"""Obtiene la colección de reportes desde MongoDB (Instancia 1)"""
return mongodb_reports["reportes"]
def get_notifications_collection() -> Collection:
"""Obtiene la colección de notificaciones desde MongoDB (Instancia 2)"""
return mongodb_notifications["notificaciones"]

View File

@@ -0,0 +1,87 @@
from application.ports.notification_repository import NotificationRepository
from domain.notifications import Notification
from infrastructure.adapters.persistence.mongodb import get_notifications_collection
from typing import List, Optional
from bson import ObjectId
from datetime import datetime
class NotificationRepositoryMongo(NotificationRepository):
"""Implementación del repositorio de Notificaciones usando MongoDB (Instancia Separada)"""
def __init__(self):
self.collection = get_notifications_collection()
def create(self, notification: Notification) -> str:
"""Crea una nueva notificación"""
notification_dict = {
"id_usuario": notification.id_usuario,
"id_reporte": notification.id_reporte,
"message": notification.message,
"fecha": notification.fecha or datetime.utcnow(),
"read": notification.read
}
result = self.collection.insert_one(notification_dict)
return str(result.inserted_id)
def get_by_id(self, notification_id: str) -> Optional[Notification]:
"""Obtiene una notificación por ID"""
try:
doc = self.collection.find_one({"_id": ObjectId(notification_id)})
if doc:
return self._to_domain(doc)
except Exception:
pass
return None
def get_by_user(self, user_id: int, limit: int = 50, offset: int = 0) -> List[Notification]:
"""Obtiene notificaciones de un usuario ordenadas por fecha descendente"""
docs = self.collection.find(
{"id_usuario": user_id}
).sort("fecha", -1).skip(offset).limit(limit)
return [self._to_domain(doc) for doc in docs]
def mark_as_read(self, notification_id: str) -> bool:
"""Marca una notificación como leída"""
try:
result = self.collection.update_one(
{"_id": ObjectId(notification_id)},
{"$set": {"read": True}}
)
return result.modified_count > 0
except Exception:
return False
def mark_all_as_read(self, user_id: int) -> int:
"""Marca todas las notificaciones de un usuario como leídas"""
result = self.collection.update_many(
{"id_usuario": user_id, "read": False},
{"$set": {"read": True}}
)
return result.modified_count
def delete(self, notification_id: str) -> bool:
"""Elimina una notificación"""
try:
result = self.collection.delete_one({"_id": ObjectId(notification_id)})
return result.deleted_count > 0
except Exception:
return False
def get_unread_count(self, user_id: int) -> int:
"""Obtiene el número de notificaciones no leídas para un usuario"""
return self.collection.count_documents({
"id_usuario": user_id,
"read": False
})
def _to_domain(self, doc: dict) -> Notification:
"""Convierte un documento de MongoDB a un objeto de dominio"""
return Notification(
id_notificacion=str(doc.get("_id")),
id_usuario=doc.get("id_usuario"),
id_reporte=doc.get("id_reporte"),
message=doc.get("message"),
fecha=doc.get("fecha"),
read=doc.get("read", False)
)

View File

@@ -17,6 +17,7 @@ class ReportEventType(str, Enum):
"""Types of report events"""
CREATE = "report.create"
UPDATE_VISIBILITY = "report.update_visibility"
UPDATE_STATUS = "report.update_status"
DELETE = "report.delete"
@@ -69,6 +70,10 @@ class ReportMessage:
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
old_estado: Optional[str] = None # Estado anterior (para UPDATE_STATUS)
new_estado: Optional[str] = None # Nuevo estado (para UPDATE_STATUS)
old_visibility: Optional[float] = None # Visibilidad anterior (para UPDATE_VISIBILITY)
new_visibility: Optional[float] = None # Nueva visibilidad (para UPDATE_VISIBILITY)
def to_dict(self):
"""Convert to dictionary"""

View File

@@ -0,0 +1,14 @@
from fastapi import FastAPI
from core.config import ConfSettings
from infrastructure.api.notifications.router import router
def create_app() -> FastAPI:
"""Factory para crear la aplicación de Notificaciones"""
app = FastAPI(
title="Notificaciones Microservice",
version="1.0.0",
description="Microservicio de gestión de notificaciones de reportes"
)
app.include_router(router)
return app

View File

@@ -0,0 +1,148 @@
from fastapi import APIRouter, HTTPException, Query, Depends, Header
from infrastructure.api.notifications.schemas import (
NotificationCreateRequest,
NotificationResponse,
NotificationListResponse,
UnreadCountResponse,
NotificationMarkAsReadRequest
)
from application.services.notification_services import NotificationService
from typing import Optional
router = APIRouter()
notification_service = NotificationService()
@router.post("/", response_model=NotificationResponse, tags=["notifications"])
async def create_notification(request: NotificationCreateRequest):
"""Crea una nueva notificación (uso interno)"""
try:
notification_id = notification_service.create_notification(
id_usuario=request.id_usuario,
id_reporte=request.id_reporte,
message=request.message
)
notification = notification_service.get_notification(notification_id)
if not notification:
raise HTTPException(status_code=500, detail="Error creating notification")
return NotificationResponse(
id_notificacion=notification.id_notificacion,
id_usuario=notification.id_usuario,
id_reporte=notification.id_reporte,
message=notification.message,
fecha=notification.fecha,
read=notification.read
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{user_id}", response_model=NotificationListResponse, tags=["notifications"])
async def get_user_notifications(
user_id: int,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0)
):
"""
Obtiene notificaciones de un usuario
Args:
user_id: ID del usuario
limit: Número máximo de notificaciones (default: 50, max: 100)
offset: Desplazamiento de registros
"""
try:
notifications = notification_service.get_user_notifications(
user_id=user_id,
limit=limit,
offset=offset
)
unread_count = notification_service.get_unread_count(user_id)
total = len(notifications) + offset # Aproximado
notification_responses = [
NotificationResponse(
id_notificacion=n.id_notificacion,
id_usuario=n.id_usuario,
id_reporte=n.id_reporte,
message=n.message,
fecha=n.fecha,
read=n.read
)
for n in notifications
]
return NotificationListResponse(
total=total,
unread_count=unread_count,
notifications=notification_responses
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{user_id}/unread-count", response_model=UnreadCountResponse, tags=["notifications"])
async def get_unread_count(user_id: int):
"""Obtiene el número de notificaciones no leídas para un usuario"""
try:
unread_count = notification_service.get_unread_count(user_id)
return UnreadCountResponse(unread_count=unread_count)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{notification_id}/read", response_model=NotificationResponse, tags=["notifications"])
async def mark_notification_as_read(notification_id: str):
"""Marca una notificación como leída"""
try:
success = notification_service.mark_as_read(notification_id)
if not success:
raise HTTPException(status_code=404, detail="Notification not found")
notification = notification_service.get_notification(notification_id)
if not notification:
raise HTTPException(status_code=404, detail="Notification not found")
return NotificationResponse(
id_notificacion=notification.id_notificacion,
id_usuario=notification.id_usuario,
id_reporte=notification.id_reporte,
message=notification.message,
fecha=notification.fecha,
read=notification.read
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{user_id}/read-all", response_model=dict, tags=["notifications"])
async def mark_all_as_read(user_id: int):
"""Marca todas las notificaciones de un usuario como leídas"""
try:
updated_count = notification_service.mark_all_as_read(user_id)
return {
"message": "All notifications marked as read",
"updated_count": updated_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{notification_id}", response_model=dict, tags=["notifications"])
async def delete_notification(notification_id: str):
"""Elimina una notificación"""
try:
success = notification_service.delete_notification(notification_id)
if not success:
raise HTTPException(status_code=404, detail="Notification not found")
return {"message": "Notification deleted successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class HealthCheck(BaseModel):
"""Health check response"""
status: str
service: str
@router.get("/", response_model=HealthCheck, tags=["health"])
async def health_check():
"""Health check endpoint"""
return {
"status": "ok",
"service": "Notificaciones Microservice"
}

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter
from infrastructure.api.notifications.notifications import router as notifications_router
from infrastructure.api.notifications.root import router as root_router
router = APIRouter()
router.include_router(
notifications_router,
prefix="/notifications",
tags=["notifications"]
)
router.include_router(
root_router,
prefix='',
tags=["root"]
)

View File

@@ -0,0 +1,43 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class NotificationCreateRequest(BaseModel):
"""Solicitud para crear una notificación"""
id_usuario: int
id_reporte: str
message: str
class NotificationResponse(BaseModel):
"""Respuesta con datos de notificación"""
id_notificacion: str
id_usuario: int
id_reporte: str
message: str
fecha: datetime
read: bool
class Config:
from_attributes = True
class NotificationListResponse(BaseModel):
"""Respuesta con lista de notificaciones"""
total: int
unread_count: int
notifications: list[NotificationResponse]
class Config:
from_attributes = True
class NotificationMarkAsReadRequest(BaseModel):
"""Solicitud para marcar como leída"""
pass
class UnreadCountResponse(BaseModel):
"""Respuesta con conteo de no leídas"""
unread_count: int

View File

@@ -1,11 +1,13 @@
"""
Punto de entrada principal para VoxPopuli Microservices
Ejecuta dos APIs en paralelo: Usuarios (puerto 8000) y Reportes (puerto 8001)
Ejecuta tres APIs en paralelo: Usuarios (puerto 8000), Reportes (puerto 8001) y Notificaciones (puerto 8002)
"""
from infrastructure.api.users.app import create_app as create_users_app
from infrastructure.api.reports.app import create_app as create_reports_app
from infrastructure.api.notifications.app import create_app as create_notifications_app
from consumers.report_consumer import ReportConsumer
from consumers.user_consumer import UserConsumer
from consumers.notification_consumer import NotificationConsumer
from core.config import ConfSettings
import threading
import uvicorn
@@ -32,6 +34,17 @@ def run_reports_api():
log_level=ConfSettings.log_level,
)
def run_notifications_api():
"""Ejecuta la API de Notificaciones en puerto 8002"""
app_notifications = create_notifications_app()
uvicorn.run(
app_notifications,
host=ConfSettings.host,
port=8002,
reload=False,
log_level=ConfSettings.log_level,
)
def run_user_consumer():
consumer = UserConsumer()
consumer.start()
@@ -40,6 +53,10 @@ def run_reports_consumer():
consumer = ReportConsumer()
consumer.start()
def run_notifications_consumer():
consumer = NotificationConsumer()
consumer.start()
def run():
"""Inicia ambas APIs en threads separados"""
@@ -49,25 +66,32 @@ def run():
users_thread = threading.Thread(target=run_users_api, daemon=True, name="Users-API")
reports_thread = threading.Thread(target=run_reports_api, daemon=True, name="Reports-API")
notifications_thread = threading.Thread(target=run_notifications_api, daemon=True, name="Notifications-API")
user_consumer_thread = threading.Thread(target=run_user_consumer, daemon=True, name="Users-Consumer")
report_consumer_thread = threading.Thread(target=run_reports_consumer, daemon=True, name="Reports-Consumer")
notifications_consumer_thread = threading.Thread(target=run_notifications_consumer, daemon=True, name="Notifications-Consumer")
users_thread.start()
reports_thread.start()
notifications_thread.start()
user_consumer_thread.start()
report_consumer_thread.start()
notifications_consumer_thread.start()
print("\n✓ API de Usuarios ejecutándose en http://0.0.0.0:8000")
print("✓ API de Reportes ejecutándose en http://0.0.0.0:8001")
print("✓ API de Notificaciones ejecutándose en http://0.0.0.0:8002")
print("\nDocumentación disponible en:")
print(" - Usuarios: http://localhost:8000/docs")
print(" - Reportes: http://localhost:8001/docs")
print(" - Notificaciones: http://localhost:8002/docs")
print("\n" + "=" * 60 + "\n")
try:
users_thread.join()
reports_thread.join()
notifications_thread.join()
except KeyboardInterrupt:
print("\n\nRecibiendo señal de salida...")
print("Cerrando APIs...")