juancmamacias commited on
Commit
92add6c
·
verified ·
1 Parent(s): dd5358b

Upload 20 files

Browse files
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}"