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 +0 -0
- odoo_db/db.py +376 -0
- odoo_db/main.py +424 -0
- odoo_db/output.py +58 -0
- odoo_db-1.3.0.dist-info/METADATA +110 -0
- odoo_db-1.3.0.dist-info/RECORD +9 -0
- odoo_db-1.3.0.dist-info/WHEEL +4 -0
- odoo_db-1.3.0.dist-info/entry_points.txt +3 -0
- odoo_db-1.3.0.dist-info/licenses/LICENSE +661 -0
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
|
+
}
|