Added everything

This commit is contained in:
Juan M. Ley
2026-03-16 21:05:52 -06:00
parent 00e997debf
commit b526e23149
44 changed files with 2147 additions and 3 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Variables de entorno para VoxPopuli Microservices
# Base de Datos MySQL (API de Usuarios)
MYSQL_URL=mysql+pymysql://user:password@localhost/voxpopuli_users
# Base de Datos MongoDB (API de Reportes)
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=voxpopuli_reports
# Configuración de Server
HOST=0.0.0.0
LOG_LEVEL=info

69
.gitignore vendored Normal file
View File

@@ -0,0 +1,69 @@
# Gitignore para VoxPopuli Microservices
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
*.code-workspace
# Environment
.env
.env.local
.env.*.local
# Bases de Datos
*.db
*.sqlite
*.sqlite3
test.db
# Logs
*.log
logs/
# Cache
.cache/
.pytest_cache/
.coverage
htmlcov/
# Testing
.tox/
.hypothesis/
# Otros
*.pyc
.mypy_cache/
.dmypy.json
dmypy.json
node_modules/
*.pem
credentials.json

290
API_EXAMPLES.json Normal file
View File

@@ -0,0 +1,290 @@
{
"endpoints": {
"usuarios": {
"crear_usuario": {
"metodo": "POST",
"url": "http://localhost:8000/users/",
"solicitud": {
"Content-Type": "application/json",
"body": {
"nombre": "Juan",
"apellido": "Pérez",
"email": "juan.perez@example.com",
"fecha_nacimiento": "1990-05-15T00:00:00",
"url_foto_perfil": "https://example.com/photos/juan.jpg",
"biografia": "Soy un usuario activo de VoxPopuli"
}
},
"respuesta_exitosa": {
"codigo": 201,
"body": {
"user_id": 1,
"nombre": "Juan",
"apellido": "Pérez",
"email": "juan.perez@example.com",
"fecha_nacimiento": "1990-05-15T00:00:00",
"fecha_creacion": "2024-01-15T10:30:00",
"calificacion": 50.0,
"numero_reportes": 0,
"url_foto_perfil": "https://example.com/photos/juan.jpg",
"biografia": "Soy un usuario activo de VoxPopuli"
}
}
},
"obtener_usuario_por_id": {
"metodo": "GET",
"url": "http://localhost:8000/users/1",
"respuesta": {
"codigo": 200,
"body": {
"user_id": 1,
"nombre": "Juan",
"apellido": "Pérez",
"email": "juan.perez@example.com",
"fecha_nacimiento": "1990-05-15T00:00:00",
"fecha_creacion": "2024-01-15T10:30:00",
"calificacion": 50.0,
"numero_reportes": 0,
"url_foto_perfil": "https://example.com/photos/juan.jpg",
"biografia": "Soy un usuario activo de VoxPopuli"
}
}
},
"obtener_usuario_por_email": {
"metodo": "GET",
"url": "http://localhost:8000/users/email/juan.perez@example.com",
"respuesta": {
"codigo": 200,
"body": {
"user_id": 1,
"nombre": "Juan",
"apellido": "Pérez",
"email": "juan.perez@example.com",
"fecha_nacimiento": "1990-05-15T00:00:00",
"fecha_creacion": "2024-01-15T10:30:00",
"calificacion": 50.0,
"numero_reportes": 0,
"url_foto_perfil": "https://example.com/photos/juan.jpg",
"biografia": "Soy un usuario activo de VoxPopuli"
}
}
},
"listar_todos_usuarios": {
"metodo": "GET",
"url": "http://localhost:8000/users/",
"respuesta": {
"codigo": 200,
"body": [
{
"user_id": 1,
"nombre": "Juan",
"apellido": "Pérez",
"email": "juan.perez@example.com",
"fecha_nacimiento": "1990-05-15T00:00:00",
"fecha_creacion": "2024-01-15T10:30:00",
"calificacion": 50.0,
"numero_reportes": 0,
"url_foto_perfil": "https://example.com/photos/juan.jpg",
"biografia": "Soy un usuario activo de VoxPopuli"
}
]
}
},
"actualizar_usuario": {
"metodo": "PUT",
"url": "http://localhost:8000/users/1",
"solicitud": {
"Content-Type": "application/json",
"body": {
"nombre": "Juan Carlos",
"apellido": "Pérez García",
"url_foto_perfil": "https://example.com/photos/juan-new.jpg",
"biografia": "Actualizando mi perfil"
}
},
"respuesta_exitosa": {
"codigo": 200,
"body": {
"user_id": 1,
"nombre": "Juan Carlos",
"apellido": "Pérez García",
"email": "juan.perez@example.com",
"fecha_nacimiento": "1990-05-15T00:00:00",
"fecha_creacion": "2024-01-15T10:30:00",
"calificacion": 50.0,
"numero_reportes": 0,
"url_foto_perfil": "https://example.com/photos/juan-new.jpg",
"biografia": "Actualizando mi perfil"
}
}
},
"eliminar_usuario": {
"metodo": "DELETE",
"url": "http://localhost:8000/users/1",
"respuesta_exitosa": {
"codigo": 204,
"body": null
}
}
},
"reportes": {
"crear_reporte": {
"metodo": "POST",
"url": "http://localhost:8001/reports/",
"solicitud": {
"Content-Type": "application/json",
"body": {
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Se encontró un bache en la calle principal que pone en riesgo el tráfico",
"ubicacion": "Calle Principal, entre Av. Central y Av. Norte, Cuadra 5"
}
},
"respuesta_exitosa": {
"codigo": 201,
"body": {
"id_reporte": "550e8400-e29b-41d4-a716-446655440000",
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Se encontró un bache en la calle principal que pone en riesgo el tráfico",
"ubicacion": "Calle Principal, entre Av. Central y Av. Norte, Cuadra 5",
"visibilidad": 50.0,
"fecha_creacion": "2024-01-15T10:35:00"
}
}
},
"obtener_reporte_por_id": {
"metodo": "GET",
"url": "http://localhost:8001/reports/550e8400-e29b-41d4-a716-446655440000",
"respuesta": {
"codigo": 200,
"body": {
"id_reporte": "550e8400-e29b-41d4-a716-446655440000",
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Se encontró un bache en la calle principal que pone en riesgo el tráfico",
"ubicacion": "Calle Principal, entre Av. Central y Av. Norte, Cuadra 5",
"visibilidad": 50.0,
"fecha_creacion": "2024-01-15T10:35:00"
}
}
},
"obtener_reportes_de_usuario": {
"metodo": "GET",
"url": "http://localhost:8001/reports/user/1",
"respuesta": {
"codigo": 200,
"body": [
{
"id_reporte": "550e8400-e29b-41d4-a716-446655440000",
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Se encontró un bache en la calle principal que pone en riesgo el tráfico",
"ubicacion": "Calle Principal, entre Av. Central y Av. Norte, Cuadra 5",
"visibilidad": 50.0,
"fecha_creacion": "2024-01-15T10:35:00"
}
]
}
},
"listar_todos_reportes": {
"metodo": "GET",
"url": "http://localhost:8001/reports/",
"respuesta": {
"codigo": 200,
"body": [
{
"id_reporte": "550e8400-e29b-41d4-a716-446655440000",
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Se encontró un bache en la calle principal que pone en riesgo el tráfico",
"ubicacion": "Calle Principal, entre Av. Central y Av. Norte, Cuadra 5",
"visibilidad": 50.0,
"fecha_creacion": "2024-01-15T10:35:00"
}
]
}
},
"obtener_reportes_shadowbaneados": {
"metodo": "GET",
"url": "http://localhost:8001/reports/shadowbanned/list?threshold=20",
"respuesta": {
"codigo": 200,
"body": [
{
"id_reporte": "550e8400-e29b-41d4-a716-446655440001",
"id_usuario": 2,
"tipo_reporte": 2,
"descripcion": "Reporte spam con baja visibilidad",
"ubicacion": "Ubicación desconocida",
"visibilidad": 10.0,
"fecha_creacion": "2024-01-14T08:00:00"
}
]
}
},
"actualizar_visibilidad": {
"metodo": "PUT",
"url": "http://localhost:8001/reports/550e8400-e29b-41d4-a716-446655440000/visibility",
"solicitud": {
"Content-Type": "application/json",
"body": {
"new_visibility": 85.0,
"penalize_author": false
}
},
"respuesta_exitosa": {
"codigo": 200,
"body": {
"message": "Visibilidad actualizada exitosamente",
"report_id": "550e8400-e29b-41d4-a716-446655440000",
"new_visibility": 85.0
}
}
},
"actualizar_visibilidad_con_penalizacion": {
"metodo": "PUT",
"url": "http://localhost:8001/reports/550e8400-e29b-41d4-a716-446655440001/visibility",
"solicitud": {
"Content-Type": "application/json",
"body": {
"new_visibility": 15.0,
"penalize_author": true
}
},
"descripcion": "Reduce la visibilidad a shadowban y penaliza la calificación del autor si se establece penalize_author=true",
"respuesta_exitosa": {
"codigo": 200,
"body": {
"message": "Visibilidad actualizada exitosamente",
"report_id": "550e8400-e29b-41d4-a716-446655440001",
"new_visibility": 15.0
}
}
},
"eliminar_reporte": {
"metodo": "DELETE",
"url": "http://localhost:8001/reports/550e8400-e29b-41d4-a716-446655440000",
"respuesta_exitosa": {
"codigo": 204,
"body": null
}
}
}
},
"tipos_reporte": {
"1": "Infraestructura/Vía pública",
"2": "Inseguridad",
"3": "Contaminación",
"4": "Servicios públicos",
"5": "Otro"
},
"codigos_estado_http": {
"200": "OK - La solicitud fue exitosa",
"201": "Created - Recurso creado exitosamente",
"204": "No Content - Eliminación exitosa",
"400": "Bad Request - Error en la solicitud",
"404": "Not Found - Recurso no encontrado",
"500": "Internal Server Error - Error en el servidor"
}
}

