Upload 20 files
Browse files- app_auth.py +1074 -0
- augment_dataset.py +293 -0
- auth/__init__.py +1 -0
- auth/__pycache__/__init__.cpython-310.pyc +0 -0
- auth/__pycache__/auth_utils.cpython-310.pyc +0 -0
- auth/__pycache__/classes_routes.cpython-310.pyc +0 -0
- auth/__pycache__/database.cpython-310.pyc +0 -0
- auth/__pycache__/dependencies.cpython-310.pyc +0 -0
- auth/__pycache__/models.cpython-310.pyc +0 -0
- auth/__pycache__/routes.cpython-310.pyc +0 -0
- auth/__pycache__/session_routes.cpython-310.pyc +0 -0
- auth/__pycache__/session_utils.cpython-310.pyc +0 -0
- auth/auth_utils.py +71 -0
- auth/classes_routes.py +355 -0
- auth/database.py +69 -0
- auth/dependencies.py +171 -0
- auth/models.py +130 -0
- auth/routes.py +224 -0
- auth/session_routes.py +191 -0
- auth/session_utils.py +134 -0
app_auth.py
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, File, UploadFile, Form, Request, BackgroundTasks, Depends, HTTPException
|
| 2 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
|
| 3 |
+
from fastapi.templating import Jinja2Templates
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
import uvicorn
|
| 7 |
+
import os
|
| 8 |
+
from augment_dataset import augment_session, get_session_stats, AVAILABLE_VARIANTS
|
| 9 |
+
from PIL import Image, ImageDraw
|
| 10 |
+
import numpy as np
|
| 11 |
+
import random
|
| 12 |
+
import io
|
| 13 |
+
import base64
|
| 14 |
+
import json
|
| 15 |
+
import zipfile
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
import shutil
|
| 18 |
+
from sqlalchemy.orm import Session
|
| 19 |
+
|
| 20 |
+
# Importar módulos de autenticación
|
| 21 |
+
from auth.database import create_tables, get_db
|
| 22 |
+
from auth.models import User, UserSession
|
| 23 |
+
from auth.routes import router as auth_router
|
| 24 |
+
from auth.classes_routes import router as classes_router
|
| 25 |
+
from auth.session_routes import router as hash_sessions_router
|
| 26 |
+
from auth.dependencies import get_current_user, get_optional_user, verify_session_access
|
| 27 |
+
|
| 28 |
+
# Crear aplicación FastAPI
|
| 29 |
+
app = FastAPI(
|
| 30 |
+
title="YOLO Multi-Class Annotator & Visualizer (JWT Auth)",
|
| 31 |
+
description="Sistema de anotación YOLO con autenticación JWT",
|
| 32 |
+
version="2.0.0"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Configurar CORS
|
| 36 |
+
app.add_middleware(
|
| 37 |
+
CORSMiddleware,
|
| 38 |
+
allow_origins=["*"], # En producción, especificar dominios específicos
|
| 39 |
+
allow_credentials=True,
|
| 40 |
+
allow_methods=["*"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Configurar templates y archivos estáticos
|
| 45 |
+
templates = Jinja2Templates(directory="templates")
|
| 46 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 47 |
+
|
| 48 |
+
# Incluir router de autenticación
|
| 49 |
+
app.include_router(auth_router)
|
| 50 |
+
app.include_router(classes_router)
|
| 51 |
+
app.include_router(hash_sessions_router)
|
| 52 |
+
|
| 53 |
+
# Crear carpetas necesarias
|
| 54 |
+
os.makedirs("static", exist_ok=True)
|
| 55 |
+
os.makedirs("templates", exist_ok=True)
|
| 56 |
+
os.makedirs("annotations", exist_ok=True)
|
| 57 |
+
os.makedirs("temp", exist_ok=True)
|
| 58 |
+
|
| 59 |
+
# Crear tablas de base de datos
|
| 60 |
+
create_tables()
|
| 61 |
+
|
| 62 |
+
# Funciones auxiliares (copiadas del original)
|
| 63 |
+
def create_session_structure(session_name, user_id=None, db=None):
|
| 64 |
+
"""Crear estructura de sesión y asociarla con usuario"""
|
| 65 |
+
session_path = f"annotations/{session_name}"
|
| 66 |
+
os.makedirs(f"{session_path}/images", exist_ok=True)
|
| 67 |
+
os.makedirs(f"{session_path}/labels", exist_ok=True)
|
| 68 |
+
|
| 69 |
+
# Si se proporciona user_id, crear relación en base de datos
|
| 70 |
+
if user_id and db:
|
| 71 |
+
existing_session = db.query(UserSession).filter(
|
| 72 |
+
UserSession.user_id == user_id,
|
| 73 |
+
UserSession.session_name == session_name
|
| 74 |
+
).first()
|
| 75 |
+
|
| 76 |
+
if not existing_session:
|
| 77 |
+
user_session = UserSession(
|
| 78 |
+
session_name=session_name,
|
| 79 |
+
user_id=user_id
|
| 80 |
+
)
|
| 81 |
+
db.add(user_session)
|
| 82 |
+
db.commit()
|
| 83 |
+
|
| 84 |
+
return session_path
|
| 85 |
+
|
| 86 |
+
def random_color():
|
| 87 |
+
return tuple(random.randint(0, 255) for _ in range(3))
|
| 88 |
+
|
| 89 |
+
def create_canvas_with_image(image_bytes, size, x, y, change_bg=True, max_size=800):
|
| 90 |
+
"""Crear canvas con imagen redimensionada automáticamente"""
|
| 91 |
+
bg_color = random_color() if change_bg else (200, 200, 200)
|
| 92 |
+
canvas = Image.new('RGB', size, bg_color)
|
| 93 |
+
|
| 94 |
+
# Cargar imagen subida (soporta múltiples formatos incluyendo WebP)
|
| 95 |
+
img = Image.open(io.BytesIO(image_bytes))
|
| 96 |
+
|
| 97 |
+
# Convertir a RGB si es necesario (para WebP con transparencia, etc.)
|
| 98 |
+
if img.mode in ('RGBA', 'LA', 'P'):
|
| 99 |
+
# Crear fondo blanco para transparencias
|
| 100 |
+
background = Image.new('RGB', img.size, (255, 255, 255))
|
| 101 |
+
if img.mode == 'P':
|
| 102 |
+
img = img.convert('RGBA')
|
| 103 |
+
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
| 104 |
+
img = background
|
| 105 |
+
elif img.mode != 'RGB':
|
| 106 |
+
img = img.convert('RGB')
|
| 107 |
+
|
| 108 |
+
# Redimensionar imagen si es muy grande manteniendo aspecto
|
| 109 |
+
original_width, original_height = img.size
|
| 110 |
+
|
| 111 |
+
# Calcular nuevo tamaño manteniendo proporción
|
| 112 |
+
if original_width > max_size or original_height > max_size:
|
| 113 |
+
ratio = min(max_size / original_width, max_size / original_height)
|
| 114 |
+
new_width = int(original_width * ratio)
|
| 115 |
+
new_height = int(original_height * ratio)
|
| 116 |
+
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
| 117 |
+
|
| 118 |
+
# Centrar imagen si es menor que el canvas
|
| 119 |
+
canvas_width, canvas_height = size
|
| 120 |
+
img_width, img_height = img.size
|
| 121 |
+
|
| 122 |
+
# Calcular posición centrada o usar coordenadas proporcionadas
|
| 123 |
+
if x == 0 and y == 0: # Auto-centrar
|
| 124 |
+
paste_x = (canvas_width - img_width) // 2
|
| 125 |
+
paste_y = (canvas_height - img_height) // 2
|
| 126 |
+
else:
|
| 127 |
+
# Usar coordenadas proporcionadas pero asegurar que la imagen esté dentro
|
| 128 |
+
paste_x = min(x, canvas_width - img_width)
|
| 129 |
+
paste_y = min(y, canvas_height - img_height)
|
| 130 |
+
|
| 131 |
+
paste_x = max(0, paste_x)
|
| 132 |
+
paste_y = max(0, paste_y)
|
| 133 |
+
|
| 134 |
+
# Pegar imagen en canvas
|
| 135 |
+
canvas.paste(img, (paste_x, paste_y))
|
| 136 |
+
|
| 137 |
+
return canvas
|
| 138 |
+
|
| 139 |
+
def image_to_base64(pil_image):
|
| 140 |
+
buffer = io.BytesIO()
|
| 141 |
+
pil_image.save(buffer, format='JPEG', quality=90)
|
| 142 |
+
img_data = base64.b64encode(buffer.getvalue()).decode()
|
| 143 |
+
return f"data:image/jpeg;base64,{img_data}"
|
| 144 |
+
|
| 145 |
+
def get_user_sessions_list(user: User, db: Session):
|
| 146 |
+
"""Obtener lista de nombres de sesiones del usuario (para compatibilidad)"""
|
| 147 |
+
if user and user.is_admin:
|
| 148 |
+
# Los admins pueden ver todas las sesiones
|
| 149 |
+
sessions_dir = "annotations"
|
| 150 |
+
all_sessions = []
|
| 151 |
+
if os.path.exists(sessions_dir):
|
| 152 |
+
for session_name in os.listdir(sessions_dir):
|
| 153 |
+
session_path = os.path.join(sessions_dir, session_name)
|
| 154 |
+
if os.path.isdir(session_path):
|
| 155 |
+
all_sessions.append(session_name)
|
| 156 |
+
return all_sessions
|
| 157 |
+
elif user:
|
| 158 |
+
# Usuarios normales solo ven sus sesiones
|
| 159 |
+
user_sessions = db.query(UserSession).filter(
|
| 160 |
+
UserSession.user_id == user.id,
|
| 161 |
+
UserSession.is_active == True
|
| 162 |
+
).all()
|
| 163 |
+
return [session.session_name for session in user_sessions]
|
| 164 |
+
else:
|
| 165 |
+
# Usuario no autenticado: mostrar todas las sesiones disponibles (modo invitado)
|
| 166 |
+
sessions_dir = "annotations"
|
| 167 |
+
all_sessions = []
|
| 168 |
+
if os.path.exists(sessions_dir):
|
| 169 |
+
for session_name in os.listdir(sessions_dir):
|
| 170 |
+
session_path = os.path.join(sessions_dir, session_name)
|
| 171 |
+
if os.path.isdir(session_path):
|
| 172 |
+
all_sessions.append(session_name)
|
| 173 |
+
return all_sessions
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def get_user_sessions_with_info(user: User, db: Session):
|
| 177 |
+
"""Obtener lista de sesiones del usuario con información completa (para API)"""
|
| 178 |
+
if user and user.is_admin:
|
| 179 |
+
# Los admins pueden ver todas las sesiones activas de la base de datos
|
| 180 |
+
user_sessions = db.query(UserSession).filter(
|
| 181 |
+
UserSession.is_active == True
|
| 182 |
+
).all()
|
| 183 |
+
|
| 184 |
+
sessions_list = []
|
| 185 |
+
for session in user_sessions:
|
| 186 |
+
session_info = {
|
| 187 |
+
'name': session.session_name,
|
| 188 |
+
'session_hash': session.session_hash,
|
| 189 |
+
'is_private': bool(session.session_hash)
|
| 190 |
+
}
|
| 191 |
+
sessions_list.append(session_info)
|
| 192 |
+
|
| 193 |
+
return sessions_list
|
| 194 |
+
elif user:
|
| 195 |
+
# Usuarios normales solo ven sus sesiones
|
| 196 |
+
user_sessions = db.query(UserSession).filter(
|
| 197 |
+
UserSession.user_id == user.id,
|
| 198 |
+
UserSession.is_active == True
|
| 199 |
+
).all()
|
| 200 |
+
|
| 201 |
+
sessions_list = []
|
| 202 |
+
for session in user_sessions:
|
| 203 |
+
session_info = {
|
| 204 |
+
'name': session.session_name,
|
| 205 |
+
'session_hash': session.session_hash,
|
| 206 |
+
'is_private': bool(session.session_hash)
|
| 207 |
+
}
|
| 208 |
+
sessions_list.append(session_info)
|
| 209 |
+
|
| 210 |
+
return sessions_list
|
| 211 |
+
else:
|
| 212 |
+
# Usuario no autenticado: mostrar sesiones disponibles con directorios físicos
|
| 213 |
+
sessions_dir = "annotations"
|
| 214 |
+
all_sessions = []
|
| 215 |
+
if os.path.exists(sessions_dir):
|
| 216 |
+
for session_name in os.listdir(sessions_dir):
|
| 217 |
+
session_path = os.path.join(sessions_dir, session_name)
|
| 218 |
+
if os.path.isdir(session_path):
|
| 219 |
+
# Para usuarios no autenticados, no mostrar información de hash
|
| 220 |
+
session_info = {
|
| 221 |
+
'name': session_name,
|
| 222 |
+
'session_hash': None,
|
| 223 |
+
'is_private': False
|
| 224 |
+
}
|
| 225 |
+
all_sessions.append(session_info)
|
| 226 |
+
return all_sessions
|
| 227 |
+
|
| 228 |
+
# ============================================================================
|
| 229 |
+
# MIDDLEWARE DE AUTENTICACIÓN
|
| 230 |
+
# ============================================================================
|
| 231 |
+
@app.middleware("http")
|
| 232 |
+
async def auth_middleware(request: Request, call_next):
|
| 233 |
+
"""Middleware para manejar autenticación en rutas protegidas"""
|
| 234 |
+
|
| 235 |
+
# Rutas públicas que no requieren autenticación
|
| 236 |
+
public_paths = [
|
| 237 |
+
"/",
|
| 238 |
+
"/login",
|
| 239 |
+
"/register",
|
| 240 |
+
"/auth/",
|
| 241 |
+
"/static/",
|
| 242 |
+
"/docs",
|
| 243 |
+
"/redoc",
|
| 244 |
+
"/openapi.json"
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
# Verificar si la ruta es pública
|
| 248 |
+
is_public = any(request.url.path.startswith(path) for path in public_paths)
|
| 249 |
+
|
| 250 |
+
if is_public:
|
| 251 |
+
response = await call_next(request)
|
| 252 |
+
return response
|
| 253 |
+
|
| 254 |
+
# Para rutas protegidas, verificar autenticación en el navegador
|
| 255 |
+
# (Las rutas API manejan su propia autenticación con dependencies)
|
| 256 |
+
if not request.url.path.startswith("/api/"):
|
| 257 |
+
# Verificar si hay token en headers (para navegador)
|
| 258 |
+
auth_header = request.headers.get("authorization")
|
| 259 |
+
if not auth_header:
|
| 260 |
+
# Redireccionar a login si no está autenticado
|
| 261 |
+
return RedirectResponse(url="/login", status_code=302)
|
| 262 |
+
|
| 263 |
+
response = await call_next(request)
|
| 264 |
+
return response
|
| 265 |
+
|
| 266 |
+
# ============================================================================
|
| 267 |
+
# ENDPOINTS PRINCIPALES
|
| 268 |
+
# ============================================================================
|
| 269 |
+
@app.get("/", response_class=HTMLResponse)
|
| 270 |
+
async def main(request: Request):
|
| 271 |
+
"""Página principal - pública"""
|
| 272 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 273 |
+
|
| 274 |
+
@app.get("/login", response_class=HTMLResponse)
|
| 275 |
+
async def login_page(request: Request):
|
| 276 |
+
"""Página de login"""
|
| 277 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 278 |
+
|
| 279 |
+
@app.get("/register", response_class=HTMLResponse)
|
| 280 |
+
async def register_page(request: Request):
|
| 281 |
+
"""Página de registro"""
|
| 282 |
+
return templates.TemplateResponse("register.html", {"request": request})
|
| 283 |
+
|
| 284 |
+
@app.get("/dashboard", response_class=HTMLResponse)
|
| 285 |
+
async def dashboard(
|
| 286 |
+
request: Request,
|
| 287 |
+
current_user: User = Depends(get_optional_user),
|
| 288 |
+
db: Session = Depends(get_db)
|
| 289 |
+
):
|
| 290 |
+
"""Dashboard principal - verificación de autenticación en frontend"""
|
| 291 |
+
if current_user:
|
| 292 |
+
user_sessions = get_user_sessions_list(current_user, db)
|
| 293 |
+
else:
|
| 294 |
+
user_sessions = []
|
| 295 |
+
|
| 296 |
+
return templates.TemplateResponse("dashboard.html", {
|
| 297 |
+
"request": request,
|
| 298 |
+
"user": current_user,
|
| 299 |
+
"sessions": user_sessions
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
@app.get("/sessions", response_class=HTMLResponse)
|
| 303 |
+
async def sessions_page(
|
| 304 |
+
request: Request,
|
| 305 |
+
current_user: User = Depends(get_optional_user),
|
| 306 |
+
db: Session = Depends(get_db)
|
| 307 |
+
):
|
| 308 |
+
"""Página de gestión de sesiones - autenticación opcional"""
|
| 309 |
+
try:
|
| 310 |
+
sessions_dir = "annotations"
|
| 311 |
+
sessions = []
|
| 312 |
+
|
| 313 |
+
# Obtener sesiones del usuario
|
| 314 |
+
user_sessions = get_user_sessions_list(current_user, db)
|
| 315 |
+
|
| 316 |
+
for session_name in user_sessions:
|
| 317 |
+
session_path = os.path.join(sessions_dir, session_name)
|
| 318 |
+
if os.path.isdir(session_path):
|
| 319 |
+
images_path = os.path.join(session_path, "images")
|
| 320 |
+
labels_path = os.path.join(session_path, "labels")
|
| 321 |
+
|
| 322 |
+
image_count = 0
|
| 323 |
+
label_count = 0
|
| 324 |
+
|
| 325 |
+
if os.path.exists(images_path):
|
| 326 |
+
image_count = len([f for f in os.listdir(images_path)
|
| 327 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
|
| 328 |
+
|
| 329 |
+
if os.path.exists(labels_path):
|
| 330 |
+
label_count = len([f for f in os.listdir(labels_path)
|
| 331 |
+
if f.lower().endswith('.txt')])
|
| 332 |
+
|
| 333 |
+
sessions.append({
|
| 334 |
+
'name': session_name,
|
| 335 |
+
'images': image_count,
|
| 336 |
+
'labels': label_count,
|
| 337 |
+
'path': session_path
|
| 338 |
+
})
|
| 339 |
+
|
| 340 |
+
return templates.TemplateResponse("sessions.html", {
|
| 341 |
+
"request": request,
|
| 342 |
+
"sessions": sessions,
|
| 343 |
+
"current_user": current_user
|
| 344 |
+
})
|
| 345 |
+
|
| 346 |
+
except Exception as e:
|
| 347 |
+
return HTMLResponse(content=f"<h1>Error</h1><p>Error: {str(e)}</p><a href='/dashboard'>← Volver</a>")
|
| 348 |
+
|
| 349 |
+
@app.get("/annotator", response_class=HTMLResponse)
|
| 350 |
+
async def annotator_page(
|
| 351 |
+
request: Request,
|
| 352 |
+
current_user: User = Depends(get_optional_user)
|
| 353 |
+
):
|
| 354 |
+
"""Anotador clásico para crear datasets - verificación de autenticación en frontend"""
|
| 355 |
+
return templates.TemplateResponse("annotator.html", {
|
| 356 |
+
"request": request,
|
| 357 |
+
"user": current_user
|
| 358 |
+
})
|
| 359 |
+
|
| 360 |
+
@app.get("/visualizer", response_class=HTMLResponse)
|
| 361 |
+
async def visualizer_page(
|
| 362 |
+
request: Request,
|
| 363 |
+
session: str = None,
|
| 364 |
+
current_user: User = Depends(get_optional_user),
|
| 365 |
+
db: Session = Depends(get_db)
|
| 366 |
+
):
|
| 367 |
+
"""Visualizador de datasets con anotaciones - verificación de autenticación en frontend"""
|
| 368 |
+
try:
|
| 369 |
+
# Obtener sesiones del usuario si está autenticado
|
| 370 |
+
if current_user:
|
| 371 |
+
user_sessions = get_user_sessions_list(current_user, db)
|
| 372 |
+
|
| 373 |
+
# Verificar acceso a sesión específica si se proporciona
|
| 374 |
+
if session and not verify_session_access(current_user, session, db):
|
| 375 |
+
raise HTTPException(status_code=403, detail="No access to this session")
|
| 376 |
+
else:
|
| 377 |
+
user_sessions = []
|
| 378 |
+
|
| 379 |
+
return templates.TemplateResponse("visualizer.html", {
|
| 380 |
+
"request": request,
|
| 381 |
+
"sessions": user_sessions,
|
| 382 |
+
"current_session": session,
|
| 383 |
+
"user": current_user
|
| 384 |
+
})
|
| 385 |
+
|
| 386 |
+
except HTTPException:
|
| 387 |
+
raise
|
| 388 |
+
except Exception as e:
|
| 389 |
+
return HTMLResponse(content=f"<h1>Error</h1><p>Error en visualizador: {str(e)}</p><a href='/dashboard'>← Volver</a>")
|
| 390 |
+
|
| 391 |
+
# ============================================================================
|
| 392 |
+
# API ENDPOINTS CON AUTENTICACIÓN
|
| 393 |
+
# ============================================================================
|
| 394 |
+
|
| 395 |
+
# ============================================================================
|
| 396 |
+
# NUEVOS ENDPOINTS PARA ACCESO POR HASH
|
| 397 |
+
# ============================================================================
|
| 398 |
+
|
| 399 |
+
@app.get("/session/{session_hash}", response_class=HTMLResponse)
|
| 400 |
+
async def session_by_hash_page(
|
| 401 |
+
request: Request,
|
| 402 |
+
session_hash: str,
|
| 403 |
+
db: Session = Depends(get_db)
|
| 404 |
+
):
|
| 405 |
+
"""
|
| 406 |
+
Página de acceso a una sesión específica por hash.
|
| 407 |
+
NO requiere autenticación - acceso público con hash.
|
| 408 |
+
"""
|
| 409 |
+
from auth.session_utils import get_session_by_hash
|
| 410 |
+
|
| 411 |
+
# Verificar que la sesión existe
|
| 412 |
+
session = get_session_by_hash(db, session_hash)
|
| 413 |
+
if not session:
|
| 414 |
+
return HTMLResponse(
|
| 415 |
+
content=f"""
|
| 416 |
+
<h1>🔍 Sesión no encontrada</h1>
|
| 417 |
+
<p>La sesión con hash <code>{session_hash}</code> no existe o está inactiva.</p>
|
| 418 |
+
<a href="/">← Ir al inicio</a>
|
| 419 |
+
""",
|
| 420 |
+
status_code=404
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
return templates.TemplateResponse("session_access.html", {
|
| 424 |
+
"request": request,
|
| 425 |
+
"session": session,
|
| 426 |
+
"session_hash": session_hash,
|
| 427 |
+
"annotator_url": f"/session/{session_hash}/annotator",
|
| 428 |
+
"visualizer_url": f"/session/{session_hash}/visualizer"
|
| 429 |
+
})
|
| 430 |
+
|
| 431 |
+
@app.get("/session/{session_hash}/annotator", response_class=HTMLResponse)
|
| 432 |
+
async def session_annotator_by_hash(
|
| 433 |
+
request: Request,
|
| 434 |
+
session_hash: str,
|
| 435 |
+
db: Session = Depends(get_db)
|
| 436 |
+
):
|
| 437 |
+
"""
|
| 438 |
+
Anotador para una sesión específica accesible por hash.
|
| 439 |
+
NO requiere autenticación.
|
| 440 |
+
"""
|
| 441 |
+
from auth.session_utils import get_session_by_hash
|
| 442 |
+
|
| 443 |
+
session = get_session_by_hash(db, session_hash)
|
| 444 |
+
if not session:
|
| 445 |
+
return HTMLResponse(content="Sesión no encontrada", status_code=404)
|
| 446 |
+
|
| 447 |
+
return templates.TemplateResponse("annotator.html", {
|
| 448 |
+
"request": request,
|
| 449 |
+
"session_hash": session_hash,
|
| 450 |
+
"session_name": session.session_name,
|
| 451 |
+
"public_access": True,
|
| 452 |
+
"user": None # Sin usuario autenticado
|
| 453 |
+
})
|
| 454 |
+
|
| 455 |
+
@app.get("/session/{session_hash}/visualizer", response_class=HTMLResponse)
|
| 456 |
+
async def session_visualizer_by_hash(
|
| 457 |
+
request: Request,
|
| 458 |
+
session_hash: str,
|
| 459 |
+
db: Session = Depends(get_db)
|
| 460 |
+
):
|
| 461 |
+
"""
|
| 462 |
+
Visualizador para una sesión específica accesible por hash.
|
| 463 |
+
NO requiere autenticación.
|
| 464 |
+
"""
|
| 465 |
+
from auth.session_utils import get_session_by_hash
|
| 466 |
+
|
| 467 |
+
session = get_session_by_hash(db, session_hash)
|
| 468 |
+
if not session:
|
| 469 |
+
return HTMLResponse(content="Sesión no encontrada", status_code=404)
|
| 470 |
+
|
| 471 |
+
return templates.TemplateResponse("visualizer.html", {
|
| 472 |
+
"request": request,
|
| 473 |
+
"session_hash": session_hash,
|
| 474 |
+
"session_name": session.session_name,
|
| 475 |
+
"current_session": session.session_name,
|
| 476 |
+
"public_access": True,
|
| 477 |
+
"sessions": [session], # Solo esta sesión
|
| 478 |
+
"user": None # Sin usuario autenticado
|
| 479 |
+
})
|
| 480 |
+
@app.get("/api/sessions")
|
| 481 |
+
async def list_sessions_api(
|
| 482 |
+
request: Request,
|
| 483 |
+
current_user: User = Depends(get_current_user),
|
| 484 |
+
db: Session = Depends(get_db)
|
| 485 |
+
):
|
| 486 |
+
"""Listar sesiones del usuario actual"""
|
| 487 |
+
try:
|
| 488 |
+
sessions_dir = "annotations"
|
| 489 |
+
sessions = []
|
| 490 |
+
|
| 491 |
+
# Obtener sesiones del usuario con información de hash
|
| 492 |
+
user_sessions = get_user_sessions_list(current_user, db)
|
| 493 |
+
|
| 494 |
+
for session_info in user_sessions:
|
| 495 |
+
# Manejar tanto el formato nuevo (dict) como el viejo (string)
|
| 496 |
+
if isinstance(session_info, dict):
|
| 497 |
+
session_name = session_info['name']
|
| 498 |
+
session_hash = session_info.get('session_hash')
|
| 499 |
+
is_private = session_info.get('is_private', False)
|
| 500 |
+
else:
|
| 501 |
+
session_name = session_info
|
| 502 |
+
session_hash = None
|
| 503 |
+
is_private = False
|
| 504 |
+
|
| 505 |
+
session_path = os.path.join(sessions_dir, session_name)
|
| 506 |
+
|
| 507 |
+
# Contar archivos si el directorio existe
|
| 508 |
+
images_count = 0
|
| 509 |
+
labels_count = 0
|
| 510 |
+
|
| 511 |
+
if os.path.isdir(session_path):
|
| 512 |
+
images_path = os.path.join(session_path, "images")
|
| 513 |
+
labels_path = os.path.join(session_path, "labels")
|
| 514 |
+
|
| 515 |
+
if os.path.exists(images_path):
|
| 516 |
+
images_count = len([f for f in os.listdir(images_path)
|
| 517 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'))])
|
| 518 |
+
|
| 519 |
+
if os.path.exists(labels_path):
|
| 520 |
+
labels_count = len([f for f in os.listdir(labels_path)
|
| 521 |
+
if f.lower().endswith('.txt')])
|
| 522 |
+
|
| 523 |
+
session_data = {
|
| 524 |
+
'name': session_name,
|
| 525 |
+
'images_count': images_count,
|
| 526 |
+
'labels_count': labels_count,
|
| 527 |
+
'is_private': is_private
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
# Agregar información de hash solo si existe
|
| 531 |
+
if session_hash:
|
| 532 |
+
session_data['session_hash'] = session_hash
|
| 533 |
+
session_data['share_url'] = f"{request.base_url}session/{session_hash}"
|
| 534 |
+
|
| 535 |
+
sessions.append(session_data)
|
| 536 |
+
|
| 537 |
+
# Ordenar sesiones por nombre
|
| 538 |
+
sessions.sort(key=lambda x: x['name'])
|
| 539 |
+
|
| 540 |
+
return {
|
| 541 |
+
"success": True,
|
| 542 |
+
"sessions": sessions,
|
| 543 |
+
"user": {
|
| 544 |
+
"username": current_user.username,
|
| 545 |
+
"is_admin": current_user.is_admin
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
except Exception as e:
|
| 549 |
+
return {"success": False, "message": f"Error al listar sesiones: {str(e)}"}
|
| 550 |
+
|
| 551 |
+
@app.get("/api/session/{session_name}/visualize")
|
| 552 |
+
async def get_session_visualize_data(
|
| 553 |
+
session_name: str,
|
| 554 |
+
limit: int = None,
|
| 555 |
+
offset: int = 0,
|
| 556 |
+
current_user: User = Depends(get_current_user),
|
| 557 |
+
db: Session = Depends(get_db)
|
| 558 |
+
):
|
| 559 |
+
"""API endpoint para obtener datos de visualización de una sesión"""
|
| 560 |
+
try:
|
| 561 |
+
# Verificar acceso a la sesión
|
| 562 |
+
if not verify_session_access(current_user, session_name, db):
|
| 563 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 564 |
+
|
| 565 |
+
session_path = os.path.join("annotations", session_name)
|
| 566 |
+
images_path = os.path.join(session_path, "images")
|
| 567 |
+
labels_path = os.path.join(session_path, "labels")
|
| 568 |
+
|
| 569 |
+
if not os.path.exists(session_path):
|
| 570 |
+
return {"success": False, "message": f"Sesión '{session_name}' no encontrada"}
|
| 571 |
+
|
| 572 |
+
images_data = []
|
| 573 |
+
total_labels = 0
|
| 574 |
+
|
| 575 |
+
if os.path.exists(images_path):
|
| 576 |
+
for filename in os.listdir(images_path):
|
| 577 |
+
if filename.lower().endswith(('.jpg', '.jpeg', '.png')):
|
| 578 |
+
# Obtener dimensiones de la imagen
|
| 579 |
+
from PIL import Image as PILImage
|
| 580 |
+
image_path = os.path.join(images_path, filename)
|
| 581 |
+
|
| 582 |
+
try:
|
| 583 |
+
with PILImage.open(image_path) as img:
|
| 584 |
+
width, height = img.size
|
| 585 |
+
except:
|
| 586 |
+
width, height = 640, 640 # Default si hay error
|
| 587 |
+
|
| 588 |
+
# Leer etiquetas para esta imagen
|
| 589 |
+
label_filename = os.path.splitext(filename)[0] + '.txt'
|
| 590 |
+
label_path = os.path.join(labels_path, label_filename)
|
| 591 |
+
|
| 592 |
+
annotations = []
|
| 593 |
+
if os.path.exists(label_path):
|
| 594 |
+
with open(label_path, 'r') as f:
|
| 595 |
+
for line_num, line in enumerate(f):
|
| 596 |
+
line = line.strip()
|
| 597 |
+
if line:
|
| 598 |
+
try:
|
| 599 |
+
parts = line.split()
|
| 600 |
+
if len(parts) >= 5:
|
| 601 |
+
class_id = int(parts[0])
|
| 602 |
+
x_center = float(parts[1])
|
| 603 |
+
y_center = float(parts[2])
|
| 604 |
+
bbox_width = float(parts[3])
|
| 605 |
+
bbox_height = float(parts[4])
|
| 606 |
+
|
| 607 |
+
# Convertir de formato YOLO normalizado a píxeles
|
| 608 |
+
x1 = int((x_center - bbox_width/2) * width)
|
| 609 |
+
y1 = int((y_center - bbox_height/2) * height)
|
| 610 |
+
x2 = int((x_center + bbox_width/2) * width)
|
| 611 |
+
y2 = int((y_center + bbox_height/2) * height)
|
| 612 |
+
|
| 613 |
+
annotations.append({
|
| 614 |
+
'class_id': class_id,
|
| 615 |
+
'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2,
|
| 616 |
+
'x_center': x_center, 'y_center': y_center,
|
| 617 |
+
'width': bbox_width, 'height': bbox_height
|
| 618 |
+
})
|
| 619 |
+
except (ValueError, IndexError) as e:
|
| 620 |
+
print(f"Error procesando línea {line_num} en {label_filename}: {e}")
|
| 621 |
+
continue
|
| 622 |
+
|
| 623 |
+
total_labels += len(annotations)
|
| 624 |
+
|
| 625 |
+
images_data.append({
|
| 626 |
+
'name': filename,
|
| 627 |
+
'labels': len(annotations),
|
| 628 |
+
'annotations': annotations,
|
| 629 |
+
'width': width,
|
| 630 |
+
'height': height
|
| 631 |
+
})
|
| 632 |
+
|
| 633 |
+
# Aplicar paginación si se especifica
|
| 634 |
+
images_to_return = images_data
|
| 635 |
+
if limit is not None and limit > 0:
|
| 636 |
+
end_index = offset + limit
|
| 637 |
+
images_to_return = images_data[offset:end_index]
|
| 638 |
+
|
| 639 |
+
return {
|
| 640 |
+
"success": True,
|
| 641 |
+
"session_name": session_name,
|
| 642 |
+
"total_images": len(images_data),
|
| 643 |
+
"total_labels": total_labels,
|
| 644 |
+
"returned_images": len(images_to_return),
|
| 645 |
+
"offset": offset,
|
| 646 |
+
"has_more": limit is not None and limit > 0 and offset + limit < len(images_data),
|
| 647 |
+
"images": images_to_return
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
except Exception as e:
|
| 651 |
+
return {"success": False, "message": f"Error: {str(e)}"}
|
| 652 |
+
|
| 653 |
+
@app.post("/api/session/{session_name}/create")
|
| 654 |
+
async def create_session_api(
|
| 655 |
+
session_name: str,
|
| 656 |
+
current_user: User = Depends(get_current_user),
|
| 657 |
+
db: Session = Depends(get_db)
|
| 658 |
+
):
|
| 659 |
+
"""Crear nueva sesión para el usuario"""
|
| 660 |
+
try:
|
| 661 |
+
# Verificar que el nombre de sesión sea válido
|
| 662 |
+
if not session_name or session_name.strip() == '':
|
| 663 |
+
return {"success": False, "message": "Nombre de sesión inválido"}
|
| 664 |
+
|
| 665 |
+
# Limpiar nombre de sesión
|
| 666 |
+
safe_session_name = "".join(c for c in session_name if c.isalnum() or c in ('_', '-')).strip()
|
| 667 |
+
if not safe_session_name:
|
| 668 |
+
return {"success": False, "message": "Nombre de sesión contiene caracteres inválidos"}
|
| 669 |
+
|
| 670 |
+
# Verificar si la sesión ya existe para este usuario
|
| 671 |
+
existing_session = db.query(UserSession).filter(
|
| 672 |
+
UserSession.user_id == current_user.id,
|
| 673 |
+
UserSession.session_name == safe_session_name
|
| 674 |
+
).first()
|
| 675 |
+
|
| 676 |
+
if existing_session:
|
| 677 |
+
return {"success": False, "message": "Ya tienes una sesión con este nombre"}
|
| 678 |
+
|
| 679 |
+
# Crear estructura de sesión
|
| 680 |
+
session_path = create_session_structure(safe_session_name, current_user.id, db)
|
| 681 |
+
|
| 682 |
+
return {
|
| 683 |
+
"success": True,
|
| 684 |
+
"message": f"Sesión '{safe_session_name}' creada exitosamente",
|
| 685 |
+
"session_name": safe_session_name
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
except Exception as e:
|
| 689 |
+
return {"success": False, "message": f"Error al crear sesión: {str(e)}"}
|
| 690 |
+
|
| 691 |
+
@app.post("/api/upload")
|
| 692 |
+
async def upload_image(
|
| 693 |
+
session: str = Form(...),
|
| 694 |
+
canvas_width: int = Form(640),
|
| 695 |
+
canvas_height: int = Form(640),
|
| 696 |
+
x: int = Form(0),
|
| 697 |
+
y: int = Form(0),
|
| 698 |
+
change_bg: bool = Form(True),
|
| 699 |
+
file: UploadFile = File(...),
|
| 700 |
+
current_user: User = Depends(get_current_user),
|
| 701 |
+
db: Session = Depends(get_db)
|
| 702 |
+
):
|
| 703 |
+
"""Subir imagen a sesión del usuario"""
|
| 704 |
+
try:
|
| 705 |
+
# Verificar acceso a la sesión
|
| 706 |
+
if not verify_session_access(current_user, session, db):
|
| 707 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 708 |
+
|
| 709 |
+
# Leer archivo
|
| 710 |
+
image_bytes = await file.read()
|
| 711 |
+
|
| 712 |
+
# Crear imagen con canvas
|
| 713 |
+
canvas_image = create_canvas_with_image(
|
| 714 |
+
image_bytes, (canvas_width, canvas_height), x, y, change_bg
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
# Generar nombre único de archivo
|
| 718 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 719 |
+
image_filename = f"{session}_{timestamp}_{file.filename}"
|
| 720 |
+
|
| 721 |
+
# Guardar imagen
|
| 722 |
+
session_path = os.path.join("annotations", session)
|
| 723 |
+
image_path = os.path.join(session_path, "images", image_filename)
|
| 724 |
+
|
| 725 |
+
# Crear directorio si no existe
|
| 726 |
+
os.makedirs(os.path.dirname(image_path), exist_ok=True)
|
| 727 |
+
|
| 728 |
+
canvas_image.save(image_path)
|
| 729 |
+
|
| 730 |
+
# Convertir a base64 para vista previa
|
| 731 |
+
preview_b64 = image_to_base64(canvas_image)
|
| 732 |
+
|
| 733 |
+
return {
|
| 734 |
+
"success": True,
|
| 735 |
+
"filename": image_filename,
|
| 736 |
+
"preview": preview_b64,
|
| 737 |
+
"message": f"Imagen subida a sesión '{session}'"
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
except Exception as e:
|
| 741 |
+
return {"success": False, "message": f"Error al subir imagen: {str(e)}"}
|
| 742 |
+
|
| 743 |
+
@app.post("/api/save_annotations")
|
| 744 |
+
async def save_annotations(
|
| 745 |
+
session: str = Form(...),
|
| 746 |
+
filename: str = Form(...),
|
| 747 |
+
annotations: str = Form(...),
|
| 748 |
+
current_user: User = Depends(get_current_user),
|
| 749 |
+
db: Session = Depends(get_db)
|
| 750 |
+
):
|
| 751 |
+
"""Guardar anotaciones de una imagen"""
|
| 752 |
+
try:
|
| 753 |
+
# Verificar acceso a la sesión
|
| 754 |
+
if not verify_session_access(current_user, session, db):
|
| 755 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 756 |
+
|
| 757 |
+
# Procesar anotaciones JSON
|
| 758 |
+
try:
|
| 759 |
+
annotations_data = json.loads(annotations)
|
| 760 |
+
except json.JSONDecodeError:
|
| 761 |
+
return {"success": False, "message": "Formato de anotaciones inválido"}
|
| 762 |
+
|
| 763 |
+
# Preparar contenido del archivo de etiquetas
|
| 764 |
+
label_content = []
|
| 765 |
+
for ann in annotations_data:
|
| 766 |
+
if all(key in ann for key in ['class_id', 'x_center', 'y_center', 'width', 'height']):
|
| 767 |
+
label_line = f"{ann['class_id']} {ann['x_center']} {ann['y_center']} {ann['width']} {ann['height']}"
|
| 768 |
+
label_content.append(label_line)
|
| 769 |
+
|
| 770 |
+
# Guardar archivo de etiquetas
|
| 771 |
+
session_path = os.path.join("annotations", session)
|
| 772 |
+
label_filename = os.path.splitext(filename)[0] + '.txt'
|
| 773 |
+
label_path = os.path.join(session_path, "labels", label_filename)
|
| 774 |
+
|
| 775 |
+
# Crear directorio si no existe
|
| 776 |
+
os.makedirs(os.path.dirname(label_path), exist_ok=True)
|
| 777 |
+
|
| 778 |
+
with open(label_path, 'w') as f:
|
| 779 |
+
f.write('\n'.join(label_content))
|
| 780 |
+
|
| 781 |
+
return {
|
| 782 |
+
"success": True,
|
| 783 |
+
"message": f"Anotaciones guardadas para {filename}",
|
| 784 |
+
"annotations_count": len(label_content)
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
except Exception as e:
|
| 788 |
+
return {"success": False, "message": f"Error al guardar anotaciones: {str(e)}"}
|
| 789 |
+
|
| 790 |
+
@app.post("/api/augment")
|
| 791 |
+
async def augment_dataset_api(
|
| 792 |
+
background_tasks: BackgroundTasks,
|
| 793 |
+
session: str = Form(...),
|
| 794 |
+
variants: list = Form(None),
|
| 795 |
+
current_user: User = Depends(get_current_user),
|
| 796 |
+
db: Session = Depends(get_db)
|
| 797 |
+
):
|
| 798 |
+
"""Aumentar dataset en background - requiere autenticación"""
|
| 799 |
+
try:
|
| 800 |
+
# Verificar acceso a la sesión
|
| 801 |
+
if not verify_session_access(current_user, session, db):
|
| 802 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 803 |
+
|
| 804 |
+
session_path = os.path.join("annotations", session)
|
| 805 |
+
|
| 806 |
+
if not os.path.exists(session_path):
|
| 807 |
+
return {"success": False, "message": f"Sesión '{session}' no encontrada"}
|
| 808 |
+
|
| 809 |
+
# Verificar que la sesión tenga imágenes
|
| 810 |
+
images_path = os.path.join(session_path, "images")
|
| 811 |
+
if not os.path.exists(images_path) or not os.listdir(images_path):
|
| 812 |
+
return {"success": False, "message": f"No hay imágenes en la sesión '{session}'"}
|
| 813 |
+
|
| 814 |
+
# Usar variantes especificadas o todas si no se especifican
|
| 815 |
+
selected_variants = variants if variants else list(AVAILABLE_VARIANTS.keys())
|
| 816 |
+
|
| 817 |
+
# Verificar que las variantes sean válidas
|
| 818 |
+
invalid_variants = [v for v in selected_variants if v not in AVAILABLE_VARIANTS]
|
| 819 |
+
if invalid_variants:
|
| 820 |
+
return {"success": False, "message": f"Variantes inválidas: {invalid_variants}"}
|
| 821 |
+
|
| 822 |
+
# Ejecutar augmentación en background con variantes seleccionadas
|
| 823 |
+
background_tasks.add_task(augment_session, session, selected_variants)
|
| 824 |
+
|
| 825 |
+
return {
|
| 826 |
+
"success": True,
|
| 827 |
+
"message": f"Aumentación iniciada para sesión '{session}' con {len(selected_variants)} variantes",
|
| 828 |
+
"session": session,
|
| 829 |
+
"selected_variants": selected_variants,
|
| 830 |
+
"available_variants": AVAILABLE_VARIANTS
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
except Exception as e:
|
| 834 |
+
return {"success": False, "message": f"Error al iniciar augmentación: {str(e)}"}
|
| 835 |
+
|
| 836 |
+
@app.get("/api/augment/progress/{session}")
|
| 837 |
+
async def get_augment_progress(
|
| 838 |
+
session: str,
|
| 839 |
+
current_user: User = Depends(get_optional_user),
|
| 840 |
+
db: Session = Depends(get_db)
|
| 841 |
+
):
|
| 842 |
+
"""Obtener progreso de augmentación"""
|
| 843 |
+
try:
|
| 844 |
+
# Verificar acceso a la sesión solo si hay usuario autenticado
|
| 845 |
+
if current_user and not verify_session_access(current_user, session, db):
|
| 846 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 847 |
+
|
| 848 |
+
progress_file = f"temp/progress_{session}.json"
|
| 849 |
+
|
| 850 |
+
if not os.path.exists(progress_file):
|
| 851 |
+
return {
|
| 852 |
+
"success": False,
|
| 853 |
+
"message": "No hay proceso de augmentación activo"
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
with open(progress_file, 'r') as f:
|
| 857 |
+
progress_data = json.load(f)
|
| 858 |
+
|
| 859 |
+
return {
|
| 860 |
+
"success": True,
|
| 861 |
+
**progress_data # Expandir las propiedades directamente
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
except Exception as e:
|
| 865 |
+
return {"success": False, "message": f"Error al obtener progreso: {str(e)}"}
|
| 866 |
+
|
| 867 |
+
@app.get("/api/stats/{session}")
|
| 868 |
+
async def get_session_stats_api(
|
| 869 |
+
session: str,
|
| 870 |
+
current_user: User = Depends(get_current_user),
|
| 871 |
+
db: Session = Depends(get_db)
|
| 872 |
+
):
|
| 873 |
+
"""Obtener estadísticas de una sesión"""
|
| 874 |
+
try:
|
| 875 |
+
# Verificar acceso a la sesión
|
| 876 |
+
if not verify_session_access(current_user, session, db):
|
| 877 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 878 |
+
|
| 879 |
+
stats = get_session_stats(session)
|
| 880 |
+
return {
|
| 881 |
+
"success": True,
|
| 882 |
+
"session": session,
|
| 883 |
+
"stats": stats
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
except Exception as e:
|
| 887 |
+
return {"success": False, "message": f"Error al obtener estadísticas: {str(e)}"}
|
| 888 |
+
|
| 889 |
+
@app.get("/api/download/{session}")
|
| 890 |
+
async def download_session(
|
| 891 |
+
session: str,
|
| 892 |
+
current_user: User = Depends(get_current_user),
|
| 893 |
+
db: Session = Depends(get_db)
|
| 894 |
+
):
|
| 895 |
+
"""Descargar sesión como archivo ZIP"""
|
| 896 |
+
try:
|
| 897 |
+
# Verificar acceso a la sesión
|
| 898 |
+
if not verify_session_access(current_user, session, db):
|
| 899 |
+
raise HTTPException(status_code=403, detail="No tienes acceso a esta sesión")
|
| 900 |
+
|
| 901 |
+
session_path = os.path.join("annotations", session)
|
| 902 |
+
|
| 903 |
+
if not os.path.exists(session_path):
|
| 904 |
+
raise HTTPException(status_code=404, detail=f"Sesión '{session}' no encontrada")
|
| 905 |
+
|
| 906 |
+
# Crear archivo ZIP temporal
|
| 907 |
+
zip_filename = f"{session}_dataset.zip"
|
| 908 |
+
zip_path = os.path.join("temp", zip_filename)
|
| 909 |
+
|
| 910 |
+
os.makedirs("temp", exist_ok=True)
|
| 911 |
+
|
| 912 |
+
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
| 913 |
+
for root, dirs, files in os.walk(session_path):
|
| 914 |
+
for file in files:
|
| 915 |
+
file_path = os.path.join(root, file)
|
| 916 |
+
arcname = os.path.relpath(file_path, session_path)
|
| 917 |
+
zipf.write(file_path, arcname)
|
| 918 |
+
|
| 919 |
+
return FileResponse(
|
| 920 |
+
path=zip_path,
|
| 921 |
+
media_type='application/zip',
|
| 922 |
+
filename=zip_filename
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
except HTTPException:
|
| 926 |
+
raise
|
| 927 |
+
except Exception as e:
|
| 928 |
+
raise HTTPException(status_code=500, detail=f"Error al descargar: {str(e)}")
|
| 929 |
+
|
| 930 |
+
@app.delete("/api/session/{session_name}")
|
| 931 |
+
async def delete_session_api(
|
| 932 |
+
session_name: str,
|
| 933 |
+
current_user: User = Depends(get_current_user),
|
| 934 |
+
db: Session = Depends(get_db)
|
| 935 |
+
):
|
| 936 |
+
"""Eliminar sesión del usuario"""
|
| 937 |
+
try:
|
| 938 |
+
# Verificar acceso a la sesión
|
| 939 |
+
if not verify_session_access(current_user, session_name, db):
|
| 940 |
+
return {"success": False, "message": "No tienes acceso a esta sesión"}
|
| 941 |
+
|
| 942 |
+
# Eliminar entrada de base de datos
|
| 943 |
+
user_session = db.query(UserSession).filter(
|
| 944 |
+
UserSession.user_id == current_user.id,
|
| 945 |
+
UserSession.session_name == session_name
|
| 946 |
+
).first()
|
| 947 |
+
|
| 948 |
+
if user_session:
|
| 949 |
+
db.delete(user_session)
|
| 950 |
+
db.commit()
|
| 951 |
+
|
| 952 |
+
# Eliminar archivos físicos
|
| 953 |
+
session_path = os.path.join("annotations", session_name)
|
| 954 |
+
if os.path.exists(session_path):
|
| 955 |
+
shutil.rmtree(session_path)
|
| 956 |
+
|
| 957 |
+
return {
|
| 958 |
+
"success": True,
|
| 959 |
+
"message": f"Sesión '{session_name}' eliminada exitosamente"
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
except Exception as e:
|
| 963 |
+
return {"success": False, "message": f"Error al eliminar sesión: {str(e)}"}
|
| 964 |
+
|
| 965 |
+
# ============================================================================
|
| 966 |
+
# ADMIN ENDPOINTS
|
| 967 |
+
# ============================================================================
|
| 968 |
+
@app.get("/api/admin/users")
|
| 969 |
+
async def list_all_users(
|
| 970 |
+
current_user: User = Depends(get_current_user),
|
| 971 |
+
db: Session = Depends(get_db)
|
| 972 |
+
):
|
| 973 |
+
"""Listar todos los usuarios (solo admins)"""
|
| 974 |
+
if not current_user.is_admin:
|
| 975 |
+
raise HTTPException(status_code=403, detail="Acceso denegado")
|
| 976 |
+
|
| 977 |
+
users = db.query(User).all()
|
| 978 |
+
return {
|
| 979 |
+
"success": True,
|
| 980 |
+
"users": [
|
| 981 |
+
{
|
| 982 |
+
"id": user.id,
|
| 983 |
+
"username": user.username,
|
| 984 |
+
"email": user.email,
|
| 985 |
+
"is_admin": user.is_admin,
|
| 986 |
+
"created_at": user.created_at.isoformat() if user.created_at else None
|
| 987 |
+
}
|
| 988 |
+
for user in users
|
| 989 |
+
]
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
@app.get("/api/admin/sessions")
|
| 993 |
+
async def list_all_sessions(
|
| 994 |
+
current_user: User = Depends(get_current_user),
|
| 995 |
+
db: Session = Depends(get_db)
|
| 996 |
+
):
|
| 997 |
+
"""Listar todas las sesiones del sistema (solo admins)"""
|
| 998 |
+
if not current_user.is_admin:
|
| 999 |
+
raise HTTPException(status_code=403, detail="Acceso denegado")
|
| 1000 |
+
|
| 1001 |
+
sessions = db.query(UserSession).all()
|
| 1002 |
+
return {
|
| 1003 |
+
"success": True,
|
| 1004 |
+
"sessions": [
|
| 1005 |
+
{
|
| 1006 |
+
"session_name": session.session_name,
|
| 1007 |
+
"user_id": session.user_id,
|
| 1008 |
+
"created_at": session.created_at.isoformat() if session.created_at else None,
|
| 1009 |
+
"is_active": session.is_active
|
| 1010 |
+
}
|
| 1011 |
+
for session in sessions
|
| 1012 |
+
]
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
# ============================================================================
|
| 1016 |
+
# ENDPOINT PARA SERVIR IMÁGENES DE SESIONES
|
| 1017 |
+
# ============================================================================
|
| 1018 |
+
@app.get("/image/{session_name}/{image_name}")
|
| 1019 |
+
async def serve_session_image(
|
| 1020 |
+
session_name: str,
|
| 1021 |
+
image_name: str,
|
| 1022 |
+
current_user: User = Depends(get_optional_user),
|
| 1023 |
+
db: Session = Depends(get_db)
|
| 1024 |
+
):
|
| 1025 |
+
"""Servir imágenes de las sesiones con control de acceso"""
|
| 1026 |
+
try:
|
| 1027 |
+
# Por ahora, permitir acceso a todas las imágenes para debugging
|
| 1028 |
+
# TODO: Restaurar control de acceso después de resolver el problema
|
| 1029 |
+
# if current_user and not verify_session_access(current_user, session_name, db):
|
| 1030 |
+
# raise HTTPException(status_code=403, detail="No access to this session")
|
| 1031 |
+
|
| 1032 |
+
image_path = os.path.join("annotations", session_name, "images", image_name)
|
| 1033 |
+
print(f"🔍 Buscando imagen en: {image_path}") # Debug
|
| 1034 |
+
print(f"🔍 Existe archivo: {os.path.exists(image_path)}") # Debug
|
| 1035 |
+
|
| 1036 |
+
if os.path.exists(image_path):
|
| 1037 |
+
print(f"✅ Sirviendo imagen: {image_path}") # Debug
|
| 1038 |
+
return FileResponse(image_path)
|
| 1039 |
+
else:
|
| 1040 |
+
# Crear imagen placeholder SVG si no existe
|
| 1041 |
+
svg_content = f"""
|
| 1042 |
+
<svg width="300" height="200" xmlns="http://www.w3.org/2000/svg">
|
| 1043 |
+
<rect width="300" height="200" fill="#f8f9fa" stroke="#dee2e6"/>
|
| 1044 |
+
<text x="150" y="90" text-anchor="middle" fill="#6c757d" font-family="Arial" font-size="14">
|
| 1045 |
+
📷 Imagen no encontrada
|
| 1046 |
+
</text>
|
| 1047 |
+
<text x="150" y="110" text-anchor="middle" fill="#adb5bd" font-family="Arial" font-size="12">
|
| 1048 |
+
{image_name}
|
| 1049 |
+
</text>
|
| 1050 |
+
<text x="150" y="130" text-anchor="middle" fill="#adb5bd" font-family="Arial" font-size="10">
|
| 1051 |
+
Sesión: {session_name}
|
| 1052 |
+
</text>
|
| 1053 |
+
</svg>
|
| 1054 |
+
"""
|
| 1055 |
+
return HTMLResponse(content=svg_content, media_type="image/svg+xml")
|
| 1056 |
+
except HTTPException:
|
| 1057 |
+
raise
|
| 1058 |
+
except Exception as e:
|
| 1059 |
+
# SVG de error
|
| 1060 |
+
svg_error = f"""
|
| 1061 |
+
<svg width="300" height="200" xmlns="http://www.w3.org/2000/svg">
|
| 1062 |
+
<rect width="300" height="200" fill="#f8d7da" stroke="#f5c6cb"/>
|
| 1063 |
+
<text x="150" y="100" text-anchor="middle" fill="#721c24" font-family="Arial" font-size="12">
|
| 1064 |
+
❌ Error: {str(e)[:30]}
|
| 1065 |
+
</text>
|
| 1066 |
+
</svg>
|
| 1067 |
+
"""
|
| 1068 |
+
return HTMLResponse(content=svg_error, media_type="image/svg+xml")
|
| 1069 |
+
|
| 1070 |
+
if __name__ == "__main__":
|
| 1071 |
+
print("🚀 Iniciando YOLO Image Annotator con JWT Auth")
|
| 1072 |
+
print("📍 Abre tu navegador en: http://localhost:8002")
|
| 1073 |
+
print("🔐 Primera cuenta registrada será ADMIN")
|
| 1074 |
+
uvicorn.run("app_auth:app", host="127.0.0.1", port=8002, reload=False)
|
augment_dataset.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
from PIL import Image, ImageEnhance, ImageFilter
|
| 4 |
+
import shutil
|
| 5 |
+
import numpy as np
|
| 6 |
+
import json
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
# Configuración de variantes disponibles
|
| 10 |
+
AVAILABLE_VARIANTS = {
|
| 11 |
+
'negativo': {
|
| 12 |
+
'name': 'Negativo',
|
| 13 |
+
'description': 'Invierte los colores de la imagen',
|
| 14 |
+
'icon': '🎭',
|
| 15 |
+
'transform': lambda img: cv2.bitwise_not(img),
|
| 16 |
+
'modify_label': False
|
| 17 |
+
},
|
| 18 |
+
'brillo': {
|
| 19 |
+
'name': 'Brillo aumentado',
|
| 20 |
+
'description': 'Aumenta el brillo de la imagen en 50%',
|
| 21 |
+
'icon': '☀️',
|
| 22 |
+
'transform': lambda img: cv2.cvtColor(np.array(ImageEnhance.Brightness(Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))).enhance(1.5)), cv2.COLOR_RGB2BGR),
|
| 23 |
+
'modify_label': False
|
| 24 |
+
},
|
| 25 |
+
'espejo': {
|
| 26 |
+
'name': 'Espejo horizontal',
|
| 27 |
+
'description': 'Crea una imagen espejo (volteo horizontal)',
|
| 28 |
+
'icon': '🪞',
|
| 29 |
+
'transform': lambda img: cv2.flip(img, 1),
|
| 30 |
+
'modify_label': True # Requiere ajustar coordenadas x
|
| 31 |
+
},
|
| 32 |
+
'rotacion': {
|
| 33 |
+
'name': 'Rotación ligera',
|
| 34 |
+
'description': 'Rota la imagen 15 grados',
|
| 35 |
+
'icon': '🔄',
|
| 36 |
+
'transform': lambda img: rotate_image(img, 15),
|
| 37 |
+
'modify_label': False # Por simplicidad, mantenemos las etiquetas originales
|
| 38 |
+
},
|
| 39 |
+
'desenfoque': {
|
| 40 |
+
'name': 'Desenfoque gaussiano',
|
| 41 |
+
'description': 'Aplica desenfoque gaussiano suave',
|
| 42 |
+
'icon': '🌀',
|
| 43 |
+
'transform': lambda img: cv2.GaussianBlur(img, (5, 5), 0),
|
| 44 |
+
'modify_label': False
|
| 45 |
+
},
|
| 46 |
+
'contraste': {
|
| 47 |
+
'name': 'Contraste aumentado',
|
| 48 |
+
'description': 'Aumenta el contraste de la imagen',
|
| 49 |
+
'icon': '🌈',
|
| 50 |
+
'transform': lambda img: cv2.cvtColor(np.array(ImageEnhance.Contrast(Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))).enhance(1.3)), cv2.COLOR_RGB2BGR),
|
| 51 |
+
'modify_label': False
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
def rotate_image(img, angle):
|
| 56 |
+
"""Rota una imagen por un ángulo específico"""
|
| 57 |
+
height, width = img.shape[:2]
|
| 58 |
+
center = (width // 2, height // 2)
|
| 59 |
+
rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
|
| 60 |
+
return cv2.warpAffine(img, rotation_matrix, (width, height))
|
| 61 |
+
|
| 62 |
+
def adjust_label_for_mirror(label_path, aug_label_path):
|
| 63 |
+
"""
|
| 64 |
+
Ajusta la coordenada x_center para el efecto espejo en formato YOLO.
|
| 65 |
+
"""
|
| 66 |
+
with open(label_path, 'r') as f_in, open(aug_label_path, 'w') as f_out:
|
| 67 |
+
for line in f_in:
|
| 68 |
+
parts = line.strip().split()
|
| 69 |
+
if len(parts) == 5:
|
| 70 |
+
# clase, x_center, y_center, ancho, alto
|
| 71 |
+
parts[1] = str(1 - float(parts[1]))
|
| 72 |
+
f_out.write(' '.join(parts) + '\n')
|
| 73 |
+
|
| 74 |
+
def augment_session(session_name, selected_variants=None, progress_callback=None):
|
| 75 |
+
"""
|
| 76 |
+
Aumenta el dataset de una sesión específica aplicando las variantes seleccionadas
|
| 77 |
+
"""
|
| 78 |
+
if selected_variants is None:
|
| 79 |
+
selected_variants = list(AVAILABLE_VARIANTS.keys())
|
| 80 |
+
|
| 81 |
+
session_path = f"annotations/{session_name}"
|
| 82 |
+
images_path = os.path.join(session_path, "images")
|
| 83 |
+
labels_path = os.path.join(session_path, "labels")
|
| 84 |
+
|
| 85 |
+
if not os.path.exists(images_path):
|
| 86 |
+
raise Exception(f"No se encontró la carpeta de imágenes: {images_path}")
|
| 87 |
+
|
| 88 |
+
# Crear carpeta de labels si no existe
|
| 89 |
+
os.makedirs(labels_path, exist_ok=True)
|
| 90 |
+
|
| 91 |
+
# Crear carpeta temp si no existe
|
| 92 |
+
os.makedirs("temp", exist_ok=True)
|
| 93 |
+
|
| 94 |
+
# Archivo de progreso temporal
|
| 95 |
+
progress_file = f"temp/progress_{session_name}.json"
|
| 96 |
+
|
| 97 |
+
# Obtener lista de imágenes
|
| 98 |
+
image_files = [f for f in os.listdir(images_path)
|
| 99 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.bmp'))]
|
| 100 |
+
|
| 101 |
+
total_operations = len(image_files) * len(selected_variants)
|
| 102 |
+
current_operation = 0
|
| 103 |
+
results = {
|
| 104 |
+
'processed_images': 0,
|
| 105 |
+
'created_variants': 0,
|
| 106 |
+
'errors': [],
|
| 107 |
+
'variants_applied': selected_variants
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
# Función para actualizar progreso
|
| 111 |
+
def update_progress():
|
| 112 |
+
progress_data = {
|
| 113 |
+
'current': current_operation,
|
| 114 |
+
'total': total_operations,
|
| 115 |
+
'completed': False,
|
| 116 |
+
'message': f'Procesando imagen {results["processed_images"] + 1} de {len(image_files)}'
|
| 117 |
+
}
|
| 118 |
+
with open(progress_file, 'w') as f:
|
| 119 |
+
json.dump(progress_data, f)
|
| 120 |
+
|
| 121 |
+
# Inicializar progreso
|
| 122 |
+
update_progress()
|
| 123 |
+
|
| 124 |
+
for img_name in image_files:
|
| 125 |
+
img_path = os.path.join(images_path, img_name)
|
| 126 |
+
label_name = os.path.splitext(img_name)[0] + '.txt'
|
| 127 |
+
label_path = os.path.join(labels_path, label_name)
|
| 128 |
+
|
| 129 |
+
# Leer imagen original
|
| 130 |
+
img = cv2.imread(img_path)
|
| 131 |
+
if img is None:
|
| 132 |
+
results['errors'].append(f'No se pudo leer {img_path}')
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
# Aplicar cada variante seleccionada
|
| 136 |
+
for variant_key in selected_variants:
|
| 137 |
+
current_operation += 1
|
| 138 |
+
|
| 139 |
+
# Actualizar progreso
|
| 140 |
+
update_progress()
|
| 141 |
+
|
| 142 |
+
if progress_callback:
|
| 143 |
+
progress = (current_operation / total_operations) * 100
|
| 144 |
+
progress_callback(progress, f"Procesando {img_name} - {AVAILABLE_VARIANTS[variant_key]['name']}")
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
variant_config = AVAILABLE_VARIANTS[variant_key]
|
| 148 |
+
|
| 149 |
+
# Aplicar transformación
|
| 150 |
+
aug_img = variant_config['transform'](img)
|
| 151 |
+
|
| 152 |
+
# Generar nombre del archivo aumentado
|
| 153 |
+
base_name = os.path.splitext(img_name)[0]
|
| 154 |
+
extension = os.path.splitext(img_name)[1]
|
| 155 |
+
aug_name = f"{base_name}_{variant_key}{extension}"
|
| 156 |
+
aug_path = os.path.join(images_path, aug_name)
|
| 157 |
+
|
| 158 |
+
# Guardar imagen aumentada
|
| 159 |
+
cv2.imwrite(aug_path, aug_img)
|
| 160 |
+
|
| 161 |
+
# Manejar etiquetas
|
| 162 |
+
aug_label_name = f"{base_name}_{variant_key}.txt"
|
| 163 |
+
aug_label_path = os.path.join(labels_path, aug_label_name)
|
| 164 |
+
|
| 165 |
+
if os.path.exists(label_path):
|
| 166 |
+
if variant_config['modify_label']:
|
| 167 |
+
adjust_label_for_mirror(label_path, aug_label_path)
|
| 168 |
+
else:
|
| 169 |
+
shutil.copy(label_path, aug_label_path)
|
| 170 |
+
|
| 171 |
+
results['created_variants'] += 1
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
results['errors'].append(f'Error procesando {img_name} con variante {variant_key}: {str(e)}')
|
| 175 |
+
|
| 176 |
+
results['processed_images'] += 1
|
| 177 |
+
|
| 178 |
+
# Marcar como completado
|
| 179 |
+
final_progress = {
|
| 180 |
+
'current': total_operations,
|
| 181 |
+
'total': total_operations,
|
| 182 |
+
'completed': True,
|
| 183 |
+
'message': f'¡Completado! {results["created_variants"]} variantes creadas'
|
| 184 |
+
}
|
| 185 |
+
with open(progress_file, 'w') as f:
|
| 186 |
+
json.dump(final_progress, f)
|
| 187 |
+
|
| 188 |
+
# Guardar log de augmentación
|
| 189 |
+
log_path = os.path.join(session_path, 'augmentation_log.json')
|
| 190 |
+
log_data = {
|
| 191 |
+
'timestamp': datetime.now().isoformat(),
|
| 192 |
+
'session_name': session_name,
|
| 193 |
+
'variants_applied': selected_variants,
|
| 194 |
+
'results': results
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
with open(log_path, 'w') as f:
|
| 198 |
+
json.dump(log_data, f, indent=2)
|
| 199 |
+
|
| 200 |
+
return results
|
| 201 |
+
|
| 202 |
+
def get_session_stats(session_name):
|
| 203 |
+
"""
|
| 204 |
+
Obtiene estadísticas de una sesión (antes de augmentación)
|
| 205 |
+
"""
|
| 206 |
+
session_path = f"annotations/{session_name}"
|
| 207 |
+
images_path = os.path.join(session_path, "images")
|
| 208 |
+
labels_path = os.path.join(session_path, "labels")
|
| 209 |
+
|
| 210 |
+
if not os.path.exists(images_path):
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
image_files = [f for f in os.listdir(images_path)
|
| 214 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.bmp'))]
|
| 215 |
+
|
| 216 |
+
label_files = []
|
| 217 |
+
if os.path.exists(labels_path):
|
| 218 |
+
label_files = [f for f in os.listdir(labels_path) if f.endswith('.txt')]
|
| 219 |
+
|
| 220 |
+
# Detectar si ya hay variantes (archivos con sufijos)
|
| 221 |
+
original_images = []
|
| 222 |
+
variant_images = []
|
| 223 |
+
|
| 224 |
+
for img_file in image_files:
|
| 225 |
+
base_name = os.path.splitext(img_file)[0]
|
| 226 |
+
is_variant = any(base_name.endswith(f'_{variant}') for variant in AVAILABLE_VARIANTS.keys())
|
| 227 |
+
|
| 228 |
+
if is_variant:
|
| 229 |
+
variant_images.append(img_file)
|
| 230 |
+
else:
|
| 231 |
+
original_images.append(img_file)
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
'total_images': len(image_files),
|
| 235 |
+
'original_images': len(original_images),
|
| 236 |
+
'variant_images': len(variant_images),
|
| 237 |
+
'label_files': len(label_files),
|
| 238 |
+
'available_variants': AVAILABLE_VARIANTS
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
# Función legacy para compatibilidad
|
| 242 |
+
def augment_images():
|
| 243 |
+
"""Función original para augmentar en estructura by_class (mantenida para compatibilidad)"""
|
| 244 |
+
BASE_DIR = 'by_class'
|
| 245 |
+
IMAGE_SUBDIR = 'images'
|
| 246 |
+
LABEL_SUBDIR = 'labels'
|
| 247 |
+
|
| 248 |
+
# Variantes legacy
|
| 249 |
+
VARIANTS = [
|
| 250 |
+
('negativo', lambda img: cv2.bitwise_not(img), False),
|
| 251 |
+
('brillo', lambda img: cv2.cvtColor(np.array(ImageEnhance.Brightness(Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))).enhance(1.5)), cv2.COLOR_RGB2BGR), False),
|
| 252 |
+
('espejo', lambda img: cv2.flip(img, 1), True),
|
| 253 |
+
]
|
| 254 |
+
for class_name in os.listdir(BASE_DIR):
|
| 255 |
+
class_path = os.path.join(BASE_DIR, class_name)
|
| 256 |
+
images_path = os.path.join(class_path, IMAGE_SUBDIR)
|
| 257 |
+
labels_path = os.path.join(class_path, LABEL_SUBDIR)
|
| 258 |
+
if not os.path.isdir(images_path):
|
| 259 |
+
continue
|
| 260 |
+
for img_name in os.listdir(images_path):
|
| 261 |
+
if not img_name.lower().endswith(('.jpg', '.jpeg', '.png')):
|
| 262 |
+
continue
|
| 263 |
+
img_path = os.path.join(images_path, img_name)
|
| 264 |
+
label_name = os.path.splitext(img_name)[0] + '.txt'
|
| 265 |
+
label_path = os.path.join(labels_path, label_name)
|
| 266 |
+
# Leer imagen
|
| 267 |
+
img = cv2.imread(img_path)
|
| 268 |
+
if img is None:
|
| 269 |
+
print(f'No se pudo leer {img_path}')
|
| 270 |
+
continue
|
| 271 |
+
# Generar variantes
|
| 272 |
+
for sufijo, transform, mirror_label in VARIANTS:
|
| 273 |
+
if sufijo == 'negativo' or sufijo == 'espejo':
|
| 274 |
+
aug_img = transform(img)
|
| 275 |
+
else:
|
| 276 |
+
aug_img = transform(img)
|
| 277 |
+
aug_name = f"{os.path.splitext(img_name)[0]}_{sufijo}{os.path.splitext(img_name)[1]}"
|
| 278 |
+
aug_path = os.path.join(images_path, aug_name)
|
| 279 |
+
cv2.imwrite(aug_path, aug_img)
|
| 280 |
+
# Copiar o modificar label
|
| 281 |
+
aug_label_name = f"{os.path.splitext(img_name)[0]}_{sufijo}.txt"
|
| 282 |
+
aug_label_path = os.path.join(labels_path, aug_label_name)
|
| 283 |
+
if os.path.exists(label_path):
|
| 284 |
+
if mirror_label:
|
| 285 |
+
adjust_label_for_mirror(label_path, aug_label_path)
|
| 286 |
+
else:
|
| 287 |
+
shutil.copy(label_path, aug_label_path)
|
| 288 |
+
else:
|
| 289 |
+
print(f'Label no encontrado: {label_path}')
|
| 290 |
+
|
| 291 |
+
if __name__ == "__main__":
|
| 292 |
+
augment_images()
|
| 293 |
+
print("Aumento de datos completado.")
|
auth/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# auth/__init__.py
|
auth/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (163 Bytes). View file
|
|
|
auth/__pycache__/auth_utils.cpython-310.pyc
ADDED
|
Binary file (2.69 kB). View file
|
|
|
auth/__pycache__/classes_routes.cpython-310.pyc
ADDED
|
Binary file (7.01 kB). View file
|
|
|
auth/__pycache__/database.cpython-310.pyc
ADDED
|
Binary file (2.22 kB). View file
|
|
|
auth/__pycache__/dependencies.cpython-310.pyc
ADDED
|
Binary file (4.31 kB). View file
|
|
|
auth/__pycache__/models.cpython-310.pyc
ADDED
|
Binary file (5.26 kB). View file
|
|
|
auth/__pycache__/routes.cpython-310.pyc
ADDED
|
Binary file (5.31 kB). View file
|
|
|
auth/__pycache__/session_routes.cpython-310.pyc
ADDED
|
Binary file (4.86 kB). View file
|
|
|
auth/__pycache__/session_utils.cpython-310.pyc
ADDED
|
Binary file (3.31 kB). View file
|
|
|
auth/auth_utils.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from fastapi import HTTPException, status
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Configuración JWT con valores por defecto
|
| 9 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production-123456789")
|
| 10 |
+
ALGORITHM = "HS256"
|
| 11 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 horas
|
| 12 |
+
|
| 13 |
+
# Configuración para hashing de passwords
|
| 14 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 15 |
+
|
| 16 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 17 |
+
"""Verificar password contra hash"""
|
| 18 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 19 |
+
|
| 20 |
+
def get_password_hash(password: str) -> str:
|
| 21 |
+
"""Hashear password con bcrypt"""
|
| 22 |
+
return pwd_context.hash(password)
|
| 23 |
+
|
| 24 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 25 |
+
"""Crear JWT token con expiración"""
|
| 26 |
+
to_encode = data.copy()
|
| 27 |
+
|
| 28 |
+
if expires_delta:
|
| 29 |
+
expire = datetime.utcnow() + expires_delta
|
| 30 |
+
else:
|
| 31 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 32 |
+
|
| 33 |
+
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
|
| 34 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 35 |
+
return encoded_jwt
|
| 36 |
+
|
| 37 |
+
def verify_token(token: str) -> Optional[str]:
|
| 38 |
+
"""Verificar JWT token y devolver username"""
|
| 39 |
+
try:
|
| 40 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 41 |
+
username: str = payload.get("sub")
|
| 42 |
+
if username is None:
|
| 43 |
+
return None
|
| 44 |
+
return username
|
| 45 |
+
except JWTError:
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def decode_token(token: str) -> dict:
|
| 49 |
+
"""Decodificar token completo para debugging"""
|
| 50 |
+
try:
|
| 51 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 52 |
+
return payload
|
| 53 |
+
except JWTError as e:
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 56 |
+
detail=f"Token invalid: {str(e)}",
|
| 57 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def get_token_from_header(authorization: str) -> Optional[str]:
|
| 61 |
+
"""Extraer token del header Authorization"""
|
| 62 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 63 |
+
return None
|
| 64 |
+
return authorization.split(" ")[1]
|
| 65 |
+
|
| 66 |
+
def validate_password_strength(password: str) -> bool:
|
| 67 |
+
"""Validar fortaleza de password (mínimo 6 caracteres para testing)"""
|
| 68 |
+
if len(password) < 6:
|
| 69 |
+
return False
|
| 70 |
+
# Aquí se pueden agregar más validaciones (mayúsculas, números, etc.)
|
| 71 |
+
return True
|
auth/classes_routes.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from .database import get_db
|
| 5 |
+
from .models import AnnotationClass, AnnotationClassCreate, AnnotationClassUpdate, AnnotationClassResponse, User
|
| 6 |
+
from .dependencies import get_current_user
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/api/classes", tags=["annotation_classes"])
|
| 9 |
+
|
| 10 |
+
# Clases por defecto para usuarios nuevos
|
| 11 |
+
DEFAULT_CLASSES = [
|
| 12 |
+
{"name": "Persona", "color": "#ff0000"},
|
| 13 |
+
{"name": "Vehículo", "color": "#00ff00"},
|
| 14 |
+
{"name": "Animal", "color": "#0000ff"},
|
| 15 |
+
{"name": "Edificio", "color": "#ffff00"},
|
| 16 |
+
{"name": "Objeto", "color": "#ff00ff"},
|
| 17 |
+
{"name": "Naturaleza", "color": "#00ffff"}
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
@router.get("/", response_model=List[AnnotationClassResponse])
|
| 21 |
+
async def get_user_classes(
|
| 22 |
+
session_name: Optional[str] = None,
|
| 23 |
+
current_user: User = Depends(get_current_user),
|
| 24 |
+
db: Session = Depends(get_db)
|
| 25 |
+
):
|
| 26 |
+
"""Obtener todas las clases del usuario para una sesión específica o globales"""
|
| 27 |
+
|
| 28 |
+
query = db.query(AnnotationClass).filter(
|
| 29 |
+
AnnotationClass.is_active == True
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Filtrar por usuario y incluir clases globales
|
| 33 |
+
query = query.filter(
|
| 34 |
+
(AnnotationClass.user_id == current_user.id) |
|
| 35 |
+
(AnnotationClass.is_global == True)
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Si se especifica una sesión, incluir clases específicas de esa sesión
|
| 39 |
+
if session_name:
|
| 40 |
+
query = query.filter(
|
| 41 |
+
(AnnotationClass.session_name == session_name) |
|
| 42 |
+
(AnnotationClass.session_name.is_(None))
|
| 43 |
+
)
|
| 44 |
+
else:
|
| 45 |
+
# Solo clases globales/generales (sin sesión específica)
|
| 46 |
+
query = query.filter(AnnotationClass.session_name.is_(None))
|
| 47 |
+
|
| 48 |
+
classes = query.order_by(AnnotationClass.created_at).all()
|
| 49 |
+
|
| 50 |
+
# Si no tiene clases, crear las por defecto
|
| 51 |
+
if not classes and not session_name:
|
| 52 |
+
classes = await create_default_classes(current_user.id, db)
|
| 53 |
+
|
| 54 |
+
return classes
|
| 55 |
+
|
| 56 |
+
@router.post("/", response_model=AnnotationClassResponse)
|
| 57 |
+
async def create_annotation_class(
|
| 58 |
+
class_data: AnnotationClassCreate,
|
| 59 |
+
current_user: User = Depends(get_current_user),
|
| 60 |
+
db: Session = Depends(get_db)
|
| 61 |
+
):
|
| 62 |
+
"""Crear una nueva clase de anotación"""
|
| 63 |
+
|
| 64 |
+
# Validar que el nombre no esté duplicado para este usuario/sesión
|
| 65 |
+
existing = db.query(AnnotationClass).filter(
|
| 66 |
+
AnnotationClass.user_id == current_user.id,
|
| 67 |
+
AnnotationClass.name == class_data.name,
|
| 68 |
+
AnnotationClass.session_name == class_data.session_name,
|
| 69 |
+
AnnotationClass.is_active == True
|
| 70 |
+
).first()
|
| 71 |
+
|
| 72 |
+
if existing:
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 75 |
+
detail=f"Ya existe una clase con el nombre '{class_data.name}'"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Solo admin puede crear clases globales
|
| 79 |
+
if class_data.is_global and not current_user.is_admin:
|
| 80 |
+
class_data.is_global = False
|
| 81 |
+
|
| 82 |
+
# Validar formato de color
|
| 83 |
+
if not class_data.color.startswith('#') or len(class_data.color) != 7:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 86 |
+
detail="Color debe estar en formato hexadecimal #RRGGBB"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
new_class = AnnotationClass(
|
| 90 |
+
name=class_data.name,
|
| 91 |
+
color=class_data.color,
|
| 92 |
+
user_id=current_user.id,
|
| 93 |
+
session_name=class_data.session_name,
|
| 94 |
+
is_global=class_data.is_global
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
db.add(new_class)
|
| 98 |
+
db.commit()
|
| 99 |
+
db.refresh(new_class)
|
| 100 |
+
|
| 101 |
+
return new_class
|
| 102 |
+
|
| 103 |
+
@router.put("/{class_id}", response_model=AnnotationClassResponse)
|
| 104 |
+
async def update_annotation_class(
|
| 105 |
+
class_id: int,
|
| 106 |
+
class_data: AnnotationClassUpdate,
|
| 107 |
+
current_user: User = Depends(get_current_user),
|
| 108 |
+
db: Session = Depends(get_db)
|
| 109 |
+
):
|
| 110 |
+
"""Actualizar una clase de anotación"""
|
| 111 |
+
|
| 112 |
+
annotation_class = db.query(AnnotationClass).filter(
|
| 113 |
+
AnnotationClass.id == class_id
|
| 114 |
+
).first()
|
| 115 |
+
|
| 116 |
+
if not annotation_class:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 119 |
+
detail="Clase no encontrada"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Verificar permisos
|
| 123 |
+
if annotation_class.user_id != current_user.id and not current_user.is_admin:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 126 |
+
detail="No tienes permisos para editar esta clase"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
# Actualizar campos
|
| 130 |
+
if class_data.name is not None:
|
| 131 |
+
# Verificar duplicados
|
| 132 |
+
existing = db.query(AnnotationClass).filter(
|
| 133 |
+
AnnotationClass.user_id == annotation_class.user_id,
|
| 134 |
+
AnnotationClass.name == class_data.name,
|
| 135 |
+
AnnotationClass.session_name == annotation_class.session_name,
|
| 136 |
+
AnnotationClass.id != class_id,
|
| 137 |
+
AnnotationClass.is_active == True
|
| 138 |
+
).first()
|
| 139 |
+
|
| 140 |
+
if existing:
|
| 141 |
+
raise HTTPException(
|
| 142 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 143 |
+
detail=f"Ya existe una clase con el nombre '{class_data.name}'"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
annotation_class.name = class_data.name
|
| 147 |
+
|
| 148 |
+
if class_data.color is not None:
|
| 149 |
+
if not class_data.color.startswith('#') or len(class_data.color) != 7:
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 152 |
+
detail="Color debe estar en formato hexadecimal #RRGGBB"
|
| 153 |
+
)
|
| 154 |
+
annotation_class.color = class_data.color
|
| 155 |
+
|
| 156 |
+
if class_data.is_active is not None:
|
| 157 |
+
annotation_class.is_active = class_data.is_active
|
| 158 |
+
|
| 159 |
+
db.commit()
|
| 160 |
+
db.refresh(annotation_class)
|
| 161 |
+
|
| 162 |
+
return annotation_class
|
| 163 |
+
|
| 164 |
+
@router.delete("/{class_id}")
|
| 165 |
+
async def delete_annotation_class(
|
| 166 |
+
class_id: int,
|
| 167 |
+
current_user: User = Depends(get_current_user),
|
| 168 |
+
db: Session = Depends(get_db)
|
| 169 |
+
):
|
| 170 |
+
"""Eliminar (desactivar) una clase de anotación"""
|
| 171 |
+
|
| 172 |
+
annotation_class = db.query(AnnotationClass).filter(
|
| 173 |
+
AnnotationClass.id == class_id
|
| 174 |
+
).first()
|
| 175 |
+
|
| 176 |
+
if not annotation_class:
|
| 177 |
+
raise HTTPException(
|
| 178 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 179 |
+
detail="Clase no encontrada"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Verificar permisos
|
| 183 |
+
if annotation_class.user_id != current_user.id and not current_user.is_admin:
|
| 184 |
+
raise HTTPException(
|
| 185 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 186 |
+
detail="No tienes permisos para eliminar esta clase"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# Soft delete
|
| 190 |
+
annotation_class.is_active = False
|
| 191 |
+
db.commit()
|
| 192 |
+
|
| 193 |
+
return {"detail": "Clase eliminada correctamente"}
|
| 194 |
+
|
| 195 |
+
@router.post("/reset-to-default")
|
| 196 |
+
async def reset_to_default_classes(
|
| 197 |
+
session_name: Optional[str] = None,
|
| 198 |
+
current_user: User = Depends(get_current_user),
|
| 199 |
+
db: Session = Depends(get_db)
|
| 200 |
+
):
|
| 201 |
+
"""Restablecer a clases por defecto"""
|
| 202 |
+
|
| 203 |
+
# Desactivar clases existentes
|
| 204 |
+
existing_classes = db.query(AnnotationClass).filter(
|
| 205 |
+
AnnotationClass.user_id == current_user.id,
|
| 206 |
+
AnnotationClass.session_name == session_name,
|
| 207 |
+
AnnotationClass.is_active == True
|
| 208 |
+
).all()
|
| 209 |
+
|
| 210 |
+
for cls in existing_classes:
|
| 211 |
+
cls.is_active = False
|
| 212 |
+
|
| 213 |
+
# Crear clases por defecto
|
| 214 |
+
new_classes = []
|
| 215 |
+
for default_class in DEFAULT_CLASSES:
|
| 216 |
+
new_class = AnnotationClass(
|
| 217 |
+
name=default_class["name"],
|
| 218 |
+
color=default_class["color"],
|
| 219 |
+
user_id=current_user.id,
|
| 220 |
+
session_name=session_name,
|
| 221 |
+
is_global=False
|
| 222 |
+
)
|
| 223 |
+
db.add(new_class)
|
| 224 |
+
new_classes.append(new_class)
|
| 225 |
+
|
| 226 |
+
db.commit()
|
| 227 |
+
|
| 228 |
+
# Refrescar objetos
|
| 229 |
+
for cls in new_classes:
|
| 230 |
+
db.refresh(cls)
|
| 231 |
+
|
| 232 |
+
return {"detail": f"Se han creado {len(new_classes)} clases por defecto", "classes": new_classes}
|
| 233 |
+
|
| 234 |
+
@router.get("/available-colors")
|
| 235 |
+
async def get_available_colors():
|
| 236 |
+
"""Obtener colores predefinidos para clases"""
|
| 237 |
+
|
| 238 |
+
colors = [
|
| 239 |
+
{"name": "Rojo", "value": "#ff0000"},
|
| 240 |
+
{"name": "Verde", "value": "#00ff00"},
|
| 241 |
+
{"name": "Azul", "value": "#0000ff"},
|
| 242 |
+
{"name": "Amarillo", "value": "#ffff00"},
|
| 243 |
+
{"name": "Magenta", "value": "#ff00ff"},
|
| 244 |
+
{"name": "Cian", "value": "#00ffff"},
|
| 245 |
+
{"name": "Naranja", "value": "#ff8800"},
|
| 246 |
+
{"name": "Rosa", "value": "#ff0088"},
|
| 247 |
+
{"name": "Púrpura", "value": "#8800ff"},
|
| 248 |
+
{"name": "Verde Lima", "value": "#88ff00"},
|
| 249 |
+
{"name": "Azul Cielo", "value": "#0088ff"},
|
| 250 |
+
{"name": "Turquesa", "value": "#00ff88"},
|
| 251 |
+
{"name": "Rojo Oscuro", "value": "#800000"},
|
| 252 |
+
{"name": "Verde Oscuro", "value": "#008000"},
|
| 253 |
+
{"name": "Azul Oscuro", "value": "#000080"},
|
| 254 |
+
{"name": "Marrón", "value": "#8B4513"},
|
| 255 |
+
{"name": "Gris", "value": "#808080"},
|
| 256 |
+
{"name": "Negro", "value": "#000000"}
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
return colors
|
| 260 |
+
|
| 261 |
+
async def create_default_classes(user_id: int, db: Session) -> List[AnnotationClass]:
|
| 262 |
+
"""Crear clases por defecto para un usuario nuevo"""
|
| 263 |
+
|
| 264 |
+
classes = []
|
| 265 |
+
for default_class in DEFAULT_CLASSES:
|
| 266 |
+
new_class = AnnotationClass(
|
| 267 |
+
name=default_class["name"],
|
| 268 |
+
color=default_class["color"],
|
| 269 |
+
user_id=user_id,
|
| 270 |
+
session_name=None, # Clases globales del usuario
|
| 271 |
+
is_global=False
|
| 272 |
+
)
|
| 273 |
+
db.add(new_class)
|
| 274 |
+
classes.append(new_class)
|
| 275 |
+
|
| 276 |
+
db.commit()
|
| 277 |
+
|
| 278 |
+
# Refrescar objetos
|
| 279 |
+
for cls in classes:
|
| 280 |
+
db.refresh(cls)
|
| 281 |
+
|
| 282 |
+
return classes
|
| 283 |
+
|
| 284 |
+
@router.post("/import")
|
| 285 |
+
async def import_classes_from_annotations(
|
| 286 |
+
session_name: str,
|
| 287 |
+
current_user: User = Depends(get_current_user),
|
| 288 |
+
db: Session = Depends(get_db)
|
| 289 |
+
):
|
| 290 |
+
"""Importar clases automáticamente desde anotaciones existentes"""
|
| 291 |
+
|
| 292 |
+
# Leer archivos de anotaciones de la sesión
|
| 293 |
+
import os
|
| 294 |
+
|
| 295 |
+
session_path = os.path.join("annotations", session_name, "labels")
|
| 296 |
+
if not os.path.exists(session_path):
|
| 297 |
+
raise HTTPException(
|
| 298 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 299 |
+
detail="Sesión no encontrada"
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# Encontrar todas las clases usadas
|
| 303 |
+
used_class_ids = set()
|
| 304 |
+
|
| 305 |
+
for filename in os.listdir(session_path):
|
| 306 |
+
if filename.endswith('.txt'):
|
| 307 |
+
filepath = os.path.join(session_path, filename)
|
| 308 |
+
try:
|
| 309 |
+
with open(filepath, 'r') as f:
|
| 310 |
+
for line in f:
|
| 311 |
+
line = line.strip()
|
| 312 |
+
if line:
|
| 313 |
+
parts = line.split()
|
| 314 |
+
if len(parts) >= 5:
|
| 315 |
+
class_id = int(parts[0])
|
| 316 |
+
used_class_ids.add(class_id)
|
| 317 |
+
except:
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
# Crear clases automáticamente
|
| 321 |
+
created_classes = []
|
| 322 |
+
existing_classes = db.query(AnnotationClass).filter(
|
| 323 |
+
AnnotationClass.user_id == current_user.id,
|
| 324 |
+
AnnotationClass.session_name == session_name,
|
| 325 |
+
AnnotationClass.is_active == True
|
| 326 |
+
).all()
|
| 327 |
+
|
| 328 |
+
existing_ids = {cls.id - 1 for cls in existing_classes} # Ajustar para índice 0
|
| 329 |
+
|
| 330 |
+
colors = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
|
| 331 |
+
"#ff8800", "#ff0088", "#8800ff", "#88ff00", "#0088ff", "#00ff88"]
|
| 332 |
+
|
| 333 |
+
for class_id in sorted(used_class_ids):
|
| 334 |
+
if class_id not in existing_ids:
|
| 335 |
+
color = colors[class_id % len(colors)]
|
| 336 |
+
new_class = AnnotationClass(
|
| 337 |
+
name=f"Clase {class_id}",
|
| 338 |
+
color=color,
|
| 339 |
+
user_id=current_user.id,
|
| 340 |
+
session_name=session_name,
|
| 341 |
+
is_global=False
|
| 342 |
+
)
|
| 343 |
+
db.add(new_class)
|
| 344 |
+
created_classes.append(new_class)
|
| 345 |
+
|
| 346 |
+
if created_classes:
|
| 347 |
+
db.commit()
|
| 348 |
+
for cls in created_classes:
|
| 349 |
+
db.refresh(cls)
|
| 350 |
+
|
| 351 |
+
return {
|
| 352 |
+
"detail": f"Se importaron {len(created_classes)} clases nuevas",
|
| 353 |
+
"created_classes": created_classes,
|
| 354 |
+
"found_class_ids": list(used_class_ids)
|
| 355 |
+
}
|
auth/database.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker
|
| 3 |
+
from .models import Base
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
# Cargar variables de entorno
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
# Configuración de base de datos MySQL
|
| 11 |
+
DB_HOST = os.getenv("DB_HOST", "localhost")
|
| 12 |
+
DB_PORT = os.getenv("DB_PORT", "3306")
|
| 13 |
+
DB_USER = os.getenv("DB_USER", "root")
|
| 14 |
+
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
| 15 |
+
DB_NAME = os.getenv("DB_NAME", "yolo_annotator")
|
| 16 |
+
|
| 17 |
+
# URL de la base de datos MySQL (única opción)
|
| 18 |
+
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
| 19 |
+
|
| 20 |
+
# Crear motor de base de datos MySQL
|
| 21 |
+
try:
|
| 22 |
+
engine = create_engine(DATABASE_URL, echo=False)
|
| 23 |
+
# Test de conexión
|
| 24 |
+
connection = engine.connect()
|
| 25 |
+
connection.close()
|
| 26 |
+
print(f"✅ Conectado a MySQL: {DB_HOST}:{DB_PORT}/{DB_NAME}")
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"❌ Error conectando a MySQL: {e}")
|
| 29 |
+
print("� Verifica que MySQL esté ejecutándose y las credenciales sean correctas.")
|
| 30 |
+
raise e
|
| 31 |
+
|
| 32 |
+
# Crear SessionLocal
|
| 33 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 34 |
+
|
| 35 |
+
# Dependency para obtener sesión de DB
|
| 36 |
+
def get_db():
|
| 37 |
+
db = SessionLocal()
|
| 38 |
+
try:
|
| 39 |
+
yield db
|
| 40 |
+
finally:
|
| 41 |
+
db.close()
|
| 42 |
+
|
| 43 |
+
# Crear todas las tablas
|
| 44 |
+
def create_tables():
|
| 45 |
+
try:
|
| 46 |
+
from sqlalchemy import inspect
|
| 47 |
+
inspector = inspect(engine)
|
| 48 |
+
existing_tables = inspector.get_table_names()
|
| 49 |
+
|
| 50 |
+
if not existing_tables:
|
| 51 |
+
# Si no hay tablas, crear todas
|
| 52 |
+
Base.metadata.create_all(bind=engine)
|
| 53 |
+
print("🆕 Tablas MySQL creadas por SQLAlchemy")
|
| 54 |
+
else:
|
| 55 |
+
print(f"✅ Usando tablas MySQL existentes: {len(existing_tables)} encontradas")
|
| 56 |
+
# Verificar que las tablas necesarias existen
|
| 57 |
+
required_tables = ['users', 'user_sessions', 'annotation_classes']
|
| 58 |
+
missing_tables = [table for table in required_tables if table not in existing_tables]
|
| 59 |
+
|
| 60 |
+
if missing_tables:
|
| 61 |
+
print(f"⚠️ Tablas faltantes: {missing_tables}")
|
| 62 |
+
# Crear solo las tablas faltantes
|
| 63 |
+
Base.metadata.create_all(bind=engine, tables=[
|
| 64 |
+
Base.metadata.tables[table] for table in missing_tables
|
| 65 |
+
if table in Base.metadata.tables
|
| 66 |
+
])
|
| 67 |
+
except Exception as e:
|
| 68 |
+
print(f"⚠️ Error al verificar/crear tablas MySQL: {e}")
|
| 69 |
+
raise e
|
auth/dependencies.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status, Request, Path
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from .database import get_db
|
| 5 |
+
from .models import User, TokenBlacklist, UserSession
|
| 6 |
+
from .auth_utils import verify_token
|
| 7 |
+
from .session_utils import verify_session_access as verify_hash_access, get_session_by_hash
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
# Security scheme
|
| 11 |
+
security = HTTPBearer(auto_error=False)
|
| 12 |
+
|
| 13 |
+
async def get_current_user(
|
| 14 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 15 |
+
db: Session = Depends(get_db)
|
| 16 |
+
) -> User:
|
| 17 |
+
"""Dependency para obtener usuario actual autenticado"""
|
| 18 |
+
|
| 19 |
+
credentials_exception = HTTPException(
|
| 20 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 21 |
+
detail="Could not validate credentials",
|
| 22 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
if not credentials:
|
| 26 |
+
raise credentials_exception
|
| 27 |
+
|
| 28 |
+
token = credentials.credentials
|
| 29 |
+
|
| 30 |
+
# Verificar si el token está en blacklist
|
| 31 |
+
blacklisted = db.query(TokenBlacklist).filter(
|
| 32 |
+
TokenBlacklist.token == token
|
| 33 |
+
).first()
|
| 34 |
+
if blacklisted:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Token has been revoked",
|
| 38 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Verificar token JWT
|
| 42 |
+
username = verify_token(token)
|
| 43 |
+
if username is None:
|
| 44 |
+
raise credentials_exception
|
| 45 |
+
|
| 46 |
+
# Buscar usuario en base de datos
|
| 47 |
+
user = db.query(User).filter(User.username == username).first()
|
| 48 |
+
if user is None:
|
| 49 |
+
raise credentials_exception
|
| 50 |
+
|
| 51 |
+
if not user.is_active:
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 54 |
+
detail="Inactive user"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return user
|
| 58 |
+
|
| 59 |
+
async def get_optional_user(
|
| 60 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 61 |
+
db: Session = Depends(get_db)
|
| 62 |
+
) -> Optional[User]:
|
| 63 |
+
"""Dependency opcional - devuelve usuario si está autenticado, None si no"""
|
| 64 |
+
try:
|
| 65 |
+
return await get_current_user(credentials, db)
|
| 66 |
+
except HTTPException:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
async def get_current_admin_user(
|
| 70 |
+
current_user: User = Depends(get_current_user)
|
| 71 |
+
) -> User:
|
| 72 |
+
"""Dependency para verificar que el usuario actual es admin"""
|
| 73 |
+
if not current_user.is_admin:
|
| 74 |
+
raise HTTPException(
|
| 75 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 76 |
+
detail="Not enough permissions"
|
| 77 |
+
)
|
| 78 |
+
return current_user
|
| 79 |
+
|
| 80 |
+
def verify_session_access(user: User, session_name: str, db: Session) -> bool:
|
| 81 |
+
"""Verificar si el usuario tiene acceso a una sesión específica"""
|
| 82 |
+
from .models import UserSession
|
| 83 |
+
|
| 84 |
+
# Los admins tienen acceso a todo
|
| 85 |
+
if user.is_admin:
|
| 86 |
+
return True
|
| 87 |
+
|
| 88 |
+
# Verificar si el usuario tiene una sesión con ese nombre
|
| 89 |
+
user_session = db.query(UserSession).filter(
|
| 90 |
+
UserSession.user_id == user.id,
|
| 91 |
+
UserSession.session_name == session_name,
|
| 92 |
+
UserSession.is_active == True
|
| 93 |
+
).first()
|
| 94 |
+
|
| 95 |
+
return user_session is not None
|
| 96 |
+
|
| 97 |
+
async def require_session_access(
|
| 98 |
+
session_name: str,
|
| 99 |
+
current_user: User = Depends(get_current_user),
|
| 100 |
+
db: Session = Depends(get_db)
|
| 101 |
+
) -> User:
|
| 102 |
+
"""Dependency que requiere acceso específico a una sesión"""
|
| 103 |
+
if not verify_session_access(current_user, session_name, db):
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 106 |
+
detail=f"No access to session '{session_name}'"
|
| 107 |
+
)
|
| 108 |
+
return current_user
|
| 109 |
+
|
| 110 |
+
# =====================================
|
| 111 |
+
# NUEVAS DEPENDENCIAS PARA HASH SESSIONS
|
| 112 |
+
# =====================================
|
| 113 |
+
|
| 114 |
+
async def get_session_by_hash_dep(
|
| 115 |
+
session_hash: str = Path(..., description="Hash único de la sesión"),
|
| 116 |
+
db: Session = Depends(get_db)
|
| 117 |
+
) -> UserSession:
|
| 118 |
+
"""
|
| 119 |
+
Dependency para obtener una sesión por su hash.
|
| 120 |
+
No requiere autenticación - cualquiera con el hash puede acceder.
|
| 121 |
+
"""
|
| 122 |
+
session = get_session_by_hash(db, session_hash)
|
| 123 |
+
if not session:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 126 |
+
detail="Session not found or inactive"
|
| 127 |
+
)
|
| 128 |
+
return session
|
| 129 |
+
|
| 130 |
+
async def verify_session_owner(
|
| 131 |
+
session_hash: str = Path(..., description="Hash único de la sesión"),
|
| 132 |
+
current_user: User = Depends(get_current_user),
|
| 133 |
+
db: Session = Depends(get_db)
|
| 134 |
+
) -> UserSession:
|
| 135 |
+
"""
|
| 136 |
+
Dependency para verificar que el usuario actual es propietario de la sesión.
|
| 137 |
+
Solo el propietario puede modificar/eliminar la sesión.
|
| 138 |
+
"""
|
| 139 |
+
session = get_session_by_hash(db, session_hash)
|
| 140 |
+
if not session:
|
| 141 |
+
raise HTTPException(
|
| 142 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 143 |
+
detail="Session not found or inactive"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Verificar propiedad (los admins también pueden acceder)
|
| 147 |
+
if session.user_id != current_user.id and not current_user.is_admin:
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 150 |
+
detail="Not authorized to access this session"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
return session
|
| 154 |
+
|
| 155 |
+
async def get_session_with_optional_auth(
|
| 156 |
+
session_hash: str = Path(..., description="Hash único de la sesión"),
|
| 157 |
+
current_user: Optional[User] = Depends(get_optional_user),
|
| 158 |
+
db: Session = Depends(get_db)
|
| 159 |
+
) -> tuple[UserSession, Optional[User]]:
|
| 160 |
+
"""
|
| 161 |
+
Dependency que obtiene la sesión y el usuario (si está autenticado).
|
| 162 |
+
Útil para endpoints que pueden funcionar con o sin autenticación.
|
| 163 |
+
"""
|
| 164 |
+
session = get_session_by_hash(db, session_hash)
|
| 165 |
+
if not session:
|
| 166 |
+
raise HTTPException(
|
| 167 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 168 |
+
detail="Session not found or inactive"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
return session, current_user
|
auth/models.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
Base = declarative_base()
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 12 |
+
username = Column(String(50), unique=True, index=True, nullable=False)
|
| 13 |
+
email = Column(String(100), unique=True, index=True, nullable=False)
|
| 14 |
+
hashed_password = Column(String(255), nullable=False)
|
| 15 |
+
is_active = Column(Boolean, default=True)
|
| 16 |
+
is_admin = Column(Boolean, default=False)
|
| 17 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 18 |
+
|
| 19 |
+
# Relación con sesiones
|
| 20 |
+
sessions = relationship("UserSession", back_populates="user")
|
| 21 |
+
|
| 22 |
+
class UserSession(Base):
|
| 23 |
+
__tablename__ = "user_sessions"
|
| 24 |
+
|
| 25 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 26 |
+
session_name = Column(String(100), nullable=False)
|
| 27 |
+
session_hash = Column(String(64), unique=True, nullable=False, index=True) # Hash único para acceso privado
|
| 28 |
+
user_id = Column(Integer, ForeignKey("users.id"))
|
| 29 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 30 |
+
is_active = Column(Boolean, default=True)
|
| 31 |
+
|
| 32 |
+
user = relationship("User", back_populates="sessions")
|
| 33 |
+
|
| 34 |
+
class TokenBlacklist(Base):
|
| 35 |
+
__tablename__ = "token_blacklist"
|
| 36 |
+
|
| 37 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 38 |
+
token = Column(String(500), unique=True, index=True)
|
| 39 |
+
blacklisted_at = Column(DateTime, default=datetime.utcnow)
|
| 40 |
+
|
| 41 |
+
class AnnotationClass(Base):
|
| 42 |
+
__tablename__ = "annotation_classes"
|
| 43 |
+
|
| 44 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 45 |
+
name = Column(String(50), nullable=False)
|
| 46 |
+
color = Column(String(7), nullable=False, default="#ff0000") # Color hex
|
| 47 |
+
user_id = Column(Integer, ForeignKey("users.id"))
|
| 48 |
+
session_name = Column(String(100), nullable=True) # Si es específica para una sesión
|
| 49 |
+
session_hash = Column(String(64), nullable=True, index=True) # Hash de sesión para acceso privado
|
| 50 |
+
is_global = Column(Boolean, default=False) # Si es global (para admin)
|
| 51 |
+
is_active = Column(Boolean, default=True)
|
| 52 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 53 |
+
|
| 54 |
+
user = relationship("User")
|
| 55 |
+
|
| 56 |
+
# Schemas Pydantic para validación
|
| 57 |
+
from pydantic import BaseModel, EmailStr
|
| 58 |
+
from typing import Optional, List
|
| 59 |
+
|
| 60 |
+
class UserCreate(BaseModel):
|
| 61 |
+
username: str
|
| 62 |
+
email: EmailStr
|
| 63 |
+
password: str
|
| 64 |
+
|
| 65 |
+
class UserLogin(BaseModel):
|
| 66 |
+
username: str
|
| 67 |
+
password: str
|
| 68 |
+
|
| 69 |
+
class UserResponse(BaseModel):
|
| 70 |
+
id: int
|
| 71 |
+
username: str
|
| 72 |
+
email: str
|
| 73 |
+
is_active: bool
|
| 74 |
+
is_admin: bool
|
| 75 |
+
created_at: datetime
|
| 76 |
+
|
| 77 |
+
class Config:
|
| 78 |
+
from_attributes = True
|
| 79 |
+
|
| 80 |
+
class Token(BaseModel):
|
| 81 |
+
access_token: str
|
| 82 |
+
token_type: str
|
| 83 |
+
user: UserResponse
|
| 84 |
+
|
| 85 |
+
class TokenData(BaseModel):
|
| 86 |
+
username: Optional[str] = None
|
| 87 |
+
|
| 88 |
+
class AnnotationClassCreate(BaseModel):
|
| 89 |
+
name: str
|
| 90 |
+
color: str = "#ff0000"
|
| 91 |
+
session_name: Optional[str] = None
|
| 92 |
+
session_hash: Optional[str] = None # Hash de sesión para acceso privado
|
| 93 |
+
is_global: bool = False
|
| 94 |
+
|
| 95 |
+
class AnnotationClassUpdate(BaseModel):
|
| 96 |
+
name: Optional[str] = None
|
| 97 |
+
color: Optional[str] = None
|
| 98 |
+
is_active: Optional[bool] = None
|
| 99 |
+
|
| 100 |
+
class AnnotationClassResponse(BaseModel):
|
| 101 |
+
id: int
|
| 102 |
+
name: str
|
| 103 |
+
color: str
|
| 104 |
+
user_id: int
|
| 105 |
+
session_name: Optional[str]
|
| 106 |
+
session_hash: Optional[str] # Incluir hash en respuesta
|
| 107 |
+
is_global: bool
|
| 108 |
+
is_active: bool
|
| 109 |
+
created_at: datetime
|
| 110 |
+
|
| 111 |
+
class Config:
|
| 112 |
+
from_attributes = True
|
| 113 |
+
|
| 114 |
+
# Esquemas para manejo de sesiones con hash
|
| 115 |
+
class SessionCreate(BaseModel):
|
| 116 |
+
session_name: str
|
| 117 |
+
|
| 118 |
+
class SessionResponse(BaseModel):
|
| 119 |
+
id: int
|
| 120 |
+
session_name: str
|
| 121 |
+
session_hash: str # Hash único para acceso
|
| 122 |
+
user_id: int
|
| 123 |
+
created_at: datetime
|
| 124 |
+
is_active: bool
|
| 125 |
+
|
| 126 |
+
class Config:
|
| 127 |
+
from_attributes = True
|
| 128 |
+
|
| 129 |
+
class SessionAccess(BaseModel):
|
| 130 |
+
session_hash: str
|
auth/routes.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Form, Request
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from .database import get_db
|
| 5 |
+
from .models import User, TokenBlacklist, UserSession, UserCreate, UserResponse, Token
|
| 6 |
+
from .auth_utils import verify_password, get_password_hash, create_access_token, validate_password_strength
|
| 7 |
+
from .dependencies import get_current_user
|
| 8 |
+
from datetime import timedelta
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/auth", tags=["authentication"])
|
| 12 |
+
|
| 13 |
+
def is_valid_email(email: str) -> bool:
|
| 14 |
+
"""Validar formato de email"""
|
| 15 |
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
| 16 |
+
return re.match(pattern, email) is not None
|
| 17 |
+
|
| 18 |
+
def is_valid_username(username: str) -> bool:
|
| 19 |
+
"""Validar formato de username (alfanumérico, guiones y guiones bajos)"""
|
| 20 |
+
pattern = r'^[a-zA-Z0-9_-]{3,20}$'
|
| 21 |
+
return re.match(pattern, username) is not None
|
| 22 |
+
|
| 23 |
+
@router.post("/register", response_model=dict)
|
| 24 |
+
async def register_user(
|
| 25 |
+
username: str = Form(...),
|
| 26 |
+
email: str = Form(...),
|
| 27 |
+
password: str = Form(...),
|
| 28 |
+
confirm_password: str = Form(...),
|
| 29 |
+
db: Session = Depends(get_db)
|
| 30 |
+
):
|
| 31 |
+
"""Registrar nuevo usuario"""
|
| 32 |
+
|
| 33 |
+
# Validaciones básicas
|
| 34 |
+
if password != confirm_password:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=400,
|
| 37 |
+
detail="Passwords do not match"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
if not validate_password_strength(password):
|
| 41 |
+
raise HTTPException(
|
| 42 |
+
status_code=400,
|
| 43 |
+
detail="Password must be at least 6 characters long"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
if not is_valid_email(email):
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=400,
|
| 49 |
+
detail="Invalid email format"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if not is_valid_username(username):
|
| 53 |
+
raise HTTPException(
|
| 54 |
+
status_code=400,
|
| 55 |
+
detail="Username must be 3-20 characters, only letters, numbers, _ and -"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Verificar si el usuario ya existe
|
| 59 |
+
if db.query(User).filter(User.username == username).first():
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=400,
|
| 62 |
+
detail="Username already registered"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
if db.query(User).filter(User.email == email).first():
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=400,
|
| 68 |
+
detail="Email already registered"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Crear usuario
|
| 72 |
+
hashed_password = get_password_hash(password)
|
| 73 |
+
|
| 74 |
+
# Primer usuario registrado es admin
|
| 75 |
+
is_first_user = db.query(User).count() == 0
|
| 76 |
+
|
| 77 |
+
user = User(
|
| 78 |
+
username=username,
|
| 79 |
+
email=email,
|
| 80 |
+
hashed_password=hashed_password,
|
| 81 |
+
is_admin=is_first_user
|
| 82 |
+
)
|
| 83 |
+
db.add(user)
|
| 84 |
+
db.commit()
|
| 85 |
+
db.refresh(user)
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"success": True,
|
| 89 |
+
"message": "User created successfully" + (" (Admin privileges granted)" if is_first_user else ""),
|
| 90 |
+
"user": {
|
| 91 |
+
"id": user.id,
|
| 92 |
+
"username": user.username,
|
| 93 |
+
"email": user.email,
|
| 94 |
+
"is_admin": user.is_admin
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
@router.post("/login")
|
| 99 |
+
async def login_user(
|
| 100 |
+
username: str = Form(...),
|
| 101 |
+
password: str = Form(...),
|
| 102 |
+
db: Session = Depends(get_db)
|
| 103 |
+
):
|
| 104 |
+
"""Login de usuario y generar token JWT"""
|
| 105 |
+
|
| 106 |
+
# Buscar usuario por username o email
|
| 107 |
+
user = db.query(User).filter(
|
| 108 |
+
(User.username == username) | (User.email == username)
|
| 109 |
+
).first()
|
| 110 |
+
|
| 111 |
+
if not user or not verify_password(password, user.hashed_password):
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 114 |
+
detail="Incorrect username/email or password",
|
| 115 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if not user.is_active:
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 121 |
+
detail="Inactive user"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Crear token JWT
|
| 125 |
+
access_token_expires = timedelta(minutes=1440) # 24 horas
|
| 126 |
+
access_token = create_access_token(
|
| 127 |
+
data={"sub": user.username},
|
| 128 |
+
expires_delta=access_token_expires
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"success": True,
|
| 133 |
+
"access_token": access_token,
|
| 134 |
+
"token_type": "bearer",
|
| 135 |
+
"message": "Login successful",
|
| 136 |
+
"user": {
|
| 137 |
+
"id": user.id,
|
| 138 |
+
"username": user.username,
|
| 139 |
+
"email": user.email,
|
| 140 |
+
"is_admin": user.is_admin
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@router.post("/logout")
|
| 145 |
+
async def logout_user(
|
| 146 |
+
request: Request,
|
| 147 |
+
current_user: User = Depends(get_current_user),
|
| 148 |
+
db: Session = Depends(get_db)
|
| 149 |
+
):
|
| 150 |
+
"""Logout - agregar token a blacklist"""
|
| 151 |
+
|
| 152 |
+
# Obtener token del header
|
| 153 |
+
auth_header = request.headers.get("authorization")
|
| 154 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 155 |
+
token = auth_header.split(" ")[1]
|
| 156 |
+
|
| 157 |
+
# Agregar a blacklist
|
| 158 |
+
blacklist_entry = TokenBlacklist(token=token)
|
| 159 |
+
db.add(blacklist_entry)
|
| 160 |
+
db.commit()
|
| 161 |
+
|
| 162 |
+
return {"success": True, "message": "Logged out successfully"}
|
| 163 |
+
|
| 164 |
+
@router.get("/me", response_model=UserResponse)
|
| 165 |
+
async def get_current_user_info(
|
| 166 |
+
current_user: User = Depends(get_current_user)
|
| 167 |
+
):
|
| 168 |
+
"""Obtener información del usuario actual"""
|
| 169 |
+
return UserResponse.from_orm(current_user)
|
| 170 |
+
|
| 171 |
+
@router.get("/profile")
|
| 172 |
+
async def get_user_profile(
|
| 173 |
+
current_user: User = Depends(get_current_user)
|
| 174 |
+
):
|
| 175 |
+
"""Obtener perfil del usuario (endpoint alternativo para compatibilidad)"""
|
| 176 |
+
return {
|
| 177 |
+
"success": True,
|
| 178 |
+
"user": {
|
| 179 |
+
"id": current_user.id,
|
| 180 |
+
"username": current_user.username,
|
| 181 |
+
"email": current_user.email,
|
| 182 |
+
"is_admin": current_user.is_admin,
|
| 183 |
+
"is_active": current_user.is_active,
|
| 184 |
+
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
@router.get("/validate")
|
| 189 |
+
async def validate_token_endpoint(
|
| 190 |
+
current_user: User = Depends(get_current_user)
|
| 191 |
+
):
|
| 192 |
+
"""Endpoint para validar si un token es válido"""
|
| 193 |
+
return {
|
| 194 |
+
"valid": True,
|
| 195 |
+
"user": {
|
| 196 |
+
"id": current_user.id,
|
| 197 |
+
"username": current_user.username,
|
| 198 |
+
"email": current_user.email,
|
| 199 |
+
"is_admin": current_user.is_admin
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
@router.get("/sessions")
|
| 204 |
+
async def get_user_sessions(
|
| 205 |
+
current_user: User = Depends(get_current_user),
|
| 206 |
+
db: Session = Depends(get_db)
|
| 207 |
+
):
|
| 208 |
+
"""Obtener sesiones del usuario actual"""
|
| 209 |
+
sessions = db.query(UserSession).filter(
|
| 210 |
+
UserSession.user_id == current_user.id,
|
| 211 |
+
UserSession.is_active == True
|
| 212 |
+
).all()
|
| 213 |
+
|
| 214 |
+
return {
|
| 215 |
+
"success": True,
|
| 216 |
+
"sessions": [
|
| 217 |
+
{
|
| 218 |
+
"id": session.id,
|
| 219 |
+
"name": session.session_name,
|
| 220 |
+
"created_at": session.created_at
|
| 221 |
+
}
|
| 222 |
+
for session in sessions
|
| 223 |
+
]
|
| 224 |
+
}
|
auth/session_routes.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from .database import get_db
|
| 5 |
+
from .models import (
|
| 6 |
+
UserSession, AnnotationClass, User,
|
| 7 |
+
SessionCreate, SessionResponse, SessionAccess,
|
| 8 |
+
AnnotationClassResponse
|
| 9 |
+
)
|
| 10 |
+
from .dependencies import (
|
| 11 |
+
get_current_user, get_session_by_hash_dep,
|
| 12 |
+
verify_session_owner, get_session_with_optional_auth
|
| 13 |
+
)
|
| 14 |
+
from .session_utils import (
|
| 15 |
+
create_private_session, get_user_sessions,
|
| 16 |
+
deactivate_session, generate_session_url
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/api/sessions", tags=["Private Sessions"])
|
| 20 |
+
|
| 21 |
+
@router.post("/create", response_model=SessionResponse)
|
| 22 |
+
async def create_session(
|
| 23 |
+
session_data: SessionCreate,
|
| 24 |
+
current_user: User = Depends(get_current_user),
|
| 25 |
+
db: Session = Depends(get_db)
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
Crear una nueva sesión privada con hash único.
|
| 29 |
+
Solo el creador tendrá acceso inicial.
|
| 30 |
+
"""
|
| 31 |
+
# Verificar que no exista ya una sesión con el mismo nombre para este usuario
|
| 32 |
+
existing = db.query(UserSession).filter(
|
| 33 |
+
UserSession.user_id == current_user.id,
|
| 34 |
+
UserSession.session_name == session_data.session_name,
|
| 35 |
+
UserSession.is_active == True
|
| 36 |
+
).first()
|
| 37 |
+
|
| 38 |
+
if existing:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 41 |
+
detail=f"Session '{session_data.session_name}' already exists"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Crear la sesión privada
|
| 45 |
+
new_session = create_private_session(
|
| 46 |
+
db=db,
|
| 47 |
+
user_id=current_user.id,
|
| 48 |
+
session_name=session_data.session_name
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
return new_session
|
| 52 |
+
|
| 53 |
+
@router.get("/my-sessions", response_model=List[SessionResponse])
|
| 54 |
+
async def get_my_sessions(
|
| 55 |
+
current_user: User = Depends(get_current_user),
|
| 56 |
+
db: Session = Depends(get_db)
|
| 57 |
+
):
|
| 58 |
+
"""
|
| 59 |
+
Obtener todas las sesiones del usuario actual con información completa
|
| 60 |
+
"""
|
| 61 |
+
from app_auth import get_user_sessions_with_info
|
| 62 |
+
sessions = get_user_sessions_with_info(current_user, db)
|
| 63 |
+
|
| 64 |
+
# Convertir a formato SessionResponse
|
| 65 |
+
session_responses = []
|
| 66 |
+
for session_info in sessions:
|
| 67 |
+
# Buscar la sesión en la base de datos para obtener todos los datos
|
| 68 |
+
session_obj = db.query(UserSession).filter(
|
| 69 |
+
UserSession.session_name == session_info['name'],
|
| 70 |
+
UserSession.is_active == True
|
| 71 |
+
).first()
|
| 72 |
+
|
| 73 |
+
if session_obj:
|
| 74 |
+
session_responses.append(session_obj)
|
| 75 |
+
|
| 76 |
+
return session_responses
|
| 77 |
+
|
| 78 |
+
@router.get("/{session_hash}", response_model=SessionResponse)
|
| 79 |
+
async def get_session_info(
|
| 80 |
+
session: UserSession = Depends(get_session_by_hash_dep)
|
| 81 |
+
):
|
| 82 |
+
"""
|
| 83 |
+
Obtener información de una sesión por su hash.
|
| 84 |
+
Acceso público - cualquiera con el hash puede ver la info básica.
|
| 85 |
+
"""
|
| 86 |
+
return session
|
| 87 |
+
|
| 88 |
+
@router.get("/{session_hash}/url")
|
| 89 |
+
async def get_session_url(
|
| 90 |
+
session: UserSession = Depends(get_session_by_hash_dep)
|
| 91 |
+
):
|
| 92 |
+
"""
|
| 93 |
+
Obtener la URL de acceso a una sesión
|
| 94 |
+
"""
|
| 95 |
+
url = generate_session_url(session.session_hash)
|
| 96 |
+
return {
|
| 97 |
+
"session_hash": session.session_hash,
|
| 98 |
+
"session_name": session.session_name,
|
| 99 |
+
"access_url": url,
|
| 100 |
+
"created_by": session.user.username
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@router.delete("/{session_hash}")
|
| 104 |
+
async def deactivate_session_endpoint(
|
| 105 |
+
session: UserSession = Depends(verify_session_owner),
|
| 106 |
+
db: Session = Depends(get_db)
|
| 107 |
+
):
|
| 108 |
+
"""
|
| 109 |
+
Desactivar una sesión (solo el propietario)
|
| 110 |
+
"""
|
| 111 |
+
success = deactivate_session(db, session.session_hash, session.user_id)
|
| 112 |
+
if success:
|
| 113 |
+
return {"message": f"Session '{session.session_name}' deactivated successfully"}
|
| 114 |
+
else:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 117 |
+
detail="Failed to deactivate session"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
@router.get("/{session_hash}/annotations", response_model=List[AnnotationClassResponse])
|
| 121 |
+
async def get_session_annotations(
|
| 122 |
+
session_and_user: tuple = Depends(get_session_with_optional_auth),
|
| 123 |
+
db: Session = Depends(get_db)
|
| 124 |
+
):
|
| 125 |
+
"""
|
| 126 |
+
Obtener todas las clases de anotación de una sesión específica.
|
| 127 |
+
Acceso por hash - no requiere autenticación.
|
| 128 |
+
"""
|
| 129 |
+
session, current_user = session_and_user
|
| 130 |
+
|
| 131 |
+
# Obtener anotaciones de esta sesión específica
|
| 132 |
+
annotations = db.query(AnnotationClass).filter(
|
| 133 |
+
AnnotationClass.session_hash == session.session_hash,
|
| 134 |
+
AnnotationClass.is_active == True
|
| 135 |
+
).all()
|
| 136 |
+
|
| 137 |
+
return annotations
|
| 138 |
+
|
| 139 |
+
@router.post("/{session_hash}/annotations", response_model=AnnotationClassResponse)
|
| 140 |
+
async def create_session_annotation(
|
| 141 |
+
annotation_data: dict,
|
| 142 |
+
session_and_user: tuple = Depends(get_session_with_optional_auth),
|
| 143 |
+
db: Session = Depends(get_db)
|
| 144 |
+
):
|
| 145 |
+
"""
|
| 146 |
+
Crear una nueva clase de anotación en una sesión específica.
|
| 147 |
+
Cualquiera con el hash puede agregar anotaciones.
|
| 148 |
+
"""
|
| 149 |
+
session, current_user = session_and_user
|
| 150 |
+
|
| 151 |
+
# Si el usuario está autenticado, usar su ID, sino usar el ID del propietario de la sesión
|
| 152 |
+
user_id = current_user.id if current_user else session.user_id
|
| 153 |
+
|
| 154 |
+
new_annotation = AnnotationClass(
|
| 155 |
+
name=annotation_data.get("name"),
|
| 156 |
+
color=annotation_data.get("color", "#ff0000"),
|
| 157 |
+
user_id=user_id,
|
| 158 |
+
session_name=session.session_name,
|
| 159 |
+
session_hash=session.session_hash,
|
| 160 |
+
is_global=False,
|
| 161 |
+
is_active=True
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
db.add(new_annotation)
|
| 165 |
+
db.commit()
|
| 166 |
+
db.refresh(new_annotation)
|
| 167 |
+
|
| 168 |
+
return new_annotation
|
| 169 |
+
|
| 170 |
+
@router.get("/{session_hash}/stats")
|
| 171 |
+
async def get_session_stats(
|
| 172 |
+
session: UserSession = Depends(get_session_by_hash_dep),
|
| 173 |
+
db: Session = Depends(get_db)
|
| 174 |
+
):
|
| 175 |
+
"""
|
| 176 |
+
Obtener estadísticas de una sesión
|
| 177 |
+
"""
|
| 178 |
+
# Contar anotaciones
|
| 179 |
+
annotation_count = db.query(AnnotationClass).filter(
|
| 180 |
+
AnnotationClass.session_hash == session.session_hash,
|
| 181 |
+
AnnotationClass.is_active == True
|
| 182 |
+
).count()
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
"session_name": session.session_name,
|
| 186 |
+
"session_hash": session.session_hash,
|
| 187 |
+
"created_by": session.user.username,
|
| 188 |
+
"created_at": session.created_at,
|
| 189 |
+
"annotation_classes_count": annotation_count,
|
| 190 |
+
"is_active": session.is_active
|
| 191 |
+
}
|
auth/session_utils.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import secrets
|
| 3 |
+
import time
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from .models import UserSession, User
|
| 7 |
+
|
| 8 |
+
def generate_session_hash(user_id: int, session_name: str) -> str:
|
| 9 |
+
"""
|
| 10 |
+
Genera un hash único para una sesión basado en:
|
| 11 |
+
- ID del usuario
|
| 12 |
+
- Nombre de la sesión
|
| 13 |
+
- Timestamp actual
|
| 14 |
+
- Salt aleatorio
|
| 15 |
+
"""
|
| 16 |
+
# Crear un salt aleatorio
|
| 17 |
+
salt = secrets.token_hex(16)
|
| 18 |
+
|
| 19 |
+
# Crear string único combinando datos
|
| 20 |
+
unique_string = f"{user_id}:{session_name}:{time.time()}:{salt}"
|
| 21 |
+
|
| 22 |
+
# Generar hash SHA-256
|
| 23 |
+
session_hash = hashlib.sha256(unique_string.encode()).hexdigest()
|
| 24 |
+
|
| 25 |
+
return session_hash
|
| 26 |
+
|
| 27 |
+
def create_private_session(
|
| 28 |
+
db: Session,
|
| 29 |
+
user_id: int,
|
| 30 |
+
session_name: str
|
| 31 |
+
) -> UserSession:
|
| 32 |
+
"""
|
| 33 |
+
Crea una nueva sesión privada con hash único
|
| 34 |
+
"""
|
| 35 |
+
# Generar hash único
|
| 36 |
+
session_hash = generate_session_hash(user_id, session_name)
|
| 37 |
+
|
| 38 |
+
# Verificar que el hash sea único (muy improbable que se repita, pero por seguridad)
|
| 39 |
+
while db.query(UserSession).filter(UserSession.session_hash == session_hash).first():
|
| 40 |
+
session_hash = generate_session_hash(user_id, session_name)
|
| 41 |
+
|
| 42 |
+
# Crear la sesión
|
| 43 |
+
new_session = UserSession(
|
| 44 |
+
session_name=session_name,
|
| 45 |
+
session_hash=session_hash,
|
| 46 |
+
user_id=user_id,
|
| 47 |
+
is_active=True
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
db.add(new_session)
|
| 51 |
+
db.commit()
|
| 52 |
+
db.refresh(new_session)
|
| 53 |
+
|
| 54 |
+
return new_session
|
| 55 |
+
|
| 56 |
+
def verify_session_access(
|
| 57 |
+
db: Session,
|
| 58 |
+
session_hash: str,
|
| 59 |
+
user_id: Optional[int] = None
|
| 60 |
+
) -> Optional[UserSession]:
|
| 61 |
+
"""
|
| 62 |
+
Verifica el acceso a una sesión mediante hash.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
db: Sesión de base de datos
|
| 66 |
+
session_hash: Hash de la sesión
|
| 67 |
+
user_id: ID del usuario (opcional, para verificación adicional)
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
UserSession si el acceso es válido, None si no
|
| 71 |
+
"""
|
| 72 |
+
query = db.query(UserSession).filter(
|
| 73 |
+
UserSession.session_hash == session_hash,
|
| 74 |
+
UserSession.is_active == True
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Si se proporciona user_id, verificar que coincida
|
| 78 |
+
if user_id is not None:
|
| 79 |
+
query = query.filter(UserSession.user_id == user_id)
|
| 80 |
+
|
| 81 |
+
return query.first()
|
| 82 |
+
|
| 83 |
+
def get_session_by_hash(db: Session, session_hash: str) -> Optional[UserSession]:
|
| 84 |
+
"""
|
| 85 |
+
Obtiene una sesión por su hash
|
| 86 |
+
"""
|
| 87 |
+
return db.query(UserSession).filter(
|
| 88 |
+
UserSession.session_hash == session_hash,
|
| 89 |
+
UserSession.is_active == True
|
| 90 |
+
).first()
|
| 91 |
+
|
| 92 |
+
def get_user_sessions(db: Session, user_id: int) -> list[UserSession]:
|
| 93 |
+
"""
|
| 94 |
+
Obtiene todas las sesiones activas de un usuario
|
| 95 |
+
"""
|
| 96 |
+
return db.query(UserSession).filter(
|
| 97 |
+
UserSession.user_id == user_id,
|
| 98 |
+
UserSession.is_active == True
|
| 99 |
+
).all()
|
| 100 |
+
|
| 101 |
+
def deactivate_session(db: Session, session_hash: str, user_id: int) -> bool:
|
| 102 |
+
"""
|
| 103 |
+
Desactiva una sesión (solo el propietario puede hacerlo)
|
| 104 |
+
"""
|
| 105 |
+
session = db.query(UserSession).filter(
|
| 106 |
+
UserSession.session_hash == session_hash,
|
| 107 |
+
UserSession.user_id == user_id,
|
| 108 |
+
UserSession.is_active == True
|
| 109 |
+
).first()
|
| 110 |
+
|
| 111 |
+
if session:
|
| 112 |
+
session.is_active = False
|
| 113 |
+
db.commit()
|
| 114 |
+
return True
|
| 115 |
+
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
def is_session_owner(db: Session, session_hash: str, user_id: int) -> bool:
|
| 119 |
+
"""
|
| 120 |
+
Verifica si un usuario es propietario de una sesión
|
| 121 |
+
"""
|
| 122 |
+
session = db.query(UserSession).filter(
|
| 123 |
+
UserSession.session_hash == session_hash,
|
| 124 |
+
UserSession.user_id == user_id,
|
| 125 |
+
UserSession.is_active == True
|
| 126 |
+
).first()
|
| 127 |
+
|
| 128 |
+
return session is not None
|
| 129 |
+
|
| 130 |
+
def generate_session_url(session_hash: str, base_url: str = "http://localhost:8002") -> str:
|
| 131 |
+
"""
|
| 132 |
+
Genera una URL para acceder a una sesión específica
|
| 133 |
+
"""
|
| 134 |
+
return f"{base_url}/session/{session_hash}"
|