Merge branch 'develop' into feature/frontend-v3

This commit is contained in:
Alexis
2026-04-13 12:49:24 +02:00
8 changed files with 182 additions and 9 deletions
+2
View File
@@ -5,6 +5,8 @@ __pycache__/
# Variables de entorno # Variables de entorno
.env .env
.env*
# Configuraciones del editor # Configuraciones del editor
.vscode/ .vscode/
+5 -2
View File
@@ -14,12 +14,14 @@ from api.routers.auth import router as auth_router
from api.routers.history import router as history_router from api.routers.history import router as history_router
from api.routers.test_mongo import router as test_mongo_router from api.routers.test_mongo import router as test_mongo_router
from api.routers.docit2mf_build import router as docit2mf_router from api.routers.docit2mf_build import router as docit2mf_router
from api.routers.google_auth import router as google_auth_router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Aquí podrías hacer comprobaciones si quieres
yield yield
# No hace falta cerrar nada con Motor
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -41,3 +43,4 @@ app.include_router(test_mongo_router, prefix="/api")
app.include_router(auth_router, prefix="/api") app.include_router(auth_router, prefix="/api")
app.include_router(history_router, prefix="/api") app.include_router(history_router, prefix="/api")
app.include_router(docit2mf_router, prefix="/api") app.include_router(docit2mf_router, prefix="/api")
app.include_router(google_auth_router, prefix="/api")
+11 -1
View File
@@ -1,13 +1,19 @@
# api/routers/docit2mf_build.py # api/routers/docit2mf_build.py
import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from slowapi import Limiter
from slowapi.util import get_remote_address
from api.models.docit2mf_models import DoCIT2MFMultiRequest from api.models.docit2mf_models import DoCIT2MFMultiRequest
from api.services.docit2mf_build_service import build_it2mf_from_level from api.services.docit2mf_build_service import build_it2mf_from_level
router = APIRouter(prefix="/criteria", tags=["criteria"]) router = APIRouter(prefix="/criteria", tags=["criteria"])
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
@router.post("/doc-it2mf/build") @router.post("/doc-it2mf/build")
@limiter.limit("10/minute")
async def build_doc_it2mf(request: DoCIT2MFMultiRequest): async def build_doc_it2mf(request: DoCIT2MFMultiRequest):
results = [] results = []
@@ -15,6 +21,10 @@ async def build_doc_it2mf(request: DoCIT2MFMultiRequest):
for level in request.levels: for level in request.levels:
results.append(build_it2mf_from_level(level)) results.append(build_it2mf_from_level(level))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) logger.warning(f"Validation error in doc-it2mf/build: {str(e)}")
raise HTTPException(status_code=400, detail="Invalid input data")
except Exception as e:
logger.error(f"Unexpected error in doc-it2mf/build: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
return {"levels": results} return {"levels": results}
+98
View File
@@ -0,0 +1,98 @@
# api/routers/google_auth.py
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from bson import ObjectId
import httpx
import os
import jwt
from api.database.mongodb import users_collection
from api.utils.security import create_access_token
router = APIRouter(prefix="/auth/google", tags=["auth"])
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
# -----------------------------
# 1. LOGIN → REDIRECCIÓN A GOOGLE
# -----------------------------
@router.get("/login")
async def google_login():
google_auth_url = (
"https://accounts.google.com/o/oauth2/auth"
"?response_type=code"
f"&client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
"&scope=openid%20email%20profile"
"&access_type=offline"
"&prompt=consent"
)
return RedirectResponse(google_auth_url)
# -----------------------------
# 2. CALLBACK → GOOGLE DEVUELVE EL CODE
# -----------------------------
@router.get("/callback")
async def google_callback(request: Request):
code = request.query_params.get("code")
if not code:
raise HTTPException(status_code=400, detail="Missing code parameter")
token_url = "https://oauth2.googleapis.com/token"
data = {
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient() as client:
token_response = await client.post(token_url, data=data)
token_json = token_response.json()
if "access_token" not in token_json:
raise HTTPException(status_code=400, detail=token_json)
access_token = token_json["access_token"]
async with httpx.AsyncClient() as client:
userinfo = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"}
)
user_data = userinfo.json()
google_id = user_data["id"]
email = user_data["email"]
name = user_data.get("name", "Usuario")
user = await users_collection.find_one({"email": email})
if not user:
new_user = {
"username": name,
"email": email,
"password_hash": None,
"google_id": google_id,
"history": [],
}
result = await users_collection.insert_one(new_user)
user_id = result.inserted_id
else:
user_id = user["_id"]
token = create_access_token({"user_id": str(user_id)})
return {"message": "Login con Google exitoso", "token": token}
+52 -1
View File
@@ -15,7 +15,6 @@ def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> Li
result = [] result = []
for item in values: for item in values:
if isinstance(item, int): if isinstance(item, int):
# valor fijo → mismo para LMF y UMF
result.append(item) result.append(item)
else: else:
lo, hi = item lo, hi = item
@@ -23,8 +22,45 @@ def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> Li
return result return result
def _sort_nodes(nodes):
"""Ordena los nodos por su coordenada X."""
return sorted(nodes, key=lambda p: p[0])
def _enforce_upper_ge_lower(lower, upper):
"""
Garantiza que la UMF (upper) nunca quede por debajo de la LMF (lower).
Ajusta los valores de pertenencia si es necesario.
"""
# left nodes
for i in range(len(lower["left_nodes"])):
lx, ly = lower["left_nodes"][i]
ux, uy = upper["left_nodes"][i]
upper["left_nodes"][i][1] = max(uy, ly)
# right nodes
for i in range(len(lower["right_nodes"])):
lx, ly = lower["right_nodes"][i]
ux, uy = upper["right_nodes"][i]
upper["right_nodes"][i][1] = max(uy, ly)
return upper
def build_it2mf_from_level(level: DoCIT2MFRequest): def build_it2mf_from_level(level: DoCIT2MFRequest):
"""
Construye una función IT2MF a partir de un nivel con intervalos de cartas blancas.
Devuelve:
{
"term": ...,
"lower": {...},
"upper": {...}
}
"""
# -------------------------
# LMF (mínimos) # LMF (mínimos)
# -------------------------
left_min = _extract_bounds(level.left_blank_cards, "min") left_min = _extract_bounds(level.left_blank_cards, "min")
right_min = _extract_bounds(level.right_blank_cards, "min") right_min = _extract_bounds(level.right_blank_cards, "min")
@@ -39,7 +75,13 @@ def build_it2mf_from_level(level: DoCIT2MFRequest):
) )
lower = build_doc_mf_level(lower_level) lower = build_doc_mf_level(lower_level)
# Ordenar nodos LMF
lower["left_nodes"] = _sort_nodes(lower["left_nodes"])
lower["right_nodes"] = _sort_nodes(lower["right_nodes"])
# -------------------------
# UMF (máximos) # UMF (máximos)
# -------------------------
left_max = _extract_bounds(level.left_blank_cards, "max") left_max = _extract_bounds(level.left_blank_cards, "max")
right_max = _extract_bounds(level.right_blank_cards, "max") right_max = _extract_bounds(level.right_blank_cards, "max")
@@ -54,6 +96,15 @@ def build_it2mf_from_level(level: DoCIT2MFRequest):
) )
upper = build_doc_mf_level(upper_level) upper = build_doc_mf_level(upper_level)
# Ordenar nodos UMF
upper["left_nodes"] = _sort_nodes(upper["left_nodes"])
upper["right_nodes"] = _sort_nodes(upper["right_nodes"])
# -------------------------
# FIX: evitar inversión vertical (UMF < LMF)
# -------------------------
upper = _enforce_upper_ge_lower(lower, upper)
return { return {
"term": level.term, "term": level.term,
"lower": lower, "lower": lower,
+8 -2
View File
@@ -4,6 +4,10 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from api.database.mongodb import users_collection from api.database.mongodb import users_collection
from bson import ObjectId from bson import ObjectId
import os
import jwt
SECRET_KEY = os.getenv("SECRET_KEY")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -17,7 +21,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def generate_token() -> str: def generate_token() -> str:
return secrets.token_hex(32) # 64 caracteres seguros return secrets.token_hex(32)
security_scheme = HTTPBearer() security_scheme = HTTPBearer()
@@ -35,6 +39,8 @@ async def get_current_user(
detail="Token inválido o usuario no autenticado", detail="Token inválido o usuario no autenticado",
) )
# devolvemos el documento tal cual (dict)
user["id"] = str(user["_id"]) user["id"] = str(user["_id"])
return user return user
def create_access_token(data: dict):
return jwt.encode(data, SECRET_KEY, algorithm="HS256")
+3
View File
@@ -5,3 +5,6 @@ pydantic
passlib passlib
bcrypt==4.0.1 bcrypt==4.0.1
email-validator email-validator
slowapi
httpx
PyJWT
+2 -2
View File
@@ -1,5 +1,3 @@
version: "3.9"
services: services:
backend: backend:
build: build:
@@ -11,6 +9,8 @@ services:
- ./backend:/app - ./backend:/app
depends_on: depends_on:
- db - db
env_file:
- backend\.env
frontend: frontend:
build: build: