18.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:06:50 +02:00
parent d72e748793
commit 0a7ae8db93
337 changed files with 399651 additions and 232598 deletions

View file

@ -3,10 +3,10 @@
import ast
import base64
import contextlib
import io
from odoo import api, fields, models, tools, _
from odoo.tools.translate import trans_export, trans_export_records
NEW_LANG_KEY = '__new__'
@ -39,13 +39,13 @@ class BaseLanguageExport(models.TransientModel):
this = self[0]
lang = this.lang if this.lang != NEW_LANG_KEY else False
with contextlib.closing(io.BytesIO()) as buf:
with io.BytesIO() as buf:
if this.export_type == 'model':
ids = self.env[this.model_name].search(ast.literal_eval(this.domain)).ids
tools.trans_export_records(lang, this.model_name, ids, buf, this.format, self._cr)
trans_export_records(lang, this.model_name, ids, buf, this.format, self._cr)
else:
mods = sorted(this.mapped('modules.name')) or ['all']
tools.trans_export(lang, mods, buf, this.format, self._cr)
trans_export(lang, mods, buf, this.format, self._cr)
out = base64.encodebytes(buf.getvalue())
filename = 'new'

View file

@ -6,15 +6,14 @@
<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"/>
<field name="name" invisible="1"/> <!-- The name is needed in the BinaryField component used to download the file -->
<group invisible="state != 'choose'" string="Export Settings">
<field name="lang"/>
<field name="format"/>
<field name="export_type"/>
<field name="modules" widget="many2many_tags" options="{'no_create': True}" invisible="export_type == 'model'"/>
<field name="model_id" options="{'no_create': True}" invisible="export_type == 'module'" required="export_type == 'model'"/>
<field name="model_name" invisible="1"/>
<field name="model_name" invisible="1"/> <!-- The model_name is needed for the option of the domain -->
<field name="domain" widget="domain" options="{'model': 'model_name'}" invisible="export_type == 'module'"/>
</group>
<div invisible="state != 'get'">

View file

@ -19,7 +19,7 @@ class BaseLanguageImport(models.TransientModel):
_description = "Language Import"
name = fields.Char('Language Name', required=True)
code = fields.Char('ISO Code', size=6, required=True,
code = fields.Char('ISO Code', 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)
@ -43,9 +43,9 @@ class BaseLanguageImport(models.TransientModel):
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)\n\nTechnical Details:\n%s',
base_lang_import.filename, tools.ustr(e))
_('File "%(file_name)s" not imported due to format mismatch or a malformed file.'
' (Valid formats are .csv, .po)\n\nTechnical Details:\n%(error_message)s',
file_name=base_lang_import.filename, error_message=e),
)
translation_importer.save(overwrite=overwrite)
return True

View file

@ -11,7 +11,7 @@
<field name="name" placeholder="e.g. English"/>
<field name="code" string="Code" placeholder="e.g. en_US"/>
<field name="data" filename="filename" options="{'accepted_file_extensions': '.csv,.po'}"/>
<field name="filename" invisible="1"/>
<field name="filename" invisible="1"/> <!-- The name is needed in the BinaryField component used to upload the file -->
<field name="overwrite" groups="base.group_no_one"/>
</group>
<footer>

View file

@ -36,7 +36,7 @@
<field name="overwrite" groups="base.group_no_one"/>
</group>
<footer>
<button name="lang_install" string="Add" data-hotkey="q" type="object" class="btn-primary"/>
<button name="lang_install" block-ui="1" string="Add" data-hotkey="q" type="object" class="btn-primary"/>
<button special="cancel" data-hotkey="x" string="Cancel" class="btn-secondary"/>
</footer>
</form>

View file

@ -12,42 +12,37 @@
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" />&amp;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 t-name="card" class="flex-row align-items-center">
<aside>
<field name="icon" widget="image_url" options="{'size': [50, 50]}" alt="Icon"/>
</aside>
<main t-att-title="record.shortdesc.value">
<field class="fw-bold fs-5 mb-0" name="shortdesc" />
<p class="text-muted small my-1 lh-sm">
<field groups="!base.group_no_one" name="summary" />
<code groups="base.group_no_one">
<field name="name" />
</code>
</p>
</main>
</t>
</templates>
</kanban>
</field>
<h3>Documents to Delete</h3>
<field name="model_ids" string="Models" nolabel="1">
<tree string="Models">
<list string="Models">
<field name="name" string="Document"/>
<field name="count"/>
</tree>
</list>
</field>
<footer>
<button string="Uninstall" class="btn-secondary" type="object" name="action_uninstall" data-hotkey="q"/>

View file

@ -21,7 +21,7 @@ class BaseModuleUpdate(models.TransientModel):
res = {
'domain': str([]),
'name': 'Modules',
'view_mode': 'tree,form',
'view_mode': 'list,form',
'res_model': 'ir.module.module',
'view_id': False,
'type': 'ir.actions.act_window',

View file

@ -7,7 +7,6 @@
<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" invisible="state != 'done'"/>
<group invisible="state != 'init'">
<span class="o_form_label" colspan="2">Click on Update below to start the process...</span>

View file

@ -3,16 +3,15 @@
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 import _
from odoo.exceptions import ValidationError, UserError
from odoo.tools import mute_logger
from odoo.tools import mute_logger, SQL
_logger = logging.getLogger('odoo.addons.base.partner.merge')
@ -101,16 +100,15 @@ class MergePartnerAutomatic(models.TransientModel):
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
def _update_foreign_keys_generic(self, model, src_records, dst_record):
""" Update all foreign key from the src_records to dst_record for any model.
:param model: model name as a string
:param src_records: merge source recordset (does not include destination one)
:param dst_record: record of destination
"""
_logger.debug('_update_foreign_keys for dst_partner: %s for src_partners: %s', dst_partner.id, str(src_partners.ids))
_logger.debug('_update_foreign_keys_generic for dst_record: %s for src_records: %s', dst_record.id, str(src_records.ids))
# find the many2one relation to a partner
Partner = self.env['res.partner']
relations = self._get_fk_on('res_partner')
relations = self._get_fk_on(self.env[model]._table)
# this guarantees cache consistency
self.env.invalidate_all()
@ -147,52 +145,55 @@ class MergePartnerAutomatic(models.TransientModel):
"%(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))
for record in src_records:
self._cr.execute(query, (dst_record.id, record.id, dst_record.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),))
self._cr.execute(query, (dst_record.id, tuple(src_records.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),))
self._cr.execute(query, (tuple(src_records.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
def _update_reference_fields_generic(self, referenced_model, src_records, dst_record, additional_update_records=None):
""" Update all reference fields from the src_records to dst_record for any model.
:param referenced_model: model name as a string
:param src_records: merge source recordset (does not include destination one)
:param dst_record: record of destination
:param additional_update_records: list of tuples (model, field_model, field_id)
"""
_logger.debug('_update_reference_fields for dst_partner: %s for src_partners: %r', dst_partner.id, src_partners.ids)
_logger.debug('_update_reference_fields_generic for dst_record: %s for src_records: %r', dst_record.id, src_records.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)])
records = Model.sudo().search([(field_model, '=', referenced_model), (field_id, '=', src.id)])
try:
with mute_logger('odoo.sql_db'), self._cr.savepoint():
records.sudo().write({field_id: dst_partner.id})
records.sudo().write({field_id: dst_record.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 record in src_records:
update_records('ir.attachment', src=record, field_model='res_model')
update_records('mail.followers', src=record, field_model='res_model')
update_records('mail.activity', src=record, field_model='res_model')
update_records('mail.message', src=record)
update_records('ir.model.data', src=record)
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)
additional_update_records = additional_update_records or []
for update_record in additional_update_records:
update_records(update_record['model'], src=record, field_model=update_record['field_model'])
records = self.env['ir.model.fields'].sudo().search([('ttype', '=', 'reference')])
records = self.env['ir.model.fields'].sudo().search([('ttype', '=', 'reference'), ('store', '=', True)])
for record in records:
try:
Model = self.env[record.model]
@ -204,35 +205,105 @@ class MergePartnerAutomatic(models.TransientModel):
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)])
for src_record in src_records:
records_ref = Model.sudo().search([(record.name, '=', '%s,%d' % (referenced_model, src_record.id))])
values = {
record.name: 'res.partner,%d' % dst_partner.id,
record.name: '%s,%d' % (referenced_model, dst_record.id),
}
records_ref.sudo().write(values)
# company_dependent fields referring the merged records
for field in self.env.registry.many2one_company_dependents[dst_record._name]:
self.env.cr.execute(SQL(
"""
UPDATE %(table)s
SET %(field)s = (
SELECT jsonb_object_agg(key,
CASE
WHEN value::int IN %(src_record_ids)s
THEN %(dest_record_id)s
ELSE value::int
END
)
FROM jsonb_each_text(%(field)s)
)
WHERE %(field)s IS NOT NULL
""",
table=SQL.identifier(self.env[field.model_name]._table),
field=SQL.identifier(field.name),
src_record_ids=tuple(src_records.ids),
dest_record_id=dst_record.id,
))
# merge the fallback values for company dependent many2one fields
self.env.cr.execute(SQL(
"""
UPDATE ir_default
SET json_value =
CASE
WHEN json_value::int IN %(src_record_ids)s
THEN %(dest_record_id)s
ELSE json_value
END
FROM ir_model_fields f
WHERE f.id = ir_default.field_id
AND f.company_dependent
AND f.relation = %(model_name)s
AND f.ttype = 'many2one'
AND json_value ~ '^[0-9]+$';
""",
src_record_ids=tuple(src_records.ids),
dest_record_id=str(dst_record.id),
model_name=dst_record._name,
))
self.env.flush_all()
# Company-dependent fields
try:
with self._cr.savepoint():
params = {
'destination_id': f'res.partner,{dst_partner.id}',
'source_ids': tuple(f'res.partner,{src}' for src in src_partners.ids),
}
self._cr.execute("""
UPDATE ir_property AS _ip1
SET res_id = %(destination_id)s
WHERE res_id IN %(source_ids)s
AND NOT EXISTS (
SELECT
FROM ir_property AS _ip2
WHERE _ip2.res_id = %(destination_id)s
AND _ip2.fields_id = _ip1.fields_id
AND _ip2.company_id IS NOT DISTINCT FROM _ip1.company_id
)""", params)
except psycopg2.Error:
_logger.info(f'Could not move ir.property from partners: {src_partners.ids} to partner: {dst_partner.id}')
# company_dependent fields of merged records
with self._cr.savepoint():
for fname, field in dst_record._fields.items():
if field.company_dependent:
self.env.execute_query(SQL(
# use the specific company dependent value of sources
# to fill the non-specific value of destination. Source
# values for rows with larger id have higher priority
# when aggregated
"""
WITH source AS (
SELECT %(field)s
FROM %(table)s
WHERE id IN %(source_ids)s
ORDER BY id
), source_agg AS (
SELECT jsonb_object_agg(key, value) AS value
FROM source, jsonb_each(%(field)s)
)
UPDATE %(table)s
SET %(field)s = source_agg.value || COALESCE(%(table)s.%(field)s, '{}'::jsonb)
FROM source_agg
WHERE id = %(destination_id)s AND source_agg.value IS NOT NULL
""",
table=SQL.identifier(dst_record._table),
field=SQL.identifier(fname),
destination_id=dst_record.id,
source_ids=tuple(src_records.ids),
))
@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
"""
self._update_foreign_keys_generic('res.partner', src_partners, dst_partner)
@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
"""
additional_update_records = [{'model': 'calendar', 'field_model': 'model_id.model'}]
self._update_reference_fields_generic('res.partner', src_partners, dst_partner, additional_update_records)
def _get_summable_fields(self):
""" Returns the list of fields that should be summed when merging partners
@ -292,6 +363,23 @@ class MergePartnerAutomatic(models.TransientModel):
except ValidationError:
_logger.info('Skip recursive partner hierarchies for parent_id %s of partner: %s', parent_id, dst_partner.id)
@api.model
def _merge_bank_accounts(self, src_partners, dst_partner):
""" Merge bank accounts of src_partners into dst_partner.
:param src_partners: merge source res.partner recordset (does not include destination one)
:param dst_partner: record of destination res.partner
"""
all_src_accounts = src_partners.bank_ids
for src_account in all_src_accounts:
duplicate_account = dst_partner.bank_ids.filtered(lambda a: a.sanitized_acc_number == src_account.sanitized_acc_number)
if duplicate_account:
self._update_foreign_keys_generic('res.partner.bank', src_account, duplicate_account)
self._update_reference_fields_generic('res.partner.bank', src_account, duplicate_account)
src_account.unlink()
else:
src_account.write({'partner_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
@ -340,6 +428,9 @@ class MergePartnerAutomatic(models.TransientModel):
'company_id': dst_partner.company_id.id
})
# Merge bank accounts before merging partners
self._merge_bank_accounts(src_partners, dst_partner)
# call sub methods to do the merge
self._update_foreign_keys(src_partners, dst_partner)
self._update_reference_fields(src_partners, dst_partner)

View file

@ -25,7 +25,6 @@
all these fields in common. (not one of the fields).
</p>
<group invisible="state not in ('selection', 'finished') or number_group == 0">
<field name="state" invisible="1" />
<field name="number_group"/>
</group>
<group string="Search duplicates based on duplicated data in"
@ -61,14 +60,14 @@
context="{'partner_show_db_id': True}"/>
</group>
<field name="partner_ids" nolabel="1">
<tree string="Partners">
<list string="Partners">
<field name="id" />
<field name="display_name" />
<field name="email" />
<field name="is_company" />
<field name="vat" />
<field name="country_id" />
</tree>
</list>
</field>
</group>
</sheet>