ViannyCruz commited on
Commit
7af932c
·
verified ·
1 Parent(s): f0cfd96

Update database.py

Browse files
Files changed (1) hide show
  1. database.py +715 -568
database.py CHANGED
@@ -1,575 +1,722 @@
1
  #!/usr/bin/env python3
2
  """
3
- BASE DE DATOS SQLITE - APLICACIÓN MÉDICA
4
- Retinopatía Diabética - Sistema de Diagnóstico
5
- Versión Web (Flask) - Con aislamiento de datos por usuario
6
  """
7
- import sqlite3
8
  import os
9
- import hashlib
10
- from datetime import datetime
11
- from typing import Optional, List, Dict
12
-
13
-
14
- DB_PATH = os.environ.get("DB_PATH", "/data/medical_app.db")
15
-
16
-
17
- class DatabaseManager:
18
- def __init__(self, db_path: str = DB_PATH):
19
- self.db_path = db_path
20
- os.makedirs(os.path.dirname(db_path), exist_ok=True)
21
- self.init_database()
22
-
23
- def get_connection(self) -> sqlite3.Connection:
24
- conn = sqlite3.connect(self.db_path)
25
- conn.row_factory = sqlite3.Row
26
- conn.execute("PRAGMA foreign_keys = ON")
27
- return conn
28
-
29
- def init_database(self):
30
- conn = self.get_connection()
31
- try:
32
- conn.execute('''
33
- CREATE TABLE IF NOT EXISTS Users (
34
- userID INTEGER PRIMARY KEY AUTOINCREMENT,
35
- username VARCHAR(30) NOT NULL UNIQUE,
36
- password VARCHAR(255) NOT NULL,
37
- role VARCHAR(20) DEFAULT 'Doctor',
38
- creationDate DATETIME DEFAULT CURRENT_TIMESTAMP
39
- )
40
- ''')
41
-
42
- conn.execute('''
43
- CREATE TABLE IF NOT EXISTS Patients (
44
- patientID INTEGER PRIMARY KEY AUTOINCREMENT,
45
- createdByUserID INTEGER NOT NULL,
46
- name VARCHAR(50) NOT NULL,
47
- birthDate DATE,
48
- gender VARCHAR(1),
49
- diabetesType VARCHAR(20),
50
- creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
51
- FOREIGN KEY (createdByUserID) REFERENCES Users(userID)
52
- )
53
- ''')
54
-
55
- conn.execute('''
56
- CREATE TABLE IF NOT EXISTS RiskFactors (
57
- riskFactorID INTEGER PRIMARY KEY AUTOINCREMENT,
58
- name VARCHAR(30) NOT NULL,
59
- description TEXT,
60
- creationDate DATETIME DEFAULT CURRENT_TIMESTAMP
61
- )
62
- ''')
63
-
64
- conn.execute('''
65
- CREATE TABLE IF NOT EXISTS PatientsRiskFactors (
66
- patientID INTEGER NOT NULL,
67
- riskFactorID INTEGER NOT NULL,
68
- creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
69
- PRIMARY KEY (patientID, riskFactorID),
70
- FOREIGN KEY (patientID) REFERENCES Patients(patientID) ON DELETE CASCADE,
71
- FOREIGN KEY (riskFactorID) REFERENCES RiskFactors(riskFactorID) ON DELETE CASCADE
72
- )
73
- ''')
74
-
75
- conn.execute('''
76
- CREATE TABLE IF NOT EXISTS Consultations (
77
- consultationID INTEGER PRIMARY KEY AUTOINCREMENT,
78
- patientID INTEGER NOT NULL,
79
- createdByUserID INTEGER NOT NULL,
80
- diabeticRetinopathy BOOLEAN DEFAULT FALSE,
81
- notes TEXT,
82
- consultationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
83
- imagePath TEXT,
84
- confidence REAL,
85
- rawOutput REAL,
86
- FOREIGN KEY (patientID) REFERENCES Patients(patientID) ON DELETE CASCADE,
87
- FOREIGN KEY (createdByUserID) REFERENCES Users(userID)
88
- )
89
- ''')
90
-
91
- conn.execute('''
92
- CREATE TABLE IF NOT EXISTS Tasks (
93
- taskID INTEGER PRIMARY KEY AUTOINCREMENT,
94
- userID INTEGER NOT NULL,
95
- text TEXT NOT NULL,
96
- completed BOOLEAN DEFAULT FALSE,
97
- creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
98
- FOREIGN KEY (userID) REFERENCES Users(userID) ON DELETE CASCADE
99
- )
100
- ''')
101
-
102
- conn.commit()
103
- self._insert_default_data(conn)
104
- print("Base de datos inicializada correctamente")
105
- except Exception as e:
106
- print(f"Error inicializando base de datos: {e}")
107
- conn.rollback()
108
- finally:
109
- conn.close()
110
-
111
- def _insert_default_data(self, conn):
112
- try:
113
- cursor = conn.execute("SELECT COUNT(*) FROM Users WHERE username = 'admin'")
114
- if cursor.fetchone()[0] == 0:
115
- admin_password = self.hash_password("admin123")
116
- conn.execute(
117
- "INSERT INTO Users (username, password, role) VALUES (?, ?, ?)",
118
- ("admin", admin_password, "Admin")
119
- )
120
- print("Usuario administrador creado: admin / admin123")
121
-
122
- cursor = conn.execute("SELECT COUNT(*) FROM RiskFactors")
123
- if cursor.fetchone()[0] == 0:
124
- risk_factors = [
125
- ("Hipertensión", "Presión arterial alta"),
126
- ("Diabetes Tipo 1", "Diabetes mellitus dependiente de insulina"),
127
- ("Diabetes Tipo 2", "Diabetes mellitus no dependiente de insulina"),
128
- ("Obesidad", "Índice de masa corporal elevado"),
129
- ("Tabaquismo", "Consumo de cigarrillos o tabaco"),
130
- ("Sedentarismo", "Falta de actividad física regular"),
131
- ("Antecedentes Familiares", "Historia familiar de diabetes o cardiovascular"),
132
- ("Edad Avanzada", "Mayor de 65 años"),
133
- ("Colesterol Alto", "Niveles elevados de colesterol"),
134
- ("Nefropatía", "Enfermedad renal relacionada con diabetes"),
135
- ]
136
- conn.executemany(
137
- "INSERT INTO RiskFactors (name, description) VALUES (?, ?)",
138
- risk_factors
139
- )
140
- print("Factores de riesgo insertados")
141
-
142
- conn.commit()
143
- except Exception as e:
144
- print(f"Error insertando datos por defecto: {e}")
145
-
146
- # ------------------------------------------------------------------ #
147
- # UTILIDADES
148
- # ------------------------------------------------------------------ #
149
- @staticmethod
150
- def hash_password(password: str) -> str:
151
- return hashlib.sha256(password.encode()).hexdigest()
152
-
153
- @staticmethod
154
- def _serialize(row) -> Dict:
155
- """Convierte sqlite3.Row a dict y serializa fechas."""
156
- d = dict(row)
157
- for key, val in d.items():
158
- if isinstance(val, (datetime,)):
159
- d[key] = str(val)
160
- return d
161
-
162
- # ------------------------------------------------------------------ #
163
- # USUARIOS
164
- # ------------------------------------------------------------------ #
165
- def authenticate_user(self, username: str, password: str) -> Optional[Dict]:
166
- conn = self.get_connection()
167
- try:
168
- hashed = self.hash_password(password)
169
- cursor = conn.execute(
170
- "SELECT userID, username, role, creationDate FROM Users WHERE username=? AND password=?",
171
- (username, hashed)
172
- )
173
- row = cursor.fetchone()
174
- return self._serialize(row) if row else None
175
- finally:
176
- conn.close()
177
-
178
- def create_user(self, username: str, password: str, role: str = "Doctor") -> bool:
179
- conn = self.get_connection()
180
- try:
181
- conn.execute(
182
- "INSERT INTO Users (username, password, role) VALUES (?, ?, ?)",
183
- (username, self.hash_password(password), role)
184
- )
185
- conn.commit()
186
- return True
187
- except sqlite3.IntegrityError:
188
- return False
189
- finally:
190
- conn.close()
191
-
192
- def get_all_users(self) -> List[Dict]:
193
- conn = self.get_connection()
194
- try:
195
- rows = conn.execute(
196
- "SELECT userID, username, role, creationDate FROM Users ORDER BY creationDate DESC"
197
- ).fetchall()
198
- return [self._serialize(r) for r in rows]
199
- finally:
200
- conn.close()
201
-
202
- def get_user(self, user_id: int) -> Optional[Dict]:
203
- conn = self.get_connection()
204
- try:
205
- row = conn.execute(
206
- "SELECT userID, username, role, creationDate FROM Users WHERE userID=?",
207
- (user_id,)
208
- ).fetchone()
209
- return self._serialize(row) if row else None
210
- finally:
211
- conn.close()
212
-
213
- def update_user(self, user_id: int, username: str = None, role: str = None, password: str = None) -> bool:
214
- conn = self.get_connection()
215
- try:
216
- if password:
217
- conn.execute(
218
- "UPDATE Users SET username=?, role=?, password=? WHERE userID=?",
219
- (username, role, self.hash_password(password), user_id)
220
- )
221
- else:
222
- conn.execute(
223
- "UPDATE Users SET username=?, role=? WHERE userID=?",
224
- (username, role, user_id)
225
- )
226
- conn.commit()
227
- return True
228
- except Exception:
229
- return False
230
- finally:
231
- conn.close()
232
-
233
- def delete_user(self, user_id: int) -> bool:
234
- conn = self.get_connection()
235
- try:
236
- conn.execute("DELETE FROM Users WHERE userID=?", (user_id,))
237
- conn.commit()
238
- return True
239
- except Exception:
240
- return False
241
- finally:
242
- conn.close()
243
-
244
- # ------------------------------------------------------------------ #
245
- # PACIENTES (con filtrado por usuario según rol)
246
- # ------------------------------------------------------------------ #
247
- def create_patient(self, created_by_user_id: int, name: str,
248
- birth_date: str = None, gender: str = None,
249
- diabetes_type: str = None) -> Optional[int]:
250
- conn = self.get_connection()
251
- try:
252
- cursor = conn.execute(
253
- "INSERT INTO Patients (createdByUserID, name, birthDate, gender, diabetesType) VALUES (?,?,?,?,?)",
254
- (created_by_user_id, name, birth_date, gender, diabetes_type)
255
- )
256
- conn.commit()
257
- return cursor.lastrowid
258
- except Exception as e:
259
- print(f"Error creando paciente: {e}")
260
- return None
261
- finally:
262
- conn.close()
263
-
264
- def get_patients(self, user_id: int, role: str) -> List[Dict]:
265
- """Admin ve todos; Doctor ve solo los suyos."""
266
- conn = self.get_connection()
267
- try:
268
- if role == "Admin":
269
- rows = conn.execute(
270
- """SELECT p.*, u.username as doctorName
271
- FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
272
- ORDER BY p.creationDate DESC"""
273
- ).fetchall()
274
- else:
275
- rows = conn.execute(
276
- """SELECT p.*, u.username as doctorName
277
- FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
278
- WHERE p.createdByUserID = ?
279
- ORDER BY p.creationDate DESC""",
280
- (user_id,)
281
- ).fetchall()
282
- return [self._serialize(r) for r in rows]
283
- finally:
284
- conn.close()
285
-
286
- def get_patient(self, patient_id: int) -> Optional[Dict]:
287
- conn = self.get_connection()
288
  try:
