tokiospg commited on
Commit
b188a3e
·
verified ·
1 Parent(s): b8fda7d

Upload 2 files

Browse files
Files changed (2) hide show
  1. database.py +530 -531
  2. requirements.txt +15 -14
database.py CHANGED
@@ -1,531 +1,530 @@
1
- import sqlite3
2
- import pandas as pd
3
- from datetime import datetime, timedelta
4
- import os
5
- import bcrypt
6
- import crypto
7
-
8
- # Persistencia: usar /data si existe (Hugging Face Persistent Storage)
9
- _hf_data = "/data"
10
- if os.path.isdir(_hf_data):
11
- DB_PATH = os.path.join(_hf_data, "proyelec_crm.db")
12
- else:
13
- DB_PATH = os.getenv("DB_PATH", "proyelec_crm.db")
14
-
15
- MAX_INTENTOS = 5 # Intentos fallidos antes de bloquear
16
- TIEMPO_BLOQUEO = 15 # Minutos de bloqueo
17
-
18
- def get_connection(db_path=DB_PATH):
19
- conn = sqlite3.connect(db_path, check_same_thread=False)
20
- if db_path != ":memory:":
21
- conn.execute('PRAGMA journal_mode=WAL;')
22
- return conn
23
-
24
- def init_db():
25
- conn = get_connection()
26
- c = conn.cursor()
27
-
28
- # Usuarios
29
- c.execute('''CREATE TABLE IF NOT EXISTS users (
30
- username TEXT PRIMARY KEY,
31
- password TEXT,
32
- role TEXT,
33
- gemini_key TEXT,
34
- tavily_key TEXT,
35
- email_user TEXT,
36
- email_pass_enc TEXT
37
- )''')
38
-
39
- # Historial de análisis
40
- c.execute('''CREATE TABLE IF NOT EXISTS history (
41
- id INTEGER PRIMARY KEY AUTOINCREMENT,
42
- username TEXT,
43
- licitacion TEXT,
44
- fecha TEXT,
45
- items INTEGER
46
- )''')
47
-
48
- # Bandeja inteligente de correos
49
- c.execute('''CREATE TABLE IF NOT EXISTS smart_inbox (
50
- id INTEGER PRIMARY KEY AUTOINCREMENT,
51
- licitacion TEXT,
52
- remitente TEXT,
53
- asunto TEXT,
54
- fecha TEXT,
55
- resumen TEXT,
56
- renglones_relacionados TEXT,
57
- cuerpo TEXT,
58
- borrador_respuesta TEXT
59
- )''')
60
-
61
- # Cache de fichas técnicas (ahorra tokens Gemini)
62
- c.execute('''CREATE TABLE IF NOT EXISTS fichas_cache (
63
- id INTEGER PRIMARY KEY AUTOINCREMENT,
64
- username TEXT,
65
- licitacion TEXT,
66
- codigo_renglon TEXT,
67
- datasheet_md TEXT,
68
- fecha TEXT,
69
- UNIQUE(username, licitacion, codigo_renglon)
70
- )''')
71
-
72
- # Tabla de cotizaciones extraídas de correos (para uso futuro)
73
- c.execute('''CREATE TABLE IF NOT EXISTS cotizaciones (
74
- id INTEGER PRIMARY KEY AUTOINCREMENT,
75
- licitacion TEXT,
76
- renglon TEXT,
77
- proveedor TEXT,
78
- precio_unitario REAL,
79
- moneda TEXT,
80
- tiempo_entrega TEXT,
81
- condiciones TEXT,
82
- fecha TEXT,
83
- email_asunto TEXT
84
- )''')
85
-
86
- # === MÚLTIPLES WORKSPACES POR USUARIO ===
87
- c.execute('''CREATE TABLE IF NOT EXISTS workspaces (
88
- id INTEGER PRIMARY KEY AUTOINCREMENT,
89
- username TEXT NOT NULL,
90
- licitacion TEXT NOT NULL,
91
- data_json TEXT,
92
- cg_json TEXT,
93
- fecha_guardado TEXT,
94
- UNIQUE(username, licitacion)
95
- )''')
96
-
97
- # app_state legacy (se mantiene para compatibilidad)
98
- c.execute('''CREATE TABLE IF NOT EXISTS app_state (
99
- username TEXT PRIMARY KEY,
100
- last_licitacion TEXT,
101
- last_data TEXT,
102
- last_cg TEXT
103
- )''')
104
-
105
- # === MONITOR DE LICITACIONES ACP ===
106
- c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_licitaciones (
107
- id INTEGER PRIMARY KEY AUTOINCREMENT,
108
- numero_licitacion TEXT UNIQUE NOT NULL,
109
- objeto TEXT,
110
- fecha_asignacion TEXT,
111
- fecha_envio_oferta TEXT,
112
- monto_ofertado REAL,
113
- moneda TEXT DEFAULT "USD",
114
- estado TEXT DEFAULT "En Preparacion",
115
- link_sli TEXT,
116
- notas TEXT,
117
- responsable TEXT,
118
- fecha_registro TEXT
119
- )''')
120
- c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_historial (
121
- id INTEGER PRIMARY KEY AUTOINCREMENT,
122
- licitacion_id INTEGER,
123
- fecha TEXT,
124
- estado_nuevo TEXT,
125
- nota TEXT,
126
- registrado_por TEXT,
127
- FOREIGN KEY(licitacion_id) REFERENCES seguimiento_licitaciones(id)
128
- )''')
129
-
130
- # Protección contra fuerza bruta en login
131
- c.execute('''CREATE TABLE IF NOT EXISTS login_attempts (
132
- username TEXT PRIMARY KEY,
133
- intentos INTEGER DEFAULT 0,
134
- bloqueado_hasta TEXT
135
- )''')
136
-
137
- # --- Migrar workspace legacy a nueva tabla si existe ---
138
- c.execute("SELECT username, last_licitacion, last_data, last_cg FROM app_state")
139
- legacy_rows = c.fetchall()
140
- for row in legacy_rows:
141
- uname, lic, data, cg = row
142
- if lic and data and cg:
143
- c.execute("""INSERT OR IGNORE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
144
- VALUES (?, ?, ?, ?, ?)""",
145
- (uname, lic, data, cg, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
146
-
147
- # Usuario admin por defecto (contraseña: admin)
148
- c.execute("SELECT * FROM users WHERE username='admin'")
149
- if not c.fetchone():
150
- salt = bcrypt.gensalt()
151
- hashed_pw = bcrypt.hashpw(b"admin", salt).decode('utf-8')
152
- c.execute("INSERT INTO users VALUES ('admin', ?, 'Gerencia', '', '', '', '')", (hashed_pw,))
153
-
154
- conn.commit()
155
- conn.close()
156
-
157
- # =============================================
158
- # WORKSPACES (MÚLTIPLES POR USUARIO)
159
- # =============================================
160
-
161
- def save_workspace(username, licitacion, data_json, cg_json):
162
- """Guarda o actualiza el workspace de una licitación específica."""
163
- conn = get_connection()
164
- c = conn.cursor()
165
- fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
166
- c.execute("""INSERT OR REPLACE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
167
- VALUES (?, ?, ?, ?, ?)""",
168
- (username, licitacion, data_json, cg_json, fecha))
169
- conn.commit()
170
- conn.close()
171
-
172
- def get_all_workspaces(username, all_users=False):
173
- """Retorna todos los workspaces. Si all_users=True, retorna de todos los usuarios (para Gerencia)."""
174
- conn = get_connection()
175
- if all_users:
176
- df = pd.read_sql_query(
177
- """SELECT username, licitacion, fecha_guardado FROM workspaces
178
- ORDER BY fecha_guardado DESC""", conn)
179
- else:
180
- df = pd.read_sql_query(
181
- """SELECT username, licitacion, fecha_guardado FROM workspaces
182
- WHERE username=? ORDER BY fecha_guardado DESC""",
183
- conn, params=(username,))
184
- conn.close()
185
- return df
186
-
187
- def load_workspace(username, licitacion):
188
- """Carga un workspace específico por licitación."""
189
- conn = get_connection()
190
- c = conn.cursor()
191
- c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=? AND licitacion=?",
192
- (username, licitacion))
193
- row = c.fetchone()
194
- conn.close()
195
- return row # (data_json, cg_json) o None
196
-
197
- def delete_workspace(username, licitacion):
198
- """Elimina un workspace guardado."""
199
- conn = get_connection()
200
- c = conn.cursor()
201
- c.execute("DELETE FROM workspaces WHERE username=? AND licitacion=?", (username, licitacion))
202
- conn.commit()
203
- conn.close()
204
-
205
- # Legacy mantener compatibilidad con código existente
206
- def save_workspace_state(username, licitacion, data_json, cg_json):
207
- save_workspace(username, licitacion, data_json, cg_json)
208
-
209
- def load_workspace_state(username):
210
- """Carga el workspace más reciente del usuario (compatibilidad legacy)."""
211
- conn = get_connection()
212
- c = conn.cursor()
213
- c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=? ORDER BY fecha_guardado DESC LIMIT 1",
214
- (username,))
215
- row = c.fetchone()
216
- conn.close()
217
- return row
218
-
219
- # =============================================
220
- # GESTIÓN DE USUARIOS (ADMIN)
221
- # =============================================
222
-
223
- def get_all_users():
224
- """Retorna todos los usuarios del sistema para el panel de administración."""
225
- conn = get_connection()
226
- df = pd.read_sql_query(
227
- "SELECT username as Usuario, role as Nivel, email_user as Correo, gemini_key as Clave_Gemini, tavily_key as Clave_Tavily FROM users ORDER BY role, Usuario", conn)
228
- conn.close()
229
-
230
- # Enmascarar las llaves visualmente para seguridad (opcional, pero recomendado)
231
- df['Clave_Gemini'] = df['Clave_Gemini'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
232
- df['Clave_Tavily'] = df['Clave_Tavily'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
233
- df['Correo'] = df['Correo'].apply(lambda x: x if x else "Sin configurar")
234
-
235
- return df
236
-
237
- def create_user(username, password_plain, role):
238
- """Crea un nuevo usuario. Retorna True si exitoso, False si el username ya existe."""
239
- conn = get_connection()
240
- c = conn.cursor()
241
- salt = bcrypt.gensalt()
242
- hashed = bcrypt.hashpw(password_plain.encode(), salt).decode('utf-8')
243
- try:
244
- c.execute("INSERT INTO users (username, password, role, gemini_key, tavily_key, email_user, email_pass_enc) VALUES (?, ?, ?, '', '', '', '')",
245
- (username, hashed, role))
246
- conn.commit()
247
- conn.close()
248
- return True
249
- except sqlite3.IntegrityError:
250
- conn.close()
251
- return False
252
-
253
- def delete_user(username):
254
- """Elimina un usuario. No permite eliminar al admin principal."""
255
- if username == 'admin':
256
- return False
257
- conn = get_connection()
258
- c = conn.cursor()
259
- c.execute("DELETE FROM users WHERE username=?", (username,))
260
- conn.commit()
261
- conn.close()
262
- return True
263
-
264
- def update_user_role(username, new_role):
265
- """Cambia el rol de un usuario."""
266
- conn = get_connection()
267
- c = conn.cursor()
268
- c.execute("UPDATE users SET role=? WHERE username=?", (new_role, username))
269
- conn.commit()
270
- conn.close()
271
-
272
- def reset_user_password(username, new_password_plain):
273
- """Resetea la contraseña de un usuario."""
274
- conn = get_connection()
275
- c = conn.cursor()
276
- salt = bcrypt.gensalt()
277
- hashed = bcrypt.hashpw(new_password_plain.encode(), salt).decode('utf-8')
278
- c.execute("UPDATE users SET password=? WHERE username=?", (hashed, username))
279
- conn.commit()
280
- conn.close()
281
-
282
- # =============================================
283
- # PROTECCIÓN CONTRA FUERZA BRUTA
284
- # =============================================
285
-
286
- def esta_bloqueado(username: str) -> tuple:
287
- """Retorna (bloqueado: bool, segundos_restantes: int)."""
288
- conn = get_connection()
289
- c = conn.cursor()
290
- c.execute("SELECT intentos, bloqueado_hasta FROM login_attempts WHERE username=?", (username,))
291
- row = c.fetchone()
292
- conn.close()
293
- if not row or not row[1]:
294
- return False, 0
295
- hasta = datetime.fromisoformat(row[1])
296
- restante = (hasta - datetime.now()).total_seconds()
297
- if restante > 0:
298
- return True, int(restante)
299
- return False, 0
300
-
301
- def registrar_intento_fallido(username: str):
302
- """Incrementa el contador de intentos fallidos; bloquea si supera el máximo."""
303
- conn = get_connection()
304
- c = conn.cursor()
305
- c.execute("INSERT OR IGNORE INTO login_attempts (username, intentos) VALUES (?, 0)", (username,))
306
- c.execute("UPDATE login_attempts SET intentos = intentos + 1 WHERE username=?", (username,))
307
- c.execute("SELECT intentos FROM login_attempts WHERE username=?", (username,))
308
- intentos = c.fetchone()[0]
309
- if intentos >= MAX_INTENTOS:
310
- hasta = (datetime.now() + timedelta(minutes=TIEMPO_BLOQUEO)).isoformat()
311
- c.execute("UPDATE login_attempts SET bloqueado_hasta=? WHERE username=?", (hasta, username))
312
- conn.commit()
313
- conn.close()
314
-
315
- def resetear_intentos(username: str):
316
- """Limpia los intentos fallidos tras un login exitoso."""
317
- conn = get_connection()
318
- c = conn.cursor()
319
- c.execute("DELETE FROM login_attempts WHERE username=?", (username,))
320
- conn.commit()
321
- conn.close()
322
-
323
- # =============================================
324
- # FUNCIONES DE USUARIOS
325
- # =============================================
326
-
327
- def get_user(username, password_plain):
328
- conn = get_connection()
329
- c = conn.cursor()
330
- c.execute("SELECT * FROM users WHERE LOWER(username)=LOWER(?)", (username,))
331
- user = c.fetchone()
332
- conn.close()
333
- if user:
334
- stored_hash = user[1]
335
- try:
336
- if bcrypt.checkpw(password_plain.encode(), stored_hash.encode('utf-8')):
337
- # Descifrar gemini_key y tavily_key antes de devolver
338
- user = list(user)
339
- user[3] = crypto.decrypt_data(user[3]) if user[3] else ""
340
- user[4] = crypto.decrypt_data(user[4]) if user[4] else ""
341
- return tuple(user)
342
- except ValueError:
343
- pass
344
- return None
345
-
346
- def update_user_profile(username, gemini, tavily, email, enc_pass):
347
- """Guarda el perfil del usuario. gemini y tavily se cifran aquí antes de guardar."""
348
- conn = get_connection()
349
- c = conn.cursor()
350
- enc_gemini = crypto.encrypt_data(gemini) if gemini else ""
351
- enc_tavily = crypto.encrypt_data(tavily) if tavily else ""
352
- c.execute("UPDATE users SET gemini_key=?, tavily_key=?, email_user=?, email_pass_enc=? WHERE username=?",
353
- (enc_gemini, enc_tavily, email, enc_pass, username))
354
- conn.commit()
355
- conn.close()
356
-
357
- def save_history(username, licitacion, items_count):
358
- conn = get_connection()
359
- c = conn.cursor()
360
- fecha_actual = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
361
- c.execute("INSERT INTO history (username, licitacion, fecha, items) VALUES (?, ?, ?, ?)",
362
- (username, licitacion, fecha_actual, items_count))
363
- conn.commit()
364
- conn.close()
365
-
366
- def get_user_history_df(username):
367
- conn = get_connection()
368
- df = pd.read_sql_query(
369
- "SELECT licitacion as 'Nº Licitación', fecha as 'Fecha Proceso', items as 'Renglones' FROM history WHERE username=? ORDER BY id DESC",
370
- conn, params=(username,))
371
- conn.close()
372
- return df
373
-
374
- def delete_history_entry(username, licitacion):
375
- conn = get_connection()
376
- c = conn.cursor()
377
- c.execute("DELETE FROM history WHERE username=? AND licitacion=?", (username, licitacion))
378
- conn.commit()
379
- conn.close()
380
-
381
- def clear_all_history(username):
382
- conn = get_connection()
383
- c = conn.cursor()
384
- c.execute("DELETE FROM history WHERE username=?", (username,))
385
- conn.commit()
386
- conn.close()
387
-
388
- def get_correos_licitacion_df(licitacion):
389
- conn = get_connection()
390
- try:
391
- df = pd.read_sql_query(
392
- "SELECT id, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta FROM smart_inbox WHERE licitacion=? ORDER BY id DESC",
393
- conn, params=(licitacion,))
394
- except Exception:
395
- df = pd.DataFrame()
396
- conn.close()
397
- return df
398
-
399
- def get_user_credentials(username):
400
- conn = get_connection()
401
- c = conn.cursor()
402
- c.execute("SELECT email_user, email_pass_enc, gemini_key, tavily_key FROM users WHERE username=?", (username,))
403
- row = c.fetchone()
404
- conn.close()
405
- if row:
406
- # Descifrar gemini_key (índice 2) y tavily_key (índice 3) antes de devolver
407
- return (row[0], row[1], crypto.decrypt_data(row[2]) if row[2] else "", crypto.decrypt_data(row[3]) if row[3] else "")
408
- return None
409
-
410
- def check_email_exists(licitacion, asunto, remitente):
411
- conn = get_connection()
412
- c = conn.cursor()
413
- c.execute("SELECT id FROM smart_inbox WHERE licitacion=? AND asunto=? AND remitente=?",
414
- (licitacion, asunto, remitente))
415
- exists = c.fetchone() is not None
416
- conn.close()
417
- return exists
418
-
419
- def insert_smart_inbox(licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador=""):
420
- conn = get_connection()
421
- c = conn.cursor()
422
- c.execute(
423
- "INSERT INTO smart_inbox (licitacion, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
424
- (licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador))
425
- conn.commit()
426
- conn.close()
427
-
428
- def insert_cotizacion(licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto):
429
- conn = get_connection()
430
- c = conn.cursor()
431
- c.execute(
432
- "INSERT INTO cotizaciones (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
433
- (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto))
434
- conn.commit()
435
- conn.close()
436
-
437
- def check_cotizacion_exists(licitacion, renglon, proveedor):
438
- conn = get_connection()
439
- c = conn.cursor()
440
- c.execute("SELECT id FROM cotizaciones WHERE licitacion=? AND renglon=? AND proveedor=?",
441
- (licitacion, renglon, proveedor))
442
- exists = c.fetchone() is not None
443
- conn.close()
444
- return exists
445
-
446
- def get_ficha_cache(username, licitacion, codigo_renglon):
447
- conn = get_connection()
448
- c = conn.cursor()
449
- c.execute("SELECT datasheet_md FROM fichas_cache WHERE username=? AND licitacion=? AND codigo_renglon=?",
450
- (username, licitacion, codigo_renglon))
451
- row = c.fetchone()
452
- conn.close()
453
- return row[0] if row else None
454
-
455
- def save_ficha_cache(username, licitacion, codigo_renglon, datasheet_md):
456
- conn = get_connection()
457
- c = conn.cursor()
458
- fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
459
- c.execute(
460
- "INSERT OR REPLACE INTO fichas_cache (username, licitacion, codigo_renglon, datasheet_md, fecha) VALUES (?, ?, ?, ?, ?)",
461
- (username, licitacion, codigo_renglon, datasheet_md, fecha))
462
- conn.commit()
463
- conn.close()
464
-
465
- # =============================================
466
- # MONITOR DE LICITACIONES ACP
467
- # =============================================
468
-
469
- ESTADOS_ACP = [
470
- "En Preparacion",
471
- "Oferta Enviada al SLI",
472
- "Cumple Tecnicamente",
473
- "No Cumple Tecnicamente",
474
- "En Evaluacion Economica",
475
- "Adjudicada",
476
- "No Adjudicada",
477
- "Desierta",
478
- ]
479
-
480
- def crear_seguimiento(numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
481
- monto_ofertado, moneda, link_sli, notas, responsable):
482
- conn = get_connection()
483
- c = conn.cursor()
484
- fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
485
- try:
486
- c.execute("""INSERT INTO seguimiento_licitaciones
487
- (numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
488
- monto_ofertado, moneda, estado, link_sli, notas, responsable, fecha_registro)
489
- VALUES (?, ?, ?, ?, ?, ?, 'En Preparacion', ?, ?, ?, ?)""",
490
- (numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
491
- monto_ofertado, moneda, link_sli, notas, responsable, fecha))
492
- conn.commit()
493
- conn.close()
494
- return True
495
- except Exception:
496
- conn.close()
497
- return False
498
-
499
- def get_seguimientos():
500
- conn = get_connection()
501
- df = pd.read_sql_query(
502
- "SELECT * FROM seguimiento_licitaciones ORDER BY fecha_registro DESC", conn)
503
- conn.close()
504
- return df
505
-
506
- def actualizar_estado(licitacion_id, nuevo_estado, nota, registrado_por):
507
- conn = get_connection()
508
- c = conn.cursor()
509
- fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
510
- c.execute("UPDATE seguimiento_licitaciones SET estado=? WHERE id=?", (nuevo_estado, licitacion_id))
511
- c.execute("""INSERT INTO seguimiento_historial (licitacion_id, fecha, estado_nuevo, nota, registrado_por)
512
- VALUES (?, ?, ?, ?, ?)""", (licitacion_id, fecha, nuevo_estado, nota, registrado_por))
513
- conn.commit()
514
- conn.close()
515
-
516
- def get_historial_seguimiento(licitacion_id):
517
- conn = get_connection()
518
- df = pd.read_sql_query(
519
- "SELECT fecha, estado_nuevo, nota, registrado_por FROM seguimiento_historial WHERE licitacion_id=? ORDER BY id DESC",
520
- conn, params=(licitacion_id,))
521
- conn.close()
522
- return df
523
-
524
- def eliminar_seguimiento(licitacion_id):
525
- conn = get_connection()
526
- c = conn.cursor()
527
- c.execute("DELETE FROM seguimiento_historial WHERE licitacion_id=?", (licitacion_id,))
528
- c.execute("DELETE FROM seguimiento_licitaciones WHERE id=?", (licitacion_id,))
529
- conn.commit()
530
- conn.close()
531
-
 
1
+ import psycopg2
2
+ import pandas as pd
3
+ from datetime import datetime, timedelta
4
+ import os
5
+ import bcrypt
6
+ import crypto
7
+ import warnings
8
+
9
+ # Suprimir advertencias de Pandas al usar psycopg2 directo
10
+ warnings.filterwarnings("ignore", category=UserWarning, module="pandas")
11
+
12
+ DATABASE_URL = os.getenv("DATABASE_URL")
13
+
14
+ MAX_INTENTOS = 5 # Intentos fallidos antes de bloquear
15
+ TIEMPO_BLOQUEO = 15 # Minutos de bloqueo
16
+
17
+ def get_connection():
18
+ if not DATABASE_URL:
19
+ raise ValueError("Falta DATABASE_URL en las variables de entorno")
20
+ conn = psycopg2.connect(DATABASE_URL)
21
+ return conn
22
+
23
+ def init_db():
24
+ conn = get_connection()
25
+ c = conn.cursor()
26
+
27
+ # Usuarios
28
+ c.execute('''CREATE TABLE IF NOT EXISTS users (
29
+ username TEXT PRIMARY KEY,
30
+ password TEXT,
31
+ role TEXT,
32
+ gemini_key TEXT,
33
+ tavily_key TEXT,
34
+ email_user TEXT,
35
+ email_pass_enc TEXT
36
+ )''')
37
+
38
+ # Historial de análisis
39
+ c.execute('''CREATE TABLE IF NOT EXISTS history (
40
+ id SERIAL PRIMARY KEY,
41
+ username TEXT,
42
+ licitacion TEXT,
43
+ fecha TEXT,
44
+ items INTEGER
45
+ )''')
46
+
47
+ # Bandeja inteligente de correos
48
+ c.execute('''CREATE TABLE IF NOT EXISTS smart_inbox (
49
+ id SERIAL PRIMARY KEY,
50
+ licitacion TEXT,
51
+ remitente TEXT,
52
+ asunto TEXT,
53
+ fecha TEXT,
54
+ resumen TEXT,
55
+ renglones_relacionados TEXT,
56
+ cuerpo TEXT,
57
+ borrador_respuesta TEXT
58
+ )''')
59
+
60
+ # Cache de fichas técnicas (ahorra tokens Gemini)
61
+ c.execute('''CREATE TABLE IF NOT EXISTS fichas_cache (
62
+ id SERIAL PRIMARY KEY,
63
+ username TEXT,
64
+ licitacion TEXT,
65
+ codigo_renglon TEXT,
66
+ datasheet_md TEXT,
67
+ fecha TEXT,
68
+ UNIQUE(username, licitacion, codigo_renglon)
69
+ )''')
70
+
71
+ # Tabla de cotizaciones extraídas de correos (para uso futuro)
72
+ c.execute('''CREATE TABLE IF NOT EXISTS cotizaciones (
73
+ id SERIAL PRIMARY KEY,
74
+ licitacion TEXT,
75
+ renglon TEXT,
76
+ proveedor TEXT,
77
+ precio_unitario REAL,
78
+ moneda TEXT,
79
+ tiempo_entrega TEXT,
80
+ condiciones TEXT,
81
+ fecha TEXT,
82
+ email_asunto TEXT
83
+ )''')
84
+
85
+ # === MÚLTIPLES WORKSPACES POR USUARIO ===
86
+ c.execute('''CREATE TABLE IF NOT EXISTS workspaces (
87
+ id SERIAL PRIMARY KEY,
88
+ username TEXT NOT NULL,
89
+ licitacion TEXT NOT NULL,
90
+ data_json TEXT,
91
+ cg_json TEXT,
92
+ fecha_guardado TEXT,
93
+ UNIQUE(username, licitacion)
94
+ )''')
95
+
96
+ # app_state legacy (se mantiene para compatibilidad)
97
+ c.execute('''CREATE TABLE IF NOT EXISTS app_state (
98
+ username TEXT PRIMARY KEY,
99
+ last_licitacion TEXT,
100
+ last_data TEXT,
101
+ last_cg TEXT
102
+ )''')
103
+
104
+ # === MONITOR DE LICITACIONES ACP ===
105
+ c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_licitaciones (
106
+ id SERIAL PRIMARY KEY,
107
+ numero_licitacion TEXT UNIQUE NOT NULL,
108
+ objeto TEXT,
109
+ fecha_asignacion TEXT,
110
+ fecha_envio_oferta TEXT,
111
+ monto_ofertado REAL,
112
+ moneda TEXT DEFAULT "USD",
113
+ estado TEXT DEFAULT "En Preparacion",
114
+ link_sli TEXT,
115
+ notas TEXT,
116
+ responsable TEXT,
117
+ fecha_registro TEXT
118
+ )''')
119
+ c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_historial (
120
+ id SERIAL PRIMARY KEY,
121
+ licitacion_id INTEGER,
122
+ fecha TEXT,
123
+ estado_nuevo TEXT,
124
+ nota TEXT,
125
+ registrado_por TEXT,
126
+ FOREIGN KEY(licitacion_id) REFERENCES seguimiento_licitaciones(id)
127
+ )''')
128
+
129
+ # Protección contra fuerza bruta en login
130
+ c.execute('''CREATE TABLE IF NOT EXISTS login_attempts (
131
+ username TEXT PRIMARY KEY,
132
+ intentos INTEGER DEFAULT 0,
133
+ bloqueado_hasta TEXT
134
+ )''')
135
+
136
+ # --- Migrar workspace legacy a nueva tabla si existe ---
137
+ c.execute("SELECT username, last_licitacion, last_data, last_cg FROM app_state")
138
+ legacy_rows = c.fetchall()
139
+ for row in legacy_rows:
140
+ uname, lic, data, cg = row
141
+ if lic and data and cg:
142
+ c.execute("""INSERT INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
143
+ VALUES (%s, %s, %s, %s, %s)""",
144
+ (uname, lic, data, cg, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
145
+
146
+ # Usuario admin por defecto (contraseña: admin)
147
+ c.execute("SELECT * FROM users WHERE username='admin'")
148
+ if not c.fetchone():
149
+ salt = bcrypt.gensalt()
150
+ hashed_pw = bcrypt.hashpw(b"admin", salt).decode('utf-8')
151
+ c.execute("INSERT INTO users VALUES ('admin', ?, 'Gerencia', '', '', '', '')", (hashed_pw,))
152
+
153
+ conn.commit()
154
+ conn.close()
155
+
156
+ # =============================================
157
+ # WORKSPACES (MÚLTIPLES POR USUARIO)
158
+ # =============================================
159
+
160
+ def save_workspace(username, licitacion, data_json, cg_json):
161
+ """Guarda o actualiza el workspace de una licitación específica."""
162
+ conn = get_connection()
163
+ c = conn.cursor()
164
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
165
+ c.execute("""INSERT OR REPLACE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
166
+ VALUES (%s, %s, %s, %s, %s)""",
167
+ (username, licitacion, data_json, cg_json, fecha))
168
+ conn.commit()
169
+ conn.close()
170
+
171
+ def get_all_workspaces(username, all_users=False):
172
+ """Retorna todos los workspaces. Si all_users=True, retorna de todos los usuarios (para Gerencia)."""
173
+ conn = get_connection()
174
+ if all_users:
175
+ df = pd.read_sql_query(
176
+ """SELECT username, licitacion, fecha_guardado FROM workspaces
177
+ ORDER BY fecha_guardado DESC""", conn)
178
+ else:
179
+ df = pd.read_sql_query(
180
+ """SELECT username, licitacion, fecha_guardado FROM workspaces
181
+ WHERE username=%s ORDER BY fecha_guardado DESC""",
182
+ conn, params=(username,))
183
+ conn.close()
184
+ return df
185
+
186
+ def load_workspace(username, licitacion):
187
+ """Carga un workspace específico por licitación."""
188
+ conn = get_connection()
189
+ c = conn.cursor()
190
+ c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=%s AND licitacion=%s",
191
+ (username, licitacion))
192
+ row = c.fetchone()
193
+ conn.close()
194
+ return row # (data_json, cg_json) o None
195
+
196
+ def delete_workspace(username, licitacion):
197
+ """Elimina un workspace guardado."""
198
+ conn = get_connection()
199
+ c = conn.cursor()
200
+ c.execute("DELETE FROM workspaces WHERE username=%s AND licitacion=%s", (username, licitacion))
201
+ conn.commit()
202
+ conn.close()
203
+
204
+ # Legacy — mantener compatibilidad con código existente
205
+ def save_workspace_state(username, licitacion, data_json, cg_json):
206
+ save_workspace(username, licitacion, data_json, cg_json)
207
+
208
+ def load_workspace_state(username):
209
+ """Carga el workspace más reciente del usuario (compatibilidad legacy)."""
210
+ conn = get_connection()
211
+ c = conn.cursor()
212
+ c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=%s ORDER BY fecha_guardado DESC LIMIT 1",
213
+ (username,))
214
+ row = c.fetchone()
215
+ conn.close()
216
+ return row
217
+
218
+ # =============================================
219
+ # GESTIÓN DE USUARIOS (ADMIN)
220
+ # =============================================
221
+
222
+ def get_all_users():
223
+ """Retorna todos los usuarios del sistema para el panel de administración."""
224
+ conn = get_connection()
225
+ df = pd.read_sql_query(
226
+ "SELECT username as Usuario, role as Nivel, email_user as Correo, gemini_key as Clave_Gemini, tavily_key as Clave_Tavily FROM users ORDER BY role, Usuario", conn)
227
+ conn.close()
228
+
229
+ # Enmascarar las llaves visualmente para seguridad (opcional, pero recomendado)
230
+ df['Clave_Gemini'] = df['Clave_Gemini'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
231
+ df['Clave_Tavily'] = df['Clave_Tavily'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
232
+ df['Correo'] = df['Correo'].apply(lambda x: x if x else "Sin configurar")
233
+
234
+ return df
235
+
236
+ def create_user(username, password_plain, role):
237
+ """Crea un nuevo usuario. Retorna True si exitoso, False si el username ya existe."""
238
+ conn = get_connection()
239
+ c = conn.cursor()
240
+ salt = bcrypt.gensalt()
241
+ hashed = bcrypt.hashpw(password_plain.encode(), salt).decode('utf-8')
242
+ try:
243
+ c.execute("INSERT INTO users (username, password, role, gemini_key, tavily_key, email_user, email_pass_enc) VALUES (%s, %s, %s, '', '', '', '')",
244
+ (username, hashed, role))
245
+ conn.commit()
246
+ conn.close()
247
+ return True
248
+ except psycopg2.IntegrityError:
249
+ conn.close()
250
+ return False
251
+
252
+ def delete_user(username):
253
+ """Elimina un usuario. No permite eliminar al admin principal."""
254
+ if username == 'admin':
255
+ return False
256
+ conn = get_connection()
257
+ c = conn.cursor()
258
+ c.execute("DELETE FROM users WHERE username=%s", (username,))
259
+ conn.commit()
260
+ conn.close()
261
+ return True
262
+
263
+ def update_user_role(username, new_role):
264
+ """Cambia el rol de un usuario."""
265
+ conn = get_connection()
266
+ c = conn.cursor()
267
+ c.execute("UPDATE users SET role=%s WHERE username=%s", (new_role, username))
268
+ conn.commit()
269
+ conn.close()
270
+
271
+ def reset_user_password(username, new_password_plain):
272
+ """Resetea la contraseña de un usuario."""
273
+ conn = get_connection()
274
+ c = conn.cursor()
275
+ salt = bcrypt.gensalt()
276
+ hashed = bcrypt.hashpw(new_password_plain.encode(), salt).decode('utf-8')
277
+ c.execute("UPDATE users SET password=%s WHERE username=%s", (hashed, username))
278
+ conn.commit()
279
+ conn.close()
280
+
281
+ # =============================================
282
+ # PROTECCIÓN CONTRA FUERZA BRUTA
283
+ # =============================================
284
+
285
+ def esta_bloqueado(username: str) -> tuple:
286
+ """Retorna (bloqueado: bool, segundos_restantes: int)."""
287
+ conn = get_connection()
288
+ c = conn.cursor()
289
+ c.execute("SELECT intentos, bloqueado_hasta FROM login_attempts WHERE username=%s", (username,))
290
+ row = c.fetchone()
291
+ conn.close()
292
+ if not row or not row[1]:
293
+ return False, 0
294
+ hasta = datetime.fromisoformat(row[1])
295
+ restante = (hasta - datetime.now()).total_seconds()
296
+ if restante > 0:
297
+ return True, int(restante)
298
+ return False, 0
299
+
300
+ def registrar_intento_fallido(username: str):
301
+ """Incrementa el contador de intentos fallidos; bloquea si supera el máximo."""
302
+ conn = get_connection()
303
+ c = conn.cursor()
304
+ c.execute("INSERT INTO login_attempts (username, intentos) VALUES (%s, 0) ON CONFLICT (username) DO NOTHING", (username,))
305
+ c.execute("UPDATE login_attempts SET intentos = intentos + 1 WHERE username=%s", (username,))
306
+ c.execute("SELECT intentos FROM login_attempts WHERE username=%s", (username,))
307
+ intentos = c.fetchone()[0]
308
+ if intentos >= MAX_INTENTOS:
309
+ hasta = (datetime.now() + timedelta(minutes=TIEMPO_BLOQUEO)).isoformat()
310
+ c.execute("UPDATE login_attempts SET bloqueado_hasta=? WHERE username=%s", (hasta, username))
311
+ conn.commit()
312
+ conn.close()
313
+
314
+ def resetear_intentos(username: str):
315
+ """Limpia los intentos fallidos tras un login exitoso."""
316
+ conn = get_connection()
317
+ c = conn.cursor()
318
+ c.execute("DELETE FROM login_attempts WHERE username=%s", (username,))
319
+ conn.commit()
320
+ conn.close()
321
+
322
+ # =============================================
323
+ # FUNCIONES DE USUARIOS
324
+ # =============================================
325
+
326
+ def get_user(username, password_plain):
327
+ conn = get_connection()
328
+ c = conn.cursor()
329
+ c.execute("SELECT * FROM users WHERE LOWER(username)=LOWER(%s)", (username,))
330
+ user = c.fetchone()
331
+ conn.close()
332
+ if user:
333
+ stored_hash = user[1]
334
+ try:
335
+ if bcrypt.checkpw(password_plain.encode(), stored_hash.encode('utf-8')):
336
+ # Descifrar gemini_key y tavily_key antes de devolver
337
+ user = list(user)
338
+ user[3] = crypto.decrypt_data(user[3]) if user[3] else ""
339
+ user[4] = crypto.decrypt_data(user[4]) if user[4] else ""
340
+ return tuple(user)
341
+ except ValueError:
342
+ pass
343
+ return None
344
+
345
+ def update_user_profile(username, gemini, tavily, email, enc_pass):
346
+ """Guarda el perfil del usuario. gemini y tavily se cifran aquí antes de guardar."""
347
+ conn = get_connection()
348
+ c = conn.cursor()
349
+ enc_gemini = crypto.encrypt_data(gemini) if gemini else ""
350
+ enc_tavily = crypto.encrypt_data(tavily) if tavily else ""
351
+ c.execute("UPDATE users SET gemini_key=?, tavily_key=?, email_user=?, email_pass_enc=? WHERE username=%s",
352
+ (enc_gemini, enc_tavily, email, enc_pass, username))
353
+ conn.commit()
354
+ conn.close()
355
+
356
+ def save_history(username, licitacion, items_count):
357
+ conn = get_connection()
358
+ c = conn.cursor()
359
+ fecha_actual = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
360
+ c.execute("INSERT INTO history (username, licitacion, fecha, items) VALUES (%s, %s, %s, %s)",
361
+ (username, licitacion, fecha_actual, items_count))
362
+ conn.commit()
363
+ conn.close()
364
+
365
+ def get_user_history_df(username):
366
+ conn = get_connection()
367
+ df = pd.read_sql_query(
368
+ "SELECT licitacion as 'Nº Licitación', fecha as 'Fecha Proceso', items as 'Renglones' FROM history WHERE username=%s ORDER BY id DESC",
369
+ conn, params=(username,))
370
+ conn.close()
371
+ return df
372
+
373
+ def delete_history_entry(username, licitacion):
374
+ conn = get_connection()
375
+ c = conn.cursor()
376
+ c.execute("DELETE FROM history WHERE username=%s AND licitacion=%s", (username, licitacion))
377
+ conn.commit()
378
+ conn.close()
379
+
380
+ def clear_all_history(username):
381
+ conn = get_connection()
382
+ c = conn.cursor()
383
+ c.execute("DELETE FROM history WHERE username=%s", (username,))
384
+ conn.commit()
385
+ conn.close()
386
+
387
+ def get_correos_licitacion_df(licitacion):
388
+ conn = get_connection()
389
+ try:
390
+ df = pd.read_sql_query(
391
+ "SELECT id, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta FROM smart_inbox WHERE licitacion=%s ORDER BY id DESC",
392
+ conn, params=(licitacion,))
393
+ except Exception:
394
+ df = pd.DataFrame()
395
+ conn.close()
396
+ return df
397
+
398
+ def get_user_credentials(username):
399
+ conn = get_connection()
400
+ c = conn.cursor()
401
+ c.execute("SELECT email_user, email_pass_enc, gemini_key, tavily_key FROM users WHERE username=%s", (username,))
402
+ row = c.fetchone()
403
+ conn.close()
404
+ if row:
405
+ # Descifrar gemini_key (índice 2) y tavily_key (índice 3) antes de devolver
406
+ return (row[0], row[1], crypto.decrypt_data(row[2]) if row[2] else "", crypto.decrypt_data(row[3]) if row[3] else "")
407
+ return None
408
+
409
+ def check_email_exists(licitacion, asunto, remitente):
410
+ conn = get_connection()
411
+ c = conn.cursor()
412
+ c.execute("SELECT id FROM smart_inbox WHERE licitacion=%s AND asunto=%s AND remitente=%s",
413
+ (licitacion, asunto, remitente))
414
+ exists = c.fetchone() is not None
415
+ conn.close()
416
+ return exists
417
+
418
+ def insert_smart_inbox(licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador=""):
419
+ conn = get_connection()
420
+ c = conn.cursor()
421
+ c.execute(
422
+ "INSERT INTO smart_inbox (licitacion, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta) VALUES (%s, %s, %s, %s, ?, ?, ?, ?)",
423
+ (licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador))
424
+ conn.commit()
425
+ conn.close()
426
+
427
+ def insert_cotizacion(licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto):
428
+ conn = get_connection()
429
+ c = conn.cursor()
430
+ c.execute(
431
+ "INSERT INTO cotizaciones (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto) VALUES (%s, %s, %s, %s, ?, ?, ?, ?, ?)",
432
+ (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto))
433
+ conn.commit()
434
+ conn.close()
435
+
436
+ def check_cotizacion_exists(licitacion, renglon, proveedor):
437
+ conn = get_connection()
438
+ c = conn.cursor()
439
+ c.execute("SELECT id FROM cotizaciones WHERE licitacion=%s AND renglon=? AND proveedor=?",
440
+ (licitacion, renglon, proveedor))
441
+ exists = c.fetchone() is not None
442
+ conn.close()
443
+ return exists
444
+
445
+ def get_ficha_cache(username, licitacion, codigo_renglon):
446
+ conn = get_connection()
447
+ c = conn.cursor()
448
+ c.execute("SELECT datasheet_md FROM fichas_cache WHERE username=%s AND licitacion=%s AND codigo_renglon=%s",
449
+ (username, licitacion, codigo_renglon))
450
+ row = c.fetchone()
451
+ conn.close()
452
+ return row[0] if row else None
453
+
454
+ def save_ficha_cache(username, licitacion, codigo_renglon, datasheet_md):
455
+ conn = get_connection()
456
+ c = conn.cursor()
457
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
458
+ c.execute(
459
+ "INSERT OR REPLACE INTO fichas_cache (username, licitacion, codigo_renglon, datasheet_md, fecha) VALUES (%s, %s, %s, %s, %s)",
460
+ (username, licitacion, codigo_renglon, datasheet_md, fecha))
461
+ conn.commit()
462
+ conn.close()
463
+
464
+ # =============================================
465
+ # MONITOR DE LICITACIONES ACP
466
+ # =============================================
467
+
468
+ ESTADOS_ACP = [
469
+ "En Preparacion",
470
+ "Oferta Enviada al SLI",
471
+ "Cumple Tecnicamente",
472
+ "No Cumple Tecnicamente",
473
+ "En Evaluacion Economica",
474
+ "Adjudicada",
475
+ "No Adjudicada",
476
+ "Desierta",
477
+ ]
478
+
479
+ def crear_seguimiento(numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
480
+ monto_ofertado, moneda, link_sli, notas, responsable):
481
+ conn = get_connection()
482
+ c = conn.cursor()
483
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
484
+ try:
485
+ c.execute("""INSERT INTO seguimiento_licitaciones
486
+ (numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
487
+ monto_ofertado, moneda, estado, link_sli, notas, responsable, fecha_registro)
488
+ VALUES (%s, %s, %s, %s, ?, ?, 'En Preparacion', ?, ?, ?, ?)""",
489
+ (numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
490
+ monto_ofertado, moneda, link_sli, notas, responsable, fecha))
491
+ conn.commit()
492
+ conn.close()
493
+ return True
494
+ except Exception:
495
+ conn.close()
496
+ return False
497
+
498
+ def get_seguimientos():
499
+ conn = get_connection()
500
+ df = pd.read_sql_query(
501
+ "SELECT * FROM seguimiento_licitaciones ORDER BY fecha_registro DESC", conn)
502
+ conn.close()
503
+ return df
504
+
505
+ def actualizar_estado(licitacion_id, nuevo_estado, nota, registrado_por):
506
+ conn = get_connection()
507
+ c = conn.cursor()
508
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
509
+ c.execute("UPDATE seguimiento_licitaciones SET estado=? WHERE id=?", (nuevo_estado, licitacion_id))
510
+ c.execute("""INSERT INTO seguimiento_historial (licitacion_id, fecha, estado_nuevo, nota, registrado_por)
511
+ VALUES (%s, %s, %s, %s, %s)""", (licitacion_id, fecha, nuevo_estado, nota, registrado_por))
512
+ conn.commit()
513
+ conn.close()
514
+
515
+ def get_historial_seguimiento(licitacion_id):
516
+ conn = get_connection()
517
+ df = pd.read_sql_query(
518
+ "SELECT fecha, estado_nuevo, nota, registrado_por FROM seguimiento_historial WHERE licitacion_id=? ORDER BY id DESC",
519
+ conn, params=(licitacion_id,))
520
+ conn.close()
521
+ return df
522
+
523
+ def eliminar_seguimiento(licitacion_id):
524
+ conn = get_connection()
525
+ c = conn.cursor()
526
+ c.execute("DELETE FROM seguimiento_historial WHERE licitacion_id=?", (licitacion_id,))
527
+ c.execute("DELETE FROM seguimiento_licitaciones WHERE id=?", (licitacion_id,))
528
+ conn.commit()
529
+ conn.close()
530
+
 
requirements.txt CHANGED
@@ -1,16 +1,17 @@
1
- streamlit
2
- pandas
3
- plotly
4
- tavily-python
5
- google-generativeai
6
- cryptography
7
- requests
8
- fastapi
9
- uvicorn
10
- python-multipart
11
- python-dotenv
12
- openpyxl
13
- playwright
14
  beautifulsoup4
15
  pypdf
16
- bcrypt
 
 
1
+ streamlit
2
+ pandas
3
+ plotly
4
+ tavily-python
5
+ google-generativeai
6
+ cryptography
7
+ requests
8
+ fastapi
9
+ uvicorn
10
+ python-multipart
11
+ python-dotenv
12
+ openpyxl
13
+ playwright
14
  beautifulsoup4
15
  pypdf
16
+ bcrypt
17
+ psycopg2-binary