odoo-db 1.3.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.
odoo_db/__init__.py ADDED
File without changes
odoo_db/db.py ADDED
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+
7
+ import psycopg
8
+ from psycopg import sql
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @contextmanager
14
+ def connect(dbname: str):
15
+ logger.debug("connecting to %s", dbname)
16
+ with psycopg.connect(f"dbname={dbname}") as conn:
17
+ yield conn
18
+
19
+
20
+ def _is_odoo(cur: psycopg.Cursor) -> bool:
21
+ cur.execute("SELECT 1 FROM pg_tables WHERE tablename='ir_module_module'")
22
+ return bool(cur.fetchone())
23
+
24
+
25
+ def list_databases() -> list[str]:
26
+ with connect("postgres") as conn, conn.cursor() as cur:
27
+ cur.execute("""
28
+ SELECT datname FROM pg_database
29
+ WHERE NOT datistemplate AND datallowconn
30
+ AND datname NOT IN ('postgres', 'template0', 'template1')
31
+ ORDER BY datname
32
+ """)
33
+ return [row[0] for row in cur.fetchall()]
34
+
35
+
36
+ @dataclass
37
+ class DbSummary:
38
+ name: str
39
+ version: str
40
+ neutralized: bool
41
+ module_count: int | None = None
42
+ user_count: int | None = None
43
+
44
+
45
+ def get_db_summary(dbname: str, verbose: bool = False) -> DbSummary | None:
46
+ try:
47
+ with connect(dbname) as conn, conn.cursor() as cur:
48
+ if not _is_odoo(cur):
49
+ return None
50
+
51
+ cur.execute("SELECT latest_version FROM ir_module_module WHERE name='base'")
52
+ row = cur.fetchone()
53
+ if not row or not row[0]:
54
+ return None
55
+ parts = row[0].split(".")
56
+ version = ".".join(parts[:2]) if len(parts) >= 2 else row[0]
57
+
58
+ cur.execute("SELECT value FROM ir_config_parameter WHERE key='database.is_neutralized'")
59
+ row = cur.fetchone()
60
+ neutralized = row is not None and row[0] == "True"
61
+
62
+ module_count = user_count = None
63
+ if verbose:
64
+ cur.execute("SELECT count(*) FROM ir_module_module WHERE state='installed'")
65
+ module_count = cur.fetchone()[0]
66
+ cur.execute("SELECT count(*) FROM res_users WHERE active=true")
67
+ user_count = cur.fetchone()[0]
68
+
69
+ return DbSummary(
70
+ name=dbname,
71
+ version=version,
72
+ neutralized=neutralized,
73
+ module_count=module_count,
74
+ user_count=user_count,
75
+ )
76
+ except Exception as exc:
77
+ logger.warning("skipping %s: %s", dbname, exc)
78
+ return None
79
+
80
+
81
+ def get_modules(dbname: str) -> list[dict]:
82
+ with connect(dbname) as conn, conn.cursor() as cur:
83
+ cur.execute("""
84
+ SELECT name, latest_version
85
+ FROM ir_module_module
86
+ WHERE state = 'installed'
87
+ ORDER BY name
88
+ """)
89
+ return [{"name": row[0], "version": row[1] or ""} for row in cur.fetchall()]
90
+
91
+
92
+ def get_crons(dbname: str) -> list[dict]:
93
+ with connect(dbname) as conn, conn.cursor() as cur:
94
+ cur.execute("""
95
+ SELECT cron_name, interval_number, interval_type, nextcall
96
+ FROM ir_cron
97
+ WHERE active = true
98
+ ORDER BY nextcall
99
+ """)
100
+ return [
101
+ {
102
+ "name": row[0],
103
+ "interval": f"{row[1]} {row[2]}",
104
+ "nextcall": str(row[3]),
105
+ }
106
+ for row in cur.fetchall()
107
+ ]
108
+
109
+
110
+ def get_jobs(dbname: str) -> list[dict] | None:
111
+ """Returns None if queue_job module not installed."""
112
+ with connect(dbname) as conn, conn.cursor() as cur:
113
+ cur.execute(
114
+ "SELECT 1 FROM ir_module_module WHERE name = ANY(%s) AND state = %s",
115
+ (["connector", "queue_job"], "installed"),
116
+ )
117
+ if not cur.fetchone():
118
+ return None
119
+ cur.execute("SELECT state, count(*) FROM queue_job GROUP BY state ORDER BY state")
120
+ return [{"state": row[0], "count": row[1]} for row in cur.fetchall()]
121
+
122
+
123
+ def get_users(dbname: str) -> list[dict]:
124
+ with connect(dbname) as conn, conn.cursor() as cur:
125
+ cur.execute("SELECT tablename FROM pg_tables WHERE tablename IN ('mail_presence', 'bus_presence')")
126
+ presence_tables = {row[0] for row in cur.fetchall()}
127
+
128
+ if "mail_presence" in presence_tables:
129
+ # Odoo 19+: mail.presence with direct status column
130
+ cur.execute("""
131
+ SELECT ru.login, rp.name, COALESCE(mp.status, 'offline') AS state
132
+ FROM res_users ru
133
+ LEFT JOIN res_partner rp ON ru.partner_id = rp.id
134
+ LEFT JOIN mail_presence mp ON mp.user_id = ru.id
135
+ WHERE ru.active = TRUE
136
+ ORDER BY ru.login
137
+ """)
138
+ elif "bus_presence" in presence_tables:
139
+ # Odoo 14-18: bus.presence.status is updated in real-time (HTTP and WebSocket)
140
+ cur.execute("""
141
+ SELECT ru.login, rp.name, COALESCE(bp.status, 'offline') AS state
142
+ FROM res_users ru
143
+ LEFT JOIN res_partner rp ON ru.partner_id = rp.id
144
+ LEFT JOIN bus_presence bp ON bp.user_id = ru.id
145
+ WHERE ru.active = TRUE
146
+ ORDER BY ru.login
147
+ """)
148
+ else:
149
+ cur.execute("""
150
+ SELECT ru.login, rp.name, 'unknown' AS state
151
+ FROM res_users ru
152
+ LEFT JOIN res_partner rp ON ru.partner_id = rp.id
153
+ WHERE ru.active = TRUE
154
+ ORDER BY ru.login
155
+ """)
156
+ return [{"login": row[0], "name": row[1] or "", "state": row[2]} for row in cur.fetchall()]
157
+
158
+
159
+ def get_stats(dbname: str, years: int = 3, top: int = 20) -> dict:
160
+ with connect(dbname) as conn, conn.cursor() as cur:
161
+ # All Odoo tables (have create_date)
162
+ cur.execute("""
163
+ SELECT c.relname
164
+ FROM pg_class c
165
+ JOIN pg_namespace n ON n.oid = c.relnamespace
166
+ JOIN pg_attribute a ON a.attrelid = c.oid
167
+ WHERE n.nspname = 'public' AND a.attname = 'create_date' AND c.relkind = 'r'
168
+ ORDER BY c.relname
169
+ """)
170
+ all_tables = [row[0] for row in cur.fetchall()]
171
+
172
+ # Map table -> model name
173
+ cur.execute("SELECT replace(model, '.', '_') AS tbl, model FROM ir_model")
174
+ table_to_model = {row[0]: row[1] for row in cur.fetchall()}
175
+
176
+ # Table sizes (top N by total size)
177
+ cur.execute(
178
+ """
179
+ SELECT relname,
180
+ pg_total_relation_size(relid) AS total_bytes,
181
+ pg_relation_size(relid) AS table_bytes
182
+ FROM pg_statio_user_tables
183
+ WHERE relname = ANY(%s)
184
+ ORDER BY total_bytes DESC
185
+ LIMIT %s
186
+ """,
187
+ (all_tables, top),
188
+ )
189
+ size_rows = cur.fetchall()
190
+ top_tables = [row[0] for row in size_rows]
191
+
192
+ # Year columns for the report
193
+ cur.execute("SELECT EXTRACT(year FROM NOW())::int")
194
+ current_year = cur.fetchone()[0]
195
+ year_cols = list(range(current_year - years + 1, current_year + 1))
196
+
197
+ # Records per year per table
198
+ table_year_counts: dict[str, dict[int, int]] = {}
199
+ for table in top_tables:
200
+ cur.execute(
201
+ sql.SQL("""
202
+ SELECT EXTRACT(year FROM create_date)::int AS yr, count(*)
203
+ FROM {}
204
+ WHERE create_date >= NOW() - make_interval(years => %s)
205
+ GROUP BY yr
206
+ """).format(sql.Identifier(table)),
207
+ (years,),
208
+ )
209
+ table_year_counts[table] = {row[0]: row[1] for row in cur.fetchall()}
210
+
211
+ # Total record count per table
212
+ total_counts: dict[str, int] = {}
213
+ for table in top_tables:
214
+ cur.execute(sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(table)))
215
+ total_counts[table] = cur.fetchone()[0]
216
+
217
+ # Index sizes per table (sum all indexes per table)
218
+ cur.execute(
219
+ """
220
+ SELECT i.relname AS table_name, sum(pg_relation_size(i.indexrelid)) AS index_bytes
221
+ FROM pg_stat_all_indexes i
222
+ JOIN pg_class c ON i.relid = c.oid
223
+ WHERE i.schemaname NOT IN ('information_schema', 'pg_catalog', 'pg_toast', 'pg_logical')
224
+ AND i.relname = ANY(%s)
225
+ GROUP BY i.relname
226
+ """,
227
+ (top_tables,),
228
+ )
229
+ index_sizes = {row[0]: row[1] for row in cur.fetchall()}
230
+
231
+ # Attachment sizes per model (dedup by checksum)
232
+ cur.execute("""
233
+ WITH unique_attachments AS (
234
+ SELECT res_model, file_size,
235
+ row_number() OVER (PARTITION BY checksum ORDER BY id) AS rowno
236
+ FROM ir_attachment
237
+ )
238
+ SELECT res_model, sum(file_size)
239
+ FROM unique_attachments
240
+ WHERE rowno = 1
241
+ GROUP BY res_model
242
+ """)
243
+ # keyed by model name (dotted), convert to table name for lookup
244
+ attachment_by_model = {row[0]: row[1] for row in cur.fetchall()}
245
+ attachment_sizes = {tbl: attachment_by_model.get(mdl, 0) for tbl, mdl in table_to_model.items()}
246
+
247
+ # Total DB size
248
+ cur.execute("SELECT pg_size_pretty(pg_database_size(current_database()))")
249
+ db_size = cur.fetchone()[0]
250
+
251
+ tables = []
252
+ for relname, total_bytes, table_bytes in size_rows:
253
+ tables.append({
254
+ "table": relname,
255
+ "model": table_to_model.get(relname, ""),
256
+ "total_records": total_counts.get(relname, 0),
257
+ "total_size_bytes": total_bytes,
258
+ "table_size_bytes": table_bytes,
259
+ "index_size_bytes": index_sizes.get(relname, 0),
260
+ "attachment_size_bytes": attachment_sizes.get(relname, 0),
261
+ "year_counts": {yr: table_year_counts.get(relname, {}).get(yr, 0) for yr in year_cols},
262
+ })
263
+
264
+ return {
265
+ "db_size": db_size,
266
+ "years": year_cols,
267
+ "tables": tables,
268
+ }
269
+
270
+
271
+ def get_not_odoo(dbname: str) -> dict:
272
+ with connect(dbname) as conn, conn.cursor() as cur:
273
+ # Views not tracked in ir_model
274
+ cur.execute("""
275
+ SELECT viewname
276
+ FROM pg_views
277
+ WHERE schemaname = 'public'
278
+ AND viewname NOT IN (
279
+ SELECT replace(model, '.', '_') FROM ir_model
280
+ )
281
+ ORDER BY viewname
282
+ """)
283
+ views = [row[0] for row in cur.fetchall()]
284
+
285
+ # Triggers (Odoo never creates triggers; aggregate events per trigger+table)
286
+ cur.execute("""
287
+ SELECT trigger_name, event_object_table,
288
+ string_agg(DISTINCT event_manipulation, '/' ORDER BY event_manipulation) AS events,
289
+ action_timing
290
+ FROM information_schema.triggers
291
+ WHERE trigger_schema = 'public'
292
+ GROUP BY trigger_name, event_object_table, action_timing
293
+ ORDER BY event_object_table, trigger_name
294
+ """)
295
+ triggers = [{"name": row[0], "table": row[1], "events": row[2], "timing": row[3]} for row in cur.fetchall()]
296
+
297
+ # Custom functions and procedures (exclude extension-provided ones like crosstab, dblink)
298
+ cur.execute("""
299
+ SELECT DISTINCT p.proname, p.prokind
300
+ FROM pg_proc p
301
+ JOIN pg_namespace n ON p.pronamespace = n.oid
302
+ LEFT JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
303
+ LEFT JOIN pg_extension e ON e.oid = d.refobjid
304
+ WHERE n.nspname = 'public'
305
+ AND e.extname IS NULL
306
+ AND p.prokind IN ('f', 'p')
307
+ ORDER BY p.proname
308
+ """)
309
+ routines = cur.fetchall()
310
+ functions = [row[0] for row in routines if row[1] == "f"]
311
+ procedures = [row[0] for row in routines if row[1] == "p"]
312
+
313
+ return {
314
+ "views": views,
315
+ "triggers": triggers,
316
+ "functions": functions,
317
+ "procedures": procedures,
318
+ }
319
+
320
+
321
+ def get_locks(dbname: str) -> dict:
322
+ with connect(dbname) as conn, conn.cursor() as cur:
323
+ cur.execute(
324
+ """
325
+ SELECT blocked_locks.pid, blocking_locks.pid
326
+ FROM pg_catalog.pg_locks blocked_locks
327
+ JOIN pg_catalog.pg_stat_activity blocked_activity
328
+ ON blocked_activity.pid = blocked_locks.pid
329
+ JOIN pg_catalog.pg_locks blocking_locks
330
+ ON blocking_locks.locktype = blocked_locks.locktype
331
+ AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
332
+ AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
333
+ AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
334
+ AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
335
+ AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
336
+ AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
337
+ AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
338
+ AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
339
+ AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
340
+ AND blocking_locks.pid != blocked_locks.pid
341
+ JOIN pg_catalog.pg_stat_activity blocking_activity
342
+ ON blocking_activity.pid = blocking_locks.pid
343
+ WHERE NOT blocked_locks.granted
344
+ AND blocked_activity.datname = %s
345
+ """,
346
+ (dbname,),
347
+ )
348
+
349
+ blocked_by: dict[int, list[int]] = {}
350
+ for blocked_pid, blocking_pid in cur.fetchall():
351
+ blocked_by.setdefault(blocked_pid, []).append(blocking_pid)
352
+
353
+ blocked = set(blocked_by.keys())
354
+ blocking = {pid for pids in blocked_by.values() for pid in pids}
355
+ blocking_not_blocked = sorted(blocking - blocked)
356
+
357
+ queries: dict[int, str] = {}
358
+ all_pids = list(blocked | blocking)
359
+ if all_pids:
360
+ cur.execute(
361
+ """
362
+ SELECT pid, left(query, 120)
363
+ FROM pg_stat_activity
364
+ WHERE pid = ANY(%s)
365
+ """,
366
+ (all_pids,),
367
+ )
368
+ queries = {row[0]: row[1] for row in cur.fetchall()}
369
+
370
+ return {
371
+ "blocked_count": len(blocked),
372
+ "blocking_count": len(blocking),
373
+ "blocking_not_blocked": blocking_not_blocked,
374
+ "details": [{"blocked_pid": bp, "blocking_pids": pids} for bp, pids in blocked_by.items()],
375
+ "queries": {str(pid): q for pid, q in queries.items()},
376
+ }