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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- 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}")
|