247
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,247 @@
# Arquitectura de VoxPopuli Microservices
## Patrones Arquitectónicos Usados
### 1. Clean Architecture (Arquitectura Limpia)
La aplicación está organizada en capas independientes:
```
┌─────────────────────────────────────────────────────────┐
│ API REST (FastAPI) │
│ Infraestructura API (HTTP Handlers) │
├─────────────────────────────────────────────────────────┤
│ Application Layer (Servicios) │
│ - Use Cases / Application Services │
│ - Lógica de negocio de la aplicación │
├─────────────────────────────────────────────────────────┤
│ Domain Layer (Dominio) │
│ - Entidades de negocio puras │
│ - Reglas de negocio independientes │
├─────────────────────────────────────────────────────────┤
│ Infrastructure Layer (Infraestructura) │
│ - Adaptadores (Repositorios) │
│ - Acceso a Datos (MySQL, MongoDB) │
└─────────────────────────────────────────────────────────┘
```
### 2. Microservicios
Dos microservicios independientes:
- **Usuarios API** (Puerto 8000) - Gestión de usuarios y credenciales
- **Reportes API** (Puerto 8001) - Gestión de reportes comunitarios
Cada microservicio:
- Tiene su propia base de datos
- Es escalable independientemente
- Se puede desplegar por separado
- Expone su propia API REST
### 3. Patrón Repository
Abstracción para acceso a datos:
```
┌──────────────────┐
│ Service Layer │
└────────┬─────────┘
│ depends on
┌──────────────────────────┐
│ Port (Interface) │
│ UserRepository │
│ ReportRepository │
└────────┬─────────────────┘
│ implemented by
┌──────────────────────────┐
│ Concrete Adapters │
│ UserRepositorySQL │
│ ReportRepositoryMongo │
└──────────────────────────┘
```
**Beneficios:**
- Independencia de la implementación de BD
- Fácil de testear (usar mocks)
- Reutilizable en diferentes contextos
### 4. Inversión de Dependencias (Dependency Inversion)
Los servicios dependen de **abstracciones (interfaces)**, no de implementaciones concretas:
```python
class CreateUser:
def __init__(self, repo: UserRepository): # Depende de interfaz
self.repo = repo
# Puede usar cualquier implementación de UserRepository
service = CreateUser(UserRepositorySQL()) # Implementación SQL
# o
service = CreateUser(UserRepositoryMock()) # Para testing
```
## Flujo de Solicitud
### Crear Usuario:
```
1. HTTP POST /users/
└─> FastAPI Handler
└─> CreateUser Use Case
└─> UserRepository.save()
└─> UserRepositorySQL
└─> SQLAlchemy
└─> MySQL Database
```
### Crear Reporte:
```
1. HTTP POST /reports/
└─> FastAPI Handler
└─> CreateReport Use Case
└─> ReportRepository.save()
└─> UserRepository.increment_reports()
└─> ReportRepositoryMongo
└─> UserRepositorySQL
└─> MongoDB
└─> MySQL
```
## Capas Detalladas
### Domain Layer (src/domain/)
**Entidades puras de negocio**:
- `User`: Representa un usuario del sistema
- `Report`: Representa un reporte comunitario
No tienen dependencias externas. Solo representan conceptos de negocio.
```python
@dataclass
class User:
user_id: int
nombre: str
email: str
# ... más campos
```
### Application Layer (src/application/)
**Use Cases y Servicios**:
#### Ports (Interfaces):
- `UserRepository`: Contrato para acceso a datos de usuarios
- `ReportRepository`: Contrato para acceso a datos de reportes
#### Services (Use Cases):
- `CreateUser`, `GetUserById`, `UpdateUser`, `DeleteUser`
- `CreateReport`, `GetReportById`, `UpdateReportVisibility`
Contienen la lógica de negocio:
```python
class CreateReport:
def execute(self, id_usuario, tipo_reporte, ...):
# 1. Validar que usuario existe
user = self.user_repo.find_by_id(id_usuario)
# 2. Crear reporte
report = Report(...)
# 3. Guardar en BD
self.repo.save(report)
# 4. Actualizar contador de usuario
self.user_repo.increment_reports(id_usuario)
```
### Infrastructure Layer (src/infrastructure/)
#### Persistence Adapters:
**user_repository_sql.py**:
- Implementa `UserRepository` usando SQLAlchemy
- Convierte entre modelo de dominio y modelo de BD
**report_repository_mongo.py**:
- Implementa `ReportRepository` usando PyMongo
- Convierte entre modelo de dominio y documento MongoDB
#### API Handlers:
**users/users.py**:
- Endpoints HTTP para gestión de usuarios
- Usa esquemas Pydantic para validación
**reports/reports.py**:
- Endpoints HTTP para gestión de reportes
- Mapeo de solicitudes a use cases
### Core Layer (src/core/)
Configuración centralizada:
- Variables de entorno
- Configuración de logging
- Configuración de bases de datos
## Testing
La arquitectura facilita el testing:
```python
# Mock Repository para testing
class UserRepositoryMock(UserRepository):
def __init__(self):
self.users = {}
def save(self, user):
self.users[user.user_id] = user
return user
# Usar en tests
def test_create_user():
mock_repo = UserRepositoryMock()
service = CreateUser(mock_repo)
user = service.execute(...)
assert mock_repo.users[1] is not None
```
## Escalabilidad
Cada componente puede escalar independientemente:
1. **Usuarios API**: Escalar horizontal con load balancer
2. **Reportes API**: Escalar horizontal con load balancer
3. **MySQL**: Replicación master-slave
4. **MongoDB**: Sharding automático
## Despliegue
Cada microservicio puede desplegarse:
- **Docker**: Contenedores independientes
- **Kubernetes**: Pods independientes
- **Serverless**: Funciones Lambda independientes
## Monitoreo
Cada API expone:
- `/health` - Health check
- `/docs` - Swagger UI
- Logging estructurado
- Métricas por endpoint
## Futuras Mejoras
1. **Autenticación**: JWT tokens en API
2. **Autorización**: RBAC (Role-Based Access Control)
3. **Rate Limiting**: Proteger contra abuso
4. **Caché**: Redis para datos frecuentes
5. **Message Queue**: RabbitMQ para comunicación asíncrona
6. **Logging Centralizado**: ELK Stack
7. **Observabilidad**: Prometheus + Grafana
8. **Tracing**: Jaeger para rastreo de solicitudes

173
DATABASE.md Normal file
View File

@@ -0,0 +1,173 @@
# Configuración de Base de Datos - VoxPopuli Microservices
## MySQL (API de Usuarios)
### Crear la base de datos:
```sql
CREATE DATABASE voxpopuli_users CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE voxpopuli_users;
CREATE TABLE usuarios (
user_id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
apellido VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
fecha_nacimiento DATETIME NOT NULL,
fecha_creacion DATETIME DEFAULT CURRENT_TIMESTAMP,
calificacion FLOAT DEFAULT 50.0,
numero_reportes INT DEFAULT 0,
url_foto_perfil VARCHAR(500),
biografia VARCHAR(1000),
INDEX idx_nombre (nombre),
INDEX idx_email (email),
INDEX idx_fecha_creacion (fecha_creacion)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Configuración en .env:
```
MYSQL_URL=mysql+pymysql://usuario:contraseña@localhost:3306/voxpopuli_users
```
### Nota sobre SQLAlchemy:
El proyecto usa SQLAlchemy para mapeo objeto-relacional (ORM). Las tablas se crean automáticamente cuando se ejecuta el código si usas:
```python
from infrastructure.adapters.persistence.db import Base, engine
# Crear todas las tablas definidas en los modelos
Base.metadata.create_all(bind=engine)
```
## MongoDB (API de Reportes)
### Crear la base de datos y colección:
```javascript
use voxpopuli_reports
db.createCollection("reportes")
// Crear índices para mejor rendimiento
db.reportes.createIndex({ "id_usuario": 1 })
db.reportes.createIndex({ "id_reporte": 1 }, { unique: true })
db.reportes.createIndex({ "visibilidad": 1 })
db.reportes.createIndex({ "fecha_creacion": -1 })
db.reportes.createIndex({ "tipo_reporte": 1 })
```
### Configuración en .env:
```
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=voxpopuli_reports
```
### Estructura de documento en MongoDB:
```json
{
"_id": ObjectId("..."),
"id_reporte": "550e8400-e29b-41d4-a716-446655440000",
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Descripción del reporte",
"ubicacion": "Ubicación específica",
"visibilidad": 50.0,
"fecha_creacion": ISODate("2024-01-15T10:30:00Z")
}
```
## Variables de Entorno
Archivo `.env` requerido:
```env
# MySQL
MYSQL_URL=mysql+pymysql://user:password@localhost/voxpopuli_users
# MongoDB
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=voxpopuli_reports
# Server
HOST=0.0.0.0
LOG_LEVEL=info
```
## Inicializar Bases de Datos
### Opción 1: Automático (Recomendado)
Crear un script `init_db.py`:
```python
from infrastructure.adapters.persistence.db import Base, engine
from infrastructure.adapters.persistence.models import UserModel
# Crear todas las tablas de MySQL
Base.metadata.create_all(bind=engine)
# Para MongoDB, la colección se crea automáticamente
print("Bases de datos inicializadas correctamente")
```
Ejecutar:
```bash
python init_db.py
```
### Opción 2: Manual
1. Crear MySQL database y tabla (ver scripts arriba)
2. Crear MongoDB database y colección (ver scripts arriba)
## Verificar Conexiones
### MySQL:
```python
from infrastructure.adapters.persistence.db import SessionLocal
db = SessionLocal()
print(db.execute("SELECT 1"))
db.close()
```
### MongoDB:
```python
from infrastructure.adapters.persistence.mongodb import get_reports_collection
collection = get_reports_collection()
print(collection.find_one())
```
## Respaldo y Restauración
### MySQL:
```bash
# Respaldo
mysqldump -u user -p voxpopuli_users > backup.sql
# Restauración
mysql -u user -p voxpopuli_users < backup.sql
```
### MongoDB:
```bash
# Respaldo
mongodump --db voxpopuli_reports --out ./backup
# Restauración
mongorestore --db voxpopuli_reports ./backup/voxpopuli_reports
```
## Notas Importantes
1. **Contraseñas**: Nunca guardes contraseñas reales en el repositorio. Usa variables de entorno.
2. **Índices**: Los índices mejoran significativamente el rendimiento en consultas frecuentes.
3. **Conexiones**: Los pools de conexión están configurados en `db.py` y `mongodb.py`.
4. **Unicode**: MySQL está configurado con UTF-8 para soportar múltiples idiomas correctamente.

52
enable-venv.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
REM Script para habilitar el entorno virtual de VoxPopuli Microservices (Windows)
setlocal enabledelayedexpansion
echo.
echo ========================================
echo VoxPopuli Microservices Setup
echo ========================================
echo.
REM Crear entorno virtual si no existe
if not exist "venv" (
echo Creando entorno virtual...
python -m venv venv
echo [OK] Entorno virtual creado
) else (
echo [OK] Entorno virtual ya existe
)
REM Activar entorno virtual
echo Activando entorno virtual...
call venv\Scripts\activate.bat
echo [OK] Entorno virtual activado
REM Instalar dependencias
if exist "requirements.txt" (
echo Instalando dependencias...
pip install -r requirements.txt
echo [OK] Dependencias instaladas
)
REM Verificar archivo .env
if not exist ".env" (
if exist ".env.example" (
echo Creando archivo .env desde .env.example...
copy .env.example .env
echo [ATENCION] Por favor, actualiza .env con tus credenciales
)
)
echo.
echo ========================================
echo [OK] Setup completado
echo ========================================
echo.
echo Para ejecutar los microservicios:
echo cd src
echo python main.py
echo.
pause

52
enable-venv.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Script para habilitar el entorno virtual de VoxPopuli Microservices
# Colores para output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}================================${NC}"
echo -e "${YELLOW}VoxPopuli Microservices Setup${NC}"
echo -e "${YELLOW}================================${NC}"
# Crear entorno virtual si no existe
if [ ! -d "venv" ]; then
echo -e "${YELLOW}Creando entorno virtual...${NC}"
python3 -m venv venv
echo -e "${GREEN}✓ Entorno virtual creado${NC}"
else
echo -e "${GREEN}✓ Entorno virtual ya existe${NC}"
fi
# Activar entorno virtual
echo -e "${YELLOW}Activando entorno virtual...${NC}"
source venv/bin/activate
echo -e "${GREEN}✓ Entorno virtual activado${NC}"
# Instalar dependencias
if [ -f "requirements.txt" ]; then
echo -e "${YELLOW}Instalando dependencias...${NC}"
pip install -r requirements.txt
echo -e "${GREEN}✓ Dependencias instaladas${NC}"
fi
# Verificar archivo .env
if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then
echo -e "${YELLOW}Creando archivo .env desde .env.example...${NC}"
cp .env.example .env
echo -e "${YELLOW}⚠ Por favor, actualiza .env con tus credenciales${NC}"
fi
fi
echo
echo -e "${GREEN}================================${NC}"
echo -e "${GREEN}✓ Setup completado${NC}"
echo -e "${GREEN}================================${NC}"
echo
echo -e "${YELLOW}Para ejecutar los microservicios:${NC}"
echo "cd src"
echo "python main.py"
echo

237
readme.md
View File

@@ -1,3 +1,234 @@
# VOX POPULI # VoxPopuli Microservices
## INFRAESTRUCTURA PARA "VOZ CIUDADANA"
#### Proyecto de HOKZAAP SOFTWARE S. de R.L. de C.V. Plataforma de reportes comunitarios con arquitectura de microservicios usando FastAPI.
## Descripción
VoxPopuli es una infraestructura de microservicios que permite a usuarios crear y reportar incidentes en la comunidad, con un sistema de calificación y visibilidad basado en votación comunitaria.
### Características principales:
- **API de Usuarios**: Gestión de usuarios y autenticación (MySQL)
- **API de Reportes**: Gestión de reportes comunitarios (MongoDB)
- Sistema de calificación de usuarios (0-100)
- Sistema de visibilidad de reportes con shadowban para contenido de baja calidad
- Penalización de usuarios que crean reportes shadowbaneados
## Estructura del Proyecto
```
VoxPopuli_Microservices/
├── src/
│ ├── domain/ # Modelos de dominio (entidades de negocio)
│ │ ├── users.py # Modelo User
│ │ └── reports.py # Modelo Report
│ ├── application/ # Capa de aplicación (use cases)
│ │ ├── ports/ # Interfaces (abstracciones)
│ │ │ ├── user_repository.py
│ │ │ └── report_repository.py
│ │ └── services/ # Casos de uso
│ │ ├── user_services.py
│ │ └── report_services.py
│ ├── infrastructure/ # Capa de infraestructura
│ │ ├── adapters/
│ │ │ └── persistence/ # Implementaciones de repositorios
│ │ │ ├── db.py # Configuración MySQL
│ │ │ ├── mongodb.py # Configuración MongoDB
│ │ │ ├── models.py # Modelos SQLAlchemy
│ │ │ ├── user_repository_sql.py
│ │ │ └── report_repository_mongo.py
│ │ └── api/ # APIs REST
│ │ ├── users/ # Microservicio de Usuarios
│ │ │ ├── app.py # Factory de la app
│ │ │ ├── router.py # Enrutador principal
│ │ │ ├── users.py # Endpoints de usuarios
│ │ │ ├── schemas.py # Esquemas Pydantic
│ │ │ └── root.py # Endpoints raíz
│ │ └── reports/ # Microservicio de Reportes
│ │ ├── app.py
│ │ ├── router.py
│ │ ├── reports.py
│ │ ├── schemas.py
│ │ └── root.py
│ ├── core/
│ │ └── config.py # Configuración de la aplicación
│ └── main.py # Punto de entrada principal
├── .env.example # Variables de entorno de ejemplo
└── requirements.txt # Dependencias de Python
```
## Requisitos
- Python 3.10+
- MySQL 8.0+
- MongoDB 4.4+
## Instalación
1. **Clonar el repositorio** (o extraer los archivos)
2. **Crear entorno virtual**:
```bash
python -m venv venv
```
3. **Activar entorno virtual**:
- En Windows:
```bash
venv\Scripts\activate
```
- En Linux/Mac:
```bash
source venv/bin/activate
```
4. **Instalar dependencias**:
```bash
pip install -r requirements.txt
```
5. **Configurar variables de entorno**:
```bash
cp .env.example .env
# Editar .env con tus credenciales de MySQL y MongoDB
```
6. **Crear base de datos MySQL**:
```sql
CREATE DATABASE voxpopuli_users CHARACTER SET utf8mb4;
```
## Ejecución
### Ejecutar ambos microservicios:
```bash
cd src
python main.py
```
Esto iniciará:
- **API de Usuarios**: http://localhost:8000
- **API de Reportes**: http://localhost:8001
### Documentación interactiva:
- Usuarios: http://localhost:8000/docs
- Reportes: http://localhost:8001/docs
## Endpoints Principales
### API de Usuarios (Puerto 8000)
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/users/` | Crear usuario |
| GET | `/users/{user_id}` | Obtener usuario por ID |
| GET | `/users/email/{email}` | Obtener usuario por email |
| GET | `/users/` | Listar todos los usuarios |
| PUT | `/users/{user_id}` | Actualizar usuario |
| DELETE | `/users/{user_id}` | Eliminar usuario |
### API de Reportes (Puerto 8001)
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/reports/` | Crear reporte |
| GET | `/reports/{report_id}` | Obtener reporte por ID |
| GET | `/reports/user/{user_id}` | Obtener reportes de un usuario |
| GET | `/reports/` | Listar todos los reportes |
| GET | `/reports/shadowbanned/list` | Listar reportes shadowbaneados |
| PUT | `/reports/{report_id}/visibility` | Actualizar visibilidad |
| DELETE | `/reports/{report_id}` | Eliminar reporte |
## Modelos de Datos
### Usuario (MySQL)
```python
- user_id: int (PK)
- nombre: str
- apellido: str
- email: str (único)
- fecha_nacimiento: datetime
- fecha_creacion: datetime
- calificacion: float (0-100)
- numero_reportes: int
- url_foto_perfil: str (opcional)
- biografia: str (opcional)
```
### Reporte (MongoDB)
```python
- id_reporte: str (UUID)
- id_usuario: int
- tipo_reporte: int (número que representa el tipo)
- descripcion: str
- ubicacion: str (opcional)
- visibilidad: float (0-100, basado en votación comunitaria)
- fecha_creacion: datetime
```
## Arquitectura
La arquitectura sigue el patrón de **Arquitectura Limpia (Clean Architecture)**:
- **Domain**: Modelos de negocio puros (entidades)
- **Application**: Lógica de negocio (casos de uso/servicios)
- **Infrastructure**: Adaptadores para bases de datos y APIs
Esto permite:
- Independencia de frameworks
- Facilidad de testing
- Separación de responsabilidades
- Reutilización de lógica de dominio
## Ejemplo de Uso
### Crear un usuario:
```bash
curl -X POST "http://localhost:8000/users/" \
-H "Content-Type: application/json" \
-d '{
"nombre": "Juan",
"apellido": "Pérez",
"email": "juan@example.com",
"fecha_nacimiento": "1990-01-15T00:00:00",
"url_foto_perfil": "https://example.com/photo.jpg",
"biografia": "Usuario de VoxPopuli"
}'
```
### Crear un reporte:
```bash
curl -X POST "http://localhost:8001/reports/" \
-H "Content-Type: application/json" \
-d '{
"id_usuario": 1,
"tipo_reporte": 1,
"descripcion": "Bache en la calle principal",
"ubicacion": "Calle Principal, Cuadra 5"
}'
```
## Desarrollo
### Ejecutar con hot reload:
Edita `src/main.py` y cambia `reload=False` a `reload=True`
### Testing:
(Próximamente se añadirán tests unitarios e integración)
## Contribuciones
Las contribuciones son bienvenidas. Por favor:
1. Fork el repositorio
2. Crea una rama para tu feature (`git checkout -b feature/AmazingFeature`)
3. Commit tus cambios (`git commit -m 'Add some AmazingFeature'`)
4. Push a la rama (`git push origin feature/AmazingFeature`)
5. Abre un Pull Request
## Licencia
Este proyecto está bajo la licencia MIT - ver archivo LICENSE para más detalles.
## Autor
Desarrollado como parte de la plataforma VoxPopuli para reportes comunitarios.

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi
uvicorn
sqlalchemy
pymysql
pydantic
pydantic-settings
pymongo
python-dotenv

View File

@@ -0,0 +1,46 @@
from abc import ABC, abstractmethod
from domain.reports import Report
from typing import List, Optional
class ReportRepository(ABC):
"""Puerto (interfaz) para el repositorio de Reportes"""
@abstractmethod
def save(self, report: Report) -> Report:
"""Guarda un reporte en la base de datos"""
pass
@abstractmethod
def find_by_id(self, report_id: str) -> Optional[Report]:
"""Obtiene un reporte por ID"""
pass
@abstractmethod
def find_by_user_id(self, user_id: int) -> List[Report]:
"""Obtiene todos los reportes de un usuario"""
pass
@abstractmethod
def find_all(self) -> List[Report]:
"""Obtiene todos los reportes"""
pass
@abstractmethod
def find_by_visibility_range(self, min_visibility: float, max_visibility: float) -> List[Report]:
"""Obtiene reportes dentro de un rango de visibilidad"""
pass
@abstractmethod
def update_visibility(self, report_id: str, new_visibility: float) -> None:
"""Actualiza la visibilidad de un reporte"""
pass
@abstractmethod
def delete(self, report_id: str) -> bool:
"""Elimina un reporte"""
pass
@abstractmethod
def find_shadowbanned(self, visibility_threshold: float = 20) -> List[Report]:
"""Obtiene reportes con baja visibilidad (shadowbaneados)"""
pass

View File

@@ -0,0 +1,46 @@
from abc import ABC, abstractmethod
from domain.users import User
from typing import List, Optional
class UserRepository(ABC):
"""Puerto (interfaz) para el repositorio de Usuarios"""
@abstractmethod
def save(self, user: User) -> User:
"""Guarda un usuario en la base de datos"""
pass
@abstractmethod
def find_by_id(self, user_id: int) -> Optional[User]:
"""Obtiene un usuario por ID"""
pass
@abstractmethod
def find_by_email(self, email: str) -> Optional[User]:
"""Obtiene un usuario por email"""
pass
@abstractmethod
def find_all(self) -> List[User]:
"""Obtiene todos los usuarios"""
pass
@abstractmethod
def update(self, user: User) -> User:
"""Actualiza un usuario"""
pass
@abstractmethod
def delete(self, user_id: int) -> bool:
"""Elimina un usuario"""
pass
@abstractmethod
def increment_reports(self, user_id: int) -> None:
"""Incrementa el contador de reportes de un usuario"""
pass
@abstractmethod
def update_rating(self, user_id: int, new_rating: float) -> None:
"""Actualiza la calificación de un usuario"""
pass

View File

View File

@@ -0,0 +1,115 @@
from domain.reports import Report
from application.ports.report_repository import ReportRepository
from application.ports.user_repository import UserRepository
from datetime import datetime
from typing import List, Optional
from uuid import uuid4
class CreateReport:
"""Use case para crear un nuevo reporte"""
def __init__(self, repo: ReportRepository, user_repo: UserRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
if not isinstance(user_repo, UserRepository):
raise TypeError("user_repo must implement UserRepository")
self.repo = repo
self.user_repo = user_repo
def execute(self, id_usuario: int, tipo_reporte: int, descripcion: str,
ubicacion: Optional[str] = None) -> Report:
# Verificar que el usuario existe
user = self.user_repo.find_by_id(id_usuario)
if not user:
raise ValueError(f"Usuario con ID {id_usuario} no existe")
report = Report(
id_reporte=str(uuid4()),
id_usuario=id_usuario,
tipo_reporte=tipo_reporte,
descripcion=descripcion,
ubicacion=ubicacion,
visibilidad=50.0, # Visibilidad inicial neutral
fecha_creacion=datetime.now()
)
# Incrementar contador de reportes del usuario
self.user_repo.increment_reports(id_usuario)
return self.repo.save(report)
class GetReportById:
"""Use case para obtener un reporte por ID"""
def __init__(self, repo: ReportRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
self.repo = repo
def execute(self, report_id: str) -> Optional[Report]:
return self.repo.find_by_id(report_id)
class GetReportsByUser:
"""Use case para obtener todos los reportes de un usuario"""
def __init__(self, repo: ReportRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
self.repo = repo
def execute(self, user_id: int) -> List[Report]:
return self.repo.find_by_user_id(user_id)
class ListAllReports:
"""Use case para obtener todos los reportes"""
def __init__(self, repo: ReportRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
self.repo = repo
def execute(self) -> List[Report]:
return self.repo.find_all()
class UpdateReportVisibility:
"""Use case para actualizar la visibilidad de un reporte basado en votación comunitaria"""
def __init__(self, repo: ReportRepository, user_repo: UserRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
self.repo = repo
self.user_repo = user_repo
def execute(self, report_id: str, new_visibility: float, penalize_author: bool = False) -> None:
# Validar rango de visibilidad
if new_visibility < 0 or new_visibility > 100:
raise ValueError("La visibilidad debe estar entre 0 y 100")
report = self.repo.find_by_id(report_id)
if not report:
raise ValueError(f"Reporte con ID {report_id} no existe")
self.repo.update_visibility(report_id, new_visibility)
# Si la visibilidad es muy baja (shadowban), penalizar al autor
if penalize_author and new_visibility < 20:
user = self.user_repo.find_by_id(report.id_usuario)
if user:
# Reducir calificación del usuario
new_rating = max(0, user.calificacion - 5)
self.user_repo.update_rating(report.id_usuario, new_rating)
class GetShadowbannedReports:
"""Use case para obtener reportes shadowbaneados (baja visibilidad)"""
def __init__(self, repo: ReportRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
self.repo = repo
def execute(self, visibility_threshold: float = 20) -> List[Report]:
return self.repo.find_shadowbanned(visibility_threshold)
class DeleteReport:
"""Use case para eliminar un reporte"""
def __init__(self, repo: ReportRepository):
if not isinstance(repo, ReportRepository):
raise TypeError("repo must implement ReportRepository")
self.repo = repo
def execute(self, report_id: str) -> bool:
return self.repo.delete(report_id)

View File

@@ -0,0 +1,90 @@
from domain.users import User
from application.ports.user_repository import UserRepository
from datetime import datetime
from typing import List, Optional
class CreateUser:
"""Use case para crear un nuevo usuario"""
def __init__(self, repo: UserRepository):
if not isinstance(repo, UserRepository):
raise TypeError("repo must implement UserRepository")
self.repo = repo
def execute(self, nombre: str, apellido: str, email: str,
fecha_nacimiento: datetime, url_foto_perfil: Optional[str] = None,
biografia: Optional[str] = None) -> User:
user = User(
user_id=0,
nombre=nombre,
apellido=apellido,
email=email,
fecha_nacimiento=fecha_nacimiento,
fecha_creacion=datetime.now(),
calificacion=50.0, # Puntuación inicial
numero_reportes=0,
url_foto_perfil=url_foto_perfil,
biografia=biografia
)
return self.repo.save(user)
class GetUserById:
"""Use case para obtener un usuario por ID"""
def __init__(self, repo: UserRepository):
if not isinstance(repo, UserRepository):
raise TypeError("repo must implement UserRepository")
self.repo = repo
def execute(self, user_id: int) -> Optional[User]:
return self.repo.find_by_id(user_id)
class GetUserByEmail:
"""Use case para obtener un usuario por email"""
def __init__(self, repo: UserRepository):
if not isinstance(repo, UserRepository):
raise TypeError("repo must implement UserRepository")
self.repo = repo
def execute(self, email: str) -> Optional[User]:
return self.repo.find_by_email(email)
class ListAllUsers:
"""Use case para obtener todos los usuarios"""
def __init__(self, repo: UserRepository):
if not isinstance(repo, UserRepository):
raise TypeError("repo must implement UserRepository")
self.repo = repo
def execute(self) -> List[User]:
return self.repo.find_all()
class UpdateUser:
"""Use case para actualizar un usuario"""
def __init__(self, repo: UserRepository):
if not isinstance(repo, UserRepository):
raise TypeError("repo must implement UserRepository")
self.repo = repo
def execute(self, user_id: int, nombre: str = None, apellido: str = None,
url_foto_perfil: str = None, biografia: str = None) -> Optional[User]:
user = self.repo.find_by_id(user_id)
if user:
if nombre:
user.nombre = nombre
if apellido:
user.apellido = apellido
if url_foto_perfil is not None:
user.url_foto_perfil = url_foto_perfil
if biografia is not None:
user.biografia = biografia
return self.repo.update(user)
return None
class DeleteUser:
"""Use case para eliminar un usuario"""
def __init__(self, repo: UserRepository):
if not isinstance(repo, UserRepository):
raise TypeError("repo must implement UserRepository")
self.repo = repo
def execute(self, user_id: int) -> bool:
return self.repo.delete(user_id)

0
src/core/__init__.py Normal file
View File

37
src/core/config.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic_settings import BaseSettings
from pydantic import Field
class Settings(BaseSettings):
"""Configuración de la aplicación VoxPopuli Microservices"""
# Base de datos MySQL
mysql_url: str = Field(
default="mysql+pymysql://user:password@localhost/voxpopuli_users",
description="URL de conexión a MySQL para API de Usuarios"
)
# Base de datos MongoDB
mongodb_url: str = Field(
default="mongodb://localhost:27017",
description="URL de conexión a MongoDB para API de Reportes"
)
mongodb_db: str = Field(
default="voxpopuli_reports",
description="Base de datos MongoDB"
)
# API
api_title: str = "VoxPopuli Microservices"
api_version: str = "1.0.0"
api_description: str = "Plataforma de reportes comunitarios con usuarios y gestión de reportes"
# Server
host: str = "0.0.0.0"
log_level: str = "info"
class Config:
env_file = ".env"
case_sensitive = False
ConfSettings = Settings()

0
src/domain/__init__.py Normal file
View File

14
src/domain/reports.py Normal file
View File

@@ -0,0 +1,14 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Report:
"""Modelo de dominio para Reporte"""
id_reporte: str
id_usuario: int
tipo_reporte: int # Número que representa el tipo
descripcion: str
ubicacion: Optional[str]
visibilidad: float # 0-100 (puntuación comunitaria)
fecha_creacion: Optional[datetime] = None

17
src/domain/users.py Normal file
View File

@@ -0,0 +1,17 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class User:
"""Modelo de dominio para Usuario"""
user_id: int
nombre: str
apellido: str
email: str
fecha_nacimiento: datetime
fecha_creacion: datetime
calificacion: float # 0-100
numero_reportes: int
url_foto_perfil: Optional[str]
biografia: Optional[str]

View File

View File

View File

@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from core.config import ConfSettings
# Base de datos MySQL para Usuarios
engine = create_engine(
ConfSettings.mysql_url,
echo=False,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Obtiene una sesión de base de datos"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, Float, DateTime
from infrastructure.adapters.persistence.db import Base
from datetime import datetime
class UserModel(Base):
"""Modelo SQLAlchemy para Usuario (MySQL)"""
__tablename__ = "usuarios"
user_id = Column(Integer, primary_key=True, index=True, autoincrement=True)
nombre = Column(String(100), nullable=False, index=True)
apellido = Column(String(100), nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
fecha_nacimiento = Column(DateTime, nullable=False)
fecha_creacion = Column(DateTime, default=datetime.utcnow, nullable=False)
calificacion = Column(Float, default=50.0, nullable=False) # 0-100
numero_reportes = Column(Integer, default=0, nullable=False)
url_foto_perfil = Column(String(500), nullable=True)
biografia = Column(String(1000), nullable=True)

View File

@@ -0,0 +1,11 @@
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]
def get_reports_collection() -> Collection:
"""Obtiene la colección de reportes desde MongoDB"""
return mongodb["reportes"]

View File

@@ -0,0 +1,81 @@
from application.ports.report_repository import ReportRepository
from domain.reports import Report
from infrastructure.adapters.persistence.mongodb import get_reports_collection
from typing import List, Optional
from bson import ObjectId
from datetime import datetime
class ReportRepositoryMongo(ReportRepository):
"""Implementación del repositorio de Reportes usando MongoDB"""
def __init__(self):
self.collection = get_reports_collection()
def save(self, report: Report) -> Report:
"""Guarda un nuevo reporte"""
report_dict = {
"id_reporte": report.id_reporte,
"id_usuario": report.id_usuario,
"tipo_reporte": report.tipo_reporte,
"descripcion": report.descripcion,
"ubicacion": report.ubicacion,
"visibilidad": report.visibilidad,
"fecha_creacion": report.fecha_creacion or datetime.utcnow()
}
result = self.collection.insert_one(report_dict)
return report
def find_by_id(self, report_id: str) -> Optional[Report]:
"""Obtiene un reporte por ID"""
doc = self.collection.find_one({"id_reporte": report_id})
if doc:
return self._to_domain(doc)
return None
def find_by_user_id(self, user_id: int) -> List[Report]:
"""Obtiene todos los reportes de un usuario"""
docs = self.collection.find({"id_usuario": user_id})
return [self._to_domain(doc) for doc in docs]
def find_all(self) -> List[Report]:
"""Obtiene todos los reportes"""
docs = self.collection.find()
return [self._to_domain(doc) for doc in docs]
def find_by_visibility_range(self, min_visibility: float, max_visibility: float) -> List[Report]:
"""Obtiene reportes dentro de un rango de visibilidad"""
docs = self.collection.find({
"visibilidad": {"$gte": min_visibility, "$lte": max_visibility}
})
return [self._to_domain(doc) for doc in docs]
def update_visibility(self, report_id: str, new_visibility: float) -> None:
"""Actualiza la visibilidad de un reporte"""
self.collection.update_one(
{"id_reporte": report_id},
{"$set": {"visibilidad": new_visibility}}
)
def delete(self, report_id: str) -> bool:
"""Elimina un reporte"""
result = self.collection.delete_one({"id_reporte": report_id})
return result.deleted_count > 0
def find_shadowbanned(self, visibility_threshold: float = 20) -> List[Report]:
"""Obtiene reportes con baja visibilidad (shadowbaneados)"""
docs = self.collection.find({
"visibilidad": {"$lt": visibility_threshold}
})
return [self._to_domain(doc) for doc in docs]
def _to_domain(self, doc: dict) -> Report:
"""Convierte un documento de MongoDB a un objeto de dominio"""
return Report(
id_reporte=doc.get("id_reporte"),
id_usuario=doc.get("id_usuario"),
tipo_reporte=doc.get("tipo_reporte"),
descripcion=doc.get("descripcion"),
ubicacion=doc.get("ubicacion"),
visibilidad=doc.get("visibilidad"),
fecha_creacion=doc.get("fecha_creacion")
)

View File

@@ -0,0 +1,104 @@
from application.ports.user_repository import UserRepository
from domain.users import User
from infrastructure.adapters.persistence.models import UserModel
from infrastructure.adapters.persistence.db import SessionLocal
from typing import List, Optional
class UserRepositorySQL(UserRepository):
"""Implementación del repositorio de Usuarios usando SQLAlchemy (MySQL)"""
def __init__(self, db_session=None):
self.db = db_session or SessionLocal()
def save(self, user: User) -> User:
"""Guarda un nuevo usuario"""
db_user = UserModel(
nombre=user.nombre,
apellido=user.apellido,
email=user.email,
fecha_nacimiento=user.fecha_nacimiento,
fecha_creacion=user.fecha_creacion,
calificacion=user.calificacion,
numero_reportes=user.numero_reportes,
url_foto_perfil=user.url_foto_perfil,
biografia=user.biografia
)
self.db.add(db_user)
self.db.commit()
self.db.refresh(db_user)
# Convertir de vuelta a dominio
return self._to_domain(db_user)
def find_by_id(self, user_id: int) -> Optional[User]:
"""Obtiene un usuario por ID"""
db_user = self.db.query(UserModel).filter(UserModel.user_id == user_id).first()
if db_user:
return self._to_domain(db_user)
return None
def find_by_email(self, email: str) -> Optional[User]:
"""Obtiene un usuario por email"""
db_user = self.db.query(UserModel).filter(UserModel.email == email).first()
if db_user:
return self._to_domain(db_user)
return None
def find_all(self) -> List[User]:
"""Obtiene todos los usuarios"""
db_users = self.db.query(UserModel).all()
return [self._to_domain(user) for user in db_users]
def update(self, user: User) -> User:
"""Actualiza un usuario"""
db_user = self.db.query(UserModel).filter(UserModel.user_id == user.user_id).first()
if db_user:
db_user.nombre = user.nombre
db_user.apellido = user.apellido
db_user.calificacion = user.calificacion
db_user.numero_reportes = user.numero_reportes
db_user.url_foto_perfil = user.url_foto_perfil
db_user.biografia = user.biografia
self.db.commit()
self.db.refresh(db_user)
return self._to_domain(db_user)
return user
def delete(self, user_id: int) -> bool:
"""Elimina un usuario"""
db_user = self.db.query(UserModel).filter(UserModel.user_id == user_id).first()
if db_user:
self.db.delete(db_user)
self.db.commit()
return True
return False
def increment_reports(self, user_id: int) -> None:
"""Incrementa el contador de reportes de un usuario"""
db_user = self.db.query(UserModel).filter(UserModel.user_id == user_id).first()
if db_user:
db_user.numero_reportes += 1
self.db.commit()
def update_rating(self, user_id: int, new_rating: float) -> None:
"""Actualiza la calificación de un usuario"""
db_user = self.db.query(UserModel).filter(UserModel.user_id == user_id).first()
if db_user:
# Asegurar que la calificación esté en el rango 0-100
db_user.calificacion = max(0, min(100, new_rating))
self.db.commit()
def _to_domain(self, db_user: UserModel) -> User:
"""Convierte un modelo SQLAlchemy a un objeto de dominio"""
return User(
user_id=db_user.user_id,
nombre=db_user.nombre,
apellido=db_user.apellido,
email=db_user.email,
fecha_nacimiento=db_user.fecha_nacimiento,
fecha_creacion=db_user.fecha_creacion,
calificacion=db_user.calificacion,
numero_reportes=db_user.numero_reportes,
url_foto_perfil=db_user.url_foto_perfil,
biografia=db_user.biografia
)

View File

View File

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

View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, HTTPException, status
from infrastructure.api.reports.schemas import ReportCreateRequest, ReportUpdateVisibilityRequest, ReportResponse
from application.services.report_services import (
CreateReport, GetReportById, GetReportsByUser, ListAllReports,
UpdateReportVisibility, GetShadowbannedReports, DeleteReport
)
from infrastructure.adapters.persistence.report_repository_mongo import ReportRepositoryMongo
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
router = APIRouter()
report_repo = ReportRepositoryMongo()
user_repo = UserRepositorySQL()
@router.post("/", response_model=ReportResponse, status_code=status.HTTP_201_CREATED)
async def create_report(report_data: ReportCreateRequest):
"""Crea un nuevo reporte"""
try:
create_use_case = CreateReport(report_repo, user_repo)
report = create_use_case.execute(
id_usuario=report_data.id_usuario,
tipo_reporte=report_data.tipo_reporte,
descripcion=report_data.descripcion,
ubicacion=report_data.ubicacion
)
return report
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error al crear reporte: {str(e)}"
)
@router.get("/{report_id}", response_model=ReportResponse)
async def get_report(report_id: str):
"""Obtiene un reporte por ID"""
get_use_case = GetReportById(report_repo)
report = get_use_case.execute(report_id)
if not report:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Reporte con ID {report_id} no encontrado"
)
return report
@router.get("/user/{user_id}", response_model=list[ReportResponse])
async def get_user_reports(user_id: int):
"""Obtiene todos los reportes de un usuario"""
get_use_case = GetReportsByUser(report_repo)
reports = get_use_case.execute(user_id)
return reports
@router.get("/", response_model=list[ReportResponse])
async def list_reports():
"""Obtiene todos los reportes"""
list_use_case = ListAllReports(report_repo)
return list_use_case.execute()
@router.get("/shadowbanned/list", response_model=list[ReportResponse])
async def get_shadowbanned_reports(threshold: float = 20):
"""Obtiene reportes shadowbaneados (baja visibilidad)"""
get_use_case = GetShadowbannedReports(report_repo)
return get_use_case.execute(threshold)
@router.put("/{report_id}/visibility", status_code=status.HTTP_200_OK)
async def update_report_visibility(report_id: str, visibility_data: ReportUpdateVisibilityRequest):
"""Actualiza la visibilidad de un reporte (basado en votación comunitaria)"""
try:
update_use_case = UpdateReportVisibility(report_repo, user_repo)
update_use_case.execute(
report_id=report_id,
new_visibility=visibility_data.new_visibility,
penalize_author=visibility_data.penalize_author
)
return {
"message": "Visibilidad actualizada exitosamente",
"report_id": report_id,
"new_visibility": visibility_data.new_visibility
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(report_id: str):
"""Elimina un reporte"""
delete_use_case = DeleteReport(report_repo)
success = delete_use_case.execute(report_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Reporte con ID {report_id} no encontrado"
)
return None

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
"""Verifica el estado de la API"""
return {
"status": "healthy",
"service": "Reportes API"
}
@router.get("/")
async def root():
"""Endpoint raíz"""
return {
"message": "API de Reportes - VoxPopuli",
"version": "1.0.0"
}

View File

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

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class ReportCreateRequest(BaseModel):
"""Solicitud para crear un reporte"""
id_usuario: int
tipo_reporte: int
descripcion: str
ubicacion: Optional[str] = None
class ReportUpdateVisibilityRequest(BaseModel):
"""Solicitud para actualizar la visibilidad de un reporte"""
new_visibility: float
penalize_author: bool = False
class ReportResponse(BaseModel):
"""Respuesta con datos de reporte"""
id_reporte: str
id_usuario: int
tipo_reporte: int
descripcion: str
ubicacion: Optional[str]
visibilidad: float
fecha_creacion: Optional[datetime]
class Config:
from_attributes = True

View File

View File

@@ -0,0 +1,13 @@
from fastapi import FastAPI
from core.config import ConfSettings
from infrastructure.api.users.router import router
def create_app() -> FastAPI:
"""Factory para crear la aplicación de Usuarios"""
app = FastAPI(
title="Usuarios Microservice",
version="1.0.0",
description="Microservicio de gestión de usuarios y autenticación"
)
app.include_router(router)
return app

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
"""Verifica el estado de la API"""
return {
"status": "healthy",
"service": "Usuarios API"
}
@router.get("/")
async def root():
"""Endpoint raíz"""
return {
"message": "API de Usuarios - VoxPopuli",
"version": "1.0.0"
}

View File

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

View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class UserCreateRequest(BaseModel):
"""Solicitud para crear un usuario"""
nombre: str
apellido: str
email: str
fecha_nacimiento: datetime
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None
class UserUpdateRequest(BaseModel):
"""Solicitud para actualizar un usuario"""
nombre: Optional[str] = None
apellido: Optional[str] = None
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None
class UserResponse(BaseModel):
"""Respuesta con datos de usuario"""
user_id: int
nombre: str
apellido: str
email: str
fecha_nacimiento: datetime
fecha_creacion: datetime
calificacion: float
numero_reportes: int
url_foto_perfil: Optional[str]
biografia: Optional[str]
class Config:
from_attributes = True

View File

@@ -0,0 +1,89 @@
from fastapi import APIRouter, HTTPException, status
from infrastructure.api.users.schemas import UserCreateRequest, UserUpdateRequest, UserResponse
from application.services.user_services import (
CreateUser, GetUserById, GetUserByEmail, ListAllUsers, UpdateUser, DeleteUser
)
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
router = APIRouter()
user_repo = UserRepositorySQL()
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(user_data: UserCreateRequest):
"""Crea un nuevo usuario"""
try:
create_use_case = CreateUser(user_repo)
user = create_use_case.execute(
nombre=user_data.nombre,
apellido=user_data.apellido,
email=user_data.email,
fecha_nacimiento=user_data.fecha_nacimiento,
url_foto_perfil=user_data.url_foto_perfil,
biografia=user_data.biografia
)
return user
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error al crear usuario: {str(e)}"
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
"""Obtiene un usuario por ID"""
get_use_case = GetUserById(user_repo)
user = get_use_case.execute(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Usuario con ID {user_id} no encontrado"
)
return user
@router.get("/email/{email}", response_model=UserResponse)
async def get_user_by_email(email: str):
"""Obtiene un usuario por email"""
get_use_case = GetUserByEmail(user_repo)
user = get_use_case.execute(email)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Usuario con email {email} no encontrado"
)
return user
@router.get("/", response_model=list[UserResponse])
async def list_users():
"""Obtiene todos los usuarios"""
list_use_case = ListAllUsers(user_repo)
return list_use_case.execute()
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_data: UserUpdateRequest):
"""Actualiza un usuario"""
update_use_case = UpdateUser(user_repo)
user = update_use_case.execute(
user_id=user_id,
nombre=user_data.nombre,
apellido=user_data.apellido,
url_foto_perfil=user_data.url_foto_perfil,
biografia=user_data.biografia
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Usuario con ID {user_id} no encontrado"
)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int):
"""Elimina un usuario"""
delete_use_case = DeleteUser(user_repo)
success = delete_use_case.execute(user_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Usuario con ID {user_id} no encontrado"
)
return None

60
src/main.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Punto de entrada principal para VoxPopuli Microservices
Ejecuta dos APIs en paralelo: Usuarios (puerto 8000) y Reportes (puerto 8001)
"""
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 core.config import ConfSettings
import threading
import uvicorn
def run_users_api():
"""Ejecuta la API de Usuarios en puerto 8000"""
app_users = create_users_app()
uvicorn.run(
app_users,
host=ConfSettings.host,
port=8000,
reload=False,
log_level=ConfSettings.log_level,
)
def run_reports_api():
"""Ejecuta la API de Reportes en puerto 8001"""
app_reports = create_reports_app()
uvicorn.run(
app_reports,
host=ConfSettings.host,
port=8001,
reload=False,
log_level=ConfSettings.log_level,
)
def run():
"""Inicia ambas APIs en threads separados"""
print("=" * 60)
print("Iniciando VoxPopuli Microservices...")
print("=" * 60)
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")
users_thread.start()
reports_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("\nDocumentación disponible en:")
print(" - Usuarios: http://localhost:8000/docs")
print(" - Reportes: http://localhost:8001/docs")
print("\n" + "=" * 60 + "\n")
try:
users_thread.join()
reports_thread.join()
except KeyboardInterrupt:
print("\n\nRecibiendo señal de salida...")
print("Cerrando APIs...")
if __name__ == "__main__":
run()