289
- row = conn.execute(
290
- """SELECT p.*, u.username as doctorName
291
- FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
292
- WHERE p.patientID = ?""",
293
- (patient_id,)
294
- ).fetchone()
295
- return self._serialize(row) if row else None
296
- finally:
297
- conn.close()
298
-
299
- def search_patients(self, search_term: str, user_id: int, role: str) -> List[Dict]:
300
- conn = self.get_connection()
301
- try:
302
- like = f"%{search_term}%"
303
- if role == "Admin":
304
- rows = conn.execute(
305
- """SELECT p.*, u.username as doctorName
306
- FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
307
- WHERE p.name LIKE ?
308
- ORDER BY p.name""",
309
- (like,)
310
- ).fetchall()
311
- else:
312
- rows = conn.execute(
313
- """SELECT p.*, u.username as doctorName
314
- FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
315
- WHERE p.name LIKE ? AND p.createdByUserID = ?
316
- ORDER BY p.name""",
317
- (like, user_id)
318
- ).fetchall()
319
- return [self._serialize(r) for r in rows]
320
- finally:
321
- conn.close()
322
-
323
- def update_patient(self, patient_id: int, **kwargs) -> bool:
324
- conn = self.get_connection()
325
- try:
326
- fields = {k: v for k, v in kwargs.items() if v is not None}
327
- if not fields:
328
- return False
329
- set_clause = ", ".join(f"{k}=?" for k in fields)
330
- conn.execute(
331
- f"UPDATE Patients SET {set_clause} WHERE patientID=?",
332
- list(fields.values()) + [patient_id]
333
- )
334
- conn.commit()
335
- return True
336
- except Exception:
337
- return False
338
- finally:
339
- conn.close()
340
-
341
- def delete_patient(self, patient_id: int) -> bool:
342
- conn = self.get_connection()
343
- try:
344
- conn.execute("DELETE FROM Patients WHERE patientID=?", (patient_id,))
345
- conn.commit()
346
- return True
347
- except Exception:
348
- return False
349
- finally:
350
- conn.close()
351
-
352
- # ------------------------------------------------------------------ #
353
- # FACTORES DE RIESGO
354
- # ------------------------------------------------------------------ #
355
- def get_all_risk_factors(self) -> List[Dict]:
356
- conn = self.get_connection()
357
- try:
358
- rows = conn.execute("SELECT * FROM RiskFactors ORDER BY name").fetchall()
359
- return [self._serialize(r) for r in rows]
360
- finally:
361
- conn.close()
362
-
363
- def get_patient_risk_factors(self, patient_id: int) -> List[Dict]:
364
- conn = self.get_connection()
365
- try:
366
- rows = conn.execute(
367
- """SELECT rf.* FROM RiskFactors rf
368
- JOIN PatientsRiskFactors prf ON rf.riskFactorID = prf.riskFactorID
369
- WHERE prf.patientID = ?""",
370
- (patient_id,)
371
- ).fetchall()
372
- return [self._serialize(r) for r in rows]
373
- finally:
374
- conn.close()
375
-
376
- def add_patient_risk_factor(self, patient_id: int, risk_factor_id: int) -> bool:
377
- conn = self.get_connection()
378
- try:
379
- conn.execute(
380
- "INSERT OR IGNORE INTO PatientsRiskFactors (patientID, riskFactorID) VALUES (?,?)",
381
- (patient_id, risk_factor_id)
382
- )
383
- conn.commit()
384
- return True
385
- except Exception:
386
- return False
387
- finally:
388
- conn.close()
389
-
390
- def remove_patient_risk_factor(self, patient_id: int, risk_factor_id: int) -> bool:
391
- conn = self.get_connection()
392
- try:
393
- conn.execute(
394
- "DELETE FROM PatientsRiskFactors WHERE patientID=? AND riskFactorID=?",
395
- (patient_id, risk_factor_id)
396
- )
397
- conn.commit()
398
- return True
399
- except Exception:
400
- return False
401
- finally:
402
- conn.close()
403
-
404
- # ------------------------------------------------------------------ #
405
- # CONSULTAS
406
- # ------------------------------------------------------------------ #
407
- def create_consultation(self, patient_id: int, created_by_user_id: int,
408
- has_dr: bool, confidence: float, raw_output: float,
409
- notes: str = "") -> Optional[int]:
410
- conn = self.get_connection()
411
- try:
412
- cursor = conn.execute(
413
- """INSERT INTO Consultations
414
- (patientID, createdByUserID, diabeticRetinopathy, notes, confidence, rawOutput)
415
- VALUES (?,?,?,?,?,?)""",
416
- (patient_id, created_by_user_id, has_dr, notes, confidence, raw_output)
417
- )
418
- conn.commit()
419
- return cursor.lastrowid
420
  except Exception as e:
