398 lines
17 KiB
Python
398 lines
17 KiB
Python
from collections.abc import Iterable
|
|
import logging
|
|
|
|
from odoo import api, Command, fields, models, modules
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import SQL
|
|
from odoo.tools.parse_version import parse_version
|
|
from odoo.tools.sql import column_exists, create_column
|
|
|
|
from ..api import ApiError, OdooDatabaseApi
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_database_api_keys(databases, fallback=True):
|
|
"""Retrieve the api keys to use for those records. Saas database without key will be provided the odoocom apikey"""
|
|
if not databases.ids:
|
|
return {}
|
|
|
|
env = databases.env
|
|
database_api_keys = dict(env.execute_query(SQL(
|
|
"""
|
|
SELECT id, database_api_key
|
|
FROM project_project
|
|
WHERE id in %s AND COALESCE(database_api_key, '') != ''
|
|
""",
|
|
tuple(databases.ids),
|
|
)))
|
|
if not fallback:
|
|
return database_api_keys
|
|
|
|
odoocom_apikey = env['ir.config_parameter'].sudo().get_param('databases.odoocom_apikey', '')
|
|
for db in databases:
|
|
if not database_api_keys.get(db.id):
|
|
database_api_keys[db.id] = odoocom_apikey
|
|
|
|
return database_api_keys
|
|
|
|
|
|
class ProjectProject(models.Model):
|
|
_inherit = 'project.project'
|
|
|
|
database_hosting = fields.Selection(
|
|
selection=[
|
|
('saas', 'Odoo Online'),
|
|
('paas', 'Odoo.sh'),
|
|
('premise', 'On Premise'),
|
|
('other', 'Outside of Odoo'),
|
|
],
|
|
string='Hosting',
|
|
copy=False,
|
|
)
|
|
database_name = fields.Char(string="Database Name", copy=False)
|
|
database_url = fields.Char(string="Database URL", copy=False)
|
|
database_version = fields.Char(string="Database Version", copy=False)
|
|
database_api_login = fields.Char(string="Database API Login", groups="databases.group_databases_user", copy=False)
|
|
database_api_key = fields.Char(
|
|
string="Database API Key",
|
|
groups="databases.group_databases_manager",
|
|
copy=False,
|
|
compute='_compute_database_api_key',
|
|
inverse='_inverse_database_api_key',
|
|
search='_search_database_api_key',
|
|
store=False, # prevent the ORM from querying the field itself
|
|
)
|
|
database_api_key_to_use = fields.Char(groups="databases.group_databases_manager", compute='_compute_database_api_key_to_use') # unused: TODO: to remove in master
|
|
database_fetch_documents = fields.Boolean("Fetch Documents", default=True)
|
|
database_fetch_draft_entries = fields.Boolean("Fetch Draft Journal Entries", default=True)
|
|
database_fetch_tax_returns = fields.Boolean("Fetch Tax Returns", default=True)
|
|
database_kpi_properties = fields.Properties("Metrics", definition='database_kpi_base_definition_id.properties_definition', copy=False)
|
|
database_kpi_base_definition_id = fields.Many2one(
|
|
'properties.base.definition',
|
|
compute='_compute_database_kpi_base_definition_id',
|
|
search='_search_database_kpi_base_definition_id',
|
|
)
|
|
database_user_ids = fields.One2many('databases.user', 'project_id', string='Database Users', copy=False)
|
|
database_last_synchro = fields.Datetime("Last Synchronization", readonly=True, copy=False)
|
|
database_nb_synchro_errors = fields.Integer("Synchronization Errors Count", readonly=True, copy=False)
|
|
database_nb_documents = fields.Integer('Amount of documents in Inbox', copy=False)
|
|
database_nb_users = fields.Integer('Amount of Users', compute='_compute_database_nb_users', store=True)
|
|
database_can_access = fields.Boolean(compute='_compute_database_can_access')
|
|
|
|
_database_url_unique = models.UniqueIndex('(database_url)', "Hey, this URL is already hooked up to a database!")
|
|
_database_url_required = models.Constraint(
|
|
"CHECK(database_hosting IS NULL OR database_url IS NOT NULL)",
|
|
"Uh-oh! It seems we are missing a url for a database!",
|
|
)
|
|
|
|
def init(self):
|
|
# The field is a compute that shouldn't be discoverable by the orm itself
|
|
# Therefore we need to create the column manually
|
|
if not column_exists(self.env.cr, 'project_project', 'database_api_key'):
|
|
create_column(self.env.cr, 'project_project', 'database_api_key', 'varchar')
|
|
return super().init()
|
|
|
|
def _compute_database_api_key(self):
|
|
db_ids_with_keys = get_database_api_keys(self, fallback=False).keys()
|
|
db_with_keys = self.filtered(lambda db: db.id in db_ids_with_keys)
|
|
# prefill the value for the form view.
|
|
# Add some *** to not confuse users.
|
|
db_with_keys.database_api_key = '****************************************'
|
|
(self - db_with_keys).database_api_key = ''
|
|
|
|
def _inverse_database_api_key(self):
|
|
db_to_key = tuple(
|
|
(database.id, database.database_api_key or '') # allow the user to clean the key to leverage odoocom key
|
|
for database in self
|
|
# prevent user to mess with their key by removing a few stars from the field by mistake
|
|
if '******' not in (database.database_api_key or '')
|
|
)
|
|
if not db_to_key:
|
|
return
|
|
self.env.cr.execute(
|
|
SQL(
|
|
"""
|
|
UPDATE project_project
|
|
SET database_api_key = db.database_api_key
|
|
FROM (VALUES %s) AS db(id, database_api_key)
|
|
WHERE project_project.id = db.id
|
|
""",
|
|
SQL(",").join(db_to_key)
|
|
)
|
|
)
|
|
self.invalidate_recordset(['database_api_key'])
|
|
|
|
def _search_database_api_key(self, operator, value):
|
|
# To prevent any attempt to discover the key value, only looking against falsy value is supported
|
|
if value or operator not in ('=', '!='):
|
|
return NotImplemented
|
|
query = SQL("SELECT id FROM project_project WHERE COALESCE(database_api_key, '') = ''")
|
|
return fields.Domain('id', 'in' if operator == '=' else 'not in', query)
|
|
|
|
def _compute_database_api_key_to_use(self):
|
|
self.database_api_key_to_use = ''
|
|
|
|
def _compute_database_kpi_base_definition_id(self):
|
|
self.database_kpi_base_definition_id = self.env['properties.base.definition'] \
|
|
._get_definition_for_property_field(self._name, 'database_kpi_properties')
|
|
|
|
@api.depends('database_user_ids')
|
|
def _compute_database_nb_users(self):
|
|
users_count_per_database = dict(
|
|
self.env['databases.user']._read_group(
|
|
[('project_id', 'in', self.ids)],
|
|
['project_id'],
|
|
['__count'],
|
|
)
|
|
)
|
|
for db in self:
|
|
db.database_nb_users = users_count_per_database.get(db, 0)
|
|
|
|
@api.depends('database_user_ids.local_user_id')
|
|
@api.depends_context('user')
|
|
def _compute_database_can_access(self):
|
|
databases_with_current_user = self.env['databases.user']._read_group(
|
|
[('login', '=', self.env.user.login)],
|
|
aggregates=['project_id:recordset'],
|
|
)[0][0]
|
|
for db in self:
|
|
db.database_can_access = db in databases_with_current_user
|
|
|
|
def _search_database_kpi_base_definition_id(self, operator, value):
|
|
if operator != "in":
|
|
return NotImplemented
|
|
|
|
database_kpi_base_definition_id = self.env['properties.base.definition'] \
|
|
._get_definition_id_for_property_field(self._name, 'database_kpi_properties')
|
|
|
|
if not isinstance(value, Iterable):
|
|
value = (value,)
|
|
return fields.Domain.TRUE if database_kpi_base_definition_id in value else fields.Domain.FALSE
|
|
|
|
@api.onchange('database_url')
|
|
def _onchange_database_url(self):
|
|
self.database_url = self._normalize_user_url(self.database_url)
|
|
|
|
def _normalize_user_url(self, url):
|
|
if not url:
|
|
return False
|
|
|
|
url = url.strip()
|
|
if not url or url.startswith(('http://', 'https://')):
|
|
return url
|
|
|
|
if url.startswith('//'):
|
|
return 'https:' + url
|
|
|
|
return 'https://' + url
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
parent = self.env['properties.base.definition'] \
|
|
._get_definition_id_for_property_field(self._name, 'database_kpi_properties')
|
|
|
|
for vals in vals_list:
|
|
# set the properties definition foreign key
|
|
vals['database_kpi_base_definition_id'] = parent
|
|
|
|
if not vals.get('database_hosting'):
|
|
# this isn't a database
|
|
continue
|
|
|
|
if not vals.get('privacy_visibility'):
|
|
# Avoid random user to be able to access the database info and kpis
|
|
vals['privacy_visibility'] = 'followers'
|
|
|
|
return super().create(vals_list)
|
|
|
|
def _field_to_sql(self, alias, fname, query=None):
|
|
if fname == 'database_kpi_base_definition_id':
|
|
# Allow the export to work
|
|
parent = self.env['properties.base.definition'] \
|
|
._get_definition_id_for_property_field(self._name, 'database_kpi_properties')
|
|
return SQL("%s", parent)
|
|
|
|
return super()._field_to_sql(alias, fname, query)
|
|
|
|
def action_open_self(self):
|
|
self.ensure_one()
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'view_mode': 'form',
|
|
'res_id': self.id,
|
|
'res_model': self._name,
|
|
}
|
|
|
|
@api.model
|
|
def _cron_synchronize_all_databases_with_odoocom(self):
|
|
""" Fetch the list from the Odoo.com SaaS if configured so, and trigger a synchronization of all the databases """
|
|
wizard = self.env['databases.synchronization.wizard'].create({})
|
|
wizard._do_update_from_odoocom()
|
|
|
|
self.env.ref('databases.ir_cron_synchronize_databases')._trigger()
|
|
|
|
if not modules.module.current_test:
|
|
self.env['ir.cron']._commit_progress(remaining=0)
|
|
|
|
@api.model
|
|
def _cron_synchronize_all_databases(self):
|
|
database_to_synchronize = self.env['project.project'].search([
|
|
('database_hosting', 'not in', (False, 'other')),
|
|
('database_nb_synchro_errors', '<', 5),
|
|
'|',
|
|
('database_last_synchro', '=', False),
|
|
('database_last_synchro', '<=', 'now -1d'),
|
|
], order='database_last_synchro NULLS FIRST')
|
|
if not modules.module.current_test:
|
|
self.env['ir.cron']._commit_progress(remaining=len(database_to_synchronize))
|
|
|
|
notifiable_wizards = self.env['databases.synchronization.wizard'].search([
|
|
('notify_user', '=', True),
|
|
])
|
|
|
|
for database in database_to_synchronize:
|
|
db_wizard = self.env['databases.synchronization.wizard'].create({
|
|
'database_ids': [Command.link(database.id)],
|
|
})
|
|
try:
|
|
db_wizard._do_synchronize()
|
|
except Exception:
|
|
_logger.exception('Error during synchronization of database %r', database.database_url)
|
|
if not modules.module.current_test:
|
|
self.env.cr.rollback()
|
|
database.database_nb_synchro_errors += 1
|
|
else:
|
|
if db_wizard.error_message:
|
|
database.database_nb_synchro_errors += 1
|
|
|
|
for wizard in notifiable_wizards.filtered('notify_user'):
|
|
if all(wizard.database_ids.mapped('database_last_synchro')):
|
|
wizard.notify_user = False
|
|
wizard.create_uid._bus_send('simple_notification', {
|
|
'title': self.env._("Synchronization of %d databases", len(wizard.database_ids)),
|
|
'message': wizard.summary_message,
|
|
'type': 'success',
|
|
'sticky': True,
|
|
})
|
|
if not modules.module.current_test:
|
|
self.env['ir.cron']._commit_progress(processed=1)
|
|
|
|
@api.model
|
|
def _get_databases_to_sync(self, dbs=None):
|
|
# We first synchronize the databases the current user can log into, since these are the ones
|
|
# they are most likely to use right away and some synchronizations may end up queued.
|
|
common_domain = [('database_hosting', 'in', ('saas', 'paas', 'premise'))]
|
|
if dbs is not None:
|
|
common_domain += [('id', 'in', dbs.ids)]
|
|
# We do 2 searchs to avoid the hasle of a SQL query with a weird order clause that would requires a left join
|
|
user_dbs = self.env['project.project'].search(
|
|
fields.Domain.AND([common_domain, [('database_user_ids.login', '=', self.env.user.login)]]),
|
|
order='database_last_synchro NULLS FIRST, id',
|
|
)
|
|
other_dbs = self.env['project.project'].search(
|
|
fields.Domain.AND([common_domain, [('id', 'not in', user_dbs.ids)]]),
|
|
order='database_last_synchro NULLS FIRST, id',
|
|
)
|
|
return user_dbs + other_dbs
|
|
|
|
@api.model
|
|
def action_synchronize_all_databases(self):
|
|
"""
|
|
Synchronize all the databases, and fetch the list from the Odoo.com SaaS if configured so.
|
|
If active_ids is not empty, consider that the user only wants to synchronize these specific databases,
|
|
and fallback to action_database_synchronize.
|
|
"""
|
|
if active_ids := self.env.context.get('active_ids'):
|
|
return self.env['project.project'].browse(active_ids).action_database_synchronize()
|
|
|
|
all_databases = self.env['project.project']._get_databases_to_sync()
|
|
wizard = self.env['databases.synchronization.wizard'].create({'database_ids': all_databases.ids})
|
|
if wizard._can_update_from_odoo_com():
|
|
wizard._do_update_from_odoocom()
|
|
return wizard._do_synchronize()
|
|
|
|
def action_database_synchronize(self):
|
|
""" Synchronize the kpis from the selected databases """
|
|
dbs = self.env['project.project']._get_databases_to_sync(self)
|
|
wizard = self.env['databases.synchronization.wizard'].create({'database_ids': dbs.ids})
|
|
wizard_action = wizard._do_synchronize()
|
|
if len(self) == 1 and wizard.error_message:
|
|
raise UserError(wizard.error_message.strip())
|
|
return wizard_action
|
|
|
|
def _get_connect_url(self):
|
|
self.ensure_one()
|
|
url = None
|
|
if self.database_hosting == 'other':
|
|
# Stored outside of Odoo, just redirect to the url
|
|
url = self.database_url
|
|
|
|
if not url and self.database_hosting == 'saas':
|
|
odoocom_url = self.env['ir.config_parameter'].sudo().get_param('databases.odoocom_apihost', 'https://www.odoo.com')
|
|
database_api_key = get_database_api_keys(self).get(self.id, '')
|
|
db_api = OdooDatabaseApi(self.database_url, self.database_name, self.database_api_login, database_api_key)
|
|
try:
|
|
db_uuid = db_api.get_database_uuid()
|
|
except ApiError:
|
|
# Either server unreachable or most probably no permissions to read ir.config_parameter
|
|
pass
|
|
else:
|
|
url = f'{odoocom_url}/my/databases/connect/{db_uuid}'
|
|
|
|
if not url and self._database_version_gte('saas~17.2'):
|
|
# Supported since odoo/odoo@c63d14a0485a553b74a8457aee158384e9ae6d3f
|
|
url = f'{self.database_url}/odoo'
|
|
|
|
if not url:
|
|
url = f'{self.database_url}/web'
|
|
|
|
return url
|
|
|
|
def action_database_connect(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'url': self._get_connect_url(),
|
|
'target': 'new',
|
|
}
|
|
|
|
def action_database_invite_users(self):
|
|
return {
|
|
'name': 'Invite Users',
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'databases.manage_users.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_mode': 'invite',
|
|
'default_database_ids': self.ids,
|
|
},
|
|
}
|
|
|
|
def action_database_remove_users(self, default_user_ids=None):
|
|
return {
|
|
'name': 'Remove Users',
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'databases.manage_users.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_mode': 'remove',
|
|
'default_database_ids': self.ids,
|
|
'default_user_ids': default_user_ids if default_user_ids else [],
|
|
},
|
|
}
|
|
|
|
def _database_version_gte(self, version):
|
|
return parse_version(self.database_version) >= parse_version(version)
|
|
|
|
def _database_get_project_template(self):
|
|
try:
|
|
template_id = int(self.env['ir.config_parameter'].sudo().get_param('databases.odoocom_project_template'))
|
|
except ValueError:
|
|
return self.browse()
|
|
|
|
return self.browse(template_id).exists()
|