diff --git a/backend/.env.example b/backend/.env.example index 19a76f1..306195c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,34 @@ - \ No newline at end of file +# Entorno de aplicación +# Valores válidos para FLASK_ENV: development, production, testing +FLASK_ENV=development +FLASK_DEBUG=1 +SECRET_KEY=cambiar-por-una-clave-segura + +# Base de datos +# Formato: postgresql://usuario:password@host:puerto/nombre_db +DATABASE_URL=postgresql://usuario:password@localhost:5432/skillstat_dev + +# JSON Web Tokens +JWT_SECRET_KEY=cambiar-por-una-clave-segura +# Tiempo de vida del token en segundos. 86400 = 24 horas. +JWT_ACCESS_TOKEN_EXPIRES=86400 + +# APIs externas +ADZUNA_APP_ID=app-id-de-adzuna +ADZUNA_APP_KEY=api-key-de-adzuna +SENDGRID_API_KEY=api-key-de-sendgrid + +# Almacenamiento de respaldos +BACKUP_STORAGE_URL=url-del-almacenamiento +BACKUP_STORAGE_KEY=clave-del-almacenamiento + +# Scheduler +# En desarrollo mantenemos el scheduler desactivado para no consumir +# la cuota de la API de Adzuna mientras programamos. +SCHEDULER_ENABLED=false +INGESTION_INTERVAL_HOURS=6 +TRENDS_INTERVAL_HOURS=24 + +# CORS +# Lista de orígenes permitidos separados por coma. +CORS_ORIGINS=http://localhost:5500,http://localhost:3000 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 49d1b14..0290049 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1,185 @@ -# __init__ — SkillStat \ No newline at end of file +# Construimos la aplicación usando el patrón application factory para poder +# crear instancias independientes según el entorno: desarrollo, producción o pruebas +import os +from flask import Flask, jsonify +from dotenv import load_dotenv + +from app.config import config_map +from app.extensions import db, jwt, cors, migrate + + +def create_app(env: str = None) -> Flask: + """Crea y configura una instancia de la aplicación Flask""" + + # Cargamos las variables de entorno antes de leer cualquier configuración para que estén disponibles cuando se instancian las clases de config + load_dotenv() + + app = Flask(__name__, instance_relative_config=False) + + # Seleccionamos la configuración según el entorno + env = env or os.environ.get("FLASK_ENV", "development") + config_class = config_map.get(env, config_map["development"]) + app.config.from_object(config_class) + + # Verificamos que la base de datos esté configurada antes de continuar + # Hacemos esta validación aquí y no en la clase de configuración para que ocurra en tiempo de ejecución real y solo cuando el entorno es producción + if env == "production" and not app.config.get("SQLALCHEMY_DATABASE_URI"): + raise RuntimeError( + "DATABASE_URL no está definida. " + "La aplicación no puede iniciar en producción sin una base de datos configurada." + ) + + _init_extensions(app) + _register_blueprints(app) + _register_error_handlers(app) + _register_health_check(app) + + return app + + +def _init_extensions(app: Flask) -> None: + """Conecta las extensiones con la instancia de la aplicación""" + db.init_app(app) + jwt.init_app(app) + cors.init_app( + app, + resources={r"/api/*": {"origins": app.config["CORS_ORIGINS"]}}, + ) + migrate.init_app(app, db) + _configure_jwt_errors() + + +def _configure_jwt_errors() -> None: + """Registra los manejadores de error de JWT para que sigan el formato + uniforme de la API en lugar del formato por defecto de la librería""" + + @jwt.expired_token_loader + def expired_token(_header, _payload): + return jsonify({ + "error": { + "code": "TOKEN_EXPIRED", + "message": "El token de acceso ha expirado. Inicia sesión de nuevo.", + } + }), 401 + + @jwt.invalid_token_loader + def invalid_token(_error): + return jsonify({ + "error": { + "code": "TOKEN_INVALID", + "message": "El token de acceso no es válido.", + } + }), 401 + + @jwt.unauthorized_loader + def missing_token(_error): + return jsonify({ + "error": { + "code": "UNAUTHORIZED", + "message": "Se requiere un token de acceso para usar este recurso.", + } + }), 401 + + +def _register_blueprints(app: Flask) -> None: + """Registra los Blueprints con sus prefijos de ruta correspondientes. + Importamos dentro de la función para evitar importaciones circulares + durante la inicialización de las extensiones""" + from app.controllers.auth_bp import auth_bp + from app.controllers.panorama_bp import panorama_bp + from app.controllers.alerts_bp import alerts_bp + from app.controllers.admin_bp import admin_bp + + app.register_blueprint(auth_bp, url_prefix="/api/auth") + app.register_blueprint(panorama_bp, url_prefix="/api/panorama") + app.register_blueprint(alerts_bp, url_prefix="/api/alerts") + app.register_blueprint(admin_bp, url_prefix="/api/admin") + + +def _register_error_handlers(app: Flask) -> None: + """Registra los manejadores de error HTTP para devolver respuestas JSON + con el formato uniforme de la API en lugar de páginas HTML por defecto""" + + @app.errorhandler(400) + def bad_request(_error): + return jsonify({ + "error": { + "code": "BAD_REQUEST", + "message": "La solicitud no tiene el formato correcto.", + } + }), 400 + + @app.errorhandler(401) + def unauthorized(_error): + return jsonify({ + "error": { + "code": "UNAUTHORIZED", + "message": "Se requiere autenticación para acceder a este recurso.", + } + }), 401 + + @app.errorhandler(403) + def forbidden(_error): + return jsonify({ + "error": { + "code": "FORBIDDEN", + "message": "No tienes permiso para realizar esta acción.", + } + }), 403 + + @app.errorhandler(404) + def not_found(_error): + return jsonify({ + "error": { + "code": "NOT_FOUND", + "message": "El recurso solicitado no existe.", + } + }), 404 + + @app.errorhandler(405) + def method_not_allowed(_error): + return jsonify({ + "error": { + "code": "METHOD_NOT_ALLOWED", + "message": "El método HTTP no está permitido para este recurso.", + } + }), 405 + + @app.errorhandler(409) + def conflict(_error): + return jsonify({ + "error": { + "code": "CONFLICT", + "message": "El recurso ya existe o hay un conflicto con el estado actual.", + } + }), 409 + + @app.errorhandler(422) + def unprocessable_entity(_error): + return jsonify({ + "error": { + "code": "VALIDATION_ERROR", + "message": "Los datos enviados no pasaron la validación.", + } + }), 422 + + @app.errorhandler(500) + def internal_error(_error): + return jsonify({ + "error": { + "code": "INTERNAL_ERROR", + "message": "Ocurrió un error interno. Por favor intenta de nuevo.", + } + }), 500 + + +def _register_health_check(app: Flask) -> None: + """Registra el endpoint de salud para verificar que el servicio está activo""" + + @app.route("/api/health") + def health_check(): + return jsonify({ + "status": "ok", + "service": "SkillStat API", + }), 200 + \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 0b17151..3526bcc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1 +1,95 @@ -# config — SkillStat \ No newline at end of file +import os +from datetime import timedelta + + +class BaseConfig: + """Configuración base compartida por todos los entornos""" + + SECRET_KEY = os.environ.get( + "SECRET_KEY", "dev-insecure-key-change-in-production" + ) + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + "pool_recycle": 300, + } + + JWT_SECRET_KEY = os.environ.get( + "JWT_SECRET_KEY", "jwt-insecure-key-change-in-production" + ) + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", 86400)) + ) + + JWT_ERROR_MESSAGE_KEY = "error" + + CORS_ORIGINS = os.environ.get( + "CORS_ORIGINS", "http://localhost:5500" + ).split(",") + + # APIs externas + ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID") + ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY") + SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") + + # Almacenamiento de respaldos + BACKUP_STORAGE_URL = os.environ.get("BACKUP_STORAGE_URL") + BACKUP_STORAGE_KEY = os.environ.get("BACKUP_STORAGE_KEY") + + # Scheduler + SCHEDULER_ENABLED = ( + os.environ.get("SCHEDULER_ENABLED", "false").lower() == "true" + ) + INGESTION_INTERVAL_HOURS = int( + os.environ.get("INGESTION_INTERVAL_HOURS", 6) + ) + TRENDS_INTERVAL_HOURS = int( + os.environ.get("TRENDS_INTERVAL_HOURS", 24) + ) + + +class DevelopmentConfig(BaseConfig): + """Configuración para el entorno de desarrollo local""" + + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/skillstat_dev", + ) + + +class ProductionConfig(BaseConfig): + """Configuración para el entorno de producción""" + + DEBUG = False + TESTING = False + + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + + SQLALCHEMY_ENGINE_OPTIONS = { + **BaseConfig.SQLALCHEMY_ENGINE_OPTIONS, + "pool_size": 10, + "max_overflow": 20, + } + + +class TestingConfig(BaseConfig): + """Configuración para el entorno de pruebas automatizadas""" + + TESTING = True + DEBUG = True + + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5) + + SCHEDULER_ENABLED = False + + +config_map = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "testing": TestingConfig, +} diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 4a8b649..32c1d60 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -1 +1,5 @@ -# admin_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas de administración del sistema: +# gestión de usuarios, ejecución de respaldos y restauración de datos +from flask import Blueprint + +admin_bp = Blueprint("admin", __name__) diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py index 905f18b..86f17ab 100644 --- a/backend/app/controllers/alerts_bp.py +++ b/backend/app/controllers/alerts_bp.py @@ -1 +1,5 @@ -# alerts_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas de gestión de alertas: +# crear, listar, actualizar y eliminar alertas del usuario autenticado +from flask import Blueprint + +alerts_bp = Blueprint("alerts", __name__) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index f4034ea..989b1f7 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -1 +1,5 @@ -# auth_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas relacionadas con autenticación: +# registro de cuenta, inicio de sesión y cierre de sesión +from flask import Blueprint + +auth_bp = Blueprint("auth", __name__) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index f9bb59f..fa6ec8d 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1 +1,5 @@ -# panorama_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas del Panorama: rankings de habilidades, +# distribución geográfica, evolución temporal y comparación entre tecnologías +from flask import Blueprint + +panorama_bp = Blueprint("panorama", __name__) diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 667691b..602a1cb 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -1 +1,9 @@ -# extensions — SkillStat \ No newline at end of file +from flask_sqlalchemy import SQLAlchemy +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from flask_migrate import Migrate + +db = SQLAlchemy() +jwt = JWTManager() +cors = CORS() +migrate = Migrate() diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 19a76f1..08afca8 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1 +1,19 @@ - \ No newline at end of file +# Incluimos todas las dependencias de producción +-r requirements.txt + +# Pruebas +pytest>=8.0,<9.0 +pytest-flask>=1.3,<2.0 +pytest-cov>=6.0,<7.0 +Faker>=30.0,<31.0 + +# Calidad de código +flake8>=7.0,<8.0 +black>=25.0,<26.0 +isort>=5.13,<6.0 + +# Análisis de seguridad estático +bandit>=1.8,<2.0 + +# Cliente HTTP para pruebas manuales de la API +httpie>=3.2,<4.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 19a76f1..63ee370 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1 +1,36 @@ - \ No newline at end of file +# Framework principal +Flask>=3.1,<4.0 +Flask-SQLAlchemy>=3.1,<4.0 +Flask-JWT-Extended>=4.7,<5.0 +Flask-CORS>=5.0,<6.0 +Flask-Migrate>=4.0,<5.0 + +# Conexión con PostgreSQL +psycopg2-binary>=2.9,<3.0 + +# Variables de entorno +python-dotenv>=1.0,<2.0 + +# Cifrado de contraseñas +bcrypt>=4.2,<5.0 + +# Validación de datos de entrada +marshmallow>=3.23,<4.0 + +# Programación de tareas automáticas +APScheduler>=3.10,<4.0 + +# Peticiones HTTP a APIs externas +requests>=2.32,<3.0 + +# Procesamiento de lenguaje natural +spacy>=3.8,<4.0 + +# Procesamiento y análisis de datos +pandas>=2.2,<3.0 + +# Envío de correos electrónicos +sendgrid>=6.11,<7.0 + +# Servidor WSGI para producción +gunicorn>=23.0,<24.0 diff --git a/backend/run.py b/backend/run.py index 63c1164..0c5ba84 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1 +1,14 @@ -# run — SkillStat \ No newline at end of file +# Iniciamos el servidor de desarrollo desde aquí. +# En producción usamos gunicorn directamente sin pasar por este archivo: +# gunicorn --bind 0.0.0.0:8000 "app:create_app()" +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run( + host="0.0.0.0", + port=5000, + debug=app.config.get("DEBUG", False), + ) + \ No newline at end of file