421
- print(f"Error creando consulta: {e}")
422
- return None
423
- finally:
424
- conn.close()
425
-
426
- def get_patient_consultations(self, patient_id: int) -> List[Dict]:
427
- conn = self.get_connection()
428
- try:
429
- rows = conn.execute(
430
- """SELECT c.*, u.username as doctorName
431
- FROM Consultations c JOIN Users u ON c.createdByUserID = u.userID
432
- WHERE c.patientID = ?
433
- ORDER BY c.consultationDate DESC""",
434
- (patient_id,)
435
- ).fetchall()
436
- return [self._serialize(r) for r in rows]
437
- finally:
438
- conn.close()
439
-
440
- def get_consultations(self, user_id: int, role: str,
441
- page: int = 1, per_page: int = 10,
442
- search: str = "", filter_type: str = "all") -> Dict:
443
- conn = self.get_connection()
444
- try:
445
- base = """FROM Consultations c
446
- JOIN Patients p ON c.patientID = p.patientID
447
- JOIN Users u ON c.createdByUserID = u.userID"""
448
- conditions = []
449
- params = []
450
-
451
- if role != "Admin":
452
- conditions.append("c.createdByUserID = ?")
453
- params.append(user_id)
454
-
455
- if search.strip():
456
- conditions.append("(p.name LIKE ? OR c.notes LIKE ?)")
457
- params.extend([f"%{search}%", f"%{search}%"])
458
-
459
- if filter_type == "positive":
460
- conditions.append("c.diabeticRetinopathy = 1")
461
- elif filter_type == "negative":
462
- conditions.append("c.diabeticRetinopathy = 0")
463
-
464
- where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
465
-
466
- total = conn.execute(f"SELECT COUNT(*) {base} {where}", params).fetchone()[0]
467
- total_pages = max(1, (total + per_page - 1) // per_page)
468
- offset = (page - 1) * per_page
469
-
470
- rows = conn.execute(
471
- f"""SELECT c.*, p.name as patientName, u.username as doctorName
472
- {base} {where}
473
- ORDER BY c.consultationDate DESC
474
- LIMIT ? OFFSET ?""",
475
- params + [per_page, offset]
476
- ).fetchall()
477
-
478
- consultations = [self._serialize(r) for r in rows]
479
- return {
480
- "success": True,
481
- "consultations": consultations,
482
- "pagination": {
483
- "current_page": page,
484
- "per_page": per_page,
485
- "total_pages": total_pages,
486
- "total_records": total,
487
- "has_previous": page > 1,
488
- "has_next": page < total_pages
489
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  }
491
- except Exception as e:
492
- return {"success": False, "error": str(e), "consultations": [], "pagination": {}}
493
- finally:
494
- conn.close()
495
-
496
- def get_dashboard_stats(self, user_id: int, role: str) -> Dict:
497
- conn = self.get_connection()
498
- try:
499
- filter_clause = "" if role == "Admin" else "AND c.createdByUserID = ?"
500
- params = [] if role == "Admin" else [user_id]
501
-
502
- today = datetime.now().strftime('%Y-%m-%d')
503
-
504
- stats = conn.execute(f"""
505
- SELECT
506
- COUNT(DISTINCT c.patientID) as total_patients,
507
- COUNT(*) as total_consultations,
508
- SUM(CASE WHEN c.diabeticRetinopathy=1 THEN 1 ELSE 0 END) as positive_cases,
509
- SUM(CASE WHEN c.diabeticRetinopathy=0 THEN 1 ELSE 0 END) as negative_cases,
510
- SUM(CASE WHEN date(c.consultationDate)=? THEN 1 ELSE 0 END) as today_consultations
511
- FROM Consultations c
512
- WHERE 1=1 {filter_clause}
513
- """, [today] + params).fetchone()
514
-
515
- row = self._serialize(stats)
516
- total = row.get("total_consultations") or 0
517
- pos = row.get("positive_cases") or 0
518
- row["positivity_rate"] = round((pos / total * 100), 1) if total > 0 else 0
519
- return {"success": True, "stats": row}
520
- except Exception as e:
521
- return {"success": False, "error": str(e)}
522
- finally:
523
- conn.close()
524
-
525
- # ------------------------------------------------------------------ #
526
- # TAREAS (por usuario)
527
- # ------------------------------------------------------------------ #
528
- def get_tasks(self, user_id: int) -> List[Dict]:
529
- conn = self.get_connection()
530
- try:
531
- rows = conn.execute(
532
- "SELECT * FROM Tasks WHERE userID=? ORDER BY completed, creationDate DESC",
533
- (user_id,)
534
- ).fetchall()
535
- return [self._serialize(r) for r in rows]
536
- finally:
537
- conn.close()
538
-
539
- def add_task(self, user_id: int, text: str) -> Optional[Dict]:
540
- conn = self.get_connection()
541
- try:
542
- cursor = conn.execute(
543
- "INSERT INTO Tasks (userID, text) VALUES (?,?)",
544
- (user_id, text)
545
- )
546
- conn.commit()
547
- row = conn.execute("SELECT * FROM Tasks WHERE taskID=?", (cursor.lastrowid,)).fetchone()
548
- return self._serialize(row)
549
- finally:
550
- conn.close()
551
-
552
- def toggle_task(self, task_id: int, user_id: int) -> bool:
553
- conn = self.get_connection()
554
- try:
555
- conn.execute(
556
- "UPDATE Tasks SET completed = NOT completed WHERE taskID=? AND userID=?",
557
- (task_id, user_id)
558
- )
559
- conn.commit()
560
- return True
561
- except Exception:
562
- return False
563
- finally:
564
- conn.close()
565
-
566
- def delete_task(self, task_id: int, user_id: int) -> bool:
567
- conn = self.get_connection()
568
- try:
569
- conn.execute("DELETE FROM Tasks WHERE taskID=? AND userID=?", (task_id, user_id))
570
- conn.commit()
571
- return True
572
- except Exception:
573
- return False
574
- finally:
575
- conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ APLICACIÓN MÉDICA - BACKEND FLASK
4
+ Retinopatía Diabética - Versión Web para Hugging Face Spaces
 
5
  """
 
6
  import os
7
+ import base64
8
+ import json
9
+ import uuid
10
+ import numpy as np
11
+ import cv2
12
+ import matplotlib
13
+ matplotlib.use('Agg')
14
+ import matplotlib.pyplot as plt
15
+ from io import BytesIO
16
+ from datetime import datetime, timedelta
17
+ from functools import wraps
18
+ from typing import Optional
19
+ from PIL import Image
20
+
21
+ from flask import Flask, request, jsonify, session, send_from_directory
22
+ import tensorflow as tf
23
+
24
+ from database import DatabaseManager
25
+
26
+ # ------------------------------------------------------------------ #
27
+ # CONFIGURACIÓN
28
+ # ------------------------------------------------------------------ #
29
+ app = Flask(__name__, static_folder='web', static_url_path='')
30
+ app.secret_key = os.environ.get("SECRET_KEY", "medical-app-secret-2024-change-in-prod")
31
+ app.permanent_session_lifetime = timedelta(hours=8)
32
+
33
+ # Necesario para que las cookies funcionen en HF Spaces (proxy/iframe)
34
+ app.config.update(
35
+ SESSION_COOKIE_SAMESITE="None",
36
+ SESSION_COOKIE_SECURE=True,
37
+ SESSION_COOKIE_HTTPONLY=True,
38
+ )
39
+
40
+ db = DatabaseManager()
41
+ model = None
42
+ CLASS_NAMES = ['Diabetic Retinopathy', 'No Diabetic Retinopathy']
43
+ OPTIMAL_THRESHOLD = 0.28
44
+
45
+ # SciPy opcional
46
+ try:
47
+ from scipy import ndimage
48
+ SCIPY_AVAILABLE = True
49
+ except ImportError:
50
+ SCIPY_AVAILABLE = False
51
+ class _FakeNdimage:
52
+ @staticmethod
53
+ def gaussian_filter(img, sigma):
54
+ k = int(2 * int(3 * sigma) + 1)
55
+ if k % 2 == 0: k += 1
56
+ return cv2.GaussianBlur(img.astype(np.float32), (k, k), sigma)
57
+ @staticmethod
58
+ def label(binary):
59
+ if len(binary.shape) == 3:
60
+ binary = cv2.cvtColor(binary.astype(np.uint8), cv2.COLOR_BGR2GRAY)
61
+ binary = (binary * 255).astype(np.uint8)
62
+ n, labels = cv2.connectedComponents(binary)
63
+ return labels, n - 1
64
+ @staticmethod
65
+ def center_of_mass(binary):
66
+ if len(binary.shape) == 3:
67
+ binary = cv2.cvtColor(binary.astype(np.uint8), cv2.COLOR_BGR2GRAY)
68
+ binary = (binary * 255).astype(np.uint8)
69
+ m = cv2.moments(binary)
70
+ if m['m00'] != 0:
71
+ return (m['m01'] / m['m00'], m['m10'] / m['m00'])
72
+ h, w = binary.shape
73
+ return (h // 2, w // 2)
74
+ ndimage = _FakeNdimage()
75
+
76
+
77
+ # ------------------------------------------------------------------ #
78
+ # DECORADORES DE AUTENTICACIÓN
79
+ # ------------------------------------------------------------------ #
80
+ def login_required(f):
81
+ @wraps(f)
82
+ def decorated(*args, **kwargs):
83
+ if not session.get('is_authenticated'):
84
+ return jsonify({'success': False, 'error': 'No autenticado', 'redirect_to_login': True}), 401
85
+ if datetime.fromisoformat(session.get('expires_at', '2000-01-01')) < datetime.now():
86
+ session.clear()
87
+ return jsonify({'success': False, 'error': 'Sesión expirada', 'redirect_to_login': True}), 401
88
+ return f(*args, **kwargs)
89
+ return decorated
90
+
91
+ def admin_required(f):
92
+ @wraps(f)
93
+ def decorated(*args, **kwargs):
94
+ if not session.get('is_authenticated'):
95
+ return jsonify({'success': False, 'error': 'No autenticado'}), 401
96
+ if session.get('role') != 'Admin':
97
+ return jsonify({'success': False, 'error': 'Acceso denegado: Solo administradores'}), 403
98
+ return f(*args, **kwargs)
99
+ return decorated
100
+
101
+
102
+ # ------------------------------------------------------------------ #
103
+ # MODELO
104
+ # ------------------------------------------------------------------ #
105
+ def load_model():
106
+ global model
107
+ model_files = [f for f in os.listdir('.') if f.endswith('.h5')]
108
+ if not model_files:
109
+ print("ERROR: No hay archivos .h5 en el directorio")
110
+ return False
111
+ model_path = model_files[0]
112
+ print(f"Cargando modelo: {model_path}")
113
+ try:
114
+ from tensorflow.keras.applications import EfficientNetB0
115
+ from tensorflow.keras.layers import Dense, GlobalAveragePooling2D, Dropout, BatchNormalization
116
+ from tensorflow.keras.regularizers import l2
117
+ from tensorflow.keras.models import Model
118
+
119
+ base = EfficientNetB0(weights='imagenet', include_top=False, input_shape=(224, 224, 3))
120
+ base.trainable = False
121
+ inputs = tf.keras.Input(shape=(224, 224, 3))
122
+ x = tf.keras.applications.efficientnet.preprocess_input(inputs)
123
+ x = base(x, training=False)
124
+ x = GlobalAveragePooling2D()(x)
125
+ x = BatchNormalization()(x)
126
+ x = Dropout(0.6)(x)
127
+ x = Dense(64, activation='relu', kernel_regularizer=l2(0.01))(x)
128
+ x = Dropout(0.5)(x)
129
+ outputs = Dense(1, activation='sigmoid', name='predictions')(x)
130
+ model = Model(inputs, outputs)
131
+ model.load_weights(model_path)
132
+
133
+ test = np.random.random((1, 224, 224, 3)).astype(np.float32) * 255
134
+ model.predict(test, verbose=0)
135
+ print(f"Modelo cargado exitosamente: {model_path}")
136
+ return True
137
+ except Exception as e:
138
+ print(f"Error cargando modelo: {e}")
139
+ return False
140
+
141
+ def preprocess_image(image_bytes) -> Optional[np.ndarray]:
142
+ try:
143
+ img = Image.open(BytesIO(image_bytes)).convert('RGB')
144
+ img = img.resize((224, 224), Image.Resampling.LANCZOS)
145
+ arr = np.array(img, dtype=np.float32)
146
+ return np.expand_dims(arr, axis=0)
147
+ except Exception as e:
148
+ print(f"Error en preprocesamiento: {e}")
149
+ return None
150
+
151
+
152
+ # ------------------------------------------------------------------ #
153
+ # GRAD-CAM
154
+ # ------------------------------------------------------------------ #
155
+ class SimpleGradCAM:
156
+ def __init__(self, model_, threshold=0.28):
157
+ self.model = model_
158
+ self.threshold = threshold
159
+
160
+ def generate(self, img_tensor):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  try:
162
+ with tf.GradientTape() as tape:
163
+ tape.watch(img_tensor)
164
+ preds = self.model(img_tensor, training=False)
165
+ loss = preds[0, 0] if preds.shape[-1] == 1 else preds[0, tf.argmax(preds[0])]
166
+ grads = tape.gradient(loss, img_tensor)
167
+ if grads is not None:
168
+ heatmap = tf.squeeze(tf.reduce_mean(tf.abs(grads), axis=-1))
169
+ heatmap = tf.maximum(heatmap, 0)
170
+ if tf.reduce_max(heatmap) > 0:
171
+ heatmap = heatmap / tf.reduce_max(heatmap)
172
+ return heatmap.numpy(), preds[0].numpy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  except Exception as e:
174
+ print(f"GradCAM error: {e}")
175
+ return self._attention(img_tensor)
176
+
177
+ def _attention(self, img_tensor):
178
+ preds = self.model(img_tensor, training=False)
179
+ gray = tf.reduce_mean(img_tensor[0], axis=-1)
180
+ k = tf.ones((5, 5, 1, 1)) / 25.0
181
+ smooth = tf.nn.conv2d(tf.expand_dims(tf.expand_dims(gray, -1), 0), k, [1,1,1,1], 'SAME')
182
+ edges = tf.abs(tf.expand_dims(gray, 0) - tf.squeeze(smooth))
183
+ att = (gray + edges) / 2.0
184
+ att = tf.maximum(att, 0)
185
+ if tf.reduce_max(att) > 0:
186
+ att = att / tf.reduce_max(att)
187
+ return att.numpy(), preds[0].numpy()
188
+
189
+ def find_critical_region(heatmap, zoom_factor=2.2, min_size=60):
190
+ h, w = heatmap.shape
191
+ max_y, max_x = np.unravel_index(np.argmax(heatmap), heatmap.shape)
192
+ thresh = max(0.7, np.percentile(heatmap, 95))
193
+ smooth = ndimage.gaussian_filter(heatmap, sigma=1.0)
194
+ mask = smooth > thresh
195
+ center_y, center_x = max_y, max_x
196
+ if np.sum(mask) > 0:
197
+ labeled, n = ndimage.label(mask)
198
+ if n > 0:
199
+ lbl = labeled[max_y, max_x]
200
+ if lbl > 0:
201
+ cy, cx = ndimage.center_of_mass(labeled == lbl)
202
+ center_y, center_x = int(cy), int(cx)
203
+ zh, zw = max(int(h / zoom_factor), min_size), max(int(w / zoom_factor), min_size)
204
+ y0 = max(0, min(center_y - zh // 2, h - zh))
205
+ x0 = max(0, min(center_x - zw // 2, w - zw))
206
+ return y0, y0 + zh, x0, x0 + zw, center_y, center_x
207
+
208
+
209
+ # ------------------------------------------------------------------ #
210
+ # RUTAS - SERVIR FRONTEND
211
+ # ------------------------------------------------------------------ #
212
+ @app.route('/')
213
+ def index():
214
+ return send_from_directory('web', 'auth-login.html')
215
+
216
+ @app.route('/<path:path>')
217
+ def static_files(path):
218
+ return send_from_directory('web', path)
219
+
220
+
221
+ # ------------------------------------------------------------------ #
222
+ # RUTAS - AUTENTICACIÓN
223
+ # ------------------------------------------------------------------ #
224
+ @app.route('/api/login', methods=['POST'])
225
+ def login():
226
+ data = request.json
227
+ user = db.authenticate_user(data.get('username', ''), data.get('password', ''))
228
+ if user:
229
+ session.permanent = True
230
+ session['user_id'] = user['userID']
231
+ session['username'] = user['username']
232
+ session['role'] = user['role']
233
+ session['is_authenticated'] = True
234
+ session['expires_at'] = (datetime.now() + timedelta(hours=8)).isoformat()
235
+ return jsonify({'success': True, 'user': user, 'message': f'Bienvenido, {user["username"]}'})
236
+ return jsonify({'success': False, 'message': 'Usuario o contraseña incorrectos'}), 401
237
+
238
+ @app.route('/api/logout', methods=['POST'])
239
+ def logout():
240
+ session.clear()
241
+ return jsonify({'success': True})
242
+
243
+ @app.route('/api/session', methods=['GET'])
244
+ @login_required
245
+ def get_session():
246
+ return jsonify({
247
+ 'success': True,
248
+ 'user': {
249
+ 'userID': session['user_id'],
250
+ 'username': session['username'],
251
+ 'role': session['role']
252
+ }
253
+ })
254
+
255
+
256
+ # ------------------------------------------------------------------ #
257
+ # RUTAS - USUARIOS (solo Admin)
258
+ # ------------------------------------------------------------------ #
259
+ @app.route('/api/users', methods=['GET'])
260
+ @login_required
261
+ @admin_required
262
+ def get_users():
263
+ return jsonify({'success': True, 'users': db.get_all_users()})
264
+
265
+ @app.route('/api/users', methods=['POST'])
266
+ @login_required
267
+ @admin_required
268
+ def create_user():
269
+ data = request.json
270
+ username = data.get('username', '').strip()
271
+ password = data.get('password', '')
272
+ role = data.get('role', 'Doctor')
273
+ if not username or not password:
274
+ return jsonify({'success': False, 'message': 'Usuario y contraseña requeridos'}), 400
275
+ if len(password) < 6:
276
+ return jsonify({'success': False, 'message': 'Contraseña mínimo 6 caracteres'}), 400
277
+ if role not in ['Doctor', 'Admin']:
278
+ return jsonify({'success': False, 'message': 'Rol inválido'}), 400
279
+ ok = db.create_user(username, password, role)
280
+ if ok:
281
+ return jsonify({'success': True, 'message': f'Usuario {username} creado'})
282
+ return jsonify({'success': False, 'message': 'El usuario ya existe'}), 409
283
+
284
+ @app.route('/api/users/<int:user_id>', methods=['PUT'])
285
+ @login_required
286
+ @admin_required
287
+ def update_user(user_id):
288
+ data = request.json
289
+ if not db.get_user(user_id):
290
+ return jsonify({'success': False, 'message': 'Usuario no encontrado'}), 404
291
+ db.update_user(user_id,
292
+ username=data.get('username'),
293
+ role=data.get('role'),
294
+ password=data.get('password') or None)
295
+ return jsonify({'success': True, 'message': 'Usuario actualizado'})
296
+
297
+ @app.route('/api/users/<int:user_id>', methods=['DELETE'])
298
+ @login_required
299
+ @admin_required
300
+ def delete_user(user_id):
301
+ if user_id == session['user_id']:
302
+ return jsonify({'success': False, 'message': 'No puedes eliminar tu propia cuenta'}), 400
303
+ all_users = db.get_all_users()
304
+ admins = [u for u in all_users if u['role'] == 'Admin']
305
+ target = db.get_user(user_id)
306
+ if target and target['role'] == 'Admin' and len(admins) <= 1:
307
+ return jsonify({'success': False, 'message': 'No se puede eliminar el último Admin'}), 400
308
+ db.delete_user(user_id)
309
+ return jsonify({'success': True, 'message': 'Usuario eliminado'})
310
+
311
+
312
+ # ------------------------------------------------------------------ #
313
+ # RUTAS - PACIENTES
314
+ # ------------------------------------------------------------------ #
315
+ @app.route('/api/patients', methods=['GET'])
316
+ @login_required
317
+ def get_patients():
318
+ search = request.args.get('search', '').strip()
319
+ uid, role = session['user_id'], session['role']
320
+ if search:
321
+ patients = db.search_patients(search, uid, role)
322
+ else:
323
+ patients = db.get_patients(uid, role)
324
+ return jsonify({'success': True, 'patients': patients})
325
+
326
+ @app.route('/api/patients', methods=['POST'])
327
+ @login_required
328
+ def create_patient():
329
+ data = request.json
330
+ name = (data.get('name') or '').strip()
331
+ if not name:
332
+ return jsonify({'success': False, 'message': 'Nombre requerido'}), 400
333
+ pid = db.create_patient(
334
+ created_by_user_id=session['user_id'],
335
+ name=name,
336
+ birth_date=data.get('birthDate'),
337
+ gender=data.get('gender'),
338
+ diabetes_type=data.get('diabetesType')
339
+ )
340
+ if pid:
341
+ patient = db.get_patient(pid)
342
+ return jsonify({'success': True, 'patient': patient})
343
+ return jsonify({'success': False, 'message': 'Error creando paciente'}), 500
344
+
345
+ @app.route('/api/patients/<int:patient_id>', methods=['GET'])
346
+ @login_required
347
+ def get_patient(patient_id):
348
+ patient = db.get_patient(patient_id)
349
+ if not patient:
350
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
351
+ # Doctors can only see their own patients
352
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
353
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
354
+ consultations = db.get_patient_consultations(patient_id)
355
+ risk_factors = db.get_patient_risk_factors(patient_id)
356
+ return jsonify({'success': True, 'patient': patient,
357
+ 'consultations': consultations, 'risk_factors': risk_factors})
358
+
359
+ @app.route('/api/patients/<int:patient_id>', methods=['PUT'])
360
+ @login_required
361
+ def update_patient(patient_id):
362
+ data = request.json
363
+ patient = db.get_patient(patient_id)
364
+ if not patient:
365
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
366
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
367
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
368
+ db.update_patient(patient_id,
369
+ name=data.get('name'),
370
+ birthDate=data.get('birthDate'),
371
+ gender=data.get('gender'),
372
+ diabetesType=data.get('diabetesType'))
373
+ return jsonify({'success': True, 'patient': db.get_patient(patient_id)})
374
+
375
+ @app.route('/api/patients/<int:patient_id>', methods=['DELETE'])
376
+ @login_required
377
+ def delete_patient(patient_id):
378
+ patient = db.get_patient(patient_id)
379
+ if not patient:
380
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
381
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
382
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
383
+ db.delete_patient(patient_id)
384
+ return jsonify({'success': True})
385
+
386
+ # Factores de riesgo
387
+ @app.route('/api/risk-factors', methods=['GET'])
388
+ @login_required
389
+ def get_risk_factors():
390
+ return jsonify({'success': True, 'risk_factors': db.get_all_risk_factors()})
391
+
392
+ @app.route('/api/patients/<int:patient_id>/risk-factors', methods=['POST'])
393
+ @login_required
394
+ def add_risk_factor(patient_id):
395
+ data = request.json
396
+ db.add_patient_risk_factor(patient_id, data['riskFactorID'])
397
+ return jsonify({'success': True})
398
+
399
+ @app.route('/api/patients/<int:patient_id>/risk-factors/<int:rf_id>', methods=['DELETE'])
400
+ @login_required
401
+ def remove_risk_factor(patient_id, rf_id):
402
+ db.remove_patient_risk_factor(patient_id, rf_id)
403
+ return jsonify({'success': True})
404
+
405
+
406
+ # ------------------------------------------------------------------ #
407
+ # RUTAS - PREDICCIÓN / IA
408
+ # ------------------------------------------------------------------ #
409
+ @app.route('/api/predict', methods=['POST'])
410
+ @login_required
411
+ def predict():
412
+ global model
413
+ if model is None:
414
+ return jsonify({'success': False, 'error': 'Modelo no cargado'}), 503
415
+
416
+ data = request.json
417
+ image_data = data.get('imageData', '')
418
+ filename = data.get('filename', 'image.jpg')
419
+
420
+ if 'base64,' in image_data:
421
+ image_data = image_data.split('base64,')[1]
422
+
423
+ try:
424
+ image_bytes = base64.b64decode(image_data)
425
+ processed = preprocess_image(image_bytes)
426
+ if processed is None:
427
+ return jsonify({'success': False, 'error': 'Error procesando imagen'}), 400
428
+
429
+ prediction = model.predict(processed, verbose=0)
430
+ raw = float(prediction[0][0])
431
+
432
+ if raw > OPTIMAL_THRESHOLD:
433
+ predicted_class = 0
434
+ confidence = raw * 100
435
+ else:
436
+ predicted_class = 1
437
+ confidence = (1 - raw) * 100
438
+
439
+ result = {
440
+ 'success': True,
441
+ 'prediction': {
442
+ 'class': CLASS_NAMES[predicted_class],
443
+ 'class_index': predicted_class,
444
+ 'confidence': round(confidence, 2),
445
+ 'raw_output': round(raw, 6)
446
+ },
447
+ 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
448
+ 'filename': filename
449
+ }
450
+ return jsonify(result)
451
+
452
+ except Exception as e:
453
+ return jsonify({'success': False, 'error': str(e)}), 500
454
+
455
+
456
+ @app.route('/api/gradcam', methods=['POST'])
457
+ @login_required
458
+ def gradcam():
459
+ global model
460
+ if model is None:
461
+ return jsonify({'success': False, 'error': 'Modelo no cargado'}), 503
462
+
463
+ data = request.json
464
+ image_data = data.get('imageData', '')
465
+ filename = data.get('filename', 'image.jpg')
466
+ prediction_result = data.get('predictionResult', {})
467
+
468
+ if prediction_result.get('prediction', {}).get('class_index', 0) != 1:
469
+ return jsonify({'success': False,
470
+ 'error': 'Grad-CAM solo para casos positivos de retinopatía'}), 400
471
+
472
+ if 'base64,' in image_data:
473
+ image_data = image_data.split('base64,')[1]
474
+
475
+ try:
476
+ image_bytes = base64.b64decode(image_data)
477
+ img_pil = Image.open(BytesIO(image_bytes)).convert('RGB')
478
+ orig_w, orig_h = img_pil.size
479
+
480
+ img_224 = img_pil.resize((224, 224), Image.Resampling.LANCZOS)
481
+ img_arr = np.array(img_224, dtype=np.float32)
482
+ orig_arr = np.array(img_pil, dtype=np.uint8)
483
+
484
+ img_tensor = tf.convert_to_tensor(np.expand_dims(img_arr, 0), dtype=tf.float32)
485
+ gcam = SimpleGradCAM(model, OPTIMAL_THRESHOLD)
486
+ heatmap, _ = gcam.generate(img_tensor)
487
+
488
+ y0, y1, x0, x1, cy, cx = find_critical_region(heatmap)
489
+
490
+ sx, sy = orig_w / 224.0, orig_h / 224.0
491
+ x0h, x1h = int(x0 * sx), int(x1 * sx)
492
+ y0h, y1h = int(y0 * sy), int(y1 * sy)
493
+
494
+ zoom_region = orig_arr[y0h:y1h, x0h:x1h]
495
+
496
+ plt.figure(figsize=(10, 10))
497
+ if zoom_region.size > 0:
498
+ plt.imshow(zoom_region)
499
+ zoom_heat = heatmap[y0:y1, x0:x1]
500
+ max_act = float(np.max(zoom_heat))
501
+ avg_act = float(np.mean(zoom_heat))
502
+ high_pct = float(np.sum(zoom_heat > 0.6) / zoom_heat.size * 100)
503
+ plt.title(f'Zona Crítica HD ({x1h-x0h}×{y1h-y0h}px)\n'
504
+ f'Activación: máx={max_act:.3f}, prom={avg_act:.3f}',
505
+ fontsize=12, pad=20)
506
+ else:
507
+ zoom_region = img_arr[y0:y1, x0:x1].astype(np.uint8)
508
+ plt.imshow(zoom_region)
509
+ plt.title('Zona Crítica', fontsize=12)
510
+ high_pct, max_act, avg_act = 0.0, 0.0, 0.0
511
+
512
+ plt.axis('off')
513
+ plt.tight_layout()
514
+ buf = BytesIO()
515
+ plt.savefig(buf, format='png', dpi=150, bbox_inches='tight',
516
+ facecolor='white', edgecolor='none')
517
+ buf.seek(0)
518
+ img_b64 = base64.b64encode(buf.getvalue()).decode()
519
+ plt.close()
520
+
521
+ if high_pct > 20:
522
+ clinical_info = f"Lesión focal intensa ({high_pct:.1f}% activación alta)"
523
+ elif high_pct > 10:
524
+ clinical_info = f"Cambios moderados en región focal ({high_pct:.1f}%)"
525
+ else:
526
+ clinical_info = "Cambios sutiles de DR detectados"
527
+
528
+ return jsonify({
529
+ 'success': True,
530
+ 'gradcam_image': f"data:image/png;base64,{img_b64}",
531
+ 'analysis': {
532
+ 'max_activation': max_act,
533
+ 'avg_activation': avg_act,
534
+ 'high_activation_pct': high_pct,
535
+ 'clinical_info': clinical_info,
536
+ 'zoom_region_hd': (x0h, y0h, x1h, y1h)
537
  }
538
+ })
539
+ except Exception as e:
540
+ import traceback; traceback.print_exc()
541
+ return jsonify({'success': False, 'error': str(e)}), 500
542
+
543
+
544
+ # ------------------------------------------------------------------ #
545
+ # RUTAS - CONSULTAS
546
+ # ------------------------------------------------------------------ #
547
+ @app.route('/api/consultations', methods=['GET'])
548
+ @login_required
549
+ def get_consultations():
550
+ page = int(request.args.get('page', 1))
551
+ per_page = int(request.args.get('per_page', 10))
552
+ search = request.args.get('search', '')
553
+ filter_type = request.args.get('filter', 'all')
554
+ result = db.get_consultations(session['user_id'], session['role'],
555
+ page, per_page, search, filter_type)
556
+ return jsonify(result)
557
+
558
+ @app.route('/api/consultations/<int:consultation_id>', methods=['DELETE'])
559
+ @login_required
560
+ def delete_consultation(consultation_id):
561
+ conn = db.get_connection()
562
+ try:
563
+ row = conn.execute(
564
+ "SELECT createdByUserID FROM Consultations WHERE consultationID=?",
565
+ (consultation_id,)
566
+ ).fetchone()
567
+ if not row:
568
+ return jsonify({'success': False, 'message': 'Consulta no encontrada'}), 404
569
+ if session['role'] != 'Admin' and row['createdByUserID'] != session['user_id']:
570
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
571
+ conn.execute("DELETE FROM Consultations WHERE consultationID=?", (consultation_id,))
572
+ conn.commit()
573
+ return jsonify({'success': True})
574
+ except Exception as e:
575
+ return jsonify({'success': False, 'error': str(e)}), 500
576
+ finally:
577
+ conn.close()
578
+
579
+ @app.route('/api/consultations', methods=['POST'])
580
+ @login_required
581
+ def save_consultation():
582
+ data = request.json
583
+ patient_id = data.get('patientId')
584
+ if not patient_id:
585
+ return jsonify({'success': False, 'message': 'patientId requerido'}), 400
586
+
587
+ patient = db.get_patient(patient_id)
588
+ if not patient:
589
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
590
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
591
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
592
+
593
+ right = data.get('rightEye', {})
594
+ left = data.get('leftEye', {})
595
+ notes = data.get('notes', '')
596
+
597
+ if right.get('hasAnalysis') and left.get('hasAnalysis'):
598
+ has_dr = right['diagnosis'] or left['diagnosis']
599
+ confidence = (right['confidence'] + left['confidence']) / 2
600
+ raw_output = (right.get('rawOutput', 0) + left.get('rawOutput', 0)) / 2
601
+ detailed_notes = (
602
+ f"BILATERAL - OD: {'Positivo' if right['diagnosis'] else 'Negativo'} "
603
+ f"({right['confidence']:.1f}%) | "
604
+ f"OI: {'Positivo' if left['diagnosis'] else 'Negativo'} "
605
+ f"({left['confidence']:.1f}%)\n{notes}"
606
+ )
607
+ elif right.get('hasAnalysis'):
608
+ has_dr = right['diagnosis']
609
+ confidence = right['confidence']
610
+ raw_output = right.get('rawOutput', 0)
611
+ detailed_notes = f"OJO DERECHO: {'Positivo' if has_dr else 'Negativo'} ({confidence:.1f}%)\n{notes}"
612
+ elif left.get('hasAnalysis'):
613
+ has_dr = left['diagnosis']
614
+ confidence = left['confidence']
615
+ raw_output = left.get('rawOutput', 0)
616
+ detailed_notes = f"OJO IZQUIERDO: {'Positivo' if has_dr else 'Negativo'} ({confidence:.1f}%)\n{notes}"
617
+ else:
618
+ return jsonify({'success': False, 'message': 'Sin análisis de imagen'}), 400
619
+
620
+ cid = db.create_consultation(patient_id, session['user_id'],
621
+ has_dr, confidence, raw_output, detailed_notes)
622
+ if cid:
623
+ return jsonify({'success': True, 'consultationID': cid,
624
+ 'message': 'Consulta guardada exitosamente'})
625
+ return jsonify({'success': False, 'message': 'Error guardando consulta'}), 500
626
+
627
+
628
+ # ------------------------------------------------------------------ #
629
+ # RUTAS - DASHBOARD
630
+ # ------------------------------------------------------------------ #
631
+ @app.route('/api/dashboard/stats', methods=['GET'])
632
+ @login_required
633
+ def dashboard_stats():
634
+ result = db.get_dashboard_stats(session['user_id'], session['role'])
635
+ # Add legacy field aliases for frontend compatibility
636
+ if result.get('success') and result.get('stats'):
637
+ s = result['stats']
638
+ s['total_unique_patients'] = s.get('total_patients', 0)
639
+ s['patients_with_rd'] = s.get('positive_cases', 0)
640
+ s['patients_without_rd'] = s.get('negative_cases', 0)
641
+ s['summary_stats'] = {
642
+ 'total_consultations': s.get('total_consultations', 0),
643
+ 'positive_cases': s.get('positive_cases', 0),
644
+ 'negative_cases': s.get('negative_cases', 0),
645
+ 'unique_patients': s.get('total_patients', 0),
646
+ }
647
+ return jsonify(result)
648
+
649
+ @app.route('/api/model/info', methods=['GET'])
650
+ @login_required
651
+ def model_info():
652
+ if model is None:
653
+ return jsonify({'loaded': False, 'error': 'Modelo no cargado'})
654
+ return jsonify({
655
+ 'loaded': True,
656
+ 'model_name': 'EfficientNetB0 - Diabetic Retinopathy Classifier',
657
+ 'input_shape': str(model.input_shape),
658
+ 'classes': CLASS_NAMES,
659
+ 'total_params': int(model.count_params()),
660
+ 'tensorflow_version': tf.__version__
661
+ })
662
+
663
+
664
+ # ------------------------------------------------------------------ #
665
+ # RUTAS - TAREAS (por usuario)
666
+ # ------------------------------------------------------------------ #
667
+ @app.route('/api/tasks', methods=['GET'])
668
+ @login_required
669
+ def get_tasks():
670
+ return jsonify({'success': True, 'tasks': db.get_tasks(session['user_id'])})
671
+
672
+ @app.route('/api/tasks', methods=['POST'])
673
+ @login_required
674
+ def add_task():
675
+ text = (request.json.get('text') or '').strip()
676
+ if not text:
677
+ return jsonify({'success': False, 'message': 'Texto requerido'}), 400
678
+ task = db.add_task(session['user_id'], text)
679
+ return jsonify({'success': True, 'task': task})
680
+
681
+ @app.route('/api/tasks/<int:task_id>/toggle', methods=['POST'])
682
+ @login_required
683
+ def toggle_task(task_id):
684
+ db.toggle_task(task_id, session['user_id'])
685
+ return jsonify({'success': True})
686
+
687
+ @app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
688
+ @login_required
689
+ def delete_task(task_id):
690
+ db.delete_task(task_id, session['user_id'])
691
+ return jsonify({'success': True})
692
+
693
+
694
+
695
+ # ------------------------------------------------------------------ #
696
+ # DEBUG — borrar después de confirmar que funciona
697
+ # ------------------------------------------------------------------ #
698
+ @app.route('/api/debug', methods=['GET'])
699
+ def debug():
700
+ import sqlite3
701
+ try:
702
+ conn = db.get_connection()
703
+ users = conn.execute("SELECT userID, username, role FROM Users").fetchall()
704
+ conn.close()
705
+ return jsonify({
706
+ 'db_path': db.db_path,
707
+ 'db_exists': os.path.exists(db.db_path),
708
+ 'users': [dict(u) for u in users],
709
+ 'model_loaded': model is not None
710
+ })
711
+ except Exception as e:
712
+ return jsonify({'error': str(e), 'db_path': db.db_path})
713
+
714
+
715
+ # ------------------------------------------------------------------ #
716
+ # ARRANQUE
717
+ # ------------------------------------------------------------------ #
718
+ if __name__ == '__main__':
719
+ print("Cargando modelo de TensorFlow...")
720
+ load_model()
721
+ port = int(os.environ.get('PORT', 7860))
722
+ app.run(host='0.0.0.0', port=port, debug=False)