mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 14:12:05 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import base_module_update
|
||||
from . import base_language_install
|
||||
from . import base_import_language
|
||||
from . import base_module_upgrade
|
||||
from . import base_module_uninstall
|
||||
from . import base_export_language
|
||||
from . import base_partner_merge
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,58 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
|
||||
NEW_LANG_KEY = '__new__'
|
||||
|
||||
class BaseLanguageExport(models.TransientModel):
|
||||
_name = "base.language.export"
|
||||
_description = 'Language Export'
|
||||
|
||||
@api.model
|
||||
def _get_languages(self):
|
||||
langs = self.env['res.lang'].get_installed()
|
||||
return [(NEW_LANG_KEY, _('New Language (Empty translation template)'))] + \
|
||||
langs
|
||||
|
||||
name = fields.Char('File Name', readonly=True)
|
||||
lang = fields.Selection(_get_languages, string='Language', required=True, default=NEW_LANG_KEY)
|
||||
format = fields.Selection([('csv','CSV File'), ('po','PO File'), ('tgz', 'TGZ Archive')],
|
||||
string='File Format', required=True, default='po')
|
||||
modules = fields.Many2many('ir.module.module', 'rel_modules_langexport', 'wiz_id', 'module_id',
|
||||
string='Apps To Export', domain=[('state','=','installed')])
|
||||
data = fields.Binary('File', readonly=True, attachment=False)
|
||||
state = fields.Selection([('choose', 'choose'), ('get', 'get')], # choose language or get the file
|
||||
default='choose')
|
||||
|
||||
def act_getfile(self):
|
||||
this = self[0]
|
||||
lang = this.lang if this.lang != NEW_LANG_KEY else False
|
||||
mods = sorted(this.mapped('modules.name')) or ['all']
|
||||
|
||||
with contextlib.closing(io.BytesIO()) as buf:
|
||||
tools.trans_export(lang, mods, buf, this.format, self._cr)
|
||||
out = base64.encodebytes(buf.getvalue())
|
||||
|
||||
filename = 'new'
|
||||
if lang:
|
||||
filename = tools.get_iso_codes(lang)
|
||||
elif len(mods) == 1:
|
||||
filename = mods[0]
|
||||
extension = this.format
|
||||
if not lang and extension == 'po':
|
||||
extension = 'pot'
|
||||
name = "%s.%s" % (filename, extension)
|
||||
this.write({'state': 'get', 'data': out, 'name': name})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'base.language.export',
|
||||
'view_mode': 'form',
|
||||
'res_id': this.id,
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="wizard_lang_export" model="ir.ui.view">
|
||||
<field name="name">Export Translations</field>
|
||||
<field name="model">base.language.export</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Export Translations">
|
||||
<field invisible="1" name="state"/>
|
||||
<field name="name" invisible="1"/>
|
||||
<group states="choose" string="Export Settings">
|
||||
<field name="lang"/>
|
||||
<field name="format"/>
|
||||
<field name="modules" widget="many2many_tags" options="{'no_create': True}"/>
|
||||
</group>
|
||||
<div states="get">
|
||||
<h2>Export Complete</h2>
|
||||
<p>Here is the exported translation file: <field name="data" readonly="1" filename="name"/></p>
|
||||
<p>This file was generated using the universal <strong>Unicode/UTF-8</strong> file encoding, please be sure to view and edit
|
||||
using the same encoding.</p>
|
||||
<p>The next step depends on the file format:
|
||||
<ul>
|
||||
<li>CSV format: you may edit it directly with your favorite spreadsheet software,
|
||||
the rightmost column (value) contains the translations</li>
|
||||
<li>PO(T) format: you should edit it with a PO editor such as
|
||||
<a href="http://www.poedit.net/" target="_blank">POEdit</a>, or your preferred text editor</li>
|
||||
<li>TGZ format: bundles multiple PO(T) files as a single archive</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>For more details about translating Odoo in your language, please refer to the
|
||||
<a href="https://github.com/odoo/odoo/wiki/Translations" target="_blank">documentation</a>.</p>
|
||||
</div>
|
||||
<footer states="choose">
|
||||
<button name="act_getfile" string="Export" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button special="cancel" data-hotkey="z" string="Cancel" type="object" class="btn-secondary"/>
|
||||
</footer>
|
||||
<footer states="get">
|
||||
<button special="cancel" data-hotkey="z" string="Close" type="object" class="btn-primary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_wizard_lang_export" model="ir.actions.act_window">
|
||||
<field name="name">Export Translation</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">base.language.export</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<menuitem action="action_wizard_lang_export" id="menu_wizard_lang_export" parent="menu_translation_export"/>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import operator
|
||||
from tempfile import TemporaryFile
|
||||
from os.path import splitext
|
||||
|
||||
from odoo import api, fields, models, tools, sql_db, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import TranslationImporter
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseLanguageImport(models.TransientModel):
|
||||
_name = "base.language.import"
|
||||
_description = "Language Import"
|
||||
|
||||
name = fields.Char('Language Name', required=True)
|
||||
code = fields.Char('ISO Code', size=6, required=True,
|
||||
help="ISO Language and Country code, e.g. en_US")
|
||||
data = fields.Binary('File', required=True, attachment=False)
|
||||
filename = fields.Char('File Name', required=True)
|
||||
overwrite = fields.Boolean('Overwrite Existing Terms',
|
||||
default=True,
|
||||
help="If you enable this option, existing translations (including custom ones) "
|
||||
"will be overwritten and replaced by those in this file")
|
||||
|
||||
def import_lang(self):
|
||||
Lang = self.env["res.lang"]
|
||||
for overwrite, base_lang_imports in tools.groupby(self, operator.itemgetter('overwrite')):
|
||||
translation_importer = TranslationImporter(self.env.cr)
|
||||
for base_lang_import in base_lang_imports:
|
||||
if not Lang._activate_lang(base_lang_import.code):
|
||||
Lang._create_lang(base_lang_import.code, lang_name=base_lang_import.name)
|
||||
try:
|
||||
with TemporaryFile('wb+') as buf:
|
||||
buf.write(base64.decodebytes(base_lang_import.data))
|
||||
fileformat = splitext(base_lang_import.filename)[-1][1:].lower()
|
||||
translation_importer.load(buf, fileformat, base_lang_import.code)
|
||||
except Exception as e:
|
||||
_logger.warning('Could not import the file due to a format mismatch or it being malformed.')
|
||||
raise UserError(
|
||||
_('File %r not imported due to format mismatch or a malformed file.'
|
||||
' (Valid formats are .csv, .po, .pot)\n\nTechnical Details:\n%s') % \
|
||||
(base_lang_import.filename, tools.ustr(e))
|
||||
)
|
||||
translation_importer.save(overwrite=overwrite)
|
||||
return True
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="view_base_import_language" model="ir.ui.view">
|
||||
<field name="name">Import Translation</field>
|
||||
<field name="model">base.language.import</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import Translation">
|
||||
<group>
|
||||
<field name="name" placeholder="e.g. English"/>
|
||||
<field name="code" string="Code" placeholder="e.g. en_US"/>
|
||||
<field name="data" filename="filename"/>
|
||||
<field name="filename" invisible="1"/>
|
||||
<field name="overwrite" groups="base.group_no_one"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="import_lang" string="Import" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_base_import_language" model="ir.actions.act_window">
|
||||
<field name="name">Import Translation</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">base.language.import</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
action="action_view_base_import_language"
|
||||
id="menu_view_base_import_language"
|
||||
parent="menu_translation_export"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class BaseLanguageInstall(models.TransientModel):
|
||||
_name = "base.language.install"
|
||||
_description = "Install Language"
|
||||
|
||||
@api.model
|
||||
def _default_lang_ids(self):
|
||||
""" Display the selected language when using the 'Update Terms' action
|
||||
from the language list view
|
||||
"""
|
||||
if self._context.get('active_model') == 'res.lang':
|
||||
return self._context.get('active_ids') or [self._context.get('active_id')]
|
||||
return False
|
||||
|
||||
# add a context on the field itself, to be sure even inactive langs are displayed
|
||||
lang_ids = fields.Many2many('res.lang', 'res_lang_install_rel',
|
||||
'language_wizard_id', 'lang_id', 'Languages',
|
||||
default=_default_lang_ids, context={'active_test': False}, required=True)
|
||||
overwrite = fields.Boolean('Overwrite Existing Terms',
|
||||
default=True,
|
||||
help="If you check this box, your customized translations will be overwritten and replaced by the official ones.")
|
||||
first_lang_id = fields.Many2one('res.lang',
|
||||
compute='_compute_first_lang_id',
|
||||
help="Used when the user only selects one language and is given the option to switch to it")
|
||||
|
||||
def _compute_first_lang_id(self):
|
||||
self.first_lang_id = False
|
||||
for lang_installer in self.filtered('lang_ids'):
|
||||
lang_installer.first_lang_id = lang_installer.lang_ids[0]
|
||||
|
||||
def lang_install(self):
|
||||
self.ensure_one()
|
||||
mods = self.env['ir.module.module'].search([('state', '=', 'installed')])
|
||||
self.lang_ids.active = True
|
||||
mods._update_translations(self.lang_ids.mapped('code'), self.overwrite)
|
||||
|
||||
if len(self.lang_ids) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'base.language.install',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'views': [[self.env.ref('base.language_install_view_form_lang_switch').id, 'form']],
|
||||
}
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'context': dict(self._context, active_ids=self.ids),
|
||||
'target': 'new',
|
||||
'params': {
|
||||
'message': _("The languages that you selected have been successfully installed.\
|
||||
Users can choose their favorite language in their preferences."),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
}
|
||||
}
|
||||
|
||||
def reload(self):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'reload',
|
||||
}
|
||||
|
||||
def switch_lang(self):
|
||||
self.env.user.lang = self.first_lang_id.code
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'reload_context',
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="base.language_install_view_form_lang_switch" model="ir.ui.view">
|
||||
<field name="name">Switch to language</field>
|
||||
<field name="model">base.language.install</field>
|
||||
<field name="priority">100</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Switch to language">
|
||||
<div>
|
||||
<strong>
|
||||
<field name="first_lang_id" readonly="True" options="{'no_open': True}" />
|
||||
</strong>
|
||||
has been successfully installed.
|
||||
Users can choose their favorite language in their preferences.
|
||||
</div>
|
||||
<footer>
|
||||
<button name="reload" string="Close" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button name="switch_lang" type="object" class="btn-primary ms-1" data-hotkey="w">
|
||||
Switch to <field name="first_lang_id" readonly="True" options="{'no_open': True}"/> & Close
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_base_language_install" model="ir.ui.view">
|
||||
<field name="name">Load a Translation</field>
|
||||
<field name="model">base.language.install</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Load a Translation">
|
||||
<group>
|
||||
<field name="lang_ids" widget="many2many_tags"
|
||||
context="{'active_test': False}" options="{'no_quick_create': True, 'no_create_edit': True}"/>
|
||||
<field name="overwrite" groups="base.group_no_one"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="lang_install" string="Add" type="object" class="btn-primary"/>
|
||||
<button special="cancel" data-hotkey="z" string="Cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_base_language_install" model="ir.actions.act_window">
|
||||
<field name="name">Add Languages</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">base.language.install</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class BaseModuleUninstall(models.TransientModel):
|
||||
_name = "base.module.uninstall"
|
||||
_description = "Module Uninstall"
|
||||
|
||||
show_all = fields.Boolean()
|
||||
module_id = fields.Many2one(
|
||||
'ir.module.module', string="Module", required=True,
|
||||
domain=[('state', 'in', ['installed', 'to upgrade', 'to install'])],
|
||||
ondelete='cascade', readonly=True,
|
||||
)
|
||||
module_ids = fields.Many2many('ir.module.module', string="Impacted modules",
|
||||
compute='_compute_module_ids')
|
||||
model_ids = fields.Many2many('ir.model', string="Impacted data models",
|
||||
compute='_compute_model_ids')
|
||||
|
||||
def _get_modules(self):
|
||||
""" Return all the modules impacted by self. """
|
||||
return self.module_id.downstream_dependencies(self.module_id)
|
||||
|
||||
@api.depends('module_id', 'show_all')
|
||||
def _compute_module_ids(self):
|
||||
for wizard in self:
|
||||
modules = wizard._get_modules().sorted(lambda m: (not m.application, m.sequence))
|
||||
wizard.module_ids = modules if wizard.show_all else modules.filtered('application')
|
||||
|
||||
def _get_models(self):
|
||||
""" Return the models (ir.model) to consider for the impact. """
|
||||
return self.env['ir.model'].search([('transient', '=', False)])
|
||||
|
||||
@api.depends('module_ids')
|
||||
def _compute_model_ids(self):
|
||||
ir_models = self._get_models()
|
||||
ir_models_xids = ir_models._get_external_ids()
|
||||
for wizard in self:
|
||||
if wizard.module_id:
|
||||
module_names = set(wizard._get_modules().mapped('name'))
|
||||
|
||||
def lost(model):
|
||||
xids = ir_models_xids.get(model.id, ())
|
||||
return xids and all(xid.split('.')[0] in module_names for xid in xids)
|
||||
|
||||
# find the models that have all their XIDs in the given modules
|
||||
self.model_ids = ir_models.filtered(lost).sorted('name')
|
||||
|
||||
@api.onchange('module_id')
|
||||
def _onchange_module_id(self):
|
||||
# if we select a technical module, show technical modules by default
|
||||
if not self.module_id.application:
|
||||
self.show_all = True
|
||||
|
||||
def action_uninstall(self):
|
||||
modules = self.module_id
|
||||
return modules.button_immediate_uninstall()
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="view_base_module_uninstall" model="ir.ui.view">
|
||||
<field name="name">Uninstall module</field>
|
||||
<field name="model">base.module.uninstall</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Uninstall module">
|
||||
<div class="alert alert-warning oe_button_box" role="alert">
|
||||
<p class="mt-3">
|
||||
Uninstalling modules can be risky, we recommend you to try it on a duplicate or test database first.
|
||||
</p>
|
||||
</div>
|
||||
<field name="module_id" invisible="1"/>
|
||||
<div class="d-flex bd-highlight">
|
||||
<div class="me-auto p-2 bd-highlight"><h3>Apps to Uninstall</h3></div>
|
||||
<div class="p-2 bd-highlight"><field name="show_all"/> Show All</div>
|
||||
</div>
|
||||
<field name="module_ids" mode="kanban" class="o_modules_field">
|
||||
<kanban create="false" class="o_modules_kanban">
|
||||
<field name="icon"/>
|
||||
<field name="state"/>
|
||||
<field name="summary"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_module_vignette">
|
||||
<t t-set="installed" t-value="record.state.raw_value == 'installed'" />
|
||||
<img t-attf-src="#{record.icon.value}" class="oe_module_icon" alt="Icon" />
|
||||
<div class="oe_module_desc" t-att-title="record.shortdesc.value">
|
||||
<h4 class="o_kanban_record_title">
|
||||
<field name="shortdesc" />&nbsp;
|
||||
</h4>
|
||||
<p class="oe_module_name">
|
||||
<field groups="!base.group_no_one" name="summary" />
|
||||
<code groups="base.group_no_one">
|
||||
<field name="name" /></code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
<h3>Documents to Delete</h3>
|
||||
<field name="model_ids" string="Models" nolabel="1">
|
||||
<tree string="Models">
|
||||
<field name="name" string="Document"/>
|
||||
<field name="count"/>
|
||||
</tree>
|
||||
</field>
|
||||
<footer>
|
||||
<button string="Uninstall" class="btn-secondary" type="object" name="action_uninstall" data-hotkey="q"/>
|
||||
<button string="Discard" class="btn-primary" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class BaseModuleUpdate(models.TransientModel):
|
||||
_name = "base.module.update"
|
||||
_description = "Update Module"
|
||||
|
||||
updated = fields.Integer('Number of modules updated', readonly=True)
|
||||
added = fields.Integer('Number of modules added', readonly=True)
|
||||
state = fields.Selection([('init', 'init'), ('done', 'done')], 'Status', readonly=True, default='init')
|
||||
|
||||
def update_module(self):
|
||||
for this in self:
|
||||
updated, added = self.env['ir.module.module'].update_list()
|
||||
this.write({'updated': updated, 'added': added, 'state': 'done'})
|
||||
return False
|
||||
|
||||
def action_module_open(self):
|
||||
res = {
|
||||
'domain': str([]),
|
||||
'name': 'Modules',
|
||||
'view_mode': 'tree,form',
|
||||
'res_model': 'ir.module.module',
|
||||
'view_id': False,
|
||||
'type': 'ir.actions.act_window',
|
||||
}
|
||||
return res
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="view_base_module_update" model="ir.ui.view">
|
||||
<field name="name">Module Update</field>
|
||||
<field name="model">base.module.update</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Update Module List">
|
||||
<field name="state" invisible="1"/>
|
||||
<separator string="Module Update Result" states="done"/>
|
||||
<group states="init">
|
||||
<span class="o_form_label" colspan="2">Click on Update below to start the process...</span>
|
||||
</group>
|
||||
<group states="done" >
|
||||
<field name="updated"/>
|
||||
<field name="added" />
|
||||
</group>
|
||||
<footer>
|
||||
<div states="init">
|
||||
<button name="update_module" string="Update" type="object" class="btn-primary"/>
|
||||
<button special="cancel" data-hotkey="z" string="Cancel" class="btn-secondary"/>
|
||||
</div>
|
||||
<div states="done">
|
||||
<button name="action_module_open" string="Open Apps" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button special="cancel" data-hotkey="z" string="Close" class="btn-secondary"/>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_base_module_update" model="ir.actions.act_window">
|
||||
<field name="name">Module Update</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">base.module.update</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Update Apps List"
|
||||
action="action_view_base_module_update"
|
||||
id="menu_view_base_module_update"
|
||||
groups="base.group_no_one"
|
||||
parent="menu_management"
|
||||
sequence="40"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class BaseModuleUpgrade(models.TransientModel):
|
||||
_name = "base.module.upgrade"
|
||||
_description = "Upgrade Module"
|
||||
|
||||
@api.model
|
||||
@api.returns('ir.module.module')
|
||||
def get_module_list(self):
|
||||
states = ['to upgrade', 'to remove', 'to install']
|
||||
return self.env['ir.module.module'].search([('state', 'in', states)])
|
||||
|
||||
@api.model
|
||||
def _default_module_info(self):
|
||||
return "\n".join("%s: %s" % (mod.name, mod.state) for mod in self.get_module_list())
|
||||
|
||||
module_info = fields.Text('Apps to Update', readonly=True, default=_default_module_info)
|
||||
|
||||
@api.model
|
||||
def get_view(self, view_id=None, view_type='form', **options):
|
||||
res = super().get_view(view_id, view_type, **options)
|
||||
if view_type != 'form':
|
||||
return res
|
||||
|
||||
if not(self._context.get('active_model') and self._context.get('active_id')):
|
||||
return res
|
||||
|
||||
if not self.get_module_list():
|
||||
res['arch'] = '''<form string="Upgrade Completed">
|
||||
<separator string="Upgrade Completed" colspan="4"/>
|
||||
<footer>
|
||||
<button name="config" string="Start Configuration" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button special="cancel" data-hotkey="z" string="Close" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>'''
|
||||
|
||||
return res
|
||||
|
||||
def upgrade_module_cancel(self):
|
||||
Module = self.env['ir.module.module']
|
||||
to_install = Module.search([('state', 'in', ['to upgrade', 'to remove'])])
|
||||
to_install.write({'state': 'installed'})
|
||||
to_uninstall = Module.search([('state', '=', 'to install')])
|
||||
to_uninstall.write({'state': 'uninstalled'})
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def upgrade_module(self):
|
||||
Module = self.env['ir.module.module']
|
||||
|
||||
# install/upgrade: double-check preconditions
|
||||
mods = Module.search([('state', 'in', ['to upgrade', 'to install'])])
|
||||
if mods:
|
||||
query = """ SELECT d.name
|
||||
FROM ir_module_module m
|
||||
JOIN ir_module_module_dependency d ON (m.id = d.module_id)
|
||||
LEFT JOIN ir_module_module m2 ON (d.name = m2.name)
|
||||
WHERE m.id in %s and (m2.state IS NULL or m2.state IN %s) """
|
||||
self._cr.execute(query, (tuple(mods.ids), ('uninstalled',)))
|
||||
unmet_packages = [row[0] for row in self._cr.fetchall()]
|
||||
if unmet_packages:
|
||||
raise UserError(_('The following modules are not installed or unknown: %s') % ('\n\n' + '\n'.join(unmet_packages)))
|
||||
|
||||
mods.download()
|
||||
|
||||
# terminate transaction before re-creating cursor below
|
||||
self._cr.commit()
|
||||
odoo.modules.registry.Registry.new(self._cr.dbname, update_module=True)
|
||||
self._cr.reset()
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def config(self):
|
||||
# pylint: disable=next-method-called
|
||||
return self.env['res.config'].next()
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="view_base_module_upgrade" model="ir.ui.view">
|
||||
<field name="name">Module Upgrade</field>
|
||||
<field name="model">base.module.upgrade</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="System Update">
|
||||
<p>This module will trigger the uninstallation of below modules.</p>
|
||||
<p><strong>This operation will permanently erase all data currently stored by the modules!</strong></p>
|
||||
<p>If you wish to cancel the process, press the cancel button below</p>
|
||||
<separator string="Impacted Apps"/>
|
||||
<field name="module_info"/>
|
||||
<footer>
|
||||
<button name="upgrade_module" string="Confirm" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" name="upgrade_module_cancel" type="object" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_base_module_upgrade" model="ir.actions.act_window">
|
||||
<field name="name">Apply Schedule Upgrade</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">base.module.upgrade</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Apply Scheduled Upgrades"
|
||||
action="action_view_base_module_upgrade"
|
||||
groups="base.group_no_one"
|
||||
id="menu_view_base_module_upgrade"
|
||||
parent="menu_management"
|
||||
sequence="50"/>
|
||||
|
||||
<record id="view_base_module_upgrade_install" model="ir.ui.view">
|
||||
<field name="name">Module Upgrade Install</field>
|
||||
<field name="model">base.module.upgrade</field>
|
||||
<field name="priority" eval="20"/>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Apply Schedule Upgrade">
|
||||
<div><span class="o_form_label">The selected modules have been updated / installed !</span></div>
|
||||
<div><span class="o_form_label">We suggest to reload the menu tab to see the new menus (Ctrl+T then Ctrl+R)."</span></div>
|
||||
<footer>
|
||||
<button name="config" string="Start configuration" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_base_module_upgrade_install" model="ir.actions.act_window">
|
||||
<field name="name">Module Upgrade Install</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">base.module.upgrade</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_base_module_upgrade_install"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,640 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from ast import literal_eval
|
||||
from collections import defaultdict
|
||||
import functools
|
||||
import itertools
|
||||
import logging
|
||||
import psycopg2
|
||||
import datetime
|
||||
|
||||
from odoo import api, fields, models, Command
|
||||
from odoo import SUPERUSER_ID, _
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
_logger = logging.getLogger('odoo.addons.base.partner.merge')
|
||||
|
||||
class MergePartnerLine(models.TransientModel):
|
||||
|
||||
_name = 'base.partner.merge.line'
|
||||
_description = 'Merge Partner Line'
|
||||
_order = 'min_id asc'
|
||||
|
||||
wizard_id = fields.Many2one('base.partner.merge.automatic.wizard', 'Wizard')
|
||||
min_id = fields.Integer('MinID')
|
||||
aggr_ids = fields.Char('Ids', required=True)
|
||||
|
||||
|
||||
class MergePartnerAutomatic(models.TransientModel):
|
||||
"""
|
||||
The idea behind this wizard is to create a list of potential partners to
|
||||
merge. We use two objects, the first one is the wizard for the end-user.
|
||||
And the second will contain the partner list to merge.
|
||||
"""
|
||||
|
||||
_name = 'base.partner.merge.automatic.wizard'
|
||||
_description = 'Merge Partner Wizard'
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(MergePartnerAutomatic, self).default_get(fields)
|
||||
active_ids = self.env.context.get('active_ids')
|
||||
if self.env.context.get('active_model') == 'res.partner' and active_ids:
|
||||
if 'state' in fields:
|
||||
res['state'] = 'selection'
|
||||
if 'partner_ids' in fields:
|
||||
res['partner_ids'] = [Command.set(active_ids)]
|
||||
if 'dst_partner_id' in fields:
|
||||
res['dst_partner_id'] = self._get_ordered_partner(active_ids)[-1].id
|
||||
return res
|
||||
|
||||
# Group by
|
||||
group_by_email = fields.Boolean('Email')
|
||||
group_by_name = fields.Boolean('Name')
|
||||
group_by_is_company = fields.Boolean('Is Company')
|
||||
group_by_vat = fields.Boolean('VAT')
|
||||
group_by_parent_id = fields.Boolean('Parent Company')
|
||||
|
||||
state = fields.Selection([
|
||||
('option', 'Option'),
|
||||
('selection', 'Selection'),
|
||||
('finished', 'Finished')
|
||||
], readonly=True, required=True, string='State', default='option')
|
||||
|
||||
number_group = fields.Integer('Group of Contacts', readonly=True)
|
||||
current_line_id = fields.Many2one('base.partner.merge.line', string='Current Line')
|
||||
line_ids = fields.One2many('base.partner.merge.line', 'wizard_id', string='Lines')
|
||||
partner_ids = fields.Many2many('res.partner', string='Contacts', context={'active_test': False})
|
||||
dst_partner_id = fields.Many2one('res.partner', string='Destination Contact')
|
||||
|
||||
exclude_contact = fields.Boolean('A user associated to the contact')
|
||||
exclude_journal_item = fields.Boolean('Journal Items associated to the contact')
|
||||
maximum_group = fields.Integer('Maximum of Group of Contacts')
|
||||
|
||||
# ----------------------------------------
|
||||
# Update method. Core methods to merge steps
|
||||
# ----------------------------------------
|
||||
|
||||
def _get_fk_on(self, table):
|
||||
""" return a list of many2one relation with the given table.
|
||||
:param table : the name of the sql table to return relations
|
||||
:returns a list of tuple 'table name', 'column name'.
|
||||
"""
|
||||
query = """
|
||||
SELECT cl1.relname as table, att1.attname as column
|
||||
FROM pg_constraint as con, pg_class as cl1, pg_class as cl2, pg_attribute as att1, pg_attribute as att2
|
||||
WHERE con.conrelid = cl1.oid
|
||||
AND con.confrelid = cl2.oid
|
||||
AND array_lower(con.conkey, 1) = 1
|
||||
AND con.conkey[1] = att1.attnum
|
||||
AND att1.attrelid = cl1.oid
|
||||
AND cl2.relname = %s
|
||||
AND att2.attname = 'id'
|
||||
AND array_lower(con.confkey, 1) = 1
|
||||
AND con.confkey[1] = att2.attnum
|
||||
AND att2.attrelid = cl2.oid
|
||||
AND con.contype = 'f'
|
||||
"""
|
||||
self._cr.execute(query, (table,))
|
||||
return self._cr.fetchall()
|
||||
|
||||
@api.model
|
||||
def _update_foreign_keys(self, src_partners, dst_partner):
|
||||
""" Update all foreign key from the src_partner to dst_partner. All many2one fields will be updated.
|
||||
:param src_partners : merge source res.partner recordset (does not include destination one)
|
||||
:param dst_partner : record of destination res.partner
|
||||
"""
|
||||
_logger.debug('_update_foreign_keys for dst_partner: %s for src_partners: %s', dst_partner.id, str(src_partners.ids))
|
||||
|
||||
# find the many2one relation to a partner
|
||||
Partner = self.env['res.partner']
|
||||
relations = self._get_fk_on('res_partner')
|
||||
|
||||
# this guarantees cache consistency
|
||||
self.env.invalidate_all()
|
||||
|
||||
for table, column in relations:
|
||||
if 'base_partner_merge_' in table: # ignore two tables
|
||||
continue
|
||||
|
||||
# get list of columns of current table (exept the current fk column)
|
||||
query = "SELECT column_name FROM information_schema.columns WHERE table_name LIKE '%s'" % (table)
|
||||
self._cr.execute(query, ())
|
||||
columns = []
|
||||
for data in self._cr.fetchall():
|
||||
if data[0] != column:
|
||||
columns.append(data[0])
|
||||
|
||||
# do the update for the current table/column in SQL
|
||||
query_dic = {
|
||||
'table': table,
|
||||
'column': column,
|
||||
'value': columns[0],
|
||||
}
|
||||
if len(columns) <= 1:
|
||||
# unique key treated
|
||||
query = """
|
||||
UPDATE "%(table)s" as ___tu
|
||||
SET "%(column)s" = %%s
|
||||
WHERE
|
||||
"%(column)s" = %%s AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "%(table)s" as ___tw
|
||||
WHERE
|
||||
"%(column)s" = %%s AND
|
||||
___tu.%(value)s = ___tw.%(value)s
|
||||
)""" % query_dic
|
||||
for partner in src_partners:
|
||||
self._cr.execute(query, (dst_partner.id, partner.id, dst_partner.id))
|
||||
else:
|
||||
try:
|
||||
with mute_logger('odoo.sql_db'), self._cr.savepoint():
|
||||
query = 'UPDATE "%(table)s" SET "%(column)s" = %%s WHERE "%(column)s" IN %%s' % query_dic
|
||||
self._cr.execute(query, (dst_partner.id, tuple(src_partners.ids),))
|
||||
except psycopg2.Error:
|
||||
# updating fails, most likely due to a violated unique constraint
|
||||
# keeping record with nonexistent partner_id is useless, better delete it
|
||||
query = 'DELETE FROM "%(table)s" WHERE "%(column)s" IN %%s' % query_dic
|
||||
self._cr.execute(query, (tuple(src_partners.ids),))
|
||||
|
||||
@api.model
|
||||
def _update_reference_fields(self, src_partners, dst_partner):
|
||||
""" Update all reference fields from the src_partner to dst_partner.
|
||||
:param src_partners : merge source res.partner recordset (does not include destination one)
|
||||
:param dst_partner : record of destination res.partner
|
||||
"""
|
||||
_logger.debug('_update_reference_fields for dst_partner: %s for src_partners: %r', dst_partner.id, src_partners.ids)
|
||||
|
||||
def update_records(model, src, field_model='model', field_id='res_id'):
|
||||
Model = self.env[model] if model in self.env else None
|
||||
if Model is None:
|
||||
return
|
||||
records = Model.sudo().search([(field_model, '=', 'res.partner'), (field_id, '=', src.id)])
|
||||
try:
|
||||
with mute_logger('odoo.sql_db'), self._cr.savepoint():
|
||||
records.sudo().write({field_id: dst_partner.id})
|
||||
records.env.flush_all()
|
||||
except psycopg2.Error:
|
||||
# updating fails, most likely due to a violated unique constraint
|
||||
# keeping record with nonexistent partner_id is useless, better delete it
|
||||
records.sudo().unlink()
|
||||
|
||||
update_records = functools.partial(update_records)
|
||||
|
||||
for partner in src_partners:
|
||||
update_records('calendar', src=partner, field_model='model_id.model')
|
||||
update_records('ir.attachment', src=partner, field_model='res_model')
|
||||
update_records('mail.followers', src=partner, field_model='res_model')
|
||||
update_records('mail.activity', src=partner, field_model='res_model')
|
||||
update_records('mail.message', src=partner)
|
||||
update_records('ir.model.data', src=partner)
|
||||
|
||||
records = self.env['ir.model.fields'].sudo().search([('ttype', '=', 'reference')])
|
||||
for record in records:
|
||||
try:
|
||||
Model = self.env[record.model]
|
||||
field = Model._fields[record.name]
|
||||
except KeyError:
|
||||
# unknown model or field => skip
|
||||
continue
|
||||
|
||||
if Model._abstract or field.compute is not None:
|
||||
continue
|
||||
|
||||
for partner in src_partners:
|
||||
records_ref = Model.sudo().search([(record.name, '=', 'res.partner,%d' % partner.id)])
|
||||
values = {
|
||||
record.name: 'res.partner,%d' % dst_partner.id,
|
||||
}
|
||||
records_ref.sudo().write(values)
|
||||
|
||||
self.env.flush_all()
|
||||
|
||||
def _get_summable_fields(self):
|
||||
""" Returns the list of fields that should be summed when merging partners
|
||||
"""
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def _update_values(self, src_partners, dst_partner):
|
||||
""" Update values of dst_partner with the ones from the src_partners.
|
||||
:param src_partners : recordset of source res.partner
|
||||
:param dst_partner : record of destination res.partner
|
||||
"""
|
||||
_logger.debug('_update_values for dst_partner: %s for src_partners: %r', dst_partner.id, src_partners.ids)
|
||||
|
||||
model_fields = dst_partner.fields_get().keys()
|
||||
summable_fields = self._get_summable_fields()
|
||||
|
||||
def write_serializer(item):
|
||||
if isinstance(item, models.BaseModel):
|
||||
return item.id
|
||||
else:
|
||||
return item
|
||||
|
||||
# get all fields that are not computed or x2many
|
||||
values = dict()
|
||||
values_by_company = defaultdict(dict) # {company: vals}
|
||||
for column in model_fields:
|
||||
field = dst_partner._fields[column]
|
||||
if field.type not in ('many2many', 'one2many') and field.compute is None:
|
||||
for item in itertools.chain(src_partners, [dst_partner]):
|
||||
if item[column]:
|
||||
if column in summable_fields and values.get(column):
|
||||
values[column] += write_serializer(item[column])
|
||||
else:
|
||||
values[column] = write_serializer(item[column])
|
||||
elif field.company_dependent and column in summable_fields:
|
||||
# sum the values of partners for each company; use sudo() to
|
||||
# compute the sum on all companies, including forbidden ones
|
||||
partners = (src_partners + dst_partner).sudo()
|
||||
for company in self.env['res.company'].sudo().search([]):
|
||||
values_by_company[company][column] = sum(
|
||||
partners.with_company(company).mapped(column)
|
||||
)
|
||||
|
||||
# remove fields that can not be updated (id and parent_id)
|
||||
values.pop('id', None)
|
||||
parent_id = values.pop('parent_id', None)
|
||||
dst_partner.write(values)
|
||||
for company, vals in values_by_company.items():
|
||||
dst_partner.with_company(company).sudo().write(vals)
|
||||
# try to update the parent_id
|
||||
if parent_id and parent_id != dst_partner.id:
|
||||
try:
|
||||
dst_partner.write({'parent_id': parent_id})
|
||||
except ValidationError:
|
||||
_logger.info('Skip recursive partner hierarchies for parent_id %s of partner: %s', parent_id, dst_partner.id)
|
||||
|
||||
def _merge(self, partner_ids, dst_partner=None, extra_checks=True):
|
||||
""" private implementation of merge partner
|
||||
:param partner_ids : ids of partner to merge
|
||||
:param dst_partner : record of destination res.partner
|
||||
:param extra_checks: pass False to bypass extra sanity check (e.g. email address)
|
||||
"""
|
||||
# super-admin can be used to bypass extra checks
|
||||
if self.env.is_admin():
|
||||
extra_checks = False
|
||||
|
||||
Partner = self.env['res.partner']
|
||||
partner_ids = Partner.browse(partner_ids).exists()
|
||||
if len(partner_ids) < 2:
|
||||
return
|
||||
|
||||
if len(partner_ids) > 3:
|
||||
raise UserError(_("For safety reasons, you cannot merge more than 3 contacts together. You can re-open the wizard several times if needed."))
|
||||
|
||||
# check if the list of partners to merge contains child/parent relation
|
||||
child_ids = self.env['res.partner']
|
||||
for partner_id in partner_ids:
|
||||
child_ids |= Partner.search([('id', 'child_of', [partner_id.id])]) - partner_id
|
||||
if partner_ids & child_ids:
|
||||
raise UserError(_("You cannot merge a contact with one of his parent."))
|
||||
|
||||
if extra_checks and len(set(partner.email for partner in partner_ids)) > 1:
|
||||
raise UserError(_("All contacts must have the same email. Only the Administrator can merge contacts with different emails."))
|
||||
|
||||
# remove dst_partner from partners to merge
|
||||
if dst_partner and dst_partner in partner_ids:
|
||||
src_partners = partner_ids - dst_partner
|
||||
else:
|
||||
ordered_partners = self._get_ordered_partner(partner_ids.ids)
|
||||
dst_partner = ordered_partners[-1]
|
||||
src_partners = ordered_partners[:-1]
|
||||
_logger.info("dst_partner: %s", dst_partner.id)
|
||||
|
||||
# Make the company of all related users consistent with destination partner company
|
||||
if dst_partner.company_id:
|
||||
partner_ids.mapped('user_ids').sudo().write({
|
||||
'company_ids': [Command.link(dst_partner.company_id.id)],
|
||||
'company_id': dst_partner.company_id.id
|
||||
})
|
||||
|
||||
# call sub methods to do the merge
|
||||
self._update_foreign_keys(src_partners, dst_partner)
|
||||
self._update_reference_fields(src_partners, dst_partner)
|
||||
self._update_values(src_partners, dst_partner)
|
||||
|
||||
self.env.add_to_compute(dst_partner._fields['partner_share'], dst_partner)
|
||||
|
||||
self._log_merge_operation(src_partners, dst_partner)
|
||||
|
||||
# delete source partner, since they are merged
|
||||
src_partners.unlink()
|
||||
|
||||
def _log_merge_operation(self, src_partners, dst_partner):
|
||||
_logger.info('(uid = %s) merged the partners %r with %s', self._uid, src_partners.ids, dst_partner.id)
|
||||
|
||||
# ----------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------
|
||||
|
||||
@api.model
|
||||
def _generate_query(self, fields, maximum_group=100):
|
||||
""" Build the SQL query on res.partner table to group them according to given criteria
|
||||
:param fields : list of column names to group by the partners
|
||||
:param maximum_group : limit of the query
|
||||
"""
|
||||
# make the list of column to group by in sql query
|
||||
sql_fields = []
|
||||
for field in fields:
|
||||
if field in ['email', 'name']:
|
||||
sql_fields.append('lower(%s)' % field)
|
||||
elif field in ['vat']:
|
||||
sql_fields.append("replace(%s, ' ', '')" % field)
|
||||
else:
|
||||
sql_fields.append(field)
|
||||
group_fields = ', '.join(sql_fields)
|
||||
|
||||
# where clause : for given group by columns, only keep the 'not null' record
|
||||
filters = []
|
||||
for field in fields:
|
||||
if field in ['email', 'name', 'vat']:
|
||||
filters.append((field, 'IS NOT', 'NULL'))
|
||||
criteria = ' AND '.join('%s %s %s' % (field, operator, value) for field, operator, value in filters)
|
||||
|
||||
# build the query
|
||||
text = [
|
||||
"SELECT min(id), array_agg(id)",
|
||||
"FROM res_partner",
|
||||
]
|
||||
|
||||
if criteria:
|
||||
text.append('WHERE %s' % criteria)
|
||||
|
||||
text.extend([
|
||||
"GROUP BY %s" % group_fields,
|
||||
"HAVING COUNT(*) >= 2",
|
||||
"ORDER BY min(id)",
|
||||
])
|
||||
|
||||
if maximum_group:
|
||||
text.append("LIMIT %s" % maximum_group,)
|
||||
|
||||
return ' '.join(text)
|
||||
|
||||
@api.model
|
||||
def _compute_selected_groupby(self):
|
||||
""" Returns the list of field names the partner can be grouped (as merge
|
||||
criteria) according to the option checked on the wizard
|
||||
"""
|
||||
groups = []
|
||||
group_by_prefix = 'group_by_'
|
||||
|
||||
for field_name in self._fields:
|
||||
if field_name.startswith(group_by_prefix):
|
||||
if field_name in self and self[field_name]:
|
||||
groups.append(field_name[len(group_by_prefix):])
|
||||
|
||||
if not groups:
|
||||
raise UserError(_("You have to specify a filter for your selection."))
|
||||
|
||||
return groups
|
||||
|
||||
@api.model
|
||||
def _partner_use_in(self, aggr_ids, models):
|
||||
""" Check if there is no occurence of this group of partner in the selected model
|
||||
:param aggr_ids : stringified list of partner ids separated with a comma (sql array_agg)
|
||||
:param models : dict mapping a model name with its foreign key with res_partner table
|
||||
"""
|
||||
return any(
|
||||
self.env[model].search_count([(field, 'in', aggr_ids)])
|
||||
for model, field in models.items()
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_ordered_partner(self, partner_ids):
|
||||
""" Helper : returns a `res.partner` recordset ordered by create_date/active fields
|
||||
:param partner_ids : list of partner ids to sort
|
||||
"""
|
||||
return self.env['res.partner'].browse(partner_ids).sorted(
|
||||
key=lambda p: (not p.active, (p.create_date or datetime.datetime(1970, 1, 1))),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def _compute_models(self):
|
||||
""" Compute the different models needed by the system if you want to exclude some partners. """
|
||||
model_mapping = {}
|
||||
if self.exclude_contact:
|
||||
model_mapping['res.users'] = 'partner_id'
|
||||
if 'account.move.line' in self.env and self.exclude_journal_item:
|
||||
model_mapping['account.move.line'] = 'partner_id'
|
||||
return model_mapping
|
||||
|
||||
# ----------------------------------------
|
||||
# Actions
|
||||
# ----------------------------------------
|
||||
|
||||
def action_skip(self):
|
||||
""" Skip this wizard line. Don't compute any thing, and simply redirect to the new step."""
|
||||
if self.current_line_id:
|
||||
self.current_line_id.unlink()
|
||||
return self._action_next_screen()
|
||||
|
||||
def _action_next_screen(self):
|
||||
""" return the action of the next screen ; this means the wizard is set to treat the
|
||||
next wizard line. Each line is a subset of partner that can be merged together.
|
||||
If no line left, the end screen will be displayed (but an action is still returned).
|
||||
"""
|
||||
self.env.invalidate_all() # FIXME: is this still necessary?
|
||||
values = {}
|
||||
if self.line_ids:
|
||||
# in this case, we try to find the next record.
|
||||
current_line = self.line_ids[0]
|
||||
current_partner_ids = literal_eval(current_line.aggr_ids)
|
||||
values.update({
|
||||
'current_line_id': current_line.id,
|
||||
'partner_ids': [Command.set(current_partner_ids)],
|
||||
'dst_partner_id': self._get_ordered_partner(current_partner_ids)[-1].id,
|
||||
'state': 'selection',
|
||||
})
|
||||
else:
|
||||
values.update({
|
||||
'current_line_id': False,
|
||||
'partner_ids': [],
|
||||
'state': 'finished',
|
||||
})
|
||||
|
||||
self.write(values)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _process_query(self, query):
|
||||
""" Execute the select request and write the result in this wizard
|
||||
:param query : the SQL query used to fill the wizard line
|
||||
"""
|
||||
self.ensure_one()
|
||||
model_mapping = self._compute_models()
|
||||
|
||||
# group partner query
|
||||
self._cr.execute(query) # pylint: disable=sql-injection
|
||||
|
||||
counter = 0
|
||||
for min_id, aggr_ids in self._cr.fetchall():
|
||||
# To ensure that the used partners are accessible by the user
|
||||
partners = self.env['res.partner'].search([('id', 'in', aggr_ids)])
|
||||
if len(partners) < 2:
|
||||
continue
|
||||
|
||||
# exclude partner according to options
|
||||
if model_mapping and self._partner_use_in(partners.ids, model_mapping):
|
||||
continue
|
||||
|
||||
self.env['base.partner.merge.line'].create({
|
||||
'wizard_id': self.id,
|
||||
'min_id': min_id,
|
||||
'aggr_ids': partners.ids,
|
||||
})
|
||||
counter += 1
|
||||
|
||||
self.write({
|
||||
'state': 'selection',
|
||||
'number_group': counter,
|
||||
})
|
||||
|
||||
_logger.info("counter: %s", counter)
|
||||
|
||||
def action_start_manual_process(self):
|
||||
""" Start the process 'Merge with Manual Check'. Fill the wizard according to the group_by and exclude
|
||||
options, and redirect to the first step (treatment of first wizard line). After, for each subset of
|
||||
partner to merge, the wizard will be actualized.
|
||||
- Compute the selected groups (with duplication)
|
||||
- If the user has selected the 'exclude_xxx' fields, avoid the partners
|
||||
"""
|
||||
self.ensure_one()
|
||||
groups = self._compute_selected_groupby()
|
||||
query = self._generate_query(groups, self.maximum_group)
|
||||
self._process_query(query)
|
||||
return self._action_next_screen()
|
||||
|
||||
def action_start_automatic_process(self):
|
||||
""" Start the process 'Merge Automatically'. This will fill the wizard with the same mechanism as 'Merge
|
||||
with Manual Check', but instead of refreshing wizard with the current line, it will automatically process
|
||||
all lines by merging partner grouped according to the checked options.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self.action_start_manual_process() # here we don't redirect to the next screen, since it is automatic process
|
||||
self.env.invalidate_all() # FIXME: is this still necessary?
|
||||
|
||||
for line in self.line_ids:
|
||||
partner_ids = literal_eval(line.aggr_ids)
|
||||
self._merge(partner_ids)
|
||||
line.unlink()
|
||||
self._cr.commit() # TODO JEM : explain why
|
||||
|
||||
self.write({'state': 'finished'})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def parent_migration_process_cb(self):
|
||||
self.ensure_one()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
min(p1.id),
|
||||
array_agg(DISTINCT p1.id)
|
||||
FROM
|
||||
res_partner as p1
|
||||
INNER join
|
||||
res_partner as p2
|
||||
ON
|
||||
p1.email = p2.email AND
|
||||
p1.name = p2.name AND
|
||||
(p1.parent_id = p2.id OR p1.id = p2.parent_id)
|
||||
WHERE
|
||||
p2.id IS NOT NULL
|
||||
GROUP BY
|
||||
p1.email,
|
||||
p1.name,
|
||||
CASE WHEN p1.parent_id = p2.id THEN p2.id
|
||||
ELSE p1.id
|
||||
END
|
||||
HAVING COUNT(*) >= 2
|
||||
ORDER BY
|
||||
min(p1.id)
|
||||
"""
|
||||
|
||||
self._process_query(query)
|
||||
|
||||
for line in self.line_ids:
|
||||
partner_ids = literal_eval(line.aggr_ids)
|
||||
self._merge(partner_ids)
|
||||
line.unlink()
|
||||
self._cr.commit()
|
||||
|
||||
self.write({'state': 'finished'})
|
||||
|
||||
self._cr.execute("""
|
||||
UPDATE
|
||||
res_partner
|
||||
SET
|
||||
is_company = NULL,
|
||||
parent_id = NULL
|
||||
WHERE
|
||||
parent_id = id
|
||||
""")
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_update_all_process(self):
|
||||
self.ensure_one()
|
||||
self.parent_migration_process_cb()
|
||||
|
||||
# NOTE JEM : seems louche to create a new wizard instead of reuse the current one with updated options.
|
||||
# since it is like this from the initial commit of this wizard, I don't change it. yet ...
|
||||
wizard = self.create({'group_by_vat': True, 'group_by_email': True, 'group_by_name': True})
|
||||
wizard.action_start_automatic_process()
|
||||
|
||||
# NOTE JEM : no idea if this query is usefull
|
||||
self._cr.execute("""
|
||||
UPDATE
|
||||
res_partner
|
||||
SET
|
||||
is_company = NULL
|
||||
WHERE
|
||||
parent_id IS NOT NULL AND
|
||||
is_company IS NOT NULL
|
||||
""")
|
||||
|
||||
return self._action_next_screen()
|
||||
|
||||
def action_merge(self):
|
||||
""" Merge Contact button. Merge the selected partners, and redirect to
|
||||
the end screen (since there is no other wizard line to process.
|
||||
"""
|
||||
if not self.partner_ids:
|
||||
self.write({'state': 'finished'})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
self._merge(self.partner_ids.ids, self.dst_partner_id)
|
||||
|
||||
if self.current_line_id:
|
||||
self.current_line_id.unlink()
|
||||
|
||||
return self._action_next_screen()
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="action_partner_deduplicate" model="ir.actions.act_window">
|
||||
<field name="name">Deduplicate Contacts</field>
|
||||
<field name="res_model">base.partner.merge.automatic.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
</record>
|
||||
|
||||
<record id="base_partner_merge_automatic_wizard_form" model="ir.ui.view">
|
||||
<field name='name'>base.partner.merge.automatic.wizard.form</field>
|
||||
<field name='model'>base.partner.merge.automatic.wizard</field>
|
||||
<field name='arch' type='xml'>
|
||||
<form string='Automatic Merge Wizard' class="o_partner_merge_wizard">
|
||||
<sheet>
|
||||
<group attrs="{'invisible': [('state', '!=', 'finished')]}">
|
||||
<h2 colspan="2">There are no more contacts to merge for this request</h2>
|
||||
<button name="%(action_partner_deduplicate)d" string="Deduplicate the other Contacts" class="oe_highlight" type="action" colspan="2"/>
|
||||
</group>
|
||||
<p class="oe_grey" attrs="{'invisible': [('state', '!=', ('option'))]}">
|
||||
Select the list of fields used to search for
|
||||
duplicated records. If you select several fields,
|
||||
Odoo will propose you to merge only those having
|
||||
all these fields in common. (not one of the fields).
|
||||
</p>
|
||||
<group attrs="{'invisible': ['|', ('state', 'not in', ('selection', 'finished')), ('number_group', '=', 0)]}">
|
||||
<field name="state" invisible="1" />
|
||||
<field name="number_group"/>
|
||||
</group>
|
||||
<group string="Search duplicates based on duplicated data in"
|
||||
attrs="{'invisible': [('state', 'not in', ('option',))]}">
|
||||
<field name='group_by_email' />
|
||||
<field name='group_by_name' />
|
||||
<field name='group_by_is_company' />
|
||||
<field name='group_by_vat' />
|
||||
<field name='group_by_parent_id' />
|
||||
</group>
|
||||
<group string="Exclude contacts having"
|
||||
attrs="{'invisible': [('state', 'not in', ('option',))]}">
|
||||
<field name='exclude_contact' />
|
||||
<field name='exclude_journal_item' />
|
||||
</group>
|
||||
<separator string="Options" attrs="{'invisible': [('state', 'not in', ('option',))]}"/>
|
||||
<group attrs="{'invisible': [('state', 'not in', ('option','finished'))]}">
|
||||
<field name='maximum_group' attrs="{'readonly': [('state', 'in', ('finished'))]}"/>
|
||||
</group>
|
||||
<separator string="Merge the following contacts"
|
||||
attrs="{'invisible': [('state', 'in', ('option', 'finished'))]}"/>
|
||||
<group attrs="{'invisible': [('state', 'in', ('option', 'finished'))]}" col="1">
|
||||
<p class="oe_grey">
|
||||
Selected contacts will be merged together.
|
||||
All documents linked to one of these contacts
|
||||
will be redirected to the destination contact.
|
||||
You can remove contacts from this list to avoid merging them.
|
||||
</p>
|
||||
<group col="2">
|
||||
<field name="dst_partner_id"
|
||||
domain="[('id', 'in', partner_ids or False)]"
|
||||
attrs="{'required': [('state', '=', 'selection')]}"
|
||||
context="{'partner_show_db_id': True}"
|
||||
options="{'always_reload': True}"/>
|
||||
</group>
|
||||
<field name="partner_ids" nolabel="1">
|
||||
<tree string="Partners">
|
||||
<field name="id" />
|
||||
<field name="display_name" />
|
||||
<field name="email" />
|
||||
<field name="is_company" />
|
||||
<field name="vat" />
|
||||
<field name="country_id" />
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name='action_merge' string='Merge Contacts'
|
||||
class='oe_highlight'
|
||||
type='object' data-hotkey="q"
|
||||
attrs="{'invisible': [('state', 'in', ('option', 'finished' ))]}" />
|
||||
<button name='action_skip' string='Skip these contacts'
|
||||
type='object'
|
||||
attrs="{'invisible': [('state', '!=', 'selection')]}" />
|
||||
<button name='action_start_manual_process'
|
||||
string='Merge with Manual Check' data-hotkey="x"
|
||||
type='object' class='oe_highlight'
|
||||
attrs="{'invisible': [('state', '!=', 'option')]}" />
|
||||
<button name='action_start_automatic_process'
|
||||
string='Merge Automatically' data-hotkey="l"
|
||||
type='object' class='oe_highlight'
|
||||
confirm="Are you sure to execute the automatic merge of your contacts ?"
|
||||
attrs="{'invisible': [('state', '!=', 'option')]}" />
|
||||
<button name='action_update_all_process'
|
||||
string='Merge Automatically all process'
|
||||
type='object' data-hotkey="y"
|
||||
confirm="Are you sure to execute the list of automatic merges of your contacts ?"
|
||||
attrs="{'invisible': [('state', '!=', 'option')]}" />
|
||||
<button special="cancel" data-hotkey="z" string="Cancel" type="object" class="btn btn-secondary oe_inline" attrs="{'invisible': [('state', '=', 'finished')]}"/>
|
||||
<button special="cancel" data-hotkey="z" string="Close" type="object" class="btn btn-secondary oe_inline" attrs="{'invisible': [('state', '!=', 'finished')]}"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_partner_merge" model="ir.actions.act_window">
|
||||
<field name="name">Merge</field>
|
||||
<field name="res_model">base.partner.merge.automatic.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="base.model_res_partner"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Add table
Add a link
Reference in a new issue