Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,34 @@

# 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
186 changes: 185 additions & 1 deletion backend/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,185 @@
# __init__ — SkillStat
# 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)
Comment on lines +4 to +22

# 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."
)
Comment on lines +24 to +30

_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")
Comment on lines +93 to +96


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

Comment on lines +181 to +185
96 changes: 95 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1 +1,95 @@
# config — SkillStat
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(",")
Comment on lines +28 to +30

# 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,
}
6 changes: 5 additions & 1 deletion backend/app/controllers/admin_bp.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# admin_bp — SkillStat
# 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__)
6 changes: 5 additions & 1 deletion backend/app/controllers/alerts_bp.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# alerts_bp — SkillStat
# 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__)
6 changes: 5 additions & 1 deletion backend/app/controllers/auth_bp.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# auth_bp — SkillStat
# 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__)
6 changes: 5 additions & 1 deletion backend/app/controllers/panorama_bp.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# panorama_bp — SkillStat
# 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__)
10 changes: 9 additions & 1 deletion backend/app/extensions.py
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
# extensions — SkillStat
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()
20 changes: 19 additions & 1 deletion backend/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@

# 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
Loading