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

Update database.py

Browse files
Files changed (1) hide show
  1. database.py +581 -712
database.py CHANGED
@@ -1,722 +1,591 @@
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)
 
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
+ # Usar /data si existe y tiene permisos, sino usar directorio local
15
+ def _get_db_path():
16
+ data_dir = os.environ.get("DB_PATH", "/data/medical_app.db")
17
+ parent = os.path.dirname(data_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  try:
19
+ os.makedirs(parent, exist_ok=True)
20
+ # Test de escritura
21
+ test = os.path.join(parent, ".write_test")
22
+ with open(test, "w") as f:
23
+ f.write("ok")
24
+ os.remove(test)
25
+ return data_dir
26
+ except Exception:
27
+ # Fallback: guardar junto al script
28
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "medical_app.db")
29
+
30
+ DB_PATH = _get_db_path()
31
+
32
+
33
+ class DatabaseManager:
34
+ def __init__(self, db_path: str = DB_PATH):
35
+ self.db_path = db_path
36
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
37
+ self.init_database()
38
+
39
+ def get_connection(self) -> sqlite3.Connection:
40
+ conn = sqlite3.connect(self.db_path)
41
+ conn.row_factory = sqlite3.Row
42
+ conn.execute("PRAGMA foreign_keys = ON")
43
+ return conn
44
+
45
+ def init_database(self):
46
+ conn = self.get_connection()
47
  try:
48
+ conn.execute('''
49
+ CREATE TABLE IF NOT EXISTS Users (
50
+ userID INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ username VARCHAR(30) NOT NULL UNIQUE,
52
+ password VARCHAR(255) NOT NULL,
53
+ role VARCHAR(20) DEFAULT 'Doctor',
54
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP
55
+ )
56
+ ''')
57
+
58
+ conn.execute('''
59
+ CREATE TABLE IF NOT EXISTS Patients (
60
+ patientID INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ createdByUserID INTEGER NOT NULL,
62
+ name VARCHAR(50) NOT NULL,
63
+ birthDate DATE,
64
+ gender VARCHAR(1),
65
+ diabetesType VARCHAR(20),
66
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
67
+ FOREIGN KEY (createdByUserID) REFERENCES Users(userID)
68
+ )
69
+ ''')
70
+
71
+ conn.execute('''
72
+ CREATE TABLE IF NOT EXISTS RiskFactors (
73
+ riskFactorID INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ name VARCHAR(30) NOT NULL,
75
+ description TEXT,
76
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP
77
+ )
78
+ ''')
79
+
80
+ conn.execute('''
81
+ CREATE TABLE IF NOT EXISTS PatientsRiskFactors (
82
+ patientID INTEGER NOT NULL,
83
+ riskFactorID INTEGER NOT NULL,
84
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
85
+ PRIMARY KEY (patientID, riskFactorID),
86
+ FOREIGN KEY (patientID) REFERENCES Patients(patientID) ON DELETE CASCADE,
87
+ FOREIGN KEY (riskFactorID) REFERENCES RiskFactors(riskFactorID) ON DELETE CASCADE
88
+ )
89
+ ''')
90
+
91
+ conn.execute('''
92
+ CREATE TABLE IF NOT EXISTS Consultations (
93
+ consultationID INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ patientID INTEGER NOT NULL,
95
+ createdByUserID INTEGER NOT NULL,
96
+ diabeticRetinopathy BOOLEAN DEFAULT FALSE,
97
+ notes TEXT,
98
+ consultationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
99
+ imagePath TEXT,
100
+ confidence REAL,
101
+ rawOutput REAL,
102
+ FOREIGN KEY (patientID) REFERENCES Patients(patientID) ON DELETE CASCADE,
103
+ FOREIGN KEY (createdByUserID) REFERENCES Users(userID)
104
+ )
105
+ ''')
106
+
107
+ conn.execute('''
108
+ CREATE TABLE IF NOT EXISTS Tasks (
109
+ taskID INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ userID INTEGER NOT NULL,
111
+ text TEXT NOT NULL,
112
+ completed BOOLEAN DEFAULT FALSE,
113
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
114
+ FOREIGN KEY (userID) REFERENCES Users(userID) ON DELETE CASCADE
115
+ )
116
+ ''')
117
+
118
+ conn.commit()
119
+ self._insert_default_data(conn)
120
+ print("Base de datos inicializada correctamente")
121
  except Exception as e:
