1# -*- coding: utf-8 -*-
2import base64
3import json
4import logging
5import os
6import shutil
7import tempfile
8import threading
9import traceback
10from xml.etree import ElementTree as ET
11import zipfile
12
13from psycopg2 import sql
14from pytz import country_timezones
15from functools import wraps
16from contextlib import closing
17from decorator import decorator
18
19import psycopg2
20
21import odoo
22from odoo import SUPERUSER_ID
23from odoo.exceptions import AccessDenied
24import odoo.release
25import odoo.sql_db
26import odoo.tools
27from odoo.sql_db import db_connect
28from odoo.release import version_info
29
30_logger = logging.getLogger(__name__)
31
32class DatabaseExists(Warning):
33    pass
34
35
36def check_db_management_enabled(method):
37    def if_db_mgt_enabled(method, self, *args, **kwargs):
38        if not odoo.tools.config['list_db']:
39            _logger.error('Database management functions blocked, admin disabled database listing')
40            raise AccessDenied()
41        return method(self, *args, **kwargs)
42    return decorator(if_db_mgt_enabled, method)
43
44#----------------------------------------------------------
45# Master password required
46#----------------------------------------------------------
47
48def check_super(passwd):
49    if passwd and odoo.tools.config.verify_admin_password(passwd):
50        return True
51    raise odoo.exceptions.AccessDenied()
52
53# This should be moved to odoo.modules.db, along side initialize().
54def _initialize_db(id, db_name, demo, lang, user_password, login='admin', country_code=None, phone=None):
55    try:
56        db = odoo.sql_db.db_connect(db_name)
57        with closing(db.cursor()) as cr:
58            # TODO this should be removed as it is done by Registry.new().
59            odoo.modules.db.initialize(cr)
60            odoo.tools.config['load_language'] = lang
61            cr.commit()
62
63        registry = odoo.modules.registry.Registry.new(db_name, demo, None, update_module=True)
64
65        with closing(db.cursor()) as cr:
66            env = odoo.api.Environment(cr, SUPERUSER_ID, {})
67
68            if lang:
69                modules = env['ir.module.module'].search([('state', '=', 'installed')])
70                modules._update_translations(lang)
71
72            if country_code:
73                country = env['res.country'].search([('code', 'ilike', country_code)])[0]
74                env['res.company'].browse(1).write({'country_id': country_code and country.id, 'currency_id': country_code and country.currency_id.id})
75                if len(country_timezones.get(country_code, [])) == 1:
76                    users = env['res.users'].search([])
77                    users.write({'tz': country_timezones[country_code][0]})
78            if phone:
79                env['res.company'].browse(1).write({'phone': phone})
80            if '@' in login:
81                env['res.company'].browse(1).write({'email': login})
82
83            # update admin's password and lang and login
84            values = {'password': user_password, 'lang': lang}
85            if login:
86                values['login'] = login
87                emails = odoo.tools.email_split(login)
88                if emails:
89                    values['email'] = emails[0]
90            env.ref('base.user_admin').write(values)
91
92            cr.execute('SELECT login, password FROM res_users ORDER BY login')
93            cr.commit()
94    except Exception as e:
95        _logger.exception('CREATE DATABASE failed:')
96
97def _create_empty_database(name):
98    db = odoo.sql_db.db_connect('postgres')
99    with closing(db.cursor()) as cr:
100        chosen_template = odoo.tools.config['db_template']
101        cr.execute("SELECT datname FROM pg_database WHERE datname = %s",
102                   (name,), log_exceptions=False)
103        if cr.fetchall():
104            raise DatabaseExists("database %r already exists!" % (name,))
105        else:
106            cr.autocommit(True)     # avoid transaction block
107
108            # 'C' collate is only safe with template0, but provides more useful indexes
109            collate = sql.SQL("LC_COLLATE 'C'" if chosen_template == 'template0' else "")
110            cr.execute(
111                sql.SQL("CREATE DATABASE {} ENCODING 'unicode' {} TEMPLATE {}").format(
112                sql.Identifier(name), collate, sql.Identifier(chosen_template)
113            ))
114
115    if odoo.tools.config['unaccent']:
116        try:
117            db = odoo.sql_db.db_connect(name)
118            with closing(db.cursor()) as cr:
119                cr.execute("CREATE EXTENSION IF NOT EXISTS unaccent")
120                cr.commit()
121        except psycopg2.Error:
122            pass
123
124@check_db_management_enabled
125def exp_create_database(db_name, demo, lang, user_password='admin', login='admin', country_code=None, phone=None):
126    """ Similar to exp_create but blocking."""
127    _logger.info('Create database `%s`.', db_name)
128    _create_empty_database(db_name)
129    _initialize_db(id, db_name, demo, lang, user_password, login, country_code, phone)
130    return True
131
132@check_db_management_enabled
133def exp_duplicate_database(db_original_name, db_name):
134    _logger.info('Duplicate database `%s` to `%s`.', db_original_name, db_name)
135    odoo.sql_db.close_db(db_original_name)
136    db = odoo.sql_db.db_connect('postgres')
137    with closing(db.cursor()) as cr:
138        cr.autocommit(True)     # avoid transaction block
139        _drop_conn(cr, db_original_name)
140        cr.execute(sql.SQL("CREATE DATABASE {} ENCODING 'unicode' TEMPLATE {}").format(
141            sql.Identifier(db_name),
142            sql.Identifier(db_original_name)
143        ))
144
145    registry = odoo.modules.registry.Registry.new(db_name)
146    with registry.cursor() as cr:
147        # if it's a copy of a database, force generation of a new dbuuid
148        env = odoo.api.Environment(cr, SUPERUSER_ID, {})
149        env['ir.config_parameter'].init(force=True)
150
151    from_fs = odoo.tools.config.filestore(db_original_name)
152    to_fs = odoo.tools.config.filestore(db_name)
153    if os.path.exists(from_fs) and not os.path.exists(to_fs):
154        shutil.copytree(from_fs, to_fs)
155    return True
156
157def _drop_conn(cr, db_name):
158    # Try to terminate all other connections that might prevent
159    # dropping the database
160    try:
161        # PostgreSQL 9.2 renamed pg_stat_activity.procpid to pid:
162        # http://www.postgresql.org/docs/9.2/static/release-9-2.html#AEN110389
163        pid_col = 'pid' if cr._cnx.server_version >= 90200 else 'procpid'
164
165        cr.execute("""SELECT pg_terminate_backend(%(pid_col)s)
166                      FROM pg_stat_activity
167                      WHERE datname = %%s AND
168                            %(pid_col)s != pg_backend_pid()""" % {'pid_col': pid_col},
169                   (db_name,))
170    except Exception:
171        pass
172
173@check_db_management_enabled
174def exp_drop(db_name):
175    if db_name not in list_dbs(True):
176        return False
177    odoo.modules.registry.Registry.delete(db_name)
178    odoo.sql_db.close_db(db_name)
179
180    db = odoo.sql_db.db_connect('postgres')
181    with closing(db.cursor()) as cr:
182        cr.autocommit(True) # avoid transaction block
183        _drop_conn(cr, db_name)
184
185        try:
186            cr.execute(sql.SQL('DROP DATABASE {}').format(sql.Identifier(db_name)))
187        except Exception as e:
188            _logger.info('DROP DB: %s failed:\n%s', db_name, e)
189            raise Exception("Couldn't drop database %s: %s" % (db_name, e))
190        else:
191            _logger.info('DROP DB: %s', db_name)
192
193    fs = odoo.tools.config.filestore(db_name)
194    if os.path.exists(fs):
195        shutil.rmtree(fs)
196    return True
197
198@check_db_management_enabled
199def exp_dump(db_name, format):
200    with tempfile.TemporaryFile(mode='w+b') as t:
201        dump_db(db_name, t, format)
202        t.seek(0)
203        return base64.b64encode(t.read()).decode()
204
205@check_db_management_enabled
206def dump_db_manifest(cr):
207    pg_version = "%d.%d" % divmod(cr._obj.connection.server_version / 100, 100)
208    cr.execute("SELECT name, latest_version FROM ir_module_module WHERE state = 'installed'")
209    modules = dict(cr.fetchall())
210    manifest = {
211        'odoo_dump': '1',
212        'db_name': cr.dbname,
213        'version': odoo.release.version,
214        'version_info': odoo.release.version_info,
215        'major_version': odoo.release.major_version,
216        'pg_version': pg_version,
217        'modules': modules,
218    }
219    return manifest
220
221@check_db_management_enabled
222def dump_db(db_name, stream, backup_format='zip'):
223    """Dump database `db` into file-like object `stream` if stream is None
224    return a file object with the dump """
225
226    _logger.info('DUMP DB: %s format %s', db_name, backup_format)
227
228    cmd = ['pg_dump', '--no-owner']
229    cmd.append(db_name)
230
231    if backup_format == 'zip':
232        with tempfile.TemporaryDirectory() as dump_dir:
233            filestore = odoo.tools.config.filestore(db_name)
234            if os.path.exists(filestore):
235                shutil.copytree(filestore, os.path.join(dump_dir, 'filestore'))
236            with open(os.path.join(dump_dir, 'manifest.json'), 'w') as fh:
237                db = odoo.sql_db.db_connect(db_name)
238                with db.cursor() as cr:
239                    json.dump(dump_db_manifest(cr), fh, indent=4)
240            cmd.insert(-1, '--file=' + os.path.join(dump_dir, 'dump.sql'))
241            odoo.tools.exec_pg_command(*cmd)
242            if stream:
243                odoo.tools.osutil.zip_dir(dump_dir, stream, include_dir=False, fnct_sort=lambda file_name: file_name != 'dump.sql')
244            else:
245                t=tempfile.TemporaryFile()
246                odoo.tools.osutil.zip_dir(dump_dir, t, include_dir=False, fnct_sort=lambda file_name: file_name != 'dump.sql')
247                t.seek(0)
248                return t
249    else:
250        cmd.insert(-1, '--format=c')
251        stdin, stdout = odoo.tools.exec_pg_command_pipe(*cmd)
252        if stream:
253            shutil.copyfileobj(stdout, stream)
254        else:
255            return stdout
256
257@check_db_management_enabled
258def exp_restore(db_name, data, copy=False):
259    def chunks(d, n=8192):
260        for i in range(0, len(d), n):
261            yield d[i:i+n]
262    data_file = tempfile.NamedTemporaryFile(delete=False)
263    try:
264        for chunk in chunks(data):
265            data_file.write(base64.b64decode(chunk))
266        data_file.close()
267        restore_db(db_name, data_file.name, copy=copy)
268    finally:
269        os.unlink(data_file.name)
270    return True
271
272@check_db_management_enabled
273def restore_db(db, dump_file, copy=False):
274    assert isinstance(db, str)
275    if exp_db_exist(db):
276        _logger.info('RESTORE DB: %s already exists', db)
277        raise Exception("Database already exists")
278
279    _create_empty_database(db)
280
281    filestore_path = None
282    with tempfile.TemporaryDirectory() as dump_dir:
283        if zipfile.is_zipfile(dump_file):
284            # v8 format
285            with zipfile.ZipFile(dump_file, 'r') as z:
286                # only extract known members!
287                filestore = [m for m in z.namelist() if m.startswith('filestore/')]
288                z.extractall(dump_dir, ['dump.sql'] + filestore)
289
290                if filestore:
291                    filestore_path = os.path.join(dump_dir, 'filestore')
292
293            pg_cmd = 'psql'
294            pg_args = ['-q', '-f', os.path.join(dump_dir, 'dump.sql')]
295
296        else:
297            # <= 7.0 format (raw pg_dump output)
298            pg_cmd = 'pg_restore'
299            pg_args = ['--no-owner', dump_file]
300
301        args = []
302        args.append('--dbname=' + db)
303        pg_args = args + pg_args
304
305        if odoo.tools.exec_pg_command(pg_cmd, *pg_args):
306            raise Exception("Couldn't restore database")
307
308        registry = odoo.modules.registry.Registry.new(db)
309        with registry.cursor() as cr:
310            env = odoo.api.Environment(cr, SUPERUSER_ID, {})
311            if copy:
312                # if it's a copy of a database, force generation of a new dbuuid
313                env['ir.config_parameter'].init(force=True)
314            if filestore_path:
315                filestore_dest = env['ir.attachment']._filestore()
316                shutil.move(filestore_path, filestore_dest)
317
318    _logger.info('RESTORE DB: %s', db)
319
320@check_db_management_enabled
321def exp_rename(old_name, new_name):
322    odoo.modules.registry.Registry.delete(old_name)
323    odoo.sql_db.close_db(old_name)
324
325    db = odoo.sql_db.db_connect('postgres')
326    with closing(db.cursor()) as cr:
327        cr.autocommit(True)     # avoid transaction block
328        _drop_conn(cr, old_name)
329        try:
330            cr.execute(sql.SQL('ALTER DATABASE {} RENAME TO {}').format(sql.Identifier(old_name), sql.Identifier(new_name)))
331            _logger.info('RENAME DB: %s -> %s', old_name, new_name)
332        except Exception as e:
333            _logger.info('RENAME DB: %s -> %s failed:\n%s', old_name, new_name, e)
334            raise Exception("Couldn't rename database %s to %s: %s" % (old_name, new_name, e))
335
336    old_fs = odoo.tools.config.filestore(old_name)
337    new_fs = odoo.tools.config.filestore(new_name)
338    if os.path.exists(old_fs) and not os.path.exists(new_fs):
339        shutil.move(old_fs, new_fs)
340    return True
341
342@check_db_management_enabled
343def exp_change_admin_password(new_password):
344    odoo.tools.config.set_admin_password(new_password)
345    odoo.tools.config.save()
346    return True
347
348@check_db_management_enabled
349def exp_migrate_databases(databases):
350    for db in databases:
351        _logger.info('migrate database %s', db)
352        odoo.tools.config['update']['base'] = True
353        odoo.modules.registry.Registry.new(db, force_demo=False, update_module=True)
354    return True
355
356#----------------------------------------------------------
357# No master password required
358#----------------------------------------------------------
359
360@odoo.tools.mute_logger('odoo.sql_db')
361def exp_db_exist(db_name):
362    ## Not True: in fact, check if connection to database is possible. The database may exists
363    try:
364        db = odoo.sql_db.db_connect(db_name)
365        with db.cursor():
366            return True
367    except Exception:
368        return False
369
370def list_dbs(force=False):
371    if not odoo.tools.config['list_db'] and not force:
372        raise odoo.exceptions.AccessDenied()
373
374    if not odoo.tools.config['dbfilter'] and odoo.tools.config['db_name']:
375        # In case --db-filter is not provided and --database is passed, Odoo will not
376        # fetch the list of databases available on the postgres server and instead will
377        # use the value of --database as comma seperated list of exposed databases.
378        res = sorted(db.strip() for db in odoo.tools.config['db_name'].split(','))
379        return res
380
381    chosen_template = odoo.tools.config['db_template']
382    templates_list = tuple(set(['postgres', chosen_template]))
383    db = odoo.sql_db.db_connect('postgres')
384    with closing(db.cursor()) as cr:
385        try:
386            cr.execute("select datname from pg_database where datdba=(select usesysid from pg_user where usename=current_user) and not datistemplate and datallowconn and datname not in %s order by datname", (templates_list,))
387            res = [odoo.tools.ustr(name) for (name,) in cr.fetchall()]
388        except Exception:
389            _logger.exception('Listing databases failed:')
390            res = []
391    return res
392
393def list_db_incompatible(databases):
394    """"Check a list of databases if they are compatible with this version of Odoo
395
396        :param databases: A list of existing Postgresql databases
397        :return: A list of databases that are incompatible
398    """
399    incompatible_databases = []
400    server_version = '.'.join(str(v) for v in version_info[:2])
401    for database_name in databases:
402        with closing(db_connect(database_name).cursor()) as cr:
403            if odoo.tools.table_exists(cr, 'ir_module_module'):
404                cr.execute("SELECT latest_version FROM ir_module_module WHERE name=%s", ('base',))
405                base_version = cr.fetchone()
406                if not base_version or not base_version[0]:
407                    incompatible_databases.append(database_name)
408                else:
409                    # e.g. 10.saas~15
410                    local_version = '.'.join(base_version[0].split('.')[:2])
411                    if local_version != server_version:
412                        incompatible_databases.append(database_name)
413            else:
414                incompatible_databases.append(database_name)
415    for database_name in incompatible_databases:
416        # release connection
417        odoo.sql_db.close_db(database_name)
418    return incompatible_databases
419
420
421def exp_list(document=False):
422    if not odoo.tools.config['list_db']:
423        raise odoo.exceptions.AccessDenied()
424    return list_dbs()
425
426def exp_list_lang():
427    return odoo.tools.scan_languages()
428
429def exp_list_countries():
430    list_countries = []
431    root = ET.parse(os.path.join(odoo.tools.config['root_path'], 'addons/base/data/res_country_data.xml')).getroot()
432    for country in root.find('data').findall('record[@model="res.country"]'):
433        name = country.find('field[@name="name"]').text
434        code = country.find('field[@name="code"]').text
435        list_countries.append([code, name])
436    return sorted(list_countries, key=lambda c: c[1])
437
438def exp_server_version():
439    """ Return the version of the server
440        Used by the client to verify the compatibility with its own version
441    """
442    return odoo.release.version
443
444#----------------------------------------------------------
445# db service dispatch
446#----------------------------------------------------------
447
448def dispatch(method, params):
449    g = globals()
450    exp_method_name = 'exp_' + method
451    if method in ['db_exist', 'list', 'list_lang', 'server_version']:
452        return g[exp_method_name](*params)
453    elif exp_method_name in g:
454        passwd = params[0]
455        params = params[1:]
456        check_super(passwd)
457        return g[exp_method_name](*params)
458    else:
459        raise KeyError("Method not found: %s" % method)
460