diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fc1d6f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..792c018 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/API_EXAMPLES.json b/API_EXAMPLES.json new file mode 100644 index 0000000..82cee85 --- /dev/null +++ b/API_EXAMPLES.json @@ -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" + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b70ba1d --- /dev/null +++ b/ARCHITECTURE.md @@ -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 diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..1c4b667 --- /dev/null +++ b/DATABASE.md @@ -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. diff --git a/enable-venv.bat b/enable-venv.bat new file mode 100644 index 0000000..5e67d00 --- /dev/null +++ b/enable-venv.bat @@ -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 diff --git a/enable-venv.sh b/enable-venv.sh new file mode 100644 index 0000000..28b619d --- /dev/null +++ b/enable-venv.sh @@ -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 diff --git a/readme.md b/readme.md index ef035c1..3ce467d 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,234 @@ -# VOX POPULI -## INFRAESTRUCTURA PARA "VOZ CIUDADANA" -#### Proyecto de HOKZAAP SOFTWARE S. de R.L. de C.V. +# VoxPopuli Microservices + +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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef9d18b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +sqlalchemy +pymysql +pydantic +pydantic-settings +pymongo +python-dotenv diff --git a/application/readme.md b/src/__init__.py similarity index 100% rename from application/readme.md rename to src/__init__.py diff --git a/domain/readme.md b/src/application/__init__.py similarity index 100% rename from domain/readme.md rename to src/application/__init__.py diff --git a/infrastructure/readme.md b/src/application/ports/__init__.py similarity index 100% rename from infrastructure/readme.md rename to src/application/ports/__init__.py diff --git a/src/application/ports/report_repository.py b/src/application/ports/report_repository.py new file mode 100644 index 0000000..b19b08b --- /dev/null +++ b/src/application/ports/report_repository.py @@ -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 diff --git a/src/application/ports/user_repository.py b/src/application/ports/user_repository.py new file mode 100644 index 0000000..5ea6aa4 --- /dev/null +++ b/src/application/ports/user_repository.py @@ -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 diff --git a/src/application/services/__init__.py b/src/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/application/services/report_services.py b/src/application/services/report_services.py new file mode 100644 index 0000000..1413920 --- /dev/null +++ b/src/application/services/report_services.py @@ -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) diff --git a/src/application/services/user_services.py b/src/application/services/user_services.py new file mode 100644 index 0000000..6afc5cb --- /dev/null +++ b/src/application/services/user_services.py @@ -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) diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..dc6fc4b --- /dev/null +++ b/src/core/config.py @@ -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() diff --git a/src/domain/__init__.py b/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/domain/reports.py b/src/domain/reports.py new file mode 100644 index 0000000..5b21ea6 --- /dev/null +++ b/src/domain/reports.py @@ -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 diff --git a/src/domain/users.py b/src/domain/users.py new file mode 100644 index 0000000..b278acb --- /dev/null +++ b/src/domain/users.py @@ -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] diff --git a/src/infrastructure/__init__.py b/src/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/adapters/__init__.py b/src/infrastructure/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/adapters/persistence/__init__.py b/src/infrastructure/adapters/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/adapters/persistence/db.py b/src/infrastructure/adapters/persistence/db.py new file mode 100644 index 0000000..9864d36 --- /dev/null +++ b/src/infrastructure/adapters/persistence/db.py @@ -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() diff --git a/src/infrastructure/adapters/persistence/models.py b/src/infrastructure/adapters/persistence/models.py new file mode 100644 index 0000000..d957736 --- /dev/null +++ b/src/infrastructure/adapters/persistence/models.py @@ -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) diff --git a/src/infrastructure/adapters/persistence/mongodb.py b/src/infrastructure/adapters/persistence/mongodb.py new file mode 100644 index 0000000..974f734 --- /dev/null +++ b/src/infrastructure/adapters/persistence/mongodb.py @@ -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"] diff --git a/src/infrastructure/adapters/persistence/report_repository_mongo.py b/src/infrastructure/adapters/persistence/report_repository_mongo.py new file mode 100644 index 0000000..56b6f1c --- /dev/null +++ b/src/infrastructure/adapters/persistence/report_repository_mongo.py @@ -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") + ) diff --git a/src/infrastructure/adapters/persistence/user_repository_sql.py b/src/infrastructure/adapters/persistence/user_repository_sql.py new file mode 100644 index 0000000..8eced27 --- /dev/null +++ b/src/infrastructure/adapters/persistence/user_repository_sql.py @@ -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 + ) diff --git a/src/infrastructure/api/__init__.py b/src/infrastructure/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/api/reports/__init__.py b/src/infrastructure/api/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/api/reports/app.py b/src/infrastructure/api/reports/app.py new file mode 100644 index 0000000..e3ed332 --- /dev/null +++ b/src/infrastructure/api/reports/app.py @@ -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 diff --git a/src/infrastructure/api/reports/reports.py b/src/infrastructure/api/reports/reports.py new file mode 100644 index 0000000..e665d50 --- /dev/null +++ b/src/infrastructure/api/reports/reports.py @@ -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 diff --git a/src/infrastructure/api/reports/root.py b/src/infrastructure/api/reports/root.py new file mode 100644 index 0000000..27901be --- /dev/null +++ b/src/infrastructure/api/reports/root.py @@ -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" + } diff --git a/src/infrastructure/api/reports/router.py b/src/infrastructure/api/reports/router.py new file mode 100644 index 0000000..236ab8f --- /dev/null +++ b/src/infrastructure/api/reports/router.py @@ -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"] +) diff --git a/src/infrastructure/api/reports/schemas.py b/src/infrastructure/api/reports/schemas.py new file mode 100644 index 0000000..1e05ca2 --- /dev/null +++ b/src/infrastructure/api/reports/schemas.py @@ -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 diff --git a/src/infrastructure/api/users/__init__.py b/src/infrastructure/api/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/api/users/app.py b/src/infrastructure/api/users/app.py new file mode 100644 index 0000000..fa7094c --- /dev/null +++ b/src/infrastructure/api/users/app.py @@ -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 diff --git a/src/infrastructure/api/users/root.py b/src/infrastructure/api/users/root.py new file mode 100644 index 0000000..1822698 --- /dev/null +++ b/src/infrastructure/api/users/root.py @@ -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" + } diff --git a/src/infrastructure/api/users/router.py b/src/infrastructure/api/users/router.py new file mode 100644 index 0000000..fde5e3b --- /dev/null +++ b/src/infrastructure/api/users/router.py @@ -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"] +) diff --git a/src/infrastructure/api/users/schemas.py b/src/infrastructure/api/users/schemas.py new file mode 100644 index 0000000..c76c341 --- /dev/null +++ b/src/infrastructure/api/users/schemas.py @@ -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 diff --git a/src/infrastructure/api/users/users.py b/src/infrastructure/api/users/users.py new file mode 100644 index 0000000..90d1154 --- /dev/null +++ b/src/infrastructure/api/users/users.py @@ -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 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..bb14eae --- /dev/null +++ b/src/main.py @@ -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()