122
+ print(f"Error inicializando base de datos: {e}")
123
+ conn.rollback()
124
+ finally:
125
+ conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
+ def _insert_default_data(self, conn):
128
+ try:
129
+ cursor = conn.execute("SELECT COUNT(*) FROM Users WHERE username = 'admin'")
130
+ if cursor.fetchone()[0] == 0:
131
+ admin_password = self.hash_password("admin123")
132
+ conn.execute(
133
+ "INSERT INTO Users (username, password, role) VALUES (?, ?, ?)",
134
+ ("admin", admin_password, "Admin")
135
+ )
136
+ print("Usuario administrador creado: admin / admin123")
137
+
138
+ cursor = conn.execute("SELECT COUNT(*) FROM RiskFactors")
139
+ if cursor.fetchone()[0] == 0:
140
+ risk_factors = [
141
+ ("Hipertensión", "Presión arterial alta"),
142
+ ("Diabetes Tipo 1", "Diabetes mellitus dependiente de insulina"),
143
+ ("Diabetes Tipo 2", "Diabetes mellitus no dependiente de insulina"),
144
+ ("Obesidad", "Índice de masa corporal elevado"),
145
+ ("Tabaquismo", "Consumo de cigarrillos o tabaco"),
146
+ ("Sedentarismo", "Falta de actividad física regular"),
147
+ ("Antecedentes Familiares", "Historia familiar de diabetes o cardiovascular"),
148
+ ("Edad Avanzada", "Mayor de 65 años"),
149
+ ("Colesterol Alto", "Niveles elevados de colesterol"),
150
+ ("Nefropatía", "Enfermedad renal relacionada con diabetes"),
151
+ ]
152
+ conn.executemany(
153
+ "INSERT INTO RiskFactors (name, description) VALUES (?, ?)",
154
+ risk_factors
155
+ )
156
+ print("Factores de riesgo insertados")
157
+
158
+ conn.commit()
159
+ except Exception as e:
160
+ print(f"Error insertando datos por defecto: {e}")
161
+
162
+ # ------------------------------------------------------------------ #
163
+ # UTILIDADES
164
+ # ------------------------------------------------------------------ #
165
+ @staticmethod
166
+ def hash_password(password: str) -> str:
167
+ return hashlib.sha256(password.encode()).hexdigest()
168
+
169
+ @staticmethod
170
+ def _serialize(row) -> Dict:
171
+ """Convierte sqlite3.Row a dict y serializa fechas."""
172
+ d = dict(row)
173
+ for key, val in d.items():
174
+ if isinstance(val, (datetime,)):
175
+ d[key] = str(val)
176
+ return d
177
+
178
+ # ------------------------------------------------------------------ #
179
+ # USUARIOS
180
+ # ------------------------------------------------------------------ #
181
+ def authenticate_user(self, username: str, password: str) -> Optional[Dict]:
182
+ conn = self.get_connection()
183
+ try:
184
+ hashed = self.hash_password(password)
185
+ cursor = conn.execute(
186
+ "SELECT userID, username, role, creationDate FROM Users WHERE username=? AND password=?",
187
+ (username, hashed)
188
+ )
189
+ row = cursor.fetchone()
190
+ return self._serialize(row) if row else None
191
+ finally:
192
+ conn.close()
193
+
194
+ def create_user(self, username: str, password: str, role: str = "Doctor") -> bool:
195
+ conn = self.get_connection()
196
+ try:
197
+ conn.execute(
198
+ "INSERT INTO Users (username, password, role) VALUES (?, ?, ?)",
199
+ (username, self.hash_password(password), role)
200
+ )
201
+ conn.commit()
202
+ return True
203
+ except sqlite3.IntegrityError:
204
+ return False
205
+ finally:
206
+ conn.close()
207
+
208
+ def get_all_users(self) -> List[Dict]:
209
+ conn = self.get_connection()
210
+ try:
211
+ rows = conn.execute(
212
+ "SELECT userID, username, role, creationDate FROM Users ORDER BY creationDate DESC"
213
+ ).fetchall()
214
+ return [self._serialize(r) for r in rows]
215
+ finally:
216
+ conn.close()
217
+
218
+ def get_user(self, user_id: int) -> Optional[Dict]:
219
+ conn = self.get_connection()
220
+ try:
221
+ row = conn.execute(
222
+ "SELECT userID, username, role, creationDate FROM Users WHERE userID=?",
223
+ (user_id,)
224
+ ).fetchone()
225
+ return self._serialize(row) if row else None
226
+ finally:
227
+ conn.close()
228
+
229
+ def update_user(self, user_id: int, username: str = None, role: str = None, password: str = None) -> bool:
230
+ conn = self.get_connection()
231
+ try:
232
+ if password:
233
+ conn.execute(
234
+ "UPDATE Users SET username=?, role=?, password=? WHERE userID=?",
235
+ (username, role, self.hash_password(password), user_id)
236
+ )
237
+ else:
238
+ conn.execute(
239
+ "UPDATE Users SET username=?, role=? WHERE userID=?",
240
+ (username, role, user_id)
241
+ )
242
+ conn.commit()
243
+ return True
244
+ except Exception:
245
+ return False
246
+ finally:
247
+ conn.close()
248
+
249
+ def delete_user(self, user_id: int) -> bool:
250
+ conn = self.get_connection()
251
+ try:
252
+ conn.execute("DELETE FROM Users WHERE userID=?", (user_id,))
253
+ conn.commit()
254
+ return True
255
+ except Exception:
256
+ return False
257
+ finally:
258
+ conn.close()
259
+
260
+ # ------------------------------------------------------------------ #
261
+ # PACIENTES (con filtrado por usuario según rol)
262
+ # ------------------------------------------------------------------ #
263
+ def create_patient(self, created_by_user_id: int, name: str,
264
+ birth_date: str = None, gender: str = None,
265
+ diabetes_type: str = None) -> Optional[int]:
266
+ conn = self.get_connection()
267
+ try:
268
+ cursor = conn.execute(
269
+ "INSERT INTO Patients (createdByUserID, name, birthDate, gender, diabetesType) VALUES (?,?,?,?,?)",
270
+ (created_by_user_id, name, birth_date, gender, diabetes_type)
271
+ )
272
+ conn.commit()
273
+ return cursor.lastrowid
274
+ except Exception as e:
275
+ print(f"Error creando paciente: {e}")
276
+ return None
277
+ finally:
278
+ conn.close()
279
+
280
+ def get_patients(self, user_id: int, role: str) -> List[Dict]:
281
+ """Admin ve todos; Doctor ve solo los suyos."""
282
+ conn = self.get_connection()
283
+ try:
284
+ if role == "Admin":
285
+ rows = conn.execute(
286
+ """SELECT p.*, u.username as doctorName
287
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
288
+ ORDER BY p.creationDate DESC"""
289
+ ).fetchall()
290
+ else:
291
+ rows = conn.execute(
292
+ """SELECT p.*, u.username as doctorName
293
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
294
+ WHERE p.createdByUserID = ?
295
+ ORDER BY p.creationDate DESC""",
296
+ (user_id,)
297
+ ).fetchall()
298
+ return [self._serialize(r) for r in rows]
299
+ finally:
300
+ conn.close()
301
+
302
+ def get_patient(self, patient_id: int) -> Optional[Dict]:
303
+ conn = self.get_connection()
304
+ try:
305
+ row = conn.execute(
306
+ """SELECT p.*, u.username as doctorName
307
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
308
+ WHERE p.patientID = ?""",
309
+ (patient_id,)
310
+ ).fetchone()
311
+ return self._serialize(row) if row else None
312
+ finally:
313
+ conn.close()
314
+
315
+ def search_patients(self, search_term: str, user_id: int, role: str) -> List[Dict]:
316
+ conn = self.get_connection()
317
+ try:
318
+ like = f"%{search_term}%"
319
+ if role == "Admin":
320
+ rows = conn.execute(
321
+ """SELECT p.*, u.username as doctorName
322
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
323
+ WHERE p.name LIKE ?
324
+ ORDER BY p.name""",
325
+ (like,)
326
+ ).fetchall()
327
+ else:
328
+ rows = conn.execute(
329
+ """SELECT p.*, u.username as doctorName
330
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
331
+ WHERE p.name LIKE ? AND p.createdByUserID = ?
332
+ ORDER BY p.name""",
333
+ (like, user_id)
334
+ ).fetchall()
335
+ return [self._serialize(r) for r in rows]
336
+ finally:
337
+ conn.close()
338
+
339
+ def update_patient(self, patient_id: int, **kwargs) -> bool:
340
+ conn = self.get_connection()
341
+ try:
342
+ fields = {k: v for k, v in kwargs.items() if v is not None}
343
+ if not fields:
344
+ return False
345
+ set_clause = ", ".join(f"{k}=?" for k in fields)
346
+ conn.execute(
347
+ f"UPDATE Patients SET {set_clause} WHERE patientID=?",
348
+ list(fields.values()) + [patient_id]
349
+ )
350
+ conn.commit()
351
+ return True
352
+ except Exception:
353
+ return False
354
+ finally:
355
+ conn.close()
356
+
357
+ def delete_patient(self, patient_id: int) -> bool:
358
+ conn = self.get_connection()
359
+ try:
360
+ conn.execute("DELETE FROM Patients WHERE patientID=?", (patient_id,))
361
+ conn.commit()
362
+ return True
363
+ except Exception:
364
+ return False
365
+ finally:
366
+ conn.close()
367
+
368
+ # ------------------------------------------------------------------ #
369
+ # FACTORES DE RIESGO
370
+ # ------------------------------------------------------------------ #
371
+ def get_all_risk_factors(self) -> List[Dict]:
372
+ conn = self.get_connection()
373
+ try:
374
+ rows = conn.execute("SELECT * FROM RiskFactors ORDER BY name").fetchall()
375
+ return [self._serialize(r) for r in rows]
376
+ finally:
377
+ conn.close()
378
 
