keepup-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
keepup/db.py ADDED
@@ -0,0 +1,663 @@
1
+ """Access to the database: configuration and two managers.
2
+
3
+ `DatabaseManagerV2` is the one to use -- a SQLAlchemy engine with a pool and a
4
+ session context manager, named parameters only. `DatabaseManager` is the legacy
5
+ layer over raw drivers with positional parameters, kept because the plugins
6
+ that still use it are converted one at a time, not because two ways of reaching
7
+ the database are wanted.
8
+
9
+ Where the database is comes from the environment when `DB_TYPE` is set and from
10
+ `config/postgres.properties` otherwise. Both paths are read from the working
11
+ directory of the started application, which is how two applications on one
12
+ framework keep separate databases. SQLite exists for development only.
13
+ """
14
+
15
+ import os
16
+ import sqlite3
17
+ from contextlib import contextmanager
18
+ from typing import Optional, List, Dict, Any
19
+
20
+ import psycopg2
21
+ from psycopg2.extras import RealDictCursor
22
+ from sqlalchemy import create_engine, text
23
+ from sqlalchemy.orm import sessionmaker, Session
24
+
25
+ #: What an application may import from this module. Everything else is
26
+ #: internal and may change without notice -- see doc/keepup.md.
27
+ __all__ = [
28
+ "DatabaseConfig",
29
+ "DatabaseManager",
30
+ "DatabaseManagerV2",
31
+ "db_config",
32
+ ]
33
+
34
+
35
+ class DatabaseConfig:
36
+ """Database configuration."""
37
+
38
+ def __init__(self):
39
+ self.db_type = 'sqlite'
40
+ self.db_host = 'localhost'
41
+ self.db_port = '5432'
42
+ self.db_name = 'keepup_app'
43
+ self.db_user = 'postgres'
44
+ self.db_password = ''
45
+ self.db_path = 'data/keepup_app.db'
46
+ self._load_config()
47
+ self.pool_size = 5
48
+ self.pool_max_overflow = 10
49
+ self.pool_timeout = 30
50
+ self.pool_recycle = 3600
51
+
52
+ def _load_config(self):
53
+ self.db_type = os.getenv('DB_TYPE', '').lower()
54
+ if not self.db_type:
55
+ self._load_from_file()
56
+ else:
57
+ self._load_from_env()
58
+
59
+ def _load_from_env(self):
60
+ self.db_host = os.getenv('DB_HOST', 'localhost')
61
+ self.db_port = os.getenv('DB_PORT', '5432')
62
+ self.db_name = os.getenv('DB_NAME', 'keepup')
63
+ self.db_user = os.getenv('DB_USER', 'postgres')
64
+ self.db_password = os.getenv('DB_PASSWORD', '')
65
+ self.db_path = os.getenv('DB_PATH', 'data/keepup.db')
66
+
67
+ self.pool_size = int(os.getenv('DB_POOL_SIZE', '5'))
68
+ self.pool_max_overflow = int(os.getenv('DB_POOL_MAX_OVERFLOW', '10'))
69
+ self.pool_timeout = int(os.getenv('DB_POOL_TIMEOUT', '30'))
70
+ self.pool_recycle = int(os.getenv('DB_POOL_RECYCLE', '3600'))
71
+
72
+ if self.db_type == 'sqlite':
73
+ os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
74
+
75
+ def _load_from_file(self):
76
+ self.config_file = "config/postgres.properties"
77
+ config = {}
78
+ if os.path.exists(self.config_file):
79
+ with open(self.config_file, 'r') as f:
80
+ for line in f:
81
+ line = line.strip()
82
+ if line and not line.startswith('#'):
83
+ if '=' in line:
84
+ key, value = line.split('=', 1)
85
+ config[key.strip()] = value.strip()
86
+
87
+ self.db_type = config.get('db.type', 'sqlite').lower()
88
+ self.db_host = config.get('db.host', 'localhost')
89
+ self.db_port = config.get('db.port', '5432')
90
+ self.db_name = config.get('db.name', 'keepup')
91
+ self.db_user = config.get('db.user', 'postgres')
92
+ # The file names where the database is; the password never lives in it.
93
+ self.db_password = os.getenv('DB_PASSWORD') or config.get('db.password', '')
94
+ self.db_path = config.get('db.path', 'data/keepup.db')
95
+
96
+ self.pool_size = int(config.get('db.pool.size', '5'))
97
+ self.pool_max_overflow = int(config.get('db.pool.max_overflow', '10'))
98
+ self.pool_timeout = int(config.get('db.pool.timeout', '30'))
99
+ self.pool_recycle = int(config.get('db.pool.recycle', '3600'))
100
+
101
+ if self.db_type == 'sqlite':
102
+ db_dir = os.path.dirname(self.db_path)
103
+ if db_dir:
104
+ os.makedirs(db_dir, exist_ok=True)
105
+
106
+ def get_connection_string(self) -> str:
107
+ if self.db_type == 'postgres':
108
+ return f"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
109
+ else:
110
+ return f"sqlite:///{self.db_path}"
111
+
112
+ def get_sqlalchemy_engine_params(self) -> dict:
113
+ params = {}
114
+ if self.is_postgres():
115
+ params.update({
116
+ 'pool_size': self.pool_size,
117
+ 'max_overflow': self.pool_max_overflow,
118
+ 'pool_timeout': self.pool_timeout,
119
+ 'pool_recycle': self.pool_recycle,
120
+ 'pool_pre_ping': True,
121
+ 'echo': os.getenv('SQL_ECHO', 'false').lower() == 'true'
122
+ })
123
+ else:
124
+ params.update({
125
+ 'pool_size': self.pool_size,
126
+ 'pool_timeout': self.pool_timeout,
127
+ })
128
+ params['connect_args'] = {'check_same_thread': False}
129
+ return params
130
+
131
+ def is_postgres(self) -> bool:
132
+ return self.db_type == 'postgres'
133
+
134
+ def is_sqlite(self) -> bool:
135
+ return self.db_type == 'sqlite'
136
+
137
+ def __str__(self):
138
+ if self.is_postgres():
139
+ return (f"DatabaseConfig(type={self.db_type}, host={self.db_host}, "
140
+ f"port={self.db_port}, db={self.db_name}, user={self.db_user}, "
141
+ f"pool_size={self.pool_size})")
142
+ else:
143
+ return f"DatabaseConfig(type={self.db_type}, path={self.db_path})"
144
+
145
+
146
+ db_config = DatabaseConfig()
147
+
148
+
149
+ class DatabaseManagerV2:
150
+ """Database access through SQLAlchemy with a connection pool.
151
+
152
+ Preferred over the legacy DatabaseManager below.
153
+ """
154
+
155
+ _engine = None
156
+ _session_factory = None
157
+
158
+ @classmethod
159
+ def initialize(cls):
160
+ """Initialise the SQLAlchemy engine."""
161
+ if cls._engine is None:
162
+ connection_string = db_config.get_connection_string()
163
+ engine_params = db_config.get_sqlalchemy_engine_params()
164
+ cls._engine = create_engine(connection_string, **engine_params)
165
+ cls._session_factory = sessionmaker(bind=cls._engine)
166
+ import logging
167
+ logging.getLogger(__name__).info(f"DatabaseManagerV2 initialized with pool_size={db_config.pool_size}")
168
+
169
+ @classmethod
170
+ @contextmanager
171
+ def get_session(cls) -> Session:
172
+ """Context manager yielding a session, committing or rolling back on exit."""
173
+ if cls._engine is None:
174
+ cls.initialize()
175
+ session = cls._session_factory()
176
+ try:
177
+ yield session
178
+ session.commit()
179
+ except Exception as e:
180
+ session.rollback()
181
+ raise e
182
+ finally:
183
+ session.close()
184
+
185
+ @classmethod
186
+ def execute(cls, query: str, params: Optional[dict] = None) -> List[Dict]:
187
+ """Run a SQL query.
188
+
189
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
190
+ """
191
+ with cls.get_session() as session:
192
+ result = session.execute(text(query), params or {})
193
+ if result.returns_rows:
194
+ result = [dict(row._mapping) for row in result.fetchall()]
195
+ return result
196
+ return []
197
+
198
+ @classmethod
199
+ def execute_one(cls, query: str, params: Optional[dict] = None) -> Optional[Dict]:
200
+ """Run a SQL query and return a single row.
201
+
202
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
203
+ """
204
+ with cls.get_session() as session:
205
+ result = session.execute(text(query), params or {})
206
+ if result.returns_rows:
207
+ row = result.fetchone()
208
+ return dict(row._mapping) if row else None
209
+ return None
210
+
211
+ @classmethod
212
+ def execute_commit(cls, query: str, params: Optional[dict] = None) -> int:
213
+ """Run a SQL query and commit.
214
+
215
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
216
+ """
217
+ with cls.get_session() as session:
218
+ result = session.execute(text(query), params or {})
219
+ return result.rowcount
220
+
221
+ @classmethod
222
+ def column_exists(cls, table: str, column: str) -> bool:
223
+ """Whether the column is already on the table, asked of the database.
224
+
225
+ Each dialect is asked the way it answers: PostgreSQL keeps a catalogue,
226
+ SQLite answers a pragma. Neither reads an error message, which is the
227
+ whole point -- see add_column_if_missing.
228
+ """
229
+ if db_config.is_postgres():
230
+ found = cls.execute_one(
231
+ "SELECT 1 AS present FROM information_schema.columns "
232
+ "WHERE table_name = :table AND column_name = :column",
233
+ {"table": table, "column": column})
234
+ return found is not None
235
+
236
+ # PRAGMA takes no parameters, so the name is inlined -- and therefore
237
+ # checked: see keepup.tables.identifier for why this is a refusal and
238
+ # not a quoting.
239
+ from keepup.tables import identifier
240
+
241
+ rows = cls.execute(f"PRAGMA table_info({identifier(table)})")
242
+ return any(str(row.get("name")) == column for row in rows or [])
243
+
244
+ @classmethod
245
+ def add_column_if_missing(cls, table: str, column: str,
246
+ definition: str) -> bool:
247
+ """Add a column unless it is already there. True if this call added it.
248
+
249
+ There are no migrations in this project: every start re-runs its DDL, so
250
+ an ALTER that has already been applied has to be a no-op rather than an
251
+ error. That used to be decided by matching the text of the failure
252
+ against "duplicate column" -- which is what SQLite says and PostgreSQL
253
+ does not. PostgreSQL answers `(psycopg2.errors.DuplicateColumn) column
254
+ "x" of relation "y" already exists`: no such substring, so the exception
255
+ was re-raised, the plugin failed to initialise, and every one of its
256
+ routes answered 404 while the application itself reported healthy. On
257
+ the stand that happened on the second start after any new column -- the
258
+ first one added it, the next one fell over.
259
+
260
+ A method rather than a function beside the class on purpose: the test
261
+ harnesses swap this module and rebind the manager by name, so a helper
262
+ of its own would quietly talk to a different database than its caller.
263
+ """
264
+ from keepup.tables import identifier
265
+
266
+ if db_config.is_postgres():
267
+ # PostgreSQL can say it in the statement, and then a repeat start
268
+ # raises nothing at all -- no aborted transaction to recover from.
269
+ cls.execute_commit(
270
+ f"ALTER TABLE {identifier(table)} "
271
+ f"ADD COLUMN IF NOT EXISTS {identifier(column)} {definition}")
272
+ return True
273
+
274
+ # SQLite has no such form: try, and ask the catalogue if it failed.
275
+ try:
276
+ cls.execute_commit(
277
+ f"ALTER TABLE {identifier(table)} ADD COLUMN {identifier(column)} {definition}")
278
+ return True
279
+ except Exception:
280
+ if cls.column_exists(table, column):
281
+ return False
282
+ raise
283
+
284
+ @classmethod
285
+ def execute_many(cls, query: str, params_list: List[dict]) -> int:
286
+ """Run a SQL query for many parameter sets.
287
+
288
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
289
+ """
290
+ total = 0
291
+ with cls.get_session() as session:
292
+ for params in params_list:
293
+ result = session.execute(text(query), params)
294
+ total += result.rowcount
295
+ return total
296
+
297
+ @classmethod
298
+ def get_pool_status(cls) -> Dict[str, Any]:
299
+ """Return the connection pool status."""
300
+ if cls._engine is None:
301
+ cls.initialize()
302
+ pool = cls._engine.pool
303
+ return {
304
+ 'size': pool.size(),
305
+ 'checked_in': pool.checkedin(),
306
+ 'overflow': pool.overflow(),
307
+ 'total': pool.size() + pool.overflow(), # total = size + overflow
308
+ 'checked_out': pool.checkedout(),
309
+ }
310
+
311
+ @classmethod
312
+ def dispose(cls):
313
+ """Close every connection."""
314
+ if cls._engine:
315
+ cls._engine.dispose()
316
+ cls._engine = None
317
+ cls._session_factory = None
318
+
319
+ @classmethod
320
+ def get_last_insert_rowid(cls, session=None) -> int:
321
+ """Return the id of the last inserted row.
322
+
323
+ Works on both PostgreSQL and SQLite.
324
+ """
325
+ if cls._engine is None:
326
+ cls.initialize()
327
+
328
+ if db_config.is_postgres():
329
+ query = "SELECT lastval() as id"
330
+ result = cls.execute_one(query)
331
+ return result["id"] if result else 0
332
+ else:
333
+ if session:
334
+ result = session.execute(text("SELECT last_insert_rowid() as id"))
335
+ row = result.fetchone()
336
+ return row[0] if row else 0
337
+ else:
338
+ with cls.get_session() as sess:
339
+ result = sess.execute(text("SELECT last_insert_rowid() as id"))
340
+ row = result.fetchone()
341
+ return row[0] if row else 0
342
+
343
+ @classmethod
344
+ def execute_commit_returning(cls, query: str, params: Optional[dict] = None, returning: str = "id") -> Optional[
345
+ Dict]:
346
+ """Run a SQL query and return the inserted record.
347
+
348
+ Uses RETURNING on PostgreSQL and last_insert_rowid on SQLite.
349
+ """
350
+ if db_config.is_postgres():
351
+ modified_query = f"{query} RETURNING {returning}"
352
+ with cls.get_session() as session:
353
+ result = session.execute(text(modified_query), params or {})
354
+ row = result.fetchone()
355
+ if row:
356
+ return dict(row._mapping)
357
+ return None
358
+ else:
359
+ with cls.get_session() as session:
360
+ session.execute(text(query), params or {})
361
+ result = session.execute(text(f"SELECT last_insert_rowid() as {returning}"))
362
+ row = result.fetchone()
363
+ if row:
364
+ return {returning: row[0]}
365
+ return None
366
+
367
+ @classmethod
368
+ def execute_commit_with_positional(cls, query: str, params: tuple) -> int:
369
+ """Run a SQL query with POSITIONAL parameters (?) and commit."""
370
+ with cls.get_session() as session:
371
+ # SQLAlchemy only binds named parameters, so ? placeholders are rewritten to :paramN.
372
+ if '?' in query:
373
+ param_count = query.count('?')
374
+ for i in range(param_count):
375
+ query = query.replace('?', f':param{i}', 1)
376
+
377
+ named_params = {f'param{i}': params[i] for i in range(len(params))}
378
+ result = session.execute(text(query), named_params)
379
+ else:
380
+ result = session.execute(text(query), params or {})
381
+ return result.rowcount
382
+
383
+
384
+ class DatabaseManager:
385
+ """Legacy database manager covering both SQLite and PostgreSQL."""
386
+
387
+ @staticmethod
388
+ def initialize(config: Optional[DatabaseConfig] = None):
389
+ """Initialise the manager (kept for backwards compatibility)."""
390
+ global db_config
391
+ if config:
392
+ db_config = config
393
+
394
+ @staticmethod
395
+ def get_connection():
396
+ """Return a database connection."""
397
+ if db_config.is_postgres():
398
+ return DatabaseManager._get_postgres_connection()
399
+ else:
400
+ return DatabaseManager._get_sqlite_connection()
401
+
402
+ @staticmethod
403
+ def _get_sqlite_connection():
404
+ """Return a SQLite connection."""
405
+ conn = sqlite3.connect(db_config.db_path)
406
+ conn.row_factory = sqlite3.Row
407
+ conn.execute('PRAGMA foreign_keys = ON')
408
+ return conn
409
+
410
+ @staticmethod
411
+ def _get_postgres_connection():
412
+ """Return a PostgreSQL connection."""
413
+ try:
414
+ conn = psycopg2.connect(
415
+ host=db_config.db_host,
416
+ port=int(db_config.db_port),
417
+ database=db_config.db_name,
418
+ user=db_config.db_user,
419
+ password=db_config.db_password,
420
+ cursor_factory=RealDictCursor,
421
+ connect_timeout=10
422
+ )
423
+ conn.autocommit = False
424
+ return conn
425
+ except psycopg2.Error as e:
426
+ raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}")
427
+
428
+ @staticmethod
429
+ def _adapt_query(query: str, params: tuple = None):
430
+ """Adapt a query and its parameters to the configured engine."""
431
+ if db_config.is_postgres():
432
+ adapted_query = query.replace('?', '%s')
433
+ return adapted_query, params
434
+ else:
435
+ return query, params
436
+
437
+ @staticmethod
438
+ def execute_query(query: str, params: tuple = None, fetch_one: bool = False):
439
+ """Run a SQL query."""
440
+ conn = DatabaseManager.get_connection()
441
+ cursor = conn.cursor()
442
+
443
+ try:
444
+ adapted_query, adapted_params = DatabaseManager._adapt_query(query, params)
445
+ cursor.execute(adapted_query, adapted_params or ())
446
+
447
+ if fetch_one:
448
+ result = cursor.fetchone()
449
+ else:
450
+ result = cursor.fetchall()
451
+
452
+ conn.commit()
453
+
454
+ if result is None:
455
+ return None
456
+
457
+ if fetch_one:
458
+ return DatabaseManager._row_to_dict(result)
459
+ else:
460
+ return [DatabaseManager._row_to_dict(row) for row in result]
461
+
462
+ except Exception as e:
463
+ conn.rollback()
464
+ raise e
465
+ finally:
466
+ cursor.close()
467
+ conn.close()
468
+
469
+ @staticmethod
470
+ def execute_commit(query: str, params: tuple = None):
471
+ """Run a query, commit, and return lastrowid."""
472
+ conn = DatabaseManager.get_connection()
473
+ cursor = conn.cursor()
474
+
475
+ try:
476
+ adapted_query, adapted_params = DatabaseManager._adapt_query(query, params)
477
+
478
+ cursor.execute(adapted_query, adapted_params or ())
479
+ conn.commit()
480
+
481
+ if db_config.is_postgres():
482
+ if "RETURNING id" in query.upper():
483
+ result = cursor.fetchone()
484
+ return result['id'] if result else None
485
+ else:
486
+ cursor.execute("SELECT LASTVAL()")
487
+ result = cursor.fetchone()
488
+ return result['lastval'] if result else None
489
+ else:
490
+ return cursor.lastrowid
491
+
492
+ except Exception as e:
493
+ conn.rollback()
494
+ raise e
495
+ finally:
496
+ cursor.close()
497
+ conn.close()
498
+
499
+ @staticmethod
500
+ def execute_sql(query: str, params: tuple = None):
501
+ """Run a SQL query and return every row."""
502
+ conn = DatabaseManager.get_connection()
503
+ cursor = conn.cursor()
504
+
505
+ try:
506
+ if db_config.is_postgres():
507
+ query = query.replace('?', '%s')
508
+
509
+ cursor.execute(query, params or ())
510
+ result = cursor.fetchall()
511
+ conn.commit()
512
+
513
+ return [DatabaseManager._row_to_dict(row) for row in result]
514
+
515
+ except Exception as e:
516
+ conn.rollback()
517
+ raise e
518
+ finally:
519
+ cursor.close()
520
+ conn.close()
521
+
522
+ @staticmethod
523
+ def execute_sql_one(query: str, params: tuple = None):
524
+ """Run a SQL query and return a single row."""
525
+ conn = DatabaseManager.get_connection()
526
+ cursor = conn.cursor()
527
+
528
+ try:
529
+ if db_config.is_postgres():
530
+ query = query.replace('?', '%s')
531
+
532
+ cursor.execute(query, params or ())
533
+ result = cursor.fetchone()
534
+ conn.commit()
535
+
536
+ if result:
537
+ return DatabaseManager._row_to_dict(result)
538
+ return None
539
+
540
+ except Exception as e:
541
+ conn.rollback()
542
+ raise e
543
+ finally:
544
+ cursor.close()
545
+ conn.close()
546
+
547
+ @staticmethod
548
+ def execute_commit_only(query: str, params: tuple = None) -> int:
549
+ """Run a SQL query, commit, and return the number of affected rows."""
550
+ conn = DatabaseManager.get_connection()
551
+ cursor = conn.cursor()
552
+
553
+ try:
554
+ if db_config.is_postgres():
555
+ query = query.replace('?', '%s')
556
+
557
+ cursor.execute(query, params or ())
558
+ conn.commit()
559
+ return cursor.rowcount
560
+
561
+ except Exception as e:
562
+ conn.rollback()
563
+ raise e
564
+ finally:
565
+ cursor.close()
566
+ conn.close()
567
+
568
+ @staticmethod
569
+ def executemany_commit(query: str, params_list: list):
570
+ """Run a query for many parameter sets and commit."""
571
+ try:
572
+ adapted_query, adapted_params = DatabaseManager._adapt_query(query, None)
573
+ conn = DatabaseManager.get_connection()
574
+ cursor = conn.cursor()
575
+ cursor.executemany(adapted_query, params_list)
576
+ conn.commit()
577
+ return True
578
+ except Exception as e:
579
+ import logging
580
+ logging.error(f"Error in executemany_commit: {str(e)}")
581
+ return False
582
+
583
+ @staticmethod
584
+ def _row_to_dict(row):
585
+ """Convert a result row to a dict."""
586
+ if row is None:
587
+ return None
588
+
589
+ if hasattr(row, '_asdict'): # namedtuple
590
+ return dict(row)
591
+ elif hasattr(row, 'keys'): # psycopg2 RealDictRow or sqlite3.Row
592
+ return {key: row[key] for key in row.keys()}
593
+ elif isinstance(row, tuple):
594
+ # Unnamed tuples: fall back to positional column names.
595
+ return {f'col_{i}': value for i, value in enumerate(row)}
596
+ else:
597
+ return dict(row)
598
+
599
+ @staticmethod
600
+ def test_connection():
601
+ """Check that the database is reachable."""
602
+ try:
603
+ conn = DatabaseManager.get_connection()
604
+ cursor = conn.cursor()
605
+
606
+ if db_config.is_postgres():
607
+ cursor.execute("SELECT version();")
608
+ result = cursor.fetchone()
609
+ db_version = result['version'] if result else "Unknown"
610
+ db_type = "PostgreSQL"
611
+ else:
612
+ cursor.execute("SELECT sqlite_version();")
613
+ result = cursor.fetchone()
614
+ db_version = result[0] if result else "Unknown"
615
+ db_type = "SQLite"
616
+
617
+ cursor.close()
618
+ conn.close()
619
+
620
+ return {
621
+ "success": True,
622
+ "database_type": db_type,
623
+ "version": db_version,
624
+ "config": str(db_config)
625
+ }
626
+ except Exception as e:
627
+ return {
628
+ "success": False,
629
+ "error": str(e),
630
+ "config": str(db_config)
631
+ }
632
+
633
+ @staticmethod
634
+ def execute_in_transaction(queries: list):
635
+ """Run a list of queries inside one transaction."""
636
+ conn = DatabaseManager.get_connection()
637
+ cursor = conn.cursor()
638
+
639
+ try:
640
+ for query, params in queries:
641
+ if db_config.is_postgres():
642
+ query = query.replace('?', '%s')
643
+ cursor.execute(query, params or ())
644
+
645
+ conn.commit()
646
+ return True
647
+ except Exception as e:
648
+ conn.rollback()
649
+ raise e
650
+ finally:
651
+ cursor.close()
652
+ conn.close()
653
+
654
+
655
+ db_config = DatabaseConfig()
656
+
657
+ # Log the resolved configuration on import, so a mis-pointed run is visible early.
658
+
659
+ if __name__ != "__main__":
660
+ import logging
661
+
662
+ logger = logging.getLogger(__name__)
663
+ logger.info(f"Database configuration loaded: {db_config}")