379
+ def get_patient_risk_factors(self, patient_id: int) -> List[Dict]:
380
+ conn = self.get_connection()
381
+ try:
382
+ rows = conn.execute(
383
+ """SELECT rf.* FROM RiskFactors rf
384
+ JOIN PatientsRiskFactors prf ON rf.riskFactorID = prf.riskFactorID
385
+ WHERE prf.patientID = ?""",
386
+ (patient_id,)
387
+ ).fetchall()
388
+ return [self._serialize(r) for r in rows]
389
+ finally:
390
+ conn.close()
391
+
392
+ def add_patient_risk_factor(self, patient_id: int, risk_factor_id: int) -> bool:
393
+ conn = self.get_connection()
394
+ try:
395
+ conn.execute(
396
+ "INSERT OR IGNORE INTO PatientsRiskFactors (patientID, riskFactorID) VALUES (?,?)",
397
+ (patient_id, risk_factor_id)
398
+ )
399
+ conn.commit()
400
+ return True
401
+ except Exception:
402
+ return False
403
+ finally:
404
+ conn.close()
405
+
406
+ def remove_patient_risk_factor(self, patient_id: int, risk_factor_id: int) -> bool:
407
+ conn = self.get_connection()
408
+ try:
409
+ conn.execute(
410
+ "DELETE FROM PatientsRiskFactors WHERE patientID=? AND riskFactorID=?",
411
+ (patient_id, risk_factor_id)
412
+ )
413
+ conn.commit()
414
+ return True
415
+ except Exception:
416
+ return False
417
+ finally:
418
+ conn.close()
419
+
420
+ # ------------------------------------------------------------------ #
421
+ # CONSULTAS
422
+ # ------------------------------------------------------------------ #
423
+ def create_consultation(self, patient_id: int, created_by_user_id: int,
424
+ has_dr: bool, confidence: float, raw_output: float,
425
+ notes: str = "") -> Optional[int]:
426
+ conn = self.get_connection()
427
+ try:
428
+ cursor = conn.execute(
429
+ """INSERT INTO Consultations
430
+ (patientID, createdByUserID, diabeticRetinopathy, notes, confidence, rawOutput)
431
+ VALUES (?,?,?,?,?,?)""",
432
+ (patient_id, created_by_user_id, has_dr, notes, confidence, raw_output)
433
+ )
434
+ conn.commit()
435
+ return cursor.lastrowid
436
+ except Exception as e:
437
+ print(f"Error creando consulta: {e}")
438
+ return None
439
+ finally:
440
+ conn.close()
441
+
442
+ def get_patient_consultations(self, patient_id: int) -> List[Dict]:
443
+ conn = self.get_connection()
444
+ try:
445
+ rows = conn.execute(
446
+ """SELECT c.*, u.username as doctorName
447
+ FROM Consultations c JOIN Users u ON c.createdByUserID = u.userID
448
+ WHERE c.patientID = ?
449
+ ORDER BY c.consultationDate DESC""",
450
+ (patient_id,)
451
+ ).fetchall()
452
+ return [self._serialize(r) for r in rows]
453
+ finally:
454
+ conn.close()
455
+
456
+ def get_consultations(self, user_id: int, role: str,
457
+ page: int = 1, per_page: int = 10,
458
+ search: str = "", filter_type: str = "all") -> Dict:
459
+ conn = self.get_connection()
460
+ try:
461
+ base = """FROM Consultations c
462
+ JOIN Patients p ON c.patientID = p.patientID
463
+ JOIN Users u ON c.createdByUserID = u.userID"""
464
+ conditions = []
465
+ params = []
466
+
467
+ if role != "Admin":
468
+ conditions.append("c.createdByUserID = ?")
469
+ params.append(user_id)
470
+
471
+ if search.strip():
472
+ conditions.append("(p.name LIKE ? OR c.notes LIKE ?)")
473
+ params.extend([f"%{search}%", f"%{search}%"])
474
+
475
+ if filter_type == "positive":
476
+ conditions.append("c.diabeticRetinopathy = 1")
477
+ elif filter_type == "negative":
478
+ conditions.append("c.diabeticRetinopathy = 0")
479
+
480
+ where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
481
+
482
+ total = conn.execute(f"SELECT COUNT(*) {base} {where}", params).fetchone()[0]
483
+ total_pages = max(1, (total + per_page - 1) // per_page)
484
+ offset = (page - 1) * per_page
485
+
486
+ rows = conn.execute(
487
+ f"""SELECT c.*, p.name as patientName, u.username as doctorName
488
+ {base} {where}
489
+ ORDER BY c.consultationDate DESC
490
+ LIMIT ? OFFSET ?""",
491
+ params + [per_page, offset]
492
+ ).fetchall()
493
+
494
+ consultations = [self._serialize(r) for r in rows]
495
+ return {
496
+ "success": True,
497
+ "consultations": consultations,
498
+ "pagination": {
499
+ "current_page": page,
500
+ "per_page": per_page,
501
+ "total_pages": total_pages,
502
+ "total_records": total,
503
+ "has_previous": page > 1,
504
+ "has_next": page < total_pages
505
+ }
506
  }
507
+ except Exception as e:
508
+ return {"success": False, "error": str(e), "consultations": [], "pagination": {}}
509
+ finally:
510
+ conn.close()
511
+
512
+ def get_dashboard_stats(self, user_id: int, role: str) -> Dict:
513
+ conn = self.get_connection()
514
+ try:
515
+ filter_clause = "" if role == "Admin" else "AND c.createdByUserID = ?"
516
+ params = [] if role == "Admin" else [user_id]
517
+
518
+ today = datetime.now().strftime('%Y-%m-%d')
519
+
520
+ stats = conn.execute(f"""
521
+ SELECT
522
+ COUNT(DISTINCT c.patientID) as total_patients,
523
+ COUNT(*) as total_consultations,
524
+ SUM(CASE WHEN c.diabeticRetinopathy=1 THEN 1 ELSE 0 END) as positive_cases,
525
+ SUM(CASE WHEN c.diabeticRetinopathy=0 THEN 1 ELSE 0 END) as negative_cases,
526
+ SUM(CASE WHEN date(c.consultationDate)=? THEN 1 ELSE 0 END) as today_consultations
527
+ FROM Consultations c
528
+ WHERE 1=1 {filter_clause}
529
+ """, [today] + params).fetchone()
530
+
531
+ row = self._serialize(stats)
532
+ total = row.get("total_consultations") or 0
533
+ pos = row.get("positive_cases") or 0
534
+ row["positivity_rate"] = round((pos / total * 100), 1) if total > 0 else 0
535
+ return {"success": True, "stats": row}
536
+ except Exception as e:
537
+ return {"success": False, "error": str(e)}
538
+ finally:
539
+ conn.close()
540
+
541
+ # ------------------------------------------------------------------ #
542
+ # TAREAS (por usuario)
543
+ # ------------------------------------------------------------------ #
544
+ def get_tasks(self, user_id: int) -> List[Dict]:
545
+ conn = self.get_connection()
546
+ try:
547
+ rows = conn.execute(
548
+ "SELECT * FROM Tasks WHERE userID=? ORDER BY completed, creationDate DESC",
549
+ (user_id,)
550
+ ).fetchall()
551
+ return [self._serialize(r) for r in rows]
552
+ finally:
553
+ conn.close()
554
+
555
+ def add_task(self, user_id: int, text: str) -> Optional[Dict]:
556
+ conn = self.get_connection()
557
+ try:
558
+ cursor = conn.execute(
559
+ "INSERT INTO Tasks (userID, text) VALUES (?,?)",
560
+ (user_id, text)
561
+ )
562
+ conn.commit()
563
+ row = conn.execute("SELECT * FROM Tasks WHERE taskID=?", (cursor.lastrowid,)).fetchone()
564
+ return self._serialize(row)
565
+ finally:
566
+ conn.close()
567
+
568
+ def toggle_task(self, task_id: int, user_id: int) -> bool:
569
+ conn = self.get_connection()
570
+ try:
571
+ conn.execute(
572
+ "UPDATE Tasks SET completed = NOT completed WHERE taskID=? AND userID=?",
573
+ (task_id, user_id)
574
+ )
575
+ conn.commit()
576
+ return True
577
+ except Exception:
578
+ return False
579
+ finally:
580
+ conn.close()
581
+
582
+ def delete_task(self, task_id: int, user_id: int) -> bool:
583
+ conn = self.get_connection()
584
+ try:
585
+ conn.execute("DELETE FROM Tasks WHERE taskID=? AND userID=?", (task_id, user_id))
586
+ conn.commit()
587
+ return True
588
+ except Exception:
589
+ return False
590
+ finally:
591
+ conn.close()