- Hi Deco Addict,
+ Hi Acme Corporation,
Welcome to My Company (San Francisco).
It's great to meet you! Now that you're on board, you'll discover what My Company (San Francisco) has to offer. My name is Marc Demo and I'll help you get the most out of Odoo. Could we plan a quick demo soon?
Feel free to reach out at any time!
diff --git a/addons/crm/tests/test_crm_lead_convert_mass.py b/addons/crm/tests/test_crm_lead_convert_mass.py
index a39dec8e28548..b15f37a9e1113 100644
--- a/addons/crm/tests/test_crm_lead_convert_mass.py
+++ b/addons/crm/tests/test_crm_lead_convert_mass.py
@@ -24,7 +24,7 @@ def test_assignment_salesmen(self):
with self.assertQueryCount(user_sales_manager=0):
test_leads = self.env['crm.lead'].browse(test_leads.ids)
- with self.assertQueryCount(user_sales_manager=543): # crm 537 / com 543 / ent 537
+ with self.assertQueryCount(user_sales_manager=597): # crm 537 / com 543 / ent 591
test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=False)
self.assertEqual(test_leads.team_id, self.sales_team_convert | self.sales_team_1)
@@ -42,7 +42,7 @@ def test_assignment_salesmen_wteam(self):
with self.assertQueryCount(user_sales_manager=0):
test_leads = self.env['crm.lead'].browse(test_leads.ids)
- with self.assertQueryCount(user_sales_manager=524): # crm 521 / com 524
+ with self.assertQueryCount(user_sales_manager=544): # crm 521 / com 544
test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=team_id)
self.assertEqual(test_leads.team_id, self.sales_team_convert)
@@ -167,7 +167,7 @@ def test_mass_convert_performances(self):
user_ids = self.assign_users.ids
# randomness: at least 1 query
- with self.assertQueryCount(user_sales_manager=1704): # crm 1410 / com 1697
+ with self.assertQueryCount(user_sales_manager=1754): # crm 1410 / com 1697
mass_convert = self.env['crm.lead2opportunity.partner.mass'].with_context({
'active_model': 'crm.lead',
'active_ids': test_leads.ids,
diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/bis3_out_invoice_gln.xml b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/bis3_out_invoice_gln.xml
new file mode 100644
index 0000000000000..9e0e092ac0f66
--- /dev/null
+++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/bis3_out_invoice_gln.xml
@@ -0,0 +1,129 @@
+
+
+ urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0
+ urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
+ ___ignore___
+ 2017-01-01
+ 2017-02-28
+ 380
+ test narration
+ USD
+ ref_partner_2
+
+ ref_move
+
+
+
+ BE0202239951
+
+ partner_1
+
+
+ Chaussée de Namur 40
+ Ramillies
+ 1367
+
+ BE
+
+
+
+ BE0202239951
+
+ VAT
+
+
+
+ partner_1
+ BE0202239951
+
+
+ partner_1
+
+
+
+
+
+ BE0477472701
+
+ partner_2
+
+
+ Rue des Bourlottes 9
+ Ramillies
+ 1367
+
+ BE
+
+
+
+ BE0477472701
+
+ VAT
+
+
+
+ partner_2
+ BE0477472701
+
+
+ partner_2
+
+
+
+
+
+ 222222222222
+
+
+
+ 30
+ ___ignore___
+
+ BE15001559627230
+
+
+
+ Payment terms: 30% Advance End of Following Month
+
+
+ 0.00
+
+ 990.00
+ 0.00
+
+ E
+ 0.0
+ Articles 226 items 11 to 15 Directive 2006/112/EN
+
+ VAT
+
+
+
+
+
+ 990.00
+ 990.00
+ 990.00
+ 0.00
+ 990.00
+
+
+ 1
+ 1.0
+ 990.00
+
+ product_a
+ product_a
+
+ E
+ 0.0
+
+ VAT
+
+
+
+
+ 990.0
+
+
+
diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py
index 89035abef3e5b..634fcc6014d48 100644
--- a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py
+++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py
@@ -547,6 +547,26 @@ def test_export_tax_exempt(self):
)
self._assert_invoice_attachment(invoice, None, 'from_odoo/bis3_out_invoice_tax_exempt.xml')
+ def test_export_gln(self):
+ """ GLN was added in a fixup module account_add_gln. """
+ # TODO master: clean that skiptest, when the module account_add_gln is merged with account
+ if 'global_location_number' not in self.partner_2._fields:
+ self.skipTest("Fixup module with GLN not installed.")
+ self.partner_2.global_location_number = "222222222222"
+ invoice = self._generate_move(
+ self.partner_1,
+ self.partner_2,
+ move_type='out_invoice',
+ invoice_line_ids=[
+ {
+ 'product_id': self.product_a.id,
+ 'price_unit': 990.0,
+ 'tax_ids': [(6, 0, self.tax_0.ids)],
+ },
+ ],
+ )
+ self._assert_invoice_attachment(invoice, None, 'from_odoo/bis3_out_invoice_gln.xml')
+
####################################################
# Test import
####################################################
diff --git a/addons/l10n_fr_fec/__init__.py b/addons/l10n_fr_fec/__init__.py
index 58d2359634e5a..3efd90457dc09 100644
--- a/addons/l10n_fr_fec/__init__.py
+++ b/addons/l10n_fr_fec/__init__.py
@@ -3,4 +3,5 @@
# Copyright (C) 2013-2015 Akretion (http://www.akretion.com)
+from . import controllers
from . import wizard
diff --git a/addons/l10n_fr_fec/controllers/__init__.py b/addons/l10n_fr_fec/controllers/__init__.py
new file mode 100644
index 0000000000000..12a7e529b6741
--- /dev/null
+++ b/addons/l10n_fr_fec/controllers/__init__.py
@@ -0,0 +1 @@
+from . import main
diff --git a/addons/l10n_fr_fec/controllers/main.py b/addons/l10n_fr_fec/controllers/main.py
new file mode 100644
index 0000000000000..bb62897877c99
--- /dev/null
+++ b/addons/l10n_fr_fec/controllers/main.py
@@ -0,0 +1,14 @@
+from odoo import http
+from odoo.http import request
+
+
+class FecDownloadController(http.Controller):
+ @http.route('/download/fec_file/ ', type='http', auth='user')
+ def download_fec(self, wizard_id):
+ wizard = request.env['account.fr.fec'].browse(wizard_id)
+ content = wizard._get_fec_stream()
+
+ return request.make_response(content, [
+ ('Content-Type', 'text/csv'),
+ ('Content-Disposition', f'attachment; filename={wizard.filename};')
+ ])
diff --git a/addons/l10n_fr_fec/tests/test_wizard.py b/addons/l10n_fr_fec/tests/test_wizard.py
index ca5ed6aab38d5..92c9788be12a1 100644
--- a/addons/l10n_fr_fec/tests/test_wizard.py
+++ b/addons/l10n_fr_fec/tests/test_wizard.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-import base64
from datetime import timedelta
from freezegun import freeze_time
@@ -67,7 +66,6 @@ def setUpClass(cls, chart_template_ref='l10n_fr.l10n_fr_pcg_chart_template'):
cls.invoice_a.action_post()
def test_generate_fec_sanitize_pieceref(self):
- self.wizard.generate_fec()
expected_content = (
"JournalCode|JournalLib|EcritureNum|EcritureDate|CompteNum|CompteLib|CompAuxNum|CompAuxLib|PieceRef|PieceDate|EcritureLib|Debit|Credit|EcritureLet|DateLet|ValidDate|Montantdevise|Idevise\r\n"
"INV|Customer Invoices|INV/2021/00001|20210502|701000|Ventes de produits finis|||-|20210502|Hello Darkness|0,00| 000000000001437,12|||20210502|-000000000001437,12|EUR\r\n"
@@ -76,5 +74,7 @@ def test_generate_fec_sanitize_pieceref(self):
"INV|Customer Invoices|INV/2021/00001|20210502|445710|TVA collectée|||-|20210502|TVA 20,0%|0,00| 000000000001293,41|||20210502|-000000000001293,41|EUR\r\n"
f"INV|Customer Invoices|INV/2021/00001|20210502|411100|Clients - Ventes de biens ou de prestations de services|{self.partner_a.id}|partner_a|-|20210502|INV/2021/00001| 000000000007760,45|0,00|||20210502| 000000000007760,45|EUR"
)
- content = base64.b64decode(self.wizard.fec_data).decode()
+ data_generator = self.wizard.with_context(fec_test_mode=True)._get_fec_stream()
+ content_bytes = b''.join(list(data_generator))
+ content = content_bytes.decode('utf-8')
self.assertEqual(expected_content, content)
diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py
index 77b88b3250fa4..51055cf4d4d42 100644
--- a/addons/l10n_fr_fec/wizard/account_fr_fec.py
+++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py
@@ -3,11 +3,14 @@
# Copyright (C) 2013-2015 Akretion (http://www.akretion.com)
-import base64
+import contextlib
+import csv
import io
+from itertools import chain
from odoo import api, fields, models, _
from odoo.exceptions import UserError, AccessDenied
+from odoo.modules.registry import Registry
from odoo.tools import float_is_zero, pycompat
from odoo.tools.misc import get_lang
from stdnum.fr import siren
@@ -19,7 +22,7 @@ class AccountFrFec(models.TransientModel):
date_from = fields.Date(string='Start Date', required=True)
date_to = fields.Date(string='End Date', required=True)
- fec_data = fields.Binary('FEC File', readonly=True)
+ fec_data = fields.Binary('FEC File', readonly=True) # This field is not used anymore.
filename = fields.Char(string='Filename', size=256, readonly=True)
test_file = fields.Boolean()
export_type = fields.Selection([
@@ -102,26 +105,8 @@ def _get_company_legal_data(self, company):
else:
return company.vat
- def generate_fec(self):
- self.ensure_one()
- if not (self.env.is_admin() or self.env.user.has_group('account.group_account_user')):
- raise AccessDenied()
- # We choose to implement the flat file instead of the XML
- # file for 2 reasons :
- # 1) the XSD file impose to have the label on the account.move
- # but Odoo has the label on the account.move.line, so that's a
- # problem !
- # 2) CSV files are easier to read/use for a regular accountant.
- # So it will be easier for the accountant to check the file before
- # sending it to the fiscal administration
- today = fields.Date.today()
- if self.date_from > today or self.date_to > today:
- raise UserError(_('You could not set the start date or the end date in the future.'))
- if self.date_from >= self.date_to:
- raise UserError(_('The start date must be inferior to the end date.'))
-
- company = self.env.company
- company_legal_data = self._get_company_legal_data(company)
+ def _get_fec_stream(self):
+ company_id = self.env.company.id
header = [
u'JournalCode', # 0
@@ -144,269 +129,317 @@ def generate_fec(self):
u'Idevise', # 17
]
- rows_to_write = [header]
- # INITIAL BALANCE
- unaffected_earnings_account = self.env['account.account'].search([
- ('account_type', '=', 'equity_unaffected'),
- ('company_id', '=', company.id)
- ], limit=1)
- unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
- if unaffected_earnings_account:
- #compute the benefit/loss of last year to add in the initial balance of the current year earnings account
- unaffected_earnings_results = self._do_query_unaffected_earnings()
- unaffected_earnings_line = False
-
if self.pool['account.account'].name.translate:
lang = self.env.user.lang or get_lang(self.env).code
aa_name = f"COALESCE(aa.name->>'{lang}', aa.name->>'en_US')"
else:
aa_name = "aa.name"
- sql_query = f'''
- SELECT
- 'OUV' AS JournalCode,
- 'Balance initiale' AS JournalLib,
- 'OUVERTURE/' || %s AS EcritureNum,
- %s AS EcritureDate,
- MIN(aa.code) AS CompteNum,
- replace(replace(MIN({aa_name}), '|', '/'), '\t', '') AS CompteLib,
- '' AS CompAuxNum,
- '' AS CompAuxLib,
- '-' AS PieceRef,
- %s AS PieceDate,
- '/' AS EcritureLib,
- replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
- replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
- '' AS EcritureLet,
- '' AS DateLet,
- %s AS ValidDate,
- '' AS Montantdevise,
- '' AS Idevise,
- MIN(aa.id) AS CompteID
- FROM
- account_move_line aml
- LEFT JOIN account_move am ON am.id=aml.move_id
- JOIN account_account aa ON aa.id = aml.account_id
- WHERE
- am.date < %s
- AND am.company_id = %s
- AND aa.include_initial_balance = 't'
- '''
-
- # For official report: only use posted entries
- if self.export_type == "official":
- sql_query += '''
- AND am.state = 'posted'
- '''
-
- sql_query += '''
- GROUP BY aml.account_id, aa.account_type
- HAVING aa.account_type not in ('asset_receivable', 'liability_payable') AND round(sum(aml.balance), %s) != 0
- '''
- formatted_date_from = fields.Date.to_string(self.date_from).replace('-', '')
- date_from = self.date_from
- formatted_date_year = date_from.year
- currency_digits = 2
-
- self._cr.execute(
- sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id,
- currency_digits))
-
- for row in self._cr.fetchall():
- listrow = list(row)
- account_id = listrow.pop()
- if not unaffected_earnings_line:
- account = self.env['account.account'].browse(account_id)
- if account.account_type == 'equity_unaffected':
- #add the benefit/loss of previous fiscal year to the first unaffected earnings account found.
- unaffected_earnings_line = True
- current_amount = float(listrow[11].replace(',', '.')) - float(listrow[12].replace(',', '.'))
- unaffected_earnings_amount = float(unaffected_earnings_results[11].replace(',', '.')) - float(unaffected_earnings_results[12].replace(',', '.'))
- listrow_amount = current_amount + unaffected_earnings_amount
- if float_is_zero(listrow_amount, precision_digits=currency_digits):
- continue
- if listrow_amount > 0:
- listrow[11] = str(listrow_amount).replace('.', ',')
- listrow[12] = '0,00'
- else:
- listrow[11] = '0,00'
- listrow[12] = str(-listrow_amount).replace('.', ',')
- rows_to_write.append(listrow)
-
- #if the unaffected earnings account wasn't in the selection yet: add it manually
- if (not unaffected_earnings_line
- and unaffected_earnings_results
- and (unaffected_earnings_results[11] != '0,00'
- or unaffected_earnings_results[12] != '0,00')):
- #search an unaffected earnings account
- unaffected_earnings_account = self.env['account.account'].search([('account_type', '=', 'equity_unaffected'),
- ('company_id', '=', company.id)], limit=1)
- if unaffected_earnings_account:
- unaffected_earnings_results[4] = unaffected_earnings_account.code
- unaffected_earnings_results[5] = unaffected_earnings_account.name
- rows_to_write.append(unaffected_earnings_results)
-
- # INITIAL BALANCE - receivable/payable
- sql_query = f'''
- SELECT
- 'OUV' AS JournalCode,
- 'Balance initiale' AS JournalLib,
- 'OUVERTURE/' || %s AS EcritureNum,
- %s AS EcritureDate,
- MIN(aa.code) AS CompteNum,
- replace(MIN({aa_name}), '|', '/') AS CompteLib,
- CASE WHEN MIN(aa.account_type) IN ('asset_receivable', 'liability_payable')
- THEN
- CASE WHEN rp.ref IS null OR rp.ref = ''
- THEN rp.id::text
- ELSE replace(rp.ref, '|', '/')
- END
- ELSE ''
- END
- AS CompAuxNum,
- CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
- THEN COALESCE(replace(rp.name, '|', '/'), '')
- ELSE ''
- END AS CompAuxLib,
- '-' AS PieceRef,
- %s AS PieceDate,
- '/' AS EcritureLib,
- replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
- replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
- '' AS EcritureLet,
- '' AS DateLet,
- %s AS ValidDate,
- '' AS Montantdevise,
- '' AS Idevise,
- MIN(aa.id) AS CompteID
- FROM
- account_move_line aml
- LEFT JOIN account_move am ON am.id=aml.move_id
- LEFT JOIN res_partner rp ON rp.id=aml.partner_id
- JOIN account_account aa ON aa.id = aml.account_id
- WHERE
- am.date < %s
- AND am.company_id = %s
- AND aa.include_initial_balance = 't'
- '''
- # For official report: only use posted entries
- if self.export_type == "official":
- sql_query += '''
- AND am.state = 'posted'
- '''
+ def format_row(row):
+ with io.StringIO() as buf:
+ writer = csv.writer(buf, delimiter='|', lineterminator='\r\n')
+ writer.writerow(row)
+ return buf.getvalue().encode()
+
+ @contextlib.contextmanager
+ def get_cursor():
+ if self.env.context.get('fec_test_mode'):
+ yield self.env.cr
+ else:
+ with Registry(self.env.cr.dbname).cursor() as cr:
+ yield cr
+
+ def stream_header():
+ yield format_row(header)
+
+ def stream_initial_balance():
+ with get_cursor() as cr:
+ fec = self.with_env(self.env(cr=cr))
+ unaffected_earnings_account = fec.env['account.account'].search([
+ ('account_type', '=', 'equity_unaffected'),
+ ('company_id', '=', company_id)
+ ], limit=1)
+ unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
+ if unaffected_earnings_account:
+ # compute the benefit/loss of last year to add in the initial balance of the current year earnings account
+ unaffected_earnings_results = fec._do_query_unaffected_earnings()
+ unaffected_earnings_line = False
+
+ sql_query = f'''
+ SELECT
+ 'OUV' AS JournalCode,
+ 'Balance initiale' AS JournalLib,
+ 'OUVERTURE/' || %s AS EcritureNum,
+ %s AS EcritureDate,
+ MIN(aa.code) AS CompteNum,
+ replace(replace(MIN({aa_name}), '|', '/'), '\t', '') AS CompteLib,
+ '' AS CompAuxNum,
+ '' AS CompAuxLib,
+ '-' AS PieceRef,
+ %s AS PieceDate,
+ '/' AS EcritureLib,
+ replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
+ replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
+ '' AS EcritureLet,
+ '' AS DateLet,
+ %s AS ValidDate,
+ '' AS Montantdevise,
+ '' AS Idevise,
+ MIN(aa.id) AS CompteID
+ FROM
+ account_move_line aml
+ LEFT JOIN account_move am ON am.id=aml.move_id
+ JOIN account_account aa ON aa.id = aml.account_id
+ WHERE
+ am.date < %s
+ AND am.company_id = %s
+ AND aa.include_initial_balance = 't'
+ '''
+
+ # For official report: only use posted entries
+ if fec.export_type == "official":
+ sql_query += '''
+ AND am.state = 'posted'
+ '''
+
+ sql_query += '''
+ GROUP BY aml.account_id, aa.account_type
+ HAVING aa.account_type not in ('asset_receivable', 'liability_payable') AND round(sum(aml.balance), %s) != 0
+ '''
+ formatted_date_from = fields.Date.to_string(fec.date_from).replace('-', '')
+ date_from = fec.date_from
+ formatted_date_year = date_from.year
+ currency_digits = 2
+
+ fec._cr.execute(
+ sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, fec.date_from, company_id,
+ currency_digits))
+
+ for row in fec._cr.fetchall():
+ listrow = list(row)
+ account_id = listrow.pop()
+ if not unaffected_earnings_line:
+ account = fec.env['account.account'].browse(account_id)
+ if account.account_type == 'equity_unaffected':
+ # add the benefit/loss of previous fiscal year to the first unaffected earnings account found.
+ unaffected_earnings_line = True
+ current_amount = float(listrow[11].replace(',', '.')) - float(listrow[12].replace(',', '.'))
+ unaffected_earnings_amount = float(unaffected_earnings_results[11].replace(',', '.')) - float(unaffected_earnings_results[12].replace(',', '.'))
+ listrow_amount = current_amount + unaffected_earnings_amount
+ if float_is_zero(listrow_amount, precision_digits=currency_digits):
+ continue
+ if listrow_amount > 0:
+ listrow[11] = str(listrow_amount).replace('.', ',')
+ listrow[12] = '0,00'
+ else:
+ listrow[11] = '0,00'
+ listrow[12] = str(-listrow_amount).replace('.', ',')
+ yield format_row(listrow)
+
+ # if the unaffected earnings account wasn't in the selection yet: add it manually
+ if (not unaffected_earnings_line
+ and unaffected_earnings_results
+ and (unaffected_earnings_results[11] != '0,00'
+ or unaffected_earnings_results[12] != '0,00')):
+ # search an unaffected earnings account
+ unaffected_earnings_account = fec.env['account.account'].search([('account_type', '=', 'equity_unaffected'),
+ ('company_id', '=', company_id)], limit=1)
+ if unaffected_earnings_account:
+ unaffected_earnings_results[4] = unaffected_earnings_account.code
+ unaffected_earnings_results[5] = unaffected_earnings_account.name
+ yield format_row(unaffected_earnings_results)
+
+ # INITIAL BALANCE - receivable/payable
+ sql_query = f'''
+ SELECT
+ 'OUV' AS JournalCode,
+ 'Balance initiale' AS JournalLib,
+ 'OUVERTURE/' || %s AS EcritureNum,
+ %s AS EcritureDate,
+ MIN(aa.code) AS CompteNum,
+ replace(MIN({aa_name}), '|', '/') AS CompteLib,
+ CASE WHEN MIN(aa.account_type) IN ('asset_receivable', 'liability_payable')
+ THEN
+ CASE WHEN rp.ref IS null OR rp.ref = ''
+ THEN rp.id::text
+ ELSE replace(rp.ref, '|', '/')
+ END
+ ELSE ''
+ END
+ AS CompAuxNum,
+ CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
+ THEN COALESCE(replace(rp.name, '|', '/'), '')
+ ELSE ''
+ END AS CompAuxLib,
+ '-' AS PieceRef,
+ %s AS PieceDate,
+ '/' AS EcritureLib,
+ replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
+ replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
+ '' AS EcritureLet,
+ '' AS DateLet,
+ %s AS ValidDate,
+ '' AS Montantdevise,
+ '' AS Idevise,
+ MIN(aa.id) AS CompteID
+ FROM
+ account_move_line aml
+ LEFT JOIN account_move am ON am.id=aml.move_id
+ LEFT JOIN res_partner rp ON rp.id=aml.partner_id
+ JOIN account_account aa ON aa.id = aml.account_id
+ WHERE
+ am.date < %s
+ AND am.company_id = %s
+ AND aa.include_initial_balance = 't'
+ '''
+
+ # For official report: only use posted entries
+ if fec.export_type == "official":
+ sql_query += '''
+ AND am.state = 'posted'
+ '''
+
+ sql_query += '''
+ GROUP BY aml.account_id, aa.account_type, rp.ref, rp.id
+ HAVING aa.account_type in ('asset_receivable', 'liability_payable') AND round(sum(aml.balance), %s) != 0
+ '''
+ fec._cr.execute(
+ sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, fec.date_from, company_id,
+ currency_digits))
+
+ for row in fec._cr.fetchall():
+ listrow = list(row)
+ account_id = listrow.pop()
+ yield format_row(listrow)
+
+ def stream_lines():
+ with get_cursor() as cr:
+ fec = self.with_env(self.env(cr=cr))
+ if fec.pool['account.journal'].name.translate:
+ lang = fec.env.user.lang or get_lang(fec.env).code
+ aj_name = f"COALESCE(aj.name->>'{lang}', aj.name->>'en_US')"
+ else:
+ aj_name = "aj.name"
+
+ query_limit = int(fec.env['ir.config_parameter'].sudo().get_param('l10n_fr_fec.batch_size', 500000)) # To prevent memory errors when fetching the results
+
+ sql_query = f'''
+ SELECT
+ REGEXP_REPLACE(replace(aj.code, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS JournalCode,
+ REGEXP_REPLACE(replace({aj_name}, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS JournalLib,
+ REGEXP_REPLACE(replace(am.name, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS EcritureNum,
+ TO_CHAR(am.date, 'YYYYMMDD') AS EcritureDate,
+ aa.code AS CompteNum,
+ REGEXP_REPLACE(replace({aa_name}, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS CompteLib,
+ CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
+ THEN
+ CASE WHEN rp.ref IS null OR rp.ref = ''
+ THEN rp.id::text
+ ELSE replace(rp.ref, '|', '/')
+ END
+ ELSE ''
+ END
+ AS CompAuxNum,
+ CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
+ THEN COALESCE(REGEXP_REPLACE(replace(rp.name, '|', '/'), '[\\t\\r\\n]', ' ', 'g'), '')
+ ELSE ''
+ END AS CompAuxLib,
+ CASE WHEN am.ref IS null OR am.ref = ''
+ THEN '-'
+ ELSE REGEXP_REPLACE(replace(am.ref, '|', '/'), '[\\t\\r\\n]', ' ', 'g')
+ END
+ AS PieceRef,
+ TO_CHAR(COALESCE(am.invoice_date, am.date), 'YYYYMMDD') AS PieceDate,
+ CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/'
+ WHEN aml.name SIMILAR TO '[\\t|\\s|\\n]*' THEN '/'
+ ELSE REGEXP_REPLACE(replace(aml.name, '|', '/'), '[\\t\\n\\r]', ' ', 'g') END AS EcritureLib,
+ replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit,
+ replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit,
+ CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet,
+ CASE WHEN aml.full_reconcile_id IS NULL THEN '' ELSE TO_CHAR(rec.create_date, 'YYYYMMDD') END AS DateLet,
+ TO_CHAR(am.date, 'YYYYMMDD') AS ValidDate,
+ CASE
+ WHEN aml.amount_currency IS NULL OR aml.amount_currency = 0 THEN ''
+ ELSE replace(to_char(aml.amount_currency, '000000000000000D99'), '.', ',')
+ END AS Montantdevise,
+ CASE WHEN aml.currency_id IS NULL THEN '' ELSE rc.name END AS Idevise
+ FROM
+ account_move_line aml
+ LEFT JOIN account_move am ON am.id=aml.move_id
+ LEFT JOIN res_partner rp ON rp.id=aml.partner_id
+ JOIN account_journal aj ON aj.id = am.journal_id
+ JOIN account_account aa ON aa.id = aml.account_id
+ LEFT JOIN res_currency rc ON rc.id = aml.currency_id
+ LEFT JOIN account_full_reconcile rec ON rec.id = aml.full_reconcile_id
+ WHERE
+ am.date >= %s
+ AND am.date <= %s
+ AND am.company_id = %s
+ {"AND am.state = 'posted'" if fec.export_type == 'official' else ""}
+ ORDER BY
+ am.date,
+ am.name,
+ aml.id
+ LIMIT %s
+ OFFSET %s
+ '''
+
+ query_offset = 0
+ has_more_results = True
+ while has_more_results:
+ fec._cr.execute(
+ sql_query,
+ (fec.date_from, fec.date_to, company_id, query_limit + 1, query_offset)
+ )
+ query_offset += query_limit
+ has_more_results = fec._cr.rowcount > query_limit # we load one more result than the limit to check if there is more
+ query_results = fec._cr.fetchall()
+ for row in query_results[:query_limit]:
+ row = list(row)
+ yield format_row(row)
- sql_query += '''
- GROUP BY aml.account_id, aa.account_type, rp.ref, rp.id
- HAVING aa.account_type in ('asset_receivable', 'liability_payable') AND round(sum(aml.balance), %s) != 0
- '''
- self._cr.execute(
- sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id,
- currency_digits))
+ def remove_trailing_newline(rows_iterator):
+ """
+ Remove the trailing newline characters (\r\n) from the last row.
- for row in self._cr.fetchall():
- listrow = list(row)
- account_id = listrow.pop()
- rows_to_write.append(listrow)
+ Each row in the iterator ends with '\r\n', but the FEC file format
+ requires no trailing newline after the final row. This function
+ yields all rows unchanged except the last one, which has its
+ trailing 2 characters (the \r\n) stripped.
+ """
+ try:
+ current_row = next(rows_iterator)
+ except StopIteration:
+ return
- # LINES
- if self.pool['account.journal'].name.translate:
- lang = self.env.user.lang or get_lang(self.env).code
- aj_name = f"COALESCE(aj.name->>'{lang}', aj.name->>'en_US')"
- else:
- aj_name = "aj.name"
+ for next_row in rows_iterator:
+ yield current_row
+ current_row = next_row
- query_limit = int(self.env['ir.config_parameter'].sudo().get_param('l10n_fr_fec.batch_size', 500000)) # To prevent memory errors when fetching the results
+ yield current_row[:-2]
- sql_query = f'''
- SELECT
- REGEXP_REPLACE(replace(aj.code, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS JournalCode,
- REGEXP_REPLACE(replace({aj_name}, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS JournalLib,
- REGEXP_REPLACE(replace(am.name, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS EcritureNum,
- TO_CHAR(am.date, 'YYYYMMDD') AS EcritureDate,
- aa.code AS CompteNum,
- REGEXP_REPLACE(replace({aa_name}, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS CompteLib,
- CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
- THEN
- CASE WHEN rp.ref IS null OR rp.ref = ''
- THEN rp.id::text
- ELSE replace(rp.ref, '|', '/')
- END
- ELSE ''
- END
- AS CompAuxNum,
- CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
- THEN COALESCE(REGEXP_REPLACE(replace(rp.name, '|', '/'), '[\\t\\r\\n]', ' ', 'g'), '')
- ELSE ''
- END AS CompAuxLib,
- CASE WHEN am.ref IS null OR am.ref = ''
- THEN '-'
- ELSE REGEXP_REPLACE(replace(am.ref, '|', '/'), '[\\t\\r\\n]', ' ', 'g')
- END
- AS PieceRef,
- TO_CHAR(COALESCE(am.invoice_date, am.date), 'YYYYMMDD') AS PieceDate,
- CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/'
- WHEN aml.name SIMILAR TO '[\\t|\\s|\\n]*' THEN '/'
- ELSE REGEXP_REPLACE(replace(aml.name, '|', '/'), '[\\t\\n\\r]', ' ', 'g') END AS EcritureLib,
- replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit,
- replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit,
- CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet,
- CASE WHEN aml.full_reconcile_id IS NULL THEN '' ELSE TO_CHAR(rec.create_date, 'YYYYMMDD') END AS DateLet,
- TO_CHAR(am.date, 'YYYYMMDD') AS ValidDate,
- CASE
- WHEN aml.amount_currency IS NULL OR aml.amount_currency = 0 THEN ''
- ELSE replace(to_char(aml.amount_currency, '000000000000000D99'), '.', ',')
- END AS Montantdevise,
- CASE WHEN aml.currency_id IS NULL THEN '' ELSE rc.name END AS Idevise
- FROM
- account_move_line aml
- LEFT JOIN account_move am ON am.id=aml.move_id
- LEFT JOIN res_partner rp ON rp.id=aml.partner_id
- JOIN account_journal aj ON aj.id = am.journal_id
- JOIN account_account aa ON aa.id = aml.account_id
- LEFT JOIN res_currency rc ON rc.id = aml.currency_id
- LEFT JOIN account_full_reconcile rec ON rec.id = aml.full_reconcile_id
- WHERE
- am.date >= %s
- AND am.date <= %s
- AND am.company_id = %s
- {"AND am.state = 'posted'" if self.export_type == 'official' else ""}
- ORDER BY
- am.date,
- am.name,
- aml.id
- LIMIT %s
- OFFSET %s
- '''
+ return remove_trailing_newline(chain(stream_header(), stream_initial_balance(), stream_lines()))
- with io.BytesIO() as fecfile:
- csv_writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
-
- # Write header and initial balances
- for initial_row in rows_to_write:
- initial_row = list(initial_row)
- # We don't skip \n at then end of the file if there are only initial balances, for simplicity. An empty period export shouldn't happen IRL.
- initial_row[-1] += u'\r\n'
- csv_writer.writerow(initial_row)
-
- # Write current period's data
- query_offset = 0
- has_more_results = True
- while has_more_results:
- self._cr.execute(
- sql_query,
- (self.date_from, self.date_to, company.id, query_limit + 1, query_offset)
- )
- query_offset += query_limit
- has_more_results = self._cr.rowcount > query_limit # we load one more result than the limit to check if there is more
- query_results = self._cr.fetchall()
- for i, row in enumerate(query_results[:query_limit]):
- if i < len(query_results) - 1:
- # The file is not allowed to end with an empty line, so we can't use lineterminator on the writer
- row = list(row)
- row[-1] += u'\r\n'
- csv_writer.writerow(row)
+ def generate_fec(self):
+ self.ensure_one()
+ if not (self.env.is_admin() or self.env.user.has_group('account.group_account_user')):
+ raise AccessDenied()
+ # We choose to implement the flat file instead of the XML
+ # file for 2 reasons :
+ # 1) the XSD file impose to have the label on the account.move
+ # but Odoo has the label on the account.move.line, so that's a
+ # problem !
+ # 2) CSV files are easier to read/use for a regular accountant.
+ # So it will be easier for the accountant to check the file before
+ # sending it to the fiscal administration
+ today = fields.Date.today()
+ if self.date_from > today or self.date_to > today:
+ raise UserError(_('You could not set the start date or the end date in the future.'))
+ if self.date_from >= self.date_to:
+ raise UserError(_('The start date must be inferior to the end date.'))
- base64_result = base64.encodebytes(fecfile.getvalue())
+ company = self.env.company
+ company_legal_data = self._get_company_legal_data(company)
end_date = fields.Date.to_string(self.date_to).replace('-', '')
suffix = ''
@@ -414,7 +447,6 @@ def generate_fec(self):
suffix = '-NONOFFICIAL'
self.write({
- 'fec_data': base64_result,
# Filename = FECYYYYMMDD where YYYMMDD is the closing date
'filename': '%sFEC%s%s.csv' % (company_legal_data, end_date, suffix),
})
@@ -426,7 +458,7 @@ def generate_fec(self):
return {
'name': 'FEC',
'type': 'ir.actions.act_url',
- 'url': "web/content/?model=account.fr.fec&id=" + str(self.id) + "&filename_field=filename&field=fec_data&download=true&filename=" + self.filename,
+ 'url': f'/download/fec_file/{self.id}',
'target': 'self',
}
diff --git a/addons/l10n_it_edi/models/account_edi_format.py b/addons/l10n_it_edi/models/account_edi_format.py
index 2a13c63bf13ab..fe507d017e3d1 100644
--- a/addons/l10n_it_edi/models/account_edi_format.py
+++ b/addons/l10n_it_edi/models/account_edi_format.py
@@ -1215,7 +1215,7 @@ def _post_invoice_edi(self, invoices):
def _get_proxy_identification(self, company):
if self.code != 'fattura_pa':
- return super()._get_proxy_identification()
+ return super()._get_proxy_identification(company)
if not company.l10n_it_codice_fiscale:
raise UserError(_('Please fill your codice fiscale to be able to receive invoices from FatturaPA'))
diff --git a/addons/l10n_ma/__init__.py b/addons/l10n_ma/__init__.py
index 67dee8c60dbf8..be9f4fab00ae2 100644
--- a/addons/l10n_ma/__init__.py
+++ b/addons/l10n_ma/__init__.py
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import models
diff --git a/addons/l10n_ma/__manifest__.py b/addons/l10n_ma/__manifest__.py
index c792c5022e037..e996de567278b 100644
--- a/addons/l10n_ma/__manifest__.py
+++ b/addons/l10n_ma/__manifest__.py
@@ -24,6 +24,8 @@
'data/account_tax_report_data.xml',
'data/account_tax_data.xml',
'data/account_chart_template_data.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_company_views.xml',
],
'license': 'LGPL-3',
}
diff --git a/addons/l10n_ma/models/__init__.py b/addons/l10n_ma/models/__init__.py
new file mode 100644
index 0000000000000..89926f8b3be44
--- /dev/null
+++ b/addons/l10n_ma/models/__init__.py
@@ -0,0 +1,2 @@
+from . import base_document_layout
+from . import res_partner
diff --git a/addons/l10n_ma/models/base_document_layout.py b/addons/l10n_ma/models/base_document_layout.py
new file mode 100644
index 0000000000000..c78f94a651001
--- /dev/null
+++ b/addons/l10n_ma/models/base_document_layout.py
@@ -0,0 +1,15 @@
+from markupsafe import Markup
+
+from odoo import api, models
+
+
+class BaseDocumentLayout(models.TransientModel):
+ _inherit = 'base.document.layout'
+
+ @api.model
+ def _default_company_details(self):
+ # OVERRIDE web/models/base_document_layout
+ company_details = super()._default_company_details()
+ if self.env.company.country_code == 'MA':
+ company_details += Markup(' ICE: %s') % self.env.company.company_registry
+ return company_details
diff --git a/addons/l10n_ma/models/res_partner.py b/addons/l10n_ma/models/res_partner.py
new file mode 100644
index 0000000000000..4aefc07a6ec31
--- /dev/null
+++ b/addons/l10n_ma/models/res_partner.py
@@ -0,0 +1,12 @@
+from odoo import api, models, _
+from odoo.exceptions import ValidationError
+
+
+class ResPartner(models.Model):
+ _inherit = ['res.partner']
+
+ @api.constrains('company_registry', 'country_id')
+ def _check_company_registry_ma(self):
+ for record in self:
+ if record.country_code == 'MA' and record.company_registry and (len(record.company_registry) != 15 or not record.company_registry.isdigit()):
+ raise ValidationError(_("ICE number should have exactly 15 digits."))
diff --git a/addons/l10n_ma/views/res_company_views.xml b/addons/l10n_ma/views/res_company_views.xml
new file mode 100644
index 0000000000000..5f334e819247d
--- /dev/null
+++ b/addons/l10n_ma/views/res_company_views.xml
@@ -0,0 +1,17 @@
+
+
+
+ res.company.form.inherit.l10n_ma
+ res.company
+
+
+
+ 1
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_ma/views/res_partner_views.xml b/addons/l10n_ma/views/res_partner_views.xml
new file mode 100644
index 0000000000000..aacf0fcc9c2c2
--- /dev/null
+++ b/addons/l10n_ma/views/res_partner_views.xml
@@ -0,0 +1,13 @@
+
+
+
+ res.partner.form.inherit.l10n_ma
+ res.partner
+
+
+
+
+
+
+
+
diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
index 048f7ed748b8b..b027348317eba 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -567,6 +567,7 @@ def _action_done(self, feedback=False, attachment_ids=None):
for model, activity_data in self._classify_by_model().items():
records = self.env[model].browse(activity_data['record_ids'])
+ existing = records.exists() # in case record was cascade-deleted in DB, skipping unlink override
for record, activity in zip(records, activity_data['activities']):
# extract value to generate next activities
if activity.chaining_type == 'trigger':
@@ -574,22 +575,25 @@ def _action_done(self, feedback=False, attachment_ids=None):
next_activities_values.append(vals)
# post message on activity, before deleting it
- activity_message = record.message_post_with_view(
- 'mail.message_activity_done',
- values={
- 'activity': activity,
- 'feedback': feedback,
- 'display_assignee': activity.user_id != self.env.user
- },
- subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_activities'),
- mail_activity_type_id=activity.activity_type_id.id,
- attachment_ids=[Command.link(attachment_id) for attachment_id in attachment_ids] if attachment_ids else [],
- )
+ if record in existing:
+ activity_message = record.message_post_with_view(
+ 'mail.message_activity_done',
+ values={
+ 'activity': activity,
+ 'feedback': feedback,
+ 'display_assignee': activity.user_id != self.env.user
+ },
+ subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_activities'),
+ mail_activity_type_id=activity.activity_type_id.id,
+ attachment_ids=[Command.link(attachment_id) for attachment_id in attachment_ids] if attachment_ids else [],
+ )
+ else:
+ activity_message = self.env['mail.message']
# Moving the attachments in the message
# TODO: Fix void res_id on attachment when you create an activity with an image
# directly, see route /web_editor/attachment/add
- if activity_attachments[activity.id]:
+ if activity_attachments[activity.id] and activity_message:
message_attachments = self.env['ir.attachment'].browse(activity_attachments[activity.id])
if message_attachments:
message_attachments.write({
@@ -597,6 +601,9 @@ def _action_done(self, feedback=False, attachment_ids=None):
'res_model': activity_message._name,
})
activity_message.attachment_ids = message_attachments
+ # removing attachments linked to activity if record is missing
+ elif activity_attachments[activity.id]:
+ self.env['ir.attachment'].browse(activity_attachments[activity.id]).unlink()
messages += activity_message
next_activities = self.env['mail.activity']
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index 6fe334bed9aca..8d523cf1e3456 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -8,7 +8,7 @@
from collections import defaultdict
from odoo import _, api, Command, fields, models, modules, tools
-from odoo.exceptions import AccessError
+from odoo.exceptions import AccessError, MissingError
from odoo.osv import expression
from odoo.tools.misc import clean_context
@@ -563,6 +563,10 @@ def _generate_model_record_ids(msg_val, msg_ids):
def create(self, values_list):
tracking_values_list = []
for values in values_list:
+ if not (self.env.su or self.env.user.has_group('base.group_user')):
+ values.pop('author_id', None)
+ values.pop('email_from', None)
+ self = self.with_context({k: v for k, v in self.env.context.items() if k not in ['default_author_id', 'default_email_from']}) # noqa: PLW0642
if 'email_from' not in values: # needed to compute reply_to
_author_id, email_from = self.env['mail.thread']._message_compute_author(values.get('author_id'), email_from=None, raise_on_email=False)
values['email_from'] = email_from
@@ -664,6 +668,9 @@ def read(self, fields=None, load='_classic_read'):
return super(Message, self).read(fields=fields, load=load)
def write(self, vals):
+ if not (self.env.su or self.env.user.has_group('base.group_user')):
+ vals.pop('author_id', None)
+ vals.pop('email_from', None)
record_changed = 'model' in vals or 'res_id' in vals
if record_changed or 'message_type' in vals:
self._invalidate_documents()
@@ -846,6 +853,9 @@ def _message_format(self, fnames, format_reply=True, legacy=False):
for message in self:
if message.model and message.res_id:
thread_ids_by_model_name[message.model].add(message.res_id)
+ # filter missing records
+ for model_name, record_ids in thread_ids_by_model_name.items():
+ thread_ids_by_model_name[model_name] = self.env[model_name].browse(record_ids).exists().ids
for vals in vals_list:
message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
@@ -857,7 +867,7 @@ def _message_format(self, fnames, format_reply=True, legacy=False):
'id': message_sudo.author_guest_id.id,
'name': message_sudo.author_guest_id.name,
} if message_sudo.author_guest_id else [('clear',)]
- if message_sudo.model and message_sudo.res_id:
+ if message_sudo.model and message_sudo.res_id and message_sudo.res_id in thread_ids_by_model_name[message_sudo.model]:
record_name = self.env[message_sudo.model].browse(message_sudo.res_id).sudo().with_prefetch(thread_ids_by_model_name[message_sudo.model]).display_name
else:
record_name = False
@@ -1011,7 +1021,9 @@ def _notify_message_notification_update(self):
try:
record.check_access_rights('read')
record.check_access_rule('read')
- except AccessError:
+ except (MissingError, AccessError):
+ # record has been removed from db without cascading notif -> avoid crash at least
+ # access error -> just skip
continue
else:
messages += message
diff --git a/addons/mail/models/mail_message_schedule.py b/addons/mail/models/mail_message_schedule.py
index e8fa207404ba5..beed2df8d3061 100644
--- a/addons/mail/models/mail_message_schedule.py
+++ b/addons/mail/models/mail_message_schedule.py
@@ -64,10 +64,14 @@ def _send_notifications(self, default_notify_kwargs=None):
for model, schedules in self._group_by_model().items():
if model:
records = self.env[model].browse(schedules.mapped('mail_message_id.res_id'))
+ existing = records.exists()
else:
records = [self.env['mail.thread']] * len(schedules)
+ existing = records
for record, schedule in zip(records, schedules):
+ if record not in existing:
+ continue
notify_kwargs = dict(default_notify_kwargs or {}, skip_existing=True)
try:
schedule_notify_kwargs = json.loads(schedule.notification_parameters)
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index 00dee035e72c3..7d5babc44f39b 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -1437,6 +1437,9 @@ def _message_parse_extract_payload(self, message, save_original=False):
if part.get_content_type() == 'binary/octet-stream':
_logger.warning("Message containing an unexpected Content-Type 'binary/octet-stream', assuming 'application/octet-stream'")
part.replace_header('Content-Type', 'application/octet-stream')
+ if part.get_content_type() == '*/*':
+ _logger.warning("Message containing an unexpected Content-Type '*/*', assuming 'application/octet-stream'")
+ part.replace_header('Content-Type', 'application/octet-stream')
if part.get_content_type() == 'multipart/alternative':
alternative = True
if part.get_content_type() == 'multipart/mixed':
diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py
index ba4a4f85c0036..d5e874ad24f67 100644
--- a/addons/mail/models/res_partner.py
+++ b/addons/mail/models/res_partner.py
@@ -3,6 +3,7 @@
from odoo import _, api, fields, models, tools
from odoo.osv import expression
+from collections import defaultdict
class Partner(models.Model):
@@ -141,7 +142,23 @@ def _message_fetch_failed(self):
('mail_message_id.model', '!=', False),
('mail_message_id.res_id', '!=', 0),
], limit=100)
- return notifications.mail_message_id._message_notification_format()
+ found = defaultdict(list)
+ for message in notifications.mail_message_id:
+ found[message.model].append(message.res_id)
+ existing = {
+ model: set(self.env[model].browse(ids).exists().ids)
+ for model, ids in found.items()
+ }
+ valid = notifications.filtered(
+ lambda n: (
+ not n.mail_message_id.model or not n.mail_message_id.res_id or
+ n.mail_message_id.res_id in existing[n.mail_message_id.model]
+ )
+ )
+ lost = notifications - valid
+ if lost:
+ lost.sudo().unlink() # no unlink right except admin, ok to remove as lost anyway
+ return valid.mail_message_id._message_notification_format()
def _get_channels_as_member(self):
"""Returns the channels of the partner."""
diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py
index 7362b41bc272b..dc7886c1706eb 100644
--- a/addons/mail/tests/common.py
+++ b/addons/mail/tests/common.py
@@ -1004,13 +1004,18 @@ def assertMailNotifications(self, messages, recipients_info):
mbody in message.body and message.message_type == mtype and
message.subtype_id == msubtype
))
+ debug_info = '\n'.join(
+ f'Msg: message_type {message.message_type}, subtype {message.subtype_id.name}, content {message.body}'
+ for message in messages
+ )
else:
message = self.env['mail.message'].sudo().search([
('body', 'ilike', mbody),
('message_type', '=', mtype),
('subtype_id', '=', msubtype.id)
], limit=1, order='id DESC')
- self.assertTrue(message, 'Mail: not found message (content: %s, message_type: %s, subtype: %s)' % (mbody, mtype, msubtype.name))
+ debug_info = ''
+ self.assertTrue(message, 'Mail: not found message (content: %s, message_type: %s, subtype: %s\n%s)' % (mbody, mtype, msubtype.name, debug_info))
# check message values
message_values = message_info.get('message_values', {})
diff --git a/addons/mail_plugin/controllers/authenticate.py b/addons/mail_plugin/controllers/authenticate.py
index 190ff2b720bc6..96b2c9664588b 100644
--- a/addons/mail_plugin/controllers/authenticate.py
+++ b/addons/mail_plugin/controllers/authenticate.py
@@ -53,6 +53,12 @@ def auth_confirm(self, scope, friendlyname, redirect, info=None, do=None, **kw):
updated_redirect = parsed_redirect.replace(query=werkzeug.urls.url_encode(params))
return request.redirect(updated_redirect.to_url(), local=False)
+ @http.route(['/mail_plugin/auth/check_version'], type='json', auth="none", cors="*",
+ methods=['POST', 'OPTIONS'])
+ def auth_check_version(self):
+ """Allow to know if the module is installed and which addin version is supported."""
+ return 1
+
# In this case, an exception will be thrown in case of preflight request if only POST is allowed.
@http.route(['/mail_client_extension/auth/access_token', '/mail_plugin/auth/access_token'], type='json', auth="none", cors="*",
methods=['POST', 'OPTIONS'])
diff --git a/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js b/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js
index ea65727313818..27e1b8cde16e0 100644
--- a/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js
+++ b/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js
@@ -237,7 +237,7 @@ QUnit.module('favorite filter widget', (hooks) => {
},
records: [
{id: 1, name: 'Azure Interior'},
- {id: 2, name: 'Deco Addict'},
+ {id: 2, name: 'Acme Corporation'},
{id: 3, name: 'Marc Demo'},
]
};
diff --git a/addons/mrp/__init__.py b/addons/mrp/__init__.py
index 4e423d2b936c7..eb561a39ed4d1 100644
--- a/addons/mrp/__init__.py
+++ b/addons/mrp/__init__.py
@@ -19,9 +19,11 @@ def _pre_init_mrp(cr):
cr.execute("""ALTER TABLE "stock_move" ADD COLUMN "is_done" bool;""")
cr.execute("""UPDATE stock_move
SET is_done=COALESCE(state in ('done', 'cancel'), FALSE);""")
- cr.execute("""ALTER TABLE "stock_move" ADD COLUMN "unit_factor" double precision;""")
- cr.execute("""UPDATE stock_move
- SET unit_factor=1;""")
+ cr.execute('ALTER TABLE stock_move ADD COLUMN IF NOT EXISTS unit_factor double precision DEFAULT 1')
+ # `stock.move.bom_line_id` is created in this module, so its default value will be NULL.
+ # `stock.move._is_manual_consumption` always returns False when there is no `bom_line_id`;
+ # therefore, we can just initialize `manual_consumption` to FALSE by default.
+ cr.execute('ALTER TABLE stock_move ADD COLUMN IF NOT EXISTS manual_consumption bool DEFAULT FALSE')
def _create_warehouse_data(cr, registry):
""" This hook is used to add a default manufacture_pull_id, manufacture
diff --git a/addons/mrp/security/mrp_security.xml b/addons/mrp/security/mrp_security.xml
index 1618df9034168..260836d0a6e3f 100644
--- a/addons/mrp/security/mrp_security.xml
+++ b/addons/mrp/security/mrp_security.xml
@@ -47,9 +47,6 @@
-
-
-
mrp_production multi-company
diff --git a/addons/payment_ogone/models/__init__.py b/addons/payment_ogone/models/__init__.py
index 08dfb8af3427c..6b48b38cae362 100644
--- a/addons/payment_ogone/models/__init__.py
+++ b/addons/payment_ogone/models/__init__.py
@@ -1,4 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import payment_provider
+from . import payment_token
from . import payment_transaction
diff --git a/addons/payment_ogone/models/payment_provider.py b/addons/payment_ogone/models/payment_provider.py
index d5a971bfe5d1b..11e828fb0baac 100644
--- a/addons/payment_ogone/models/payment_provider.py
+++ b/addons/payment_ogone/models/payment_provider.py
@@ -44,6 +44,22 @@ def _compute_feature_support_fields(self):
'support_tokenization': True,
})
+ # === ACTION METHODS === #
+
+ def action_open_worldline_provider(self):
+ """Open the Worldline module form."""
+ worldline_module = self.env['ir.module.module'].search([
+ ('name', '=', 'payment_worldline'),
+ ])
+ return worldline_module and {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'ir.module.module',
+ 'res_id': worldline_module.id,
+ 'view_mode': 'form',
+ 'view_id': self.env.ref('base.module_form').id,
+ 'target': 'current',
+ }
+
#=== BUSINESS METHODS ===#
@api.model
diff --git a/addons/payment_ogone/models/payment_token.py b/addons/payment_ogone/models/payment_token.py
new file mode 100644
index 0000000000000..53bdcdaa5f302
--- /dev/null
+++ b/addons/payment_ogone/models/payment_token.py
@@ -0,0 +1,18 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class PaymentToken(models.Model):
+ _inherit = 'payment.token'
+
+ def write(self, values):
+ """Override to prevent archiving of tokens during Ogone to Worldline migration.
+
+ :param dict values: The values to write on the record.
+ :return: None if the write is skipped, otherwise the result of the parent write.
+ :rtype: bool or None
+ """
+ if self.env.context.get('skip_token_archival') and 'active' in values:
+ values.pop('active')
+ return super().write(values)
diff --git a/addons/payment_ogone/views/payment_provider_views.xml b/addons/payment_ogone/views/payment_provider_views.xml
index a85182a2d3312..5ece6ae9880a8 100644
--- a/addons/payment_ogone/views/payment_provider_views.xml
+++ b/addons/payment_ogone/views/payment_provider_views.xml
@@ -11,7 +11,12 @@
role="alert"
attrs="{'invisible': [('code', '!=', 'ogone')]}">
This provider is deprecated.
- Consider disabling it and moving to Stripe.
+ Please install
+
+ the Worldline provider
+
+ as a drop-in replacement. Your data and payment tokens will be automatically
+ migrated from Ogone to Worldline.
diff --git a/addons/payment_worldline/README.md b/addons/payment_worldline/README.md
new file mode 100644
index 0000000000000..8bcd155080f95
--- /dev/null
+++ b/addons/payment_worldline/README.md
@@ -0,0 +1,48 @@
+# Worldline
+
+## Technical details
+
+API: [Worldline Direct API](https://docs.direct.worldline-solutions.com/en/api-reference)
+version `2`
+
+This module integrates Worldline using the generic payment with redirection flow based on form
+submission provided by the `payment` module.
+
+This is achieved by following the [Hosted Checkout Page]
+(https://docs.direct.worldline-solutions.com/en/integration/basic-integration-methods/hosted-checkout-page)
+guide.
+
+## Supported features
+
+- Payment with redirection flow
+- Webhook notifications
+- Tokenization with payment
+
+## Not implemented features
+
+- Tokenization without payment
+- Manual capture
+- Refunds
+
+## Module history
+
+- `18.0`
+ - The first version of the module is merged. odoo/odoo#175194.
+- `17.0`
+ - Backported from v18.0. odoo/odoo#215206.
+- `16.0`
+ - Backported from v18.0. odoo/odoo#215758.
+
+## Testing instructions
+
+https://docs.direct.worldline-solutions.com/en/integration/how-to-integrate/test-cases/index
+
+Use any name, any date in the future, and any 3 or 4 digits CVC.
+
+### VISA
+
+**Card Number**: `4330264936344675`
+
+### 3D Secure 2 (VISA)
+
+**Card Number**: `4874970686672022`
diff --git a/addons/payment_worldline/__init__.py b/addons/payment_worldline/__init__.py
new file mode 100644
index 0000000000000..189e53b028e54
--- /dev/null
+++ b/addons/payment_worldline/__init__.py
@@ -0,0 +1,14 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import controllers
+from . import models
+
+from odoo.addons.payment import setup_provider, reset_payment_provider
+
+
+def post_init_hook(cr, registry):
+ setup_provider(cr, registry, 'worldline')
+
+
+def uninstall_hook(cr, registry):
+ reset_payment_provider(cr, registry, 'worldline')
diff --git a/addons/payment_worldline/__manifest__.py b/addons/payment_worldline/__manifest__.py
new file mode 100644
index 0000000000000..95adc71583ce9
--- /dev/null
+++ b/addons/payment_worldline/__manifest__.py
@@ -0,0 +1,24 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': "Payment Provider: Worldline",
+ 'category': 'Accounting/Payment Providers',
+ 'sequence': 350,
+ 'summary': "A French payment provider covering several European countries.",
+ 'description': " ", # Non-empty string to avoid loading the README file.
+ 'depends': ['payment'],
+ 'data': [
+ 'views/payment_provider_views.xml',
+ 'views/payment_worldline_templates.xml',
+
+ 'data/payment_provider_data.xml',
+ ],
+ 'assets': {
+ 'web.assets_frontend': [
+ 'payment_worldline/static/src/js/payment_form.js',
+ ],
+ },
+ 'post_init_hook': 'post_init_hook',
+ 'uninstall_hook': 'uninstall_hook',
+ 'license': 'LGPL-3',
+}
diff --git a/addons/payment_worldline/const.py b/addons/payment_worldline/const.py
new file mode 100644
index 0000000000000..b41dcaada0822
--- /dev/null
+++ b/addons/payment_worldline/const.py
@@ -0,0 +1,27 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+PAYMENT_METHOD_TYPES = [
+ {'name': 'bancontact', 'countries': ['BE'], 'currencies': ['EUR'], 'code': 3012},
+ {'name': 'eps', 'countries': ['AT'], 'currencies': ['EUR'], 'code': 5406},
+ {'name': 'ideal', 'countries': ['NL'], 'currencies': ['EUR'], 'code': 809},
+ {'name': 'p24', 'countries': ['DE', 'PL'], 'currencies': ['PLN'], 'code': 3124},
+]
+
+# Mapping of transaction states to Worldline's payment statuses.
+# See https://docs.direct.worldline-solutions.com/en/integration/api-developer-guide/statuses.
+PAYMENT_STATUS_MAPPING = {
+ 'pending': (
+ 'CREATED', 'REDIRECTED', 'AUTHORIZATION_REQUESTED', 'PENDING_CAPTURE', 'CAPTURE_REQUESTED'
+ ),
+ 'done': ('CAPTURED',),
+ 'cancel': ('CANCELLED',),
+ 'declined': ('REJECTED', 'REJECTED_CAPTURE'),
+}
+
+# Mapping of response codes indicating Worldline handled the request
+# See https://apireference.connect.worldline-solutions.com/s2sapi/v1/en_US/json/response-codes.html.
+VALID_RESPONSE_CODES = {
+ 200: 'Successful',
+ 201: 'Created',
+ 402: 'Payment Rejected',
+}
diff --git a/addons/payment_worldline/controllers/__init__.py b/addons/payment_worldline/controllers/__init__.py
new file mode 100644
index 0000000000000..80ee4da1c5ecc
--- /dev/null
+++ b/addons/payment_worldline/controllers/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import main
diff --git a/addons/payment_worldline/controllers/main.py b/addons/payment_worldline/controllers/main.py
new file mode 100644
index 0000000000000..75d0db5bd4052
--- /dev/null
+++ b/addons/payment_worldline/controllers/main.py
@@ -0,0 +1,106 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import hashlib
+import hmac
+import logging
+import pprint
+from base64 import b64encode
+
+from werkzeug.exceptions import Forbidden
+
+from odoo import http
+from odoo.exceptions import ValidationError
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class WorldlineController(http.Controller):
+ _return_url = '/payment/worldline/return'
+ _webhook_url = '/payment/worldline/webhook'
+
+ @http.route(_return_url, type='http', auth='public', methods=['GET'])
+ def worldline_return_from_checkout(self, **data):
+ """ Process the notification data sent by Worldline after redirection.
+
+ :param dict data: The notification data, including the provider id appended to the URL in
+ `_get_specific_rendering_values`.
+ """
+ _logger.info("Handling redirection from Worldline with data:\n%s", pprint.pformat(data))
+
+ provider_id = int(data['provider_id'])
+ provider_sudo = request.env['payment.provider'].sudo().browse(provider_id).exists()
+ if not provider_sudo or provider_sudo.code != 'worldline':
+ _logger.warning("Received payment data with invalid provider id.")
+ raise Forbidden()
+
+ # Fetch the checkout session data from Worldline.
+ checkout_session_data = provider_sudo._worldline_make_request(
+ f'hostedcheckouts/{data["hostedCheckoutId"]}', method='GET'
+ )
+ _logger.info(
+ "Response of '/hostedcheckouts/' request:\n%s",
+ pprint.pformat(checkout_session_data),
+ )
+ notification_data = checkout_session_data.get('createdPaymentOutput', {})
+
+ # Handle the notification data.
+ tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
+ 'worldline', notification_data
+ )
+ tx_sudo._handle_notification_data('worldline', notification_data)
+ return request.redirect('/payment/status')
+
+ @http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False)
+ def worldline_webhook(self):
+ """ Process the notification data sent by Worldline to the webhook.
+
+ See https://docs.direct.worldline-solutions.com/en/integration/api-developer-guide/webhooks.
+
+ :return: An empty string to acknowledge the notification.
+ :rtype: str
+ """
+ notification_data = request.get_json_data()
+ _logger.info(
+ "Notification received from Worldline with data:\n%s", pprint.pformat(notification_data)
+ )
+ try:
+ # Check the integrity of the notification.
+ tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
+ 'worldline', notification_data
+ )
+ received_signature = request.httprequest.headers.get('X-GCS-Signature')
+ request_data = request.httprequest.data
+ self._verify_notification_signature(request_data, received_signature, tx_sudo)
+
+ # Handle the notification data.
+ tx_sudo._handle_notification_data('worldline', notification_data)
+ except ValidationError: # Acknowledge the notification to avoid getting spammed.
+ _logger.exception("Unable to handle the notification data; skipping to acknowledge.")
+
+ return request.make_json_response('') # Acknowledge the notification.
+
+ @staticmethod
+ def _verify_notification_signature(request_data, received_signature, tx_sudo):
+ """ Check that the received signature matches the expected one.
+
+ :param dict|bytes request_data: The request data.
+ :param str received_signature: The signature to compare with the expected signature.
+ :param payment.transaction tx_sudo: The sudoed transaction referenced by the notification
+ data.
+ :return: None
+ :raise Forbidden: If the signatures don't match.
+ """
+ # Retrieve the received signature from the payload.
+ if not received_signature:
+ _logger.warning("Received notification with missing signature.")
+ raise Forbidden()
+
+ # Compare the received signature with the expected signature computed from the payload.
+ webhook_secret = tx_sudo.provider_id.worldline_webhook_secret
+ expected_signature = b64encode(
+ hmac.new(webhook_secret.encode(), request_data, hashlib.sha256).digest()
+ )
+ if not hmac.compare_digest(received_signature.encode(), expected_signature):
+ _logger.warning("Received notification with invalid signature.")
+ raise Forbidden()
diff --git a/addons/payment_worldline/data/neutralize.sql b/addons/payment_worldline/data/neutralize.sql
new file mode 100644
index 0000000000000..b9c7da57efb48
--- /dev/null
+++ b/addons/payment_worldline/data/neutralize.sql
@@ -0,0 +1,7 @@
+-- disable worldline payment provider
+UPDATE payment_provider
+ SET worldline_pspid = NULL,
+ worldline_api_key = NULL,
+ worldline_api_secret = NULL,
+ worldline_webhook_key = NULL,
+ worldline_webhook_secret = NULL;
diff --git a/addons/payment_worldline/data/payment_provider_data.xml b/addons/payment_worldline/data/payment_provider_data.xml
new file mode 100644
index 0000000000000..abe78bb534fd0
--- /dev/null
+++ b/addons/payment_worldline/data/payment_provider_data.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ Worldline
+
+
+
+ worldline
+
+ True
+
+
+
diff --git a/addons/payment_worldline/models/__init__.py b/addons/payment_worldline/models/__init__.py
new file mode 100644
index 0000000000000..08dfb8af3427c
--- /dev/null
+++ b/addons/payment_worldline/models/__init__.py
@@ -0,0 +1,4 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import payment_provider
+from . import payment_transaction
diff --git a/addons/payment_worldline/models/payment_provider.py b/addons/payment_worldline/models/payment_provider.py
new file mode 100644
index 0000000000000..ac6c745344f38
--- /dev/null
+++ b/addons/payment_worldline/models/payment_provider.py
@@ -0,0 +1,194 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import hashlib
+import hmac
+import logging
+import pprint
+from wsgiref.handlers import format_date_time
+
+import requests
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+from odoo.fields import Datetime
+
+from odoo.addons.payment_worldline import const
+
+_logger = logging.getLogger(__name__)
+
+
+class PaymentProvider(models.Model):
+ _inherit = 'payment.provider'
+
+ code = fields.Selection(
+ selection_add=[('worldline', "Worldline")], ondelete={'worldline': 'set default'}
+ )
+ worldline_pspid = fields.Char(string="Worldline PSPID", required_if_provider='worldline')
+ worldline_api_key = fields.Char(string="Worldline API Key", required_if_provider='worldline')
+ worldline_api_secret = fields.Char(
+ string="Worldline API Secret", required_if_provider='worldline'
+ )
+ worldline_webhook_key = fields.Char(
+ string="Worldline Webhook Key", required_if_provider='worldline'
+ )
+ worldline_webhook_secret = fields.Char(
+ string="Worldline Webhook Secret", required_if_provider='worldline'
+ )
+
+ # === COMPUTE METHODS === #
+
+ def _compute_feature_support_fields(self):
+ """ Override of `payment` to enable additional features. """
+ super()._compute_feature_support_fields()
+ self.filtered(lambda p: p.code == 'worldline').update({
+ 'support_tokenization': True,
+ })
+
+ # === BUSINESS METHODS === #
+
+ @api.model
+ def _setup_provider(self, code):
+ """Override of `payment` to migrate Ogone data including tokens to Worldline."""
+ super()._setup_provider(code)
+ if code != 'worldline':
+ return
+
+ ogone_providers = self.env['payment.provider'].search(
+ [('code', '=', 'ogone'), ('state', '!=', 'disabled')]
+ )
+ if not ogone_providers:
+ return
+
+ default_worldline_provider = self.env.ref(
+ 'payment_worldline.payment_provider_worldline', raise_if_not_found=False
+ )
+ if not default_worldline_provider:
+ return
+
+ # Migrate data from each Ogone provider to Worldline.
+ for ogone_provider in ogone_providers:
+ company = ogone_provider.company_id
+
+ # Create a new Worldline provider for every Ogone provider in the same company.
+ worldline_provider = default_worldline_provider.copy({'company_id': company.id})
+
+ # Copy the credentials from Ogone to Worldline.
+ worldline_provider.write({
+ 'name': _(
+ "Worldline (migrated from %(ogone_provider_name)s)",
+ ogone_provider_name=ogone_provider.name,
+ ),
+ 'allow_tokenization': ogone_provider.allow_tokenization,
+ 'maximum_amount': ogone_provider.maximum_amount,
+ 'available_country_ids': ogone_provider.available_country_ids.ids,
+ 'worldline_pspid': ogone_provider.ogone_pspid,
+ 'worldline_api_key': ogone_provider.ogone_userid,
+ 'worldline_api_secret': ogone_provider.ogone_password,
+ })
+
+ # Transfer tokens from Ogone to Worldline.
+ tokens = self.env['payment.token'].search([('provider_id', '=', ogone_provider.id)])
+ if tokens:
+ tokens.provider_id = worldline_provider.id
+ _logger.info(
+ "Transferred %d token(s) from Ogone provider %s to Worldline provider %s",
+ len(tokens), ogone_provider.name, worldline_provider.name
+ )
+
+ ogone_providers.with_context(skip_token_archival=True).state = 'disabled'
+
+ # Remove the Ogone account payment method.
+ account_payment_method = self.env.get('account.payment.method')
+ if account_payment_method:
+ account_payment_method.search([('code', '=', 'ogone')]).unlink()
+
+ def _worldline_make_request(self, endpoint, payload=None, method='POST', idempotency_key=None):
+ """ Make a request to Worldline API at the specified endpoint.
+
+ Note: self.ensure_one()
+
+ :param str endpoint: The endpoint to be reached by the request.
+ :param dict payload: The payload of the request.
+ :param str method: The HTTP method of the request.
+ :param str idempotency_key: The idempotency key to pass in the request.
+ :return: The JSON-formatted content of the response.
+ :rtype: dict
+ :raise ValidationError: If an HTTP error occurs.
+ """
+ self.ensure_one()
+
+ api_url = self._worldline_get_api_url()
+ url = f'{api_url}/v2/{self.worldline_pspid}/{endpoint}'
+ content_type = 'application/json; charset=utf-8' if method == 'POST' else ''
+ dt = format_date_time(Datetime.now().timestamp()) # Datetime in locale-independent RFC1123
+ signature = self._worldline_calculate_signature(
+ method, endpoint, content_type, dt, idempotency_key=idempotency_key
+ )
+ authorization_header = f'GCS v1HMAC:{self.worldline_api_key}:{signature}'
+ headers = {
+ 'Authorization': authorization_header,
+ 'Date': dt,
+ 'Content-Type': content_type,
+ }
+ if method == 'POST' and idempotency_key:
+ headers['X-GCS-Idempotence-Key'] = idempotency_key
+ try:
+ response = requests.request(method, url, json=payload, headers=headers, timeout=10)
+ try:
+ if response.status_code not in const.VALID_RESPONSE_CODES:
+ response.raise_for_status()
+ except requests.exceptions.HTTPError:
+ _logger.exception(
+ "Invalid API request at %s with data:\n%s", url, pprint.pformat(payload)
+ )
+ msg = ', '.join(
+ [error.get('message', '') for error in response.json().get('errors', [])]
+ )
+ raise ValidationError(
+ "Worldline: " + _("The communication with the API failed. Details: %s", msg)
+ )
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
+ _logger.exception("Unable to reach endpoint at %s", url)
+ raise ValidationError(
+ "Worldline: " + _("Could not establish the connection to the API.")
+ )
+ return response.json()
+
+ def _worldline_get_api_url(self):
+ """ Return the URL of the API corresponding to the provider's state.
+
+ :return: The API URL.
+ :rtype: str
+ """
+ if self.state == 'enabled':
+ return 'https://payment.direct.worldline-solutions.com'
+ else: # 'test'
+ return 'https://payment.preprod.direct.worldline-solutions.com'
+
+ def _worldline_calculate_signature(
+ self, method, endpoint, content_type, dt_rfc, idempotency_key=None
+ ):
+ """ Compute the signature for the provided data.
+
+ See https://docs.direct.worldline-solutions.com/en/integration/api-developer-guide/authentication.
+
+ :param str method: The HTTP method of the request
+ :param str endpoint: The endpoint to be reached by the request.
+ :param str content_type: The 'Content-Type' header of the request.
+ :param datetime.datetime dt_rfc: The timestamp of the request, in RFC1123 format.
+ :param str idempotency_key: The idempotency key to pass in the request.
+ :return: The calculated signature.
+ :rtype: str
+ """
+ # specific order required: method, content_type, date, custom headers, endpoint
+ values_to_sign = [method, content_type, dt_rfc]
+ if idempotency_key:
+ values_to_sign.append(f'x-gcs-idempotence-key:{idempotency_key}')
+ values_to_sign.append(f'/v2/{self.worldline_pspid}/{endpoint}')
+
+ signing_str = '\n'.join(values_to_sign) + '\n'
+ signature = hmac.new(
+ self.worldline_api_secret.encode(), signing_str.encode(), hashlib.sha256
+ )
+ return base64.b64encode(signature.digest()).decode('utf-8')
diff --git a/addons/payment_worldline/models/payment_transaction.py b/addons/payment_worldline/models/payment_transaction.py
new file mode 100644
index 0000000000000..472ff8e175f49
--- /dev/null
+++ b/addons/payment_worldline/models/payment_transaction.py
@@ -0,0 +1,357 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import pprint
+
+from werkzeug import urls
+
+from odoo import _, models
+from odoo.exceptions import UserError, ValidationError
+
+from odoo.addons.payment import utils as payment_utils
+from odoo.addons.payment_worldline import const
+from odoo.addons.payment_worldline.controllers.main import WorldlineController
+
+_logger = logging.getLogger(__name__)
+
+
+class PaymentTransaction(models.Model):
+ _inherit = 'payment.transaction'
+
+ def _compute_reference(self, provider_code, prefix=None, separator='-', **kwargs):
+ """ Override of `payment` to ensure that Worldline requirement for references is satisfied.
+
+ Worldline requires for references to be at most 30 characters long.
+
+ :param str provider_code: The code of the provider handling the transaction.
+ :param str prefix: The custom prefix used to compute the full reference.
+ :param str separator: The custom separator used to separate the prefix from the suffix.
+ :return: The unique reference for the transaction.
+ :rtype: str
+ """
+ reference = super()._compute_reference(
+ provider_code, prefix=prefix, separator=separator, **kwargs
+ )
+
+ # Return if not Worldline or within 30 chars Worldline transaction merchantReference limit
+ if provider_code != 'worldline' or len(reference) <= 30:
+ return reference
+
+ prefix = payment_utils.singularize_reference_prefix(prefix='WL')
+ return super()._compute_reference(
+ provider_code, prefix=prefix, separator=separator, **kwargs
+ )
+
+ def _get_specific_processing_values(self, processing_values):
+ """ Override of `payment` to redirect failed token-flow transactions.
+
+ If the financial institution insists on user authentication,
+ this override will reset the transaction, and switch the flow to redirect.
+
+ Note: self.ensure_one() from `_get_processing_values`.
+
+ :param dict processing_values: The generic processing values of the transaction.
+ :return: The dict of provider-specific processing values.
+ :rtype: dict
+ """
+ res = super()._get_specific_processing_values(processing_values)
+ if (
+ self.provider_code == 'worldline'
+ and self.operation == 'online_token'
+ and self.state == 'error'
+ and self.state_message.endswith('AUTHORIZATION_REQUESTED')
+ ):
+ # Tokenized payment failed due to 3-D Secure authentication request.
+ # Reset transaction to draft and switch to redirect flow.
+ self.write({'state': 'draft', 'operation': 'online_redirect'})
+ res['force_flow'] = 'redirect'
+ return res
+
+ def _get_specific_rendering_values(self, processing_values):
+ """ Override of `payment` to return Worldline-specific processing values.
+
+ Note: self.ensure_one() from `_get_processing_values`.
+
+ :param dict processing_values: The generic processing values of the transaction.
+ :return: The dict of provider-specific processing values.
+ :rtype: dict
+ """
+ res = super()._get_specific_rendering_values(processing_values)
+ if self.provider_code != 'worldline':
+ return res
+
+ checkout_session_data = self._worldline_create_checkout_session()
+ return {'api_url': checkout_session_data['redirectUrl']}
+
+ def _worldline_create_checkout_session(self):
+ """ Create a hosted checkout session and return the response data.
+
+ :return: The hosted checkout session data.
+ :rtype: dict
+ """
+ self.ensure_one()
+
+ base_url = self.provider_id.get_base_url()
+ return_route = WorldlineController._return_url
+ return_url_params = urls.url_encode({'provider_id': str(self.provider_id.id)})
+ return_url = f'{urls.url_join(base_url, return_route)}?{return_url_params}'
+ first_name, last_name = payment_utils.split_partner_name(self.partner_name)
+ payload = {
+ 'hostedCheckoutSpecificInput': {
+ 'locale': self.partner_lang or '',
+ 'returnUrl': return_url,
+ 'showResultPage': False,
+ },
+ 'cardPaymentMethodSpecificInput': {
+ 'authorizationMode': 'SALE', # Force the capture.
+ 'tokenize': self.tokenize,
+ },
+ 'order': {
+ 'amountOfMoney': {
+ 'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
+ 'currencyCode': self.currency_id.name,
+ },
+ 'customer': { # required to create a token and for some redirected payment methods
+ 'billingAddress': {
+ 'city': self.partner_city or '',
+ 'countryCode': self.partner_country_id.code or '',
+ 'state': self.partner_state_id.name or '',
+ 'street': self.partner_address or '',
+ 'zip': self.partner_zip or '',
+ },
+ 'contactDetails': {
+ 'emailAddress': self.partner_email or '',
+ 'phoneNumber': self.partner_phone or '',
+ },
+ 'personalInformation': {
+ 'name': {
+ 'firstName': first_name or '',
+ 'surname': last_name or '',
+ },
+ },
+ },
+ 'references': {
+ 'descriptor': self.reference,
+ 'merchantReference': self.reference,
+ },
+ },
+ }
+ country_code = self.partner_country_id and self.partner_country_id.code
+ currency_name = self.currency_id and self.currency_id.name
+
+ # Filter payment method codes based on country and currency
+ available_product_codes = [
+ pmt['code'] for pmt in const.PAYMENT_METHOD_TYPES
+ if (not pmt['countries'] or country_code in pmt['countries'])
+ and (not pmt['currencies'] or currency_name in pmt['currencies'])
+ ]
+
+ # Construct the payload dynamically
+ payload['hostedCheckoutSpecificInput']['paymentProductFilters'] = {
+ 'restrictTo': {
+ 'groups': ['cards'],
+ 'products': available_product_codes,
+ },
+ }
+
+ _logger.info(
+ "Sending '/hostedcheckouts' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(payload)
+ )
+ checkout_session_data = self.provider_id._worldline_make_request(
+ 'hostedcheckouts', payload=payload
+ )
+ _logger.info(
+ "Response of '/hostedcheckouts' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(checkout_session_data),
+ )
+ return checkout_session_data
+
+ def _send_payment_request(self):
+ """ Override of `payment` to send a payment request to Worldline.
+
+ Note: self.ensure_one()
+
+ :return: None
+ :raise UserError: If the transaction is not linked to a token.
+ """
+ super()._send_payment_request()
+ if self.provider_code != 'worldline':
+ return
+
+ # Prepare the payment request to Worldline.
+ if not self.token_id:
+ raise UserError("Worldline: " + _("The transaction is not linked to a token."))
+
+ payload = {
+ 'cardPaymentMethodSpecificInput': {
+ 'authorizationMode': 'SALE', # Force the capture.
+ 'token': self.token_id.provider_ref,
+ 'unscheduledCardOnFileRequestor': 'merchantInitiated',
+ 'unscheduledCardOnFileSequenceIndicator': 'subsequent',
+ },
+ 'order': {
+ 'amountOfMoney': {
+ 'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
+ 'currencyCode': self.currency_id.name,
+ },
+ 'references': {
+ 'merchantReference': self.reference,
+ },
+ },
+ }
+
+ # Make the payment request to Worldline.
+ response_content = self.provider_id._worldline_make_request(
+ 'payments',
+ payload=payload,
+ idempotency_key=payment_utils.generate_idempotency_key(
+ self, scope='payment_request_token'
+ ),
+ )
+
+ # Handle the payment request response.
+ _logger.info(
+ "Response of /payment request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(response_content),
+ )
+ self._handle_notification_data('worldline', response_content)
+
+ def _get_tx_from_notification_data(self, provider_code, notification_data):
+ """ Override of `payment` to find the transaction based on Worldline data.
+
+ :param str provider_code: The code of the provider that handled the transaction.
+ :param dict notification_data: The notification data sent by the provider.
+ :return: The transaction if found.
+ :rtype: payment.transaction
+ :raise ValidationError: If inconsistent data are received.
+ :raise ValidationError: If the data match no transaction.
+ """
+ tx = super()._get_tx_from_notification_data(provider_code, notification_data)
+ if provider_code != 'worldline' or len(tx) == 1:
+ return tx
+
+ # In case of failed payment, paymentResult could be given as a separate key
+ payment_result = notification_data.get('paymentResult', notification_data)
+ payment_output = payment_result.get('payment', {}).get('paymentOutput', {})
+ reference = payment_output.get('references', {}).get('merchantReference', '')
+ if not reference:
+ raise ValidationError(
+ "Worldline: " + _("Received data with missing merchant reference.")
+ )
+
+ tx = self.search([('reference', '=', reference), ('provider_code', '=', 'worldline')])
+ if not tx:
+ raise ValidationError(
+ "Worldline: " + _("No transaction found matching reference %s.", reference)
+ )
+
+ return tx
+
+ def _process_notification_data(self, notification_data):
+ """ Override of `payment' to process the transaction based on Worldline data.
+
+ Note: self.ensure_one()
+
+ :param dict notification_data: The notification data sent by the provider.
+ :return: None
+ :raise ValidationError: If inconsistent data are received.
+ """
+ super()._process_notification_data(notification_data)
+ if self.provider_code != 'worldline':
+ return
+
+ # In case of failed payment, paymentResult could be given as a separate key
+ payment_result = notification_data.get('paymentResult', notification_data)
+ payment_data = payment_result.get('payment', {})
+
+ # Update the provider reference.
+ self.provider_reference = payment_data.get('id', '').rsplit('_', 1)[0]
+
+ # Update the payment state.
+ status = payment_data.get('status')
+ if not status:
+ raise ValidationError("Worldline: " + _("Received data with missing payment state."))
+
+ payment_output = payment_data.get('paymentOutput', {})
+ if 'cardPaymentMethodSpecificOutput' in payment_output:
+ payment_method_data = payment_output['cardPaymentMethodSpecificOutput']
+ else:
+ payment_method_data = payment_output.get('redirectPaymentMethodSpecificOutput', {})
+ has_token_data = 'token' in payment_method_data
+ if status in const.PAYMENT_STATUS_MAPPING['pending']:
+ if status == 'AUTHORIZATION_REQUESTED' and self.operation in ('online_token', 'offline'):
+ self._set_error("Worldline: " + status)
+ elif (
+ self.operation == 'validation'
+ and status in {'PENDING_CAPTURE', 'CAPTURE_REQUESTED'}
+ and has_token_data
+ ):
+ self._worldline_tokenize_from_notification_data(payment_method_data)
+ self._set_done()
+ else:
+ self._set_pending()
+ elif status in const.PAYMENT_STATUS_MAPPING['done']:
+ if self.tokenize and has_token_data:
+ self._worldline_tokenize_from_notification_data(payment_method_data)
+ self._set_done()
+ else:
+ error_code = None
+ errors = payment_data.get('statusOutput', {}).get('errors')
+ if errors:
+ error_code = errors[0].get('errorCode')
+ if status in const.PAYMENT_STATUS_MAPPING['cancel']:
+ self._set_canceled("Worldline: " + _(
+ "Transaction cancelled with error code %(error_code)s.",
+ error_code=error_code,
+ ))
+ elif status in const.PAYMENT_STATUS_MAPPING['declined']:
+ self._set_error("Worldline: " + _(
+ "Transaction declined with error code %(error_code)s.",
+ error_code=error_code,
+ ))
+ else: # Classify unsupported payment status as the `error` tx state.
+ _logger.info(
+ "Received data with invalid payment status (%(status)s) for transaction with "
+ "reference %(ref)s.",
+ {'status': status, 'ref': self.reference},
+ )
+ self._set_error("Worldline: " + _(
+ "Received invalid transaction status %(status)s with error code "
+ "%(error_code)s.",
+ status=status,
+ error_code=error_code,
+ ))
+
+ def _worldline_tokenize_from_notification_data(self, pm_data):
+ """ Create a new token based on the notification data.
+
+ Note: self.ensure_one()
+
+ :param dict pm_data: The payment method data sent by the provider
+ :return: None
+ """
+ self.ensure_one()
+ payment_product_id = pm_data.get('paymentProductId')
+
+ # Skip generating tokens for payment methods other than Card.
+ for pmt in const.PAYMENT_METHOD_TYPES:
+ if pmt['code'] == payment_product_id:
+ _logger.info(
+ "Skipping token creation for '%s' (code: %s)", pmt['name'], pmt['code']
+ )
+ return
+
+ token = self.env['payment.token'].create({
+ 'provider_id': self.provider_id.id,
+ 'payment_details': pm_data.get('card', {}).get('cardNumber', '')[-4:], # Padded with *
+ 'partner_id': self.partner_id.id,
+ 'provider_ref': pm_data['token'],
+ 'verified': True,
+ })
+ self.write({'token_id': token, 'tokenize': False})
+ _logger.info(
+ "Created token with id %(token_id)s for partner with id %(partner_id)s from "
+ "transaction with reference %(ref)s",
+ {'token_id': token.id, 'partner_id': self.partner_id.id, 'ref': self.reference},
+ )
diff --git a/addons/payment_worldline/static/description/icon.png b/addons/payment_worldline/static/description/icon.png
new file mode 100644
index 0000000000000..b925454678838
Binary files /dev/null and b/addons/payment_worldline/static/description/icon.png differ
diff --git a/addons/payment_worldline/static/description/icon.svg b/addons/payment_worldline/static/description/icon.svg
new file mode 100644
index 0000000000000..09d687e891d50
--- /dev/null
+++ b/addons/payment_worldline/static/description/icon.svg
@@ -0,0 +1 @@
+
diff --git a/addons/payment_worldline/static/src/js/payment_form.js b/addons/payment_worldline/static/src/js/payment_form.js
new file mode 100644
index 0000000000000..ade6d379217f1
--- /dev/null
+++ b/addons/payment_worldline/static/src/js/payment_form.js
@@ -0,0 +1,29 @@
+/** @odoo-module */
+
+import checkoutForm from 'payment.checkout_form';
+import manageForm from 'payment.manage_form';
+
+const worldlineMixin = {
+
+ /**
+ * Allow forcing redirect to 3-D Secure authentication for Ogone token flow.
+ *
+ * @override method from payment.payment_form_mixin
+ * @private
+ * @param {string} provider_code - The code of the token's provider
+ * @param {number} tokenId - The id of the token handling the transaction
+ * @param {object} processingValues - The processing values of the transaction
+ * @return {undefined}
+ */
+ _processTokenPayment: function (provider_code, tokenId, processingValues) {
+ if (provider_code === 'worldline' && processingValues.force_flow === 'redirect') {
+ delete processingValues.force_flow;
+ this._processRedirectPayment(...arguments);
+ } else {
+ this._super(...arguments);
+ }
+ }
+};
+
+checkoutForm.include(worldlineMixin);
+manageForm.include(worldlineMixin);
diff --git a/addons/payment_worldline/tests/__init__.py b/addons/payment_worldline/tests/__init__.py
new file mode 100644
index 0000000000000..12f4e0fbe15f1
--- /dev/null
+++ b/addons/payment_worldline/tests/__init__.py
@@ -0,0 +1,4 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import common
+from . import test_worldline
diff --git a/addons/payment_worldline/tests/common.py b/addons/payment_worldline/tests/common.py
new file mode 100644
index 0000000000000..be9a0c606bdcd
--- /dev/null
+++ b/addons/payment_worldline/tests/common.py
@@ -0,0 +1,155 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.payment import utils as payment_utils
+from odoo.addons.payment.tests.common import PaymentCommon
+
+
+class WorldlineCommon(PaymentCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.worldline = cls._prepare_provider('worldline', update_values={
+ 'worldline_pspid': 'dummy',
+ 'worldline_api_key': 'dummy',
+ 'worldline_api_secret': 'dummy',
+ 'worldline_webhook_key': 'dummy',
+ 'worldline_webhook_secret': 'dummy',
+ })
+
+ cls.provider = cls.worldline
+ cls.currency = cls.currency_euro
+ cls.notification_amount_and_currency = {
+ 'amount': payment_utils.to_minor_currency_units(cls.amount, cls.currency),
+ 'currencyCode': cls.currency.name,
+ }
+
+ cls.notification_data = {
+ 'payment': {
+ 'paymentOutput': {
+ 'references': {
+ 'merchantReference': cls.reference,
+ },
+ 'cardPaymentMethodSpecificOutput': {
+ 'paymentProductId': 1,
+ 'card': {
+ 'cardNumber': "******4242"
+ },
+ 'token': 'whateverToken'
+ },
+ 'amountOfMoney': cls.notification_amount_and_currency,
+ },
+ 'id': '1234567890_0',
+ 'status': 'CAPTURED',
+ },
+ }
+
+ cls.notification_data_insufficient_funds = {
+ 'errorId': 'ffffffff-fff-fffff-ffff-ffffffffffff',
+ 'errors': [{
+ 'category': 'IO_ERROR',
+ 'code': '9999',
+ 'errorCode': '30511001',
+ 'httpStatusCode': 402,
+ 'id': 'EXTERNAL_ACQUIRER_ERROR',
+ 'message': '',
+ 'retriable': False,
+ }],
+ 'paymentResult': {
+ 'creationOutput': {
+ 'externalReference': 'aaaaaaaa-5555-eeee-eeee-eeeeeeeeeeee',
+ 'isNewToken': False,
+ 'token': 'aaaaaaaa-5555-eeee-eeee-eeeeeeeeeeee',
+ 'tokenizationSucceeded': False,
+ },
+ 'payment': {
+ 'id': '7777777000_0',
+ 'paymentOutput': {
+ 'acquiredAmount': {'amount': 0, 'currencyCode': 'EUR'},
+ 'amountOfMoney': cls.notification_amount_and_currency,
+ 'cardPaymentMethodSpecificOutput': {
+ 'acquirerInformation': {'name': "Test Pay"},
+ 'card': {
+ 'bin': '50010000',
+ 'cardNumber': '************7777',
+ 'countryCode': 'BE',
+ 'expiryDate': '1244',
+ },
+ 'fraudResults': {'cvvResult': 'P', 'fraudServiceResult': 'accepted'},
+ 'paymentProductId': 3,
+ 'threeDSecureResults': {'eci': '9', 'xid': 'zOMTQ5TcODUxMg=='},
+ 'token': 'aaaaaaaa-5555-eeee-eeee-eeeeeeeeeeee',
+ },
+ 'customer': {'device': {'ipAddressCountryCode': '99'}},
+ 'paymentMethod': 'card',
+ 'references': {'merchantReference': cls.reference},
+ },
+ 'status': 'REJECTED',
+ 'statusOutput': {
+ 'errors': [{
+ 'category': 'IO_ERROR',
+ 'code': '9999',
+ 'errorCode': '30511001',
+ 'httpStatusCode': 402,
+ 'id': 'EXTERNAL_ACQUIRER_ERROR',
+ 'message': '',
+ 'retriable': False,
+ }],
+ 'isAuthorized': False,
+ 'isCancellable': False,
+ 'isRefundable': False,
+ 'statusCategory': 'UNSUCCESSFUL',
+ 'statusCode': 2,
+ },
+ },
+ },
+ }
+
+ cls.notification_data_expired_card = {
+ 'apiFullVersion': 'v1.1',
+ 'apiVersion': 'v1',
+ 'created': '2025-02-20T03:09:47.3706109+01:00',
+ 'id': 'ffffffff-fff-fffff-ffff-ffffffffffff',
+ 'merchantId': 'MyCompany',
+ 'payment': {
+ 'id': '9999999999_0',
+ 'paymentOutput': {
+ 'acquiredAmount': {'amount': 0, 'currencyCode': 'EUR'},
+ 'amountOfMoney': cls.notification_amount_and_currency,
+ 'cardPaymentMethodSpecificOutput': {
+ 'acquirerInformation': {'name': "Test Pay"},
+ 'card': {
+ 'bin': '47777700',
+ 'cardNumber': '************9999',
+ 'countryCode': 'FR',
+ 'expiryDate': '1234',
+ },
+ 'fraudResults': {'cvvResult': 'P', 'fraudServiceResult': 'accepted'},
+ 'paymentProductId': 1,
+ 'threeDSecureResults': {'eci': '9'},
+ 'token': 'ODOO-ALIAS-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'},
+ 'customer': {'device': {'ipAddressCountryCode': '99'}},
+ 'paymentMethod': 'card',
+ 'references': {'merchantReference': cls.reference},
+ },
+ 'status': 'REJECTED',
+ 'statusOutput': {
+ 'errors': [{
+ 'category': 'PAYMENT_PLATFORM_ERROR',
+ 'code': '9999',
+ 'errorCode': '30331001',
+ 'httpStatusCode': 402,
+ 'id': 'INVALID_CARD',
+ 'message': '',
+ 'retriable': False,
+ }],
+ 'isAuthorized': False,
+ 'isCancellable': False,
+ 'isRefundable': False,
+ 'statusCategory': 'UNSUCCESSFUL',
+ 'statusCode': 2
+ },
+ },
+ 'type': 'payment.rejected',
+ }
diff --git a/addons/payment_worldline/tests/test_worldline.py b/addons/payment_worldline/tests/test_worldline.py
new file mode 100644
index 0000000000000..0433eec77351e
--- /dev/null
+++ b/addons/payment_worldline/tests/test_worldline.py
@@ -0,0 +1,160 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import hashlib
+import hmac
+import json
+from base64 import b64encode
+from unittest.mock import patch
+
+from werkzeug.exceptions import Forbidden
+
+from odoo.tests import tagged
+from odoo.tools import mute_logger
+
+from odoo.addons.payment.tests.http_common import PaymentHttpCommon
+from odoo.addons.payment_worldline.controllers.main import WorldlineController
+from odoo.addons.payment_worldline.tests.common import WorldlineCommon
+
+
+@tagged('post_install', '-at_install')
+class WorldlineTest(WorldlineCommon, PaymentHttpCommon):
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def _webhook_notification_flow(self, payload):
+ """ Send a notification to the webhook, ignore the signature, and check the response. """
+ url = self._build_url(WorldlineController._webhook_url)
+ with patch(
+ 'odoo.addons.payment_worldline.controllers.main.WorldlineController'
+ '._verify_notification_signature'
+ ):
+ response = self._make_json_request(url, data=payload)
+ self.assertEqual(
+ response.json(), '', msg="The webhook should always respond ''.",
+ )
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_webhook_notification_confirms_transaction(self):
+ """ Test the processing of a webhook notification. """
+ tx = self._create_transaction('redirect')
+ self.assertFalse(tx.tokenize, "No token should be asked.")
+ self._webhook_notification_flow(self.notification_data)
+ self.assertFalse(tx.token_id, "No token should be created.")
+ self.assertEqual(tx.state, 'done')
+ self.assertEqual(tx.provider_reference, '1234567890')
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_webhook_notification_creates_token(self):
+ """ Test the processing of a webhook notification when creating a token. """
+ tx = self._create_transaction('redirect', tokenize=True)
+ self.assertTrue(tx.tokenize, "A token should be asked.")
+ self._webhook_notification_flow(self.notification_data)
+ self.assertEqual(tx.state, 'done')
+ self.assertFalse(tx.tokenize, "No token should be asked any more.")
+ self.assertTrue(tx.token_id, "A token should have been created and linked to the tx.")
+ self.assertEqual(tx.token_id.provider_ref, 'whateverToken')
+ self.assertEqual(tx.token_id.payment_details, '4242')
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_failed_webhook_notification_set_tx_as_error_1(self):
+ """ Test the processing of a webhook notification for a failed transaction. """
+ tx = self._create_transaction('redirect')
+ test = self.notification_data_insufficient_funds
+ self._webhook_notification_flow(test)
+ self.assertEqual(tx.state, 'error')
+ self.assertEqual(
+ tx.state_message,
+ "Worldline: Transaction declined with error code 30511001.",
+ )
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_failed_webhook_notification_set_tx_as_error_2(self):
+ """ Test the processing of a webhook notification for a failed transaction. """
+ tx = self._create_transaction('redirect')
+ test = self.notification_data_expired_card
+ self._webhook_notification_flow(test)
+ self.assertEqual(tx.state, 'error')
+ self.assertEqual(
+ tx.state_message,
+ "Worldline: Transaction declined with error code 30331001.",
+ )
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_failed_webhook_notification_set_tx_as_cancel(self):
+ """Test the processing of a webhook notification for a cancelled transaction."""
+ tx = self._create_transaction('redirect')
+ test = {
+ 'payment': {
+ 'paymentOutput': self.notification_data['payment']['paymentOutput'],
+ 'hostedCheckoutSpecificOutput': {
+ 'hostedCheckoutId': '123456789',
+ },
+ 'status': 'CANCELLED',
+ 'statusOutput': {
+ 'errors': [{
+ 'errorCode': '30171001',
+ }],
+ },
+ },
+ }
+ self._webhook_notification_flow(test)
+ self.assertEqual(tx.state, 'cancel')
+ self.assertEqual(
+ tx.state_message,
+ "Worldline: Transaction cancelled with error code 30171001.",
+ )
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_webhook_notification_triggers_signature_check(self):
+ """ Test that receiving a webhook notification triggers a signature check. """
+ self._create_transaction('redirect')
+ url = self._build_url(WorldlineController._webhook_url)
+ with patch(
+ 'odoo.addons.payment_worldline.controllers.main.WorldlineController'
+ '._verify_notification_signature'
+ ) as signature_check_mock, patch(
+ 'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
+ '._handle_notification_data'
+ ):
+ self._make_json_request(url, data=self.notification_data)
+ self.assertEqual(signature_check_mock.call_count, 1)
+
+ def test_accept_notification_with_valid_signature(self):
+ """ Test the verification of a notification with a valid signature. """
+ tx = self._create_transaction('redirect')
+ unencoded_result = hmac.new(
+ self.worldline.worldline_webhook_secret.encode(),
+ json.dumps(self.notification_data).encode(),
+ hashlib.sha256,
+ ).digest()
+ expected_signature = b64encode(unencoded_result)
+ self._assert_does_not_raise(
+ Forbidden,
+ WorldlineController._verify_notification_signature,
+ json.dumps(self.notification_data).encode(),
+ expected_signature,
+ tx,
+ )
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_reject_notification_with_missing_signature(self):
+ """ Test the verification of a notification with a missing signature. """
+ tx = self._create_transaction('redirect')
+ self.assertRaises(
+ Forbidden,
+ WorldlineController._verify_notification_signature,
+ json.dumps(self.notification_data).encode(),
+ None,
+ tx,
+ )
+
+ @mute_logger('odoo.addons.payment_worldline.controllers.main')
+ def test_reject_notification_with_invalid_signature(self):
+ """ Test the verification of a notification with an invalid signature. """
+ tx = self._create_transaction('redirect')
+ self.assertRaises(
+ Forbidden,
+ WorldlineController._verify_notification_signature,
+ json.dumps(self.notification_data).encode(),
+ 'dummy',
+ tx,
+ )
diff --git a/addons/payment_worldline/views/payment_provider_views.xml b/addons/payment_worldline/views/payment_provider_views.xml
new file mode 100644
index 0000000000000..0f74828d30edd
--- /dev/null
+++ b/addons/payment_worldline/views/payment_provider_views.xml
@@ -0,0 +1,53 @@
+
+
+
+
+ Worldline Provider Form
+ payment.provider
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/payment_worldline/views/payment_worldline_templates.xml b/addons/payment_worldline/views/payment_worldline_templates.xml
new file mode 100644
index 0000000000000..28813d66c9b4b
--- /dev/null
+++ b/addons/payment_worldline/views/payment_worldline_templates.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/addons/point_of_sale/static/tests/tours/point_of_sale.js b/addons/point_of_sale/static/tests/tours/point_of_sale.js
index fe2b376f90a0d..04452e6a3475a 100644
--- a/addons/point_of_sale/static/tests/tours/point_of_sale.js
+++ b/addons/point_of_sale/static/tests/tours/point_of_sale.js
@@ -98,8 +98,8 @@ odoo.define('point_of_sale.tour.pricelist', function (require) {
content: "open partner list",
trigger: "button.set-partner",
}, {
- content: "select Deco Addict",
- trigger: ".partner-line:contains('Deco Addict')",
+ content: "select Acme Corporation",
+ trigger: ".partner-line:contains('Acme Corporation')",
}, {
content: "click pricelist button",
trigger: ".control-button.o_pricelist_button",
diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py
index 9690a20526368..3ccfcda44f9bb 100644
--- a/addons/point_of_sale/tests/test_frontend.py
+++ b/addons/point_of_sale/tests/test_frontend.py
@@ -50,7 +50,7 @@ def setUpClass(cls, chart_template_ref=None):
})
env['res.partner'].create({
- 'name': 'Deco Addict',
+ 'name': 'Acme Corporation',
})
env['res.partner'].create([{
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 4d1b1bda2fa33..3bbea4b0a26a6 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -11,7 +11,7 @@
from odoo.osv import expression
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, format_amount, format_date, formatLang, get_lang, groupby
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
-from odoo.exceptions import UserError, ValidationError
+from odoo.exceptions import AccessDenied, UserError, ValidationError
class PurchaseOrder(models.Model):
@@ -697,6 +697,8 @@ def retrieve_dashboard(self):
""" This function returns the values to populate the custom dashboard in
the purchase order views.
"""
+ if not self.env.user._is_internal():
+ raise AccessDenied()
self.check_access_rights('read')
result = {
diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py
index a62722d00d50d..5f28785d1783f 100644
--- a/addons/sale_loyalty/models/sale_order.py
+++ b/addons/sale_loyalty/models/sale_order.py
@@ -1107,7 +1107,16 @@ def _try_apply_code(self, code):
if not program or not program.active:
return {'error': _('This code is invalid (%s).', code), 'not_found': True}
- elif (program.limit_usage and program.total_order_count >= program.max_usage) or\
+
+ # Lock the loyalty program row to block several processes that try to
+ # read it at the same time. We also use NOWAIT to make sure we trigger a
+ # serialization error when the processes don't have the lock and thus,
+ # trigger a retry of the transaction.
+ self.env.cr.execute("""
+ SELECT id FROM loyalty_program WHERE id=%s FOR UPDATE NOWAIT
+ """, (program.id,))
+
+ if (program.limit_usage and program.total_order_count >= program.max_usage) or\
(program.date_to and program.date_to < fields.Date.context_today(self)):
return {'error': _('This code is expired (%s).', code)}
diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml
index e6eecf9883a2c..fc04ae09fbc6b 100644
--- a/addons/sale_timesheet/data/sale_service_demo.xml
+++ b/addons/sale_timesheet/data/sale_service_demo.xml
@@ -224,10 +224,10 @@
4
-
+
- DECO
+ ACME
@@ -348,7 +348,7 @@
}"/>
-
+
+
+
+ Dummy rule, just to enable rule evaluation, shows some specific errors
+
+ [('email_from', '!=', 'donotsetmewiththisvalue')]
+
+
Public: public only
diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py
index ac107104b21fe..d0f3a940ff797 100644
--- a/addons/test_mail/tests/test_mail_activity.py
+++ b/addons/test_mail/tests/test_mail_activity.py
@@ -23,7 +23,10 @@ class TestActivityCommon(TestMailCommon):
@classmethod
def setUpClass(cls):
super(TestActivityCommon, cls).setUpClass()
- cls.test_record = cls.env['mail.test.activity'].with_context(cls._test_context).create({'name': 'Test'})
+ cls.test_record, cls.test_record_2 = cls.env['mail.test.activity'].create([
+ {'name': 'Test'},
+ {'name': 'Test_2'},
+ ])
# reset ctx
cls._reset_mail_context(cls.test_record)
@@ -339,7 +342,7 @@ def test_activity_mixin(self):
self.test_record.activity_feedback(
['test_mail.mail_act_test_todo'],
user_id=self.user_admin.id,
- feedback='Test feedback',)
+ feedback='Test feedback 1')
self.assertEqual(self.test_record.activity_ids, act2 | act3)
# Reschedule all activities, should update the record state
@@ -353,19 +356,20 @@ def test_activity_mixin(self):
# Perform todo activities for remaining people
self.test_record.activity_feedback(
['test_mail.mail_act_test_todo'],
- feedback='Test feedback')
+ feedback='Test feedback 2')
# Setting activities as done should delete them and post messages
self.assertEqual(self.test_record.activity_ids, act2)
- self.assertEqual(len(self.test_record.message_ids), 2)
- self.assertEqual(self.test_record.message_ids.mapped('subtype_id'), self.env.ref('mail.mt_activities'))
+ self.assertEqual(len(self.test_record.message_ids), 3)
+ feedback2, feedback1, _create_log = self.test_record.message_ids
+ self.assertEqual((feedback2 + feedback1).subtype_id, self.env.ref('mail.mt_activities'))
# Perform meeting activities
self.test_record.activity_unlink(['test_mail.mail_act_test_meeting'])
# Canceling activities should simply remove them
self.assertEqual(self.test_record.activity_ids, self.env['mail.activity'])
- self.assertEqual(len(self.test_record.message_ids), 2)
+ self.assertEqual(len(self.test_record.message_ids), 3, 'Should not produce additional message')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_activity_mixin_archive(self):
@@ -429,7 +433,7 @@ def test_feedback_w_attachments(self):
# Checking if the attachment has been forwarded to the message
# when marking an activity as "Done"
activity.action_feedback()
- activity_message = test_record.message_ids[-1]
+ activity_message = test_record.message_ids[0]
self.assertEqual(set(activity_message.attachment_ids.ids), set(attachments.ids))
for attachment in attachments:
self.assertEqual(attachment.res_id, activity_message.id)
@@ -694,6 +698,106 @@ def test_my_activity_flow_employee(self):
self.assertEqual(test_record_1, record)
+@tests.tagged("mail_activity", "post_install", "-at_install")
+class TestActivitySystray(TestActivityCommon):
+ """Test for systray_get_activities"""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.test_lead_records = cls.env['mail.test.multi.company.with.activity'].create([
+ {'name': 'Test Lead 1'},
+ {'name': 'Test Lead 2'},
+ {'name': 'Test Lead 3 (to remove)'}
+ ])
+ cls.deleted_record = cls.test_lead_records[2]
+ cls.dt_reference = datetime(2024, 1, 15, 8, 0, 0)
+
+ # records and leads and free activities
+ # have 1 record (or activity) for today, one for tomorrow
+ cls.test_activities = cls.env['mail.activity']
+ for record, summary, dt in (
+ (cls.test_record, "Summary Today'", cls.dt_reference),
+ (cls.test_record_2, "Summary Tomorrow'", cls.dt_reference + timedelta(days=1)),
+ (cls.test_lead_records[0], "Summary Today'", cls.dt_reference),
+ (cls.test_lead_records[1], "Summary Tomorrow'", cls.dt_reference + timedelta(days=1)),
+ (cls.test_lead_records[2], "Summary Tomorrow'", cls.dt_reference + timedelta(days=1)),
+ ):
+ cls.test_activities += record.with_user(cls.user_employee).activity_schedule(
+ "test_mail.mail_act_test_todo_generic",
+ date_deadline=dt.date(),
+ summary=summary,
+ user_id=cls.user_employee.id,
+ )
+ cls.test_lead_activities = cls.test_activities[2:]
+
+ # add atttachments on lead-like test records
+ cls.lead_act_attachments = cls.env['ir.attachment'].create(
+ cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-3]) +
+ cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-2]) +
+ cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-1])
+ )
+
+ # In the mean time, some FK deletes the record where the message is
+ # scheduled, skipping its unlink() override
+ cls.env.cr.execute(
+ f"DELETE FROM {cls.test_lead_records._table} WHERE id = %s", (cls.deleted_record.id,)
+ )
+ cls.env.invalidate_all()
+
+ @users("employee")
+ def test_systray_activities_for_various_records(self):
+ """Check that activities made on archived or not archived records, as
+ well as on removed record, to check systray activities behavior and
+ robustness. """
+ # archive record 1
+ self.test_record.action_archive()
+ self.assertFalse(self.test_activities[0].exists())
+
+ with freeze_time(self.dt_reference):
+ data = self.env['res.users'].with_user(self.user_employee).systray_get_activities()
+ self.assertEqual(len(data), 2, 'Should have activities for 2 test models')
+ for model_name, msg, (exp_total, exp_today, exp_planned, exp_overdue) in [
+ (self.test_record._name, 'Archiving removes activities', (0, 0, 1, 0)),
+ (self.test_lead_records._name, 'Planned do not count in total', (1, 1, 1, 0)),
+ ]:
+ with self.subTest(model_name=model_name, msg=msg):
+ group_values = next(values for values in data if values['model'] == model_name)
+ self.assertEqual(group_values['total_count'], exp_total)
+ self.assertEqual(group_values['today_count'], exp_today)
+ self.assertEqual(group_values['planned_count'], exp_planned)
+ self.assertEqual(group_values['overdue_count'], exp_overdue)
+
+ # check search results with removed records
+ self.env.invalidate_all()
+ test_with_removed = self.env['mail.activity'].sudo().search([
+ ('id', 'in', self.test_activities.ids),
+ ('res_model', '=', self.test_lead_records._name),
+ ])
+ self.assertEqual(len(test_with_removed), 3, 'Without ACL check, activities linked to removed records are kept')
+ test_with_removed = self.env['mail.activity'].search([
+ ('id', 'in', self.test_activities.ids),
+ ('res_model', '=', self.test_lead_records._name),
+ ])
+ self.assertEqual(len(test_with_removed), 2, 'Should filter out activities linked to removed records')
+
+ # be sure activities on removed records do not crash when managed, and that
+ # lost attachments are removed as well
+ self.env.invalidate_all()
+ lead_activities = self.test_lead_activities.with_user(self.user_employee)
+ lead_act_attachments = self.lead_act_attachments.with_user(self.user_employee)
+ self.assertEqual(len(lead_activities), 3, 'Simulate UI where activities are still displayed even if record removed')
+ self.assertEqual(len(lead_act_attachments), 3, 'Simulate UI where activities are still displayed even if record removed')
+ messages, _next_activities = lead_activities._action_done()
+ self.assertEqual(len(messages), 2, 'Should have posted one message / live record')
+ self.assertFalse(lead_activities.exists(), 'Mark done should unlink activities')
+ self.assertEqual(
+ set(lead_act_attachments.exists().mapped('res_id')), set(messages.ids),
+ 'Mark done should clean up attachments linked to removed record, and linked other attachments to messages')
+ self.assertEqual(
+ set(lead_act_attachments.exists().mapped('res_model')), set(['mail.message'] * 2))
+
+
@tests.tagged('mail_activity')
class TestORM(TestActivityCommon):
"""Test for read_progress_bar"""
diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py
index 3a2261adac2a5..9c177606d616c 100644
--- a/addons/test_mail/tests/test_mail_gateway.py
+++ b/addons/test_mail/tests/test_mail_gateway.py
@@ -41,6 +41,21 @@ def test_message_parse_and_replace_binary_octetstream(self):
"Content-Type 'binary/octet-stream', assuming 'application/octet-stream'"),
])
+ def test_message_parse_and_replace_wildcard(self):
+ """Incoming email containing a wrong Content-Type (*/*) as described in RFC2046/section-3"""
+ mail_with_wildcard_mime = self.format(test_mail_data.MAIL_PDF_MIME_TEMPLATE, pdf_mime="*/*")
+ self.assertIn("Content-Type: */*", mail_with_wildcard_mime, "Wildcard for content-type not found")
+ with self.assertLogs("odoo.addons.mail.models.mail_thread", level="WARNING") as capture:
+ extracted_mail = self.env['mail.thread'].message_parse(self.from_string(mail_with_wildcard_mime))
+
+ self.assertEqual(len(extracted_mail['attachments']), 1)
+ attachment = extracted_mail['attachments'][0]
+ self.assertEqual(attachment.fname, 'scan_soraya.lernout_1691652648.pdf')
+ self.assertEqual(capture.output, [
+ ("WARNING:odoo.addons.mail.models.mail_thread:Message containing an unexpected "
+ "Content-Type '*/*', assuming 'application/octet-stream'"),
+ ])
+
def test_message_parse_body(self):
# test pure plaintext
plaintext = self.format(test_mail_data.MAIL_TEMPLATE_PLAINTEXT, email_from='"Sylvie Lelitre" ')
diff --git a/addons/test_mail/tests/test_mail_message.py b/addons/test_mail/tests/test_mail_message.py
index 8001be40ed108..67cce70cd663d 100644
--- a/addons/test_mail/tests/test_mail_message.py
+++ b/addons/test_mail/tests/test_mail_message.py
@@ -1,10 +1,132 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import json
+
+from odoo.addons.base.models.ir_mail_server import MailDeliveryException
+from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.exceptions import UserError
from odoo.tools import is_html_empty, mute_logger, formataddr
-from odoo.tests import tagged, users
+from odoo.tests import tagged, users, HttpCase
+
+
+@tagged('mail_message', 'mail_controller', 'post_install', '-at_install')
+class TestMessageHelpersRobustness(TestMailCommon, HttpCase):
+ """ Test message helpers robustness, currently mainly linked to records
+ being removed from DB due to cascading deletion, which let side records
+ alive in DB. """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.user_employee_2 = mail_new_test_user(
+ cls.env,
+ email='eglantine@example.com',
+ groups='base.group_user',
+ login='employee2',
+ notification_type='email',
+ name='Eglantine Employee',
+ )
+ cls.partner_employee_2 = cls.user_employee_2.partner_id
+
+ cls.test_records_simple, _partners = cls._create_records_for_batch(
+ 'mail.test.simple', 3,
+ )
+
+ def setUp(self):
+ super().setUp()
+ # cleanup db
+ self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]).unlink()
+
+ # handy shortcut variables
+ self.deleted_record = self.test_records_simple[2]
+
+ # generate crashed notifications
+ with mute_logger('odoo.addons.mail.models.mail_mail'), self.mock_mail_gateway():
+ def _send_email(*args, **kwargs):
+ raise MailDeliveryException("Some exception")
+ self.send_email_mocked.side_effect = _send_email
+
+ for record in self.test_records_simple.with_user(self.user_employee):
+ record.message_post(
+ body="Setup",
+ message_type='comment',
+ partner_ids=self.partner_employee_2.ids,
+ subtype_id=self.env.ref('mail.mt_comment').id,
+ )
+
+ # In the mean time, some FK deletes the record where the message is
+ # # scheduled, skipping its unlink() override
+ self.env.cr.execute(
+ f"DELETE FROM {self.test_records_simple._table} WHERE id = %s", (self.deleted_record.id,)
+ )
+ self.env.invalidate_all()
+
+ def test_assert_initial_values(self):
+ notifs_by_employee = self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)])
+ self.assertEqual(
+ set(notifs_by_employee.mapped('mail_message_id.res_id')),
+ set(self.test_records_simple.ids)
+ )
+ self.assertEqual(len(notifs_by_employee), 3)
+ self.assertTrue(all(notif.notification_status == 'exception' for notif in notifs_by_employee))
+ self.assertTrue(all(notif.res_partner_id == self.partner_employee_2 for notif in notifs_by_employee))
+
+ def test_load_message_failures(self):
+ self.authenticate(self.user_employee.login, self.user_employee.login)
+ response = self.opener.post(
+ self.env.user.get_base_url() + '/mail/load_message_failures',
+ json={},
+ )
+ result = json.loads(response.content)['result']
+ self.assertEqual({r['res_id'] for r in result}, set(self.test_records_simple[:2].ids))
+ self.assertEqual(
+ set(self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]).mapped('mail_message_id.res_id')),
+ set((self.test_records_simple - self.deleted_record).ids),
+ 'Should have cleaned notifications linked to unexisting records'
+ )
+
+ def test_message_fetch(self):
+ # set notifications to unread, so that we can simulate inbox usage
+ p2_notifications = self.env['mail.notification'].search([('res_partner_id', '=', self.partner_employee_2.id)])
+ p2_notifications.is_read = False
+
+ self.authenticate(self.user_employee_2.login, self.user_employee_2.login)
+ response = self.opener.post(
+ self.env.user.get_base_url() + '/mail/inbox/messages',
+ json={},
+ )
+ result = json.loads(response.content)['result']
+ self.assertEqual(
+ {r['res_id'] for r in result}, set(self.test_records_simple.ids),
+ 'Currently reading message on missing record, crash avoided'
+ )
+ p2_notifications.with_user(self.user_employee_2).mail_message_id.set_message_done()
+
+ response = self.opener.post(
+ self.env.user.get_base_url() + '/mail/history/messages',
+ json={},
+ )
+ result = json.loads(response.content)['result']
+ self.assertEqual(
+ {r['res_id'] for r in result}, set(self.test_records_simple.ids),
+ 'Currently reading message on missing record, crash avoided'
+ )
+
+ def test_notify_cancel_by_type(self):
+ """ Test canceling notifications, notably when having missing records. """
+ self.env.invalidate_all()
+ notifs_by_employee = self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)])
+
+ # do not crash even if removed record
+ self.test_records_simple.with_user(self.user_employee).notify_cancel_by_type('email')
+ self.env.invalidate_all()
+
+ notifs_by_employee = notifs_by_employee.exists()
+ self.assertEqual(len(notifs_by_employee), 3, 'Currently keep notifications for missing records')
+ self.assertTrue(all(notif.notification_status == 'canceled' for notif in notifs_by_employee))
@tagged('mail_message')
diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py
index fec97e23de404..f055ad7850e14 100644
--- a/addons/test_mail/tests/test_message_post.py
+++ b/addons/test_mail/tests/test_message_post.py
@@ -49,6 +49,9 @@ def setUpClass(cls):
'name': 'Test',
'email_from': 'ignasse@example.com'
})
+ cls.test_records_simple, _partners = cls._create_records_for_batch(
+ 'mail.test.simple', 3,
+ )
cls.test_record_container = cls.env['mail.test.container.mc'].create({
'name': 'MC Container',
})
@@ -879,43 +882,69 @@ def test_message_post_schedule(self):
scheduled_datetime = now + timedelta(days=5)
self.user_admin.write({'notification_type': 'inbox'})
- test_record = self.test_record.with_env(self.env)
- test_record.message_subscribe((self.partner_1 | self.partner_admin).ids)
+ test_records = self.test_records_simple.with_env(self.env)
+ test_records.message_subscribe((self.partner_1 | self.partner_admin).ids)
+
+ # handy shortcut variables
+ deleted_record = test_records[2]
+ remaining_records = test_records - deleted_record
+ messages = self.env['mail.message']
with freeze_time(now), \
self.assertMsgWithoutNotifications(), \
self.capture_triggers(cron_id) as capt:
- msg = test_record.message_post(
- body='Test ',
- message_type='comment',
- subject='Subject',
- subtype_xmlid='mail.mt_comment',
- scheduled_date=scheduled_datetime,
- )
- self.assertEqual(capt.records.call_at, scheduled_datetime,
- msg='Should have created a cron trigger for the scheduled sending')
+ for test_record in test_records:
+ messages += test_record.message_post(
+ body=f'Test on {test_record.name} ',
+ message_type='comment',
+ subject=f'Subject for {test_record.name}',
+ subtype_xmlid='mail.mt_comment',
+ scheduled_date=scheduled_datetime,
+ )
+ self.assertEqual(
+ capt.records.mapped('call_at'), [scheduled_datetime] * 3,
+ msg='Should have created a cron trigger / scheduled post')
self.assertFalse(self._new_mails)
self.assertFalse(self._mails)
- schedules = self.env['mail.message.schedule'].sudo().search([('mail_message_id', '=', msg.id)])
- self.assertEqual(len(schedules), 1, msg='Should have scheduled the message')
- self.assertEqual(schedules.scheduled_datetime, scheduled_datetime)
+ schedules = self.env['mail.message.schedule'].sudo().search([('mail_message_id', 'in', messages.ids)])
+ self.assertEqual(len(schedules), 3, msg='Should have one scheduled record / message to post')
+ self.assertEqual(schedules.mapped('scheduled_datetime'), [scheduled_datetime] * 3)
# trigger cron now -> should not sent as in future
with freeze_time(now):
self.env['mail.message.schedule'].sudo()._send_notifications_cron()
- self.assertTrue(schedules.exists(), msg='Should not have sent the message')
+ self.assertTrue(schedules.exists(), msg='Should not have sent the messages')
+
+ # In the mean time, some FK deletes the record where the message is
+ # # scheduled, skipping its unlink() override
+ test_record_names = test_records.mapped('name')
+ self.env.cr.execute(
+ f"DELETE FROM {test_records._table} WHERE id = %s", (deleted_record.id,)
+ )
+ test_records.invalidate_recordset()
# Send the scheduled message from the cron at right date
with freeze_time(now + timedelta(days=5)), self.mock_mail_gateway(mail_unlink_sent=True):
self.env['mail.message.schedule'].sudo()._send_notifications_cron()
- self.assertFalse(schedules.exists(), msg='Should have sent the message')
+ self.assertFalse(schedules.exists(), msg='Should have sent the messages')
+
# check notifications have been sent
- recipients_info = [{'content': 'Test ', 'notif': [
- {'partner': self.partner_admin, 'type': 'inbox'},
- {'partner': self.partner_1, 'type': 'email'},
- ]}]
- self.assertMailNotifications(msg, recipients_info)
+ for msg, test_record, test_record_name in zip(messages, test_records, test_record_names):
+ with self.subTest(test_record_name=test_record_name):
+ if test_record != deleted_record:
+ # unlinked record -> skip notification
+ self.assertMailNotifications(msg, [{
+ 'content': f'Test on {test_record_name}',
+ 'email_values': {
+ 'subject': f'Subject for {test_record_name}',
+ },
+ 'notif': [
+ {'partner': self.partner_admin, 'type': 'inbox'},
+ {'partner': self.partner_1, 'type': 'email'},
+ ],
+ }])
+ self.assertEqual(len(self._new_mails), len(remaining_records), 'Should have skipped unlinked record')
# manually create a new schedule date, resend it -> should not crash (aka
# don't create duplicate notifications, ...)
@@ -932,7 +961,7 @@ def test_message_post_schedule(self):
with freeze_time(now), \
self.mock_mail_gateway(mail_unlink_sent=False), \
self.capture_triggers(cron_id) as capt:
- msg = test_record.message_post(
+ msg = test_records[0].message_post(
body='Test ',
message_type='comment',
subject='Subject',
diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py
index f892f7f1730cd..bc7de09e29b9a 100644
--- a/addons/test_mail/tests/test_performance.py
+++ b/addons/test_mail/tests/test_performance.py
@@ -272,7 +272,7 @@ def test_adv_activity_full(self):
# voip module read activity_type during create leading to one less query in enterprise on action_feedback
_category = activity.activity_type_id.category
- with self.assertQueryCount(admin=15, employee=15):
+ with self.assertQueryCount(admin=16, employee=16):
activity.action_feedback(feedback='Zizisse Done !')
@warmup
@@ -307,7 +307,7 @@ def test_adv_activity_mixin(self):
record.write({'name': 'Dupe write'})
- with self.assertQueryCount(admin=17, employee=17):
+ with self.assertQueryCount(admin=18, employee=18):
record.action_close('Dupe feedback')
self.assertEqual(record.activity_ids, self.env['mail.activity'])
@@ -333,7 +333,7 @@ def test_adv_activity_mixin_w_attachments(self):
record.write({'name': 'Dupe write'})
- with self.assertQueryCount(admin=21, employee=21): # com+tm 20/20
+ with self.assertQueryCount(admin=22, employee=22): # com+tm 20/20
record.action_close('Dupe feedback', attachment_ids=attachments.ids)
# notifications
@@ -404,7 +404,7 @@ def test_mail_composer_form_attachments(self):
composer_form.attachment_ids.add(attachment)
composer = composer_form.save()
- with self.assertQueryCount(admin=54, employee=54): # tm+com 47/47
+ with self.assertQueryCount(admin=55, employee=55): # tm+com 47/47
composer._action_send_mail()
# notifications
@@ -525,7 +525,7 @@ def test_mail_composer_w_template_form(self):
)
composer = composer_form.save()
- with self.assertQueryCount(admin=50, employee=50):
+ with self.assertQueryCount(admin=51, employee=51):
composer._action_send_mail()
# notifications
@@ -555,7 +555,7 @@ def test_mail_composer_w_template_form_attachments(self):
)
composer = composer_form.save()
- with self.assertQueryCount(admin=71, employee=71):
+ with self.assertQueryCount(admin=72, employee=72):
composer._action_send_mail()
# notifications
@@ -585,7 +585,7 @@ def test_message_assignation_email(self):
@warmup
def test_message_assignation_inbox(self):
record = self.env['mail.test.track'].create({'name': 'Test'})
- with self.assertQueryCount(admin=20, employee=20):
+ with self.assertQueryCount(admin=21, employee=21):
record.write({
'user_id': self.user_test.id,
})
@@ -635,7 +635,7 @@ def test_message_log_with_view(self):
def test_message_log_with_post(self):
record = self.env['mail.test.simple'].create({'name': 'Test'})
- with self.assertQueryCount(admin=7, employee=7):
+ with self.assertQueryCount(admin=8, employee=8):
record.message_post(
body='Test message_post as log ',
subtype_xmlid='mail.mt_note',
@@ -646,7 +646,7 @@ def test_message_log_with_post(self):
def test_message_post_no_notification(self):
record = self.env['mail.test.simple'].create({'name': 'Test'})
- with self.assertQueryCount(admin=7, employee=7):
+ with self.assertQueryCount(admin=8, employee=8):
record.message_post(
body='Test Post Performances basic ',
partner_ids=[],
@@ -671,7 +671,7 @@ def test_message_post_one_email_notification(self):
def test_message_post_one_inbox_notification(self):
record = self.env['mail.test.simple'].create({'name': 'Test'})
- with self.assertQueryCount(admin=18, employee=18):
+ with self.assertQueryCount(admin=20, employee=20):
record.message_post(
body='Test Post Performances with an inbox ping ',
partner_ids=self.user_test.partner_id.ids,
@@ -902,7 +902,7 @@ def test_complex_message_post_view(self):
}).create({})
composer._onchange_template_id_wrapper()
- with self.assertQueryCount(admin=141, employee=141):
+ with self.assertQueryCount(admin=151, employee=151):
messages_as_sudo = test_records.message_post_with_view(
'test_mail.mail_template_simple_test',
values={'partner': self.user_test.partner_id},
@@ -1204,7 +1204,7 @@ def test_message_format(self):
]
}])
- with self.assertQueryCount(employee=6):
+ with self.assertQueryCount(employee=7):
res = messages.message_format()
self.assertEqual(len(res), 2)
for message in res:
@@ -1213,7 +1213,7 @@ def test_message_format(self):
self.env.flush_all()
self.env.invalidate_all()
- with self.assertQueryCount(employee=19):
+ with self.assertQueryCount(employee=20):
res = messages.message_format()
self.assertEqual(len(res), 2)
for message in res:
@@ -1234,14 +1234,14 @@ def test_message_format_group_thread_name_by_model(self):
'res_id': record.id
} for record in records])
- with self.assertQueryCount(employee=5):
+ with self.assertQueryCount(employee=7):
res = messages.message_format()
self.assertEqual(len(res), 6)
self.env.flush_all()
self.env.invalidate_all()
- with self.assertQueryCount(employee=14):
+ with self.assertQueryCount(employee=16):
res = messages.message_format()
self.assertEqual(len(res), 6)
diff --git a/addons/test_mail_full/tests/test_mail_performance.py b/addons/test_mail_full/tests/test_mail_performance.py
index 0d37eb0254822..82ad20615ebde 100644
--- a/addons/test_mail_full/tests/test_mail_performance.py
+++ b/addons/test_mail_full/tests/test_mail_performance.py
@@ -81,7 +81,7 @@ def test_message_post_w_followers(self):
record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket.ids)
attachments = self.env['ir.attachment'].create(self.test_attachments_vals)
- with self.assertQueryCount(employee=91): # tmf: 60
+ with self.assertQueryCount(employee=92): # tmf: 60
new_message = record_ticket.message_post(
attachment_ids=attachments.ids,
body='Test Content ',
diff --git a/addons/test_mail_full/tests/test_rating.py b/addons/test_mail_full/tests/test_rating.py
index 057dc73ebb7f0..55206bcf27e29 100644
--- a/addons/test_mail_full/tests/test_rating.py
+++ b/addons/test_mail_full/tests/test_rating.py
@@ -141,7 +141,7 @@ def test_rating_last_value_perfs(self):
partners = self.env['res.partner'].sudo().create([
{'name': 'Jean-Luc %s' % (idx), 'email': 'jean-luc-%s@opoo.com' % (idx)} for idx in range(RECORD_COUNT)])
- with self.assertQueryCount(employee=1516): # tmf 1516 / com 5510
+ with self.assertQueryCount(employee=1614): # tmf 1614
record_ratings = self.env['mail.test.rating'].create([{
'customer_id': partners[idx].id,
'name': 'Test Rating',
@@ -149,13 +149,13 @@ def test_rating_last_value_perfs(self):
} for idx in range(RECORD_COUNT)])
self.flush_tracking()
- with self.assertQueryCount(employee=2004): # tmf 2004
+ with self.assertQueryCount(employee=2104): # tmf 2104
for record in record_ratings:
access_token = record._rating_get_access_token()
record.rating_apply(1, token=access_token)
self.flush_tracking()
- with self.assertQueryCount(employee=2003): # tmf 2003
+ with self.assertQueryCount(employee=2103): # tmf 2103
for record in record_ratings:
access_token = record._rating_get_access_token()
record.rating_apply(5, token=access_token)
diff --git a/addons/web/controllers/database.py b/addons/web/controllers/database.py
index 7168a5d2eebdf..ddafd973e8fd4 100644
--- a/addons/web/controllers/database.py
+++ b/addons/web/controllers/database.py
@@ -126,6 +126,8 @@ def backup(self, master_pwd, name, backup_format='zip'):
dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
try:
odoo.service.db.check_super(master_pwd)
+ if name not in http.db_list():
+ raise Exception("Database %r is not known" % name)
ts = datetime.datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S")
filename = "%s_%s.%s" % (name, ts, backup_format)
headers = [
diff --git a/addons/web/controllers/pivot.py b/addons/web/controllers/pivot.py
index 331daee43487b..1404afaf97046 100644
--- a/addons/web/controllers/pivot.py
+++ b/addons/web/controllers/pivot.py
@@ -30,8 +30,8 @@ def export_xlsx(self, data, **kw):
header_plain = workbook.add_format({'pattern': 1, 'bg_color': '#AAAAAA'})
bold = workbook.add_format({'bold': True})
- measure_count = jdata['measure_count']
- origin_count = jdata['origin_count']
+ measure_count = min(jdata['measure_count'], 100000)
+ origin_count = min(jdata['origin_count'], 100000)
# Step 1: writing col group headers
col_group_headers = jdata['col_group_headers']
@@ -50,11 +50,12 @@ def export_xlsx(self, data, **kw):
if cell['height'] > 1:
carry.append({'x': x, 'height': cell['height'] - 1})
x = x + measure_count * (2 * origin_count - 1)
- for j in range(header['width']):
+ width = min(header['width'], 100000)
+ for j in range(width):
worksheet.write(y, x + j, header['title'] if j == 0 else '', header_plain)
if header['height'] > 1:
carry.append({'x': x, 'height': header['height'] - 1})
- x = x + header['width']
+ x = x + width
while (carry and carry[0]['x'] == x):
cell = carry.popleft()
for j in range(measure_count * (2 * origin_count - 1)):
diff --git a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js
index 0397ee08d18e1..21a68e2cbb298 100644
--- a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js
+++ b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js
@@ -16,7 +16,18 @@ export class Many2ManyBinaryField extends Component {
}
get files() {
- return this.props.value.records.map((record) => record.data);
+ const attachments = this.props.value.records.map((record) => record.data);
+ const attachmentsNotSupported = this.props.record.data[this.props.attachmentsNotSupportedField]
+
+ if (this.props.record.resModel == 'account.invoice.send' && attachmentsNotSupported) {
+ for (const attachment of attachments) {
+ if (attachment.id && attachment.id in attachmentsNotSupported) {
+ attachment.tooltip = attachmentsNotSupported[attachment.id];
+ }
+ }
+ }
+
+ return attachments;
}
getUrl(id) {
@@ -52,6 +63,7 @@ Many2ManyBinaryField.components = {
Many2ManyBinaryField.props = {
...standardFieldProps,
acceptedFileExtensions: { type: String, optional: true },
+ attachmentsNotSupportedField: { type: String, optional: true },
className: { type: String, optional: true },
uploadText: { type: String, optional: true },
};
@@ -65,6 +77,7 @@ Many2ManyBinaryField.isEmpty = () => false;
Many2ManyBinaryField.extractProps = ({ attrs, field }) => {
return {
acceptedFileExtensions: attrs.options.accepted_file_extensions,
+ attachmentsNotSupportedField: attrs.options.attachments_not_supported_field,
className: attrs.class,
uploadText: field.string,
};
diff --git a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml
index c92e670dfb672..1567acb77e2b4 100644
--- a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml
+++ b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml
@@ -26,7 +26,7 @@
-
+
diff --git a/addons/web/static/src/webclient/barcode/crop_overlay.js b/addons/web/static/src/webclient/barcode/crop_overlay.js
index 7b38ac4e162d9..5bd4c93b13f45 100644
--- a/addons/web/static/src/webclient/barcode/crop_overlay.js
+++ b/addons/web/static/src/webclient/barcode/crop_overlay.js
@@ -2,6 +2,7 @@
import { Component, useRef, onPatched } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
+import { isIOS } from "@web/core/browser/feature_detection";
import { clamp } from "@web/core/utils/numbers";
export class CropOverlay extends Component {
@@ -17,6 +18,7 @@ export class CropOverlay extends Component {
onPatched(() => {
this.setupCropRect();
});
+ this.isIOS = isIOS();
}
setupCropRect() {
diff --git a/addons/web/static/src/webclient/barcode/crop_overlay.scss b/addons/web/static/src/webclient/barcode/crop_overlay.scss
index 132ba6e7a73cd..a6d83ef103fec 100644
--- a/addons/web/static/src/webclient/barcode/crop_overlay.scss
+++ b/addons/web/static/src/webclient/barcode/crop_overlay.scss
@@ -6,13 +6,16 @@
grid-column: 1 / -1;
}
- .o_crop_overlay {
+ .o_crop_overlay::after {
+ content: '';
+ display: block;
+ }
+
+ .o_crop_overlay:not(.o_crop_overlay_ios) {
background-color: RGB(0 0 0 / 0.75);
mix-blend-mode: darken;
&::after {
- content: '';
- display: block;
height: 100%;
width: 100%;
clip-path: inset(var(--o-crop-y, 0px) var(--o-crop-x, 0px));
@@ -20,6 +23,16 @@
}
}
+ .o_crop_overlay.o_crop_overlay_ios {
+ position: relative;
+
+ &::after {
+ position: absolute;
+ inset: var(--o-crop-y, 0px) var(--o-crop-x, 0px);
+ border: 1px solid black;
+ }
+ }
+
.o_crop_icon {
--o-crop-icon-width: 20px;
--o-crop-icon-height: 20px;
diff --git a/addons/web/static/src/webclient/barcode/crop_overlay.xml b/addons/web/static/src/webclient/barcode/crop_overlay.xml
index 60001068eef44..907954b104e4a 100644
--- a/addons/web/static/src/webclient/barcode/crop_overlay.xml
+++ b/addons/web/static/src/webclient/barcode/crop_overlay.xml
@@ -9,7 +9,7 @@
>
-
+
diff --git a/addons/web/static/tests/legacy/control_panel/control_panel_tests.js b/addons/web/static/tests/legacy/control_panel/control_panel_tests.js
index 5b23b59774304..c1b3e213376de 100644
--- a/addons/web/static/tests/legacy/control_panel/control_panel_tests.js
+++ b/addons/web/static/tests/legacy/control_panel/control_panel_tests.js
@@ -55,7 +55,7 @@ odoo.define('web.control_panel_tests', function (require) {
env: {
session: {
async rpc() {
- return [[10, "Deco Addict"]];
+ return [[10, "Acme Corporation"]];
},
},
},
@@ -65,8 +65,8 @@ odoo.define('web.control_panel_tests', function (require) {
assert.deepEqual(
cpHelpers.getFacetTexts(controlPanel).map(t => t.replace(/\s/g, "")),
[
- "BarDecoAddict",
- "BarOpDecoAddict",
+ "BarAcmeCorporation",
+ "BarOpAcmeCorporation",
"Foofoo",
"FooOpfoo_op",
"SelecRed"
diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml
index 40e3d13fae1c7..7d223e4ece8e6 100644
--- a/addons/web/views/report_templates.xml
+++ b/addons/web/views/report_templates.xml
@@ -113,7 +113,7 @@
- Deco Addict
+ Acme Corporation
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
index 8898148786224..eca11f48b9c79 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
@@ -3552,7 +3552,23 @@ export class OdooEditor extends EventTarget {
this._compositionStep();
this.historyRollback();
ev.preventDefault();
- getDeepRange(this.editable, { select: true, correctTripleClick: true });
+ const deepRange = getDeepRange(this.editable, { correctTripleClick: true });
+ const startEl = deepRange && closestElement(deepRange.startContainer);
+ const endEl = deepRange && closestElement(deepRange.endContainer);
+ if (startEl && endEl && startEl.isContentEditable && endEl.isContentEditable) {
+ const { startContainer, startOffset, endContainer, endOffset } = deepRange;
+ const direction = getCursorDirection(
+ newSelection.anchorNode,
+ newSelection.anchorOffset,
+ newSelection.focusNode,
+ newSelection.focusOffset
+ );
+ if (direction === DIRECTIONS.RIGHT) {
+ setSelection(startContainer, startOffset, endContainer, endOffset);
+ } else {
+ setSelection(endContainer, endOffset, startContainer, startOffset);
+ }
+ }
// To remove only the anchor cell's content when multiple table cells are selected on Enter,
// we need to change the selection to focus only on the anchor cell. This can't be done in `oEnter`
// because `deleteRange` responsible for removing content, execute before `oEnter` in `_applyRawCommand`.
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js
index 78cd048316479..b3a945184ad6a 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js
@@ -63,7 +63,9 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false,
if (
isDeletable(leftNode)
) {
+ const parentEl = leftNode.parentElement;
leftNode.remove();
+ fillEmpty(parentEl);
return;
}
if (!isBlock(leftNode) || isVisibleEmpty(leftNode)) {
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteForward.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteForward.js
index a50634a4228bb..ca37db7229e3b 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteForward.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteForward.js
@@ -146,12 +146,14 @@ HTMLElement.prototype.oDeleteForward = function (offset) {
if (firstLeafNode && (isFontAwesome(firstLeafNode) || isNotEditableNode(firstLeafNode))) {
const nextSibling = firstLeafNode.nextSibling;
const nextSiblingText = nextSibling ? nextSibling.textContent : '';
+ const parentEl = firstLeafNode.parentElement;
firstLeafNode.remove();
if (isEditorTab(firstLeafNode) && nextSiblingText[0] === '\u200B') {
// When deleting an editor tab, we need to ensure it's related ZWS
// il deleted as well.
nextSibling.textContent = nextSiblingText.replace('\u200B', '');
}
+ fillEmpty(parentEl);
return;
}
if (
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js
index 14d6e9c7e63f6..9aa1d986995c5 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js
@@ -1151,6 +1151,13 @@ X[]
contentAfter: unformat(` a[]d `),
});
});
+ it('should fill empty block with a ', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: ' [] ',
+ stepFunction: deleteForward,
+ contentAfter: ' []
',
+ });
+ });
});
});
describe('Selection not collapsed', () => {
@@ -2791,6 +2798,11 @@ X[]
stepFunction: deleteBackward,
contentAfter: ' []
',
});
+ await testEditor(BasicEditor, {
+ contentBefore: ' [] ',
+ stepFunction: deleteBackward,
+ contentAfter: ' []
',
+ });
});
it('should merge a paragraph with text into a paragraph with text removing spaces', async () => {
await testEditor(BasicEditor, {
@@ -3686,6 +3698,15 @@ X[]
contentAfter: ' abc []
',
});
});
+ it('inserts an empty paragraph when Enter is pressed before a non-editable element', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: ' [] ',
+ stepFunction: async editor => {
+ await triggerEvent(editor.editable, 'input', { data: 'Enter', inputType: 'insertParagraph' });
+ },
+ contentAfter: '
[] ',
+ });
+ });
});
describe('Pre', () => {
it('should insert a line break within the pre', async () => {
@@ -4342,6 +4363,15 @@ X[]
contentAfter: ' abc []
',
});
});
+ it('inserts a when a line break is inserted before a non-editable element', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: ' [] ',
+ stepFunction: async editor => {
+ await triggerEvent(editor.editable, 'input', { data: 'Enter', inputType: 'insertLineBreak' });
+ },
+ contentAfter: ' []
',
+ });
+ });
});
describe('Consecutive', () => {
it('should insert two at the beggining of an empty paragraph', async () => {
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js
index dd28b24c964ed..def4a63b560b4 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js
@@ -24,6 +24,7 @@ const LinkPopoverWidget = Widget.extend({
this.$target = $(target);
this.container = this.options.container || this.target.ownerDocument.body;
this.href = this.$target.attr('href'); // for template
+ this.isDocument = !!$(target).attr("data-mimetype");
this._dp = new DropPrevious();
},
/**
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index 31e9c917ff54a..b61cd025b309b 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -432,11 +432,10 @@ const Wysiwyg = Widget.extend({
if ($target.is(this.customizableLinksSelector)
&& $target.is('a')
- && $target[0].isContentEditable
+ && ($target[0].isContentEditable || $target.attr("data-mimetype"))
&& !$target.attr('data-oe-model')
&& !$target.find('> [data-oe-model]').length
- && !$target[0].closest('.o_extra_menu_items')
- && $target[0].isContentEditable) {
+ && !$target[0].closest('.o_extra_menu_items')) {
if (ev.ctrlKey || ev.metaKey) {
window.open($target[0].href, '_blank');
}
@@ -1967,23 +1966,28 @@ const Wysiwyg = Widget.extend({
// Unselect all media.
this.$editable.find('.o_we_selected_image').removeClass('o_we_selected_image');
if (isInMedia) {
- this.odooEditor.automaticStepSkipStack();
- // Select the media in the DOM.
- const selection = this.odooEditor.document.getSelection();
- const range = this.odooEditor.document.createRange();
- range.selectNode(this.lastMediaClicked);
- selection.removeAllRanges();
- selection.addRange(range);
- // Toggle the 'active' class on the active image tool buttons.
- for (const button of this.toolbar.$el.find('#image-shape div, #fa-spin')) {
- button.classList.toggle('active', $(e.target).hasClass(button.id));
- }
- for (const button of this.toolbar.$el.find('#image-width div')) {
- button.classList.toggle('active', e.target.style.width === button.id);
+ // Hide the toolbar if the media has a data-mimetype (e.g. attachment).
+ if ($target.attr("data-mimetype")) {
+ this.odooEditor.toolbarHide();
+ } else {
+ this.odooEditor.automaticStepSkipStack();
+ // Select the media in the DOM.
+ const selection = this.odooEditor.document.getSelection();
+ const range = this.odooEditor.document.createRange();
+ range.selectNode(this.lastMediaClicked);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ // Toggle the 'active' class on the active image tool buttons.
+ for (const button of this.toolbar.$el.find('#image-shape div, #fa-spin')) {
+ button.classList.toggle('active', $(e.target).hasClass(button.id));
+ }
+ for (const button of this.toolbar.$el.find('#image-width div')) {
+ button.classList.toggle('active', e.target.style.width === button.id);
+ }
+ this.toolbar.el.querySelector('#image-transform').classList.toggle('active', e.target.matches('[style*="transform"]'));
+ this._updateMediaJustifyButton();
+ this._updateFaResizeButtons();
}
- this.toolbar.el.querySelector('#image-transform').classList.toggle('active', e.target.matches('[style*="transform"]'));
- this._updateMediaJustifyButton();
- this._updateFaResizeButtons();
}
if (isInMedia) {
// Handle the media/link's tooltip.
@@ -2518,7 +2522,7 @@ const Wysiwyg = Widget.extend({
if (
isVisible &&
(
- (this.options.autohideToolbar && !this.odooEditor.document.getSelection().isCollapsed) ||
+ (this.options.autohideToolbar && !this.odooEditor.document.getSelection().isCollapsed && !this.linkPopover.$target.attr("data-mimetype")) ||
!selectionInLink
)
) {
diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml
index 1cf6ac024e55e..325a7e59c5e1e 100644
--- a/addons/web_editor/static/src/xml/wysiwyg.xml
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -173,14 +173,14 @@
-
+
-
+
-
+
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index f21af9128ebf3..3fb015f8a7bec 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -7,6 +7,7 @@
import logging
import re
import requests
+import urllib.parse
import werkzeug.urls
import werkzeug.utils
import werkzeug.wrappers
@@ -58,9 +59,9 @@ def __call__(self, path=None, path_args=None, **kw):
paths[key] = u"%s" % value
elif value:
if isinstance(value, list) or isinstance(value, set):
- fragments.append(werkzeug.urls.url_encode([(key, item) for item in value]))
+ fragments.append(urllib.parse.urlencode([(key, item) for item in value]))
else:
- fragments.append(werkzeug.urls.url_encode([(key, value)]))
+ fragments.append(urllib.parse.urlencode([(key, value)]))
for key in path_args:
value = paths.get(key)
if value is not None:
@@ -632,7 +633,7 @@ def pagenew(self, path="", add_menu=False, template=False, redirect=False, **kwa
# If that URL is also a menu, we update it accordingly.
# NB: we don't want to slugify on menu creation as it could redirect
# towards files (with spaces, apostrophes, etc.).
- menu = request.env['website.menu'].search([('url', '=', '/' + path)])
+ menu = request.env['website.menu'].search([('url', '=', '/' + path), ('page_id', '=', False)])
if menu:
menu.page_id = page['page_id']
diff --git a/addons/website_customer/data/res_partner_demo.xml b/addons/website_customer/data/res_partner_demo.xml
index ab978cc81cf08..eca5f36794ba1 100644
--- a/addons/website_customer/data/res_partner_demo.xml
+++ b/addons/website_customer/data/res_partner_demo.xml
@@ -4,10 +4,10 @@
- Deco Addict designs, develops, integrates and supports HR and Supply Chain processes in order to make our customers more productive, responsive and profitable.
+ Acme Corporation designs, develops, integrates and supports HR and Supply Chain processes in order to make our customers more productive, responsive and profitable.
- Deco Addict designs, develops, integrates and supports HR and Supply
+ Acme Corporation designs, develops, integrates and supports HR and Supply
Chain processes in order to make our customers more productive,
responsive and profitable.
@@ -17,7 +17,7 @@
installed IT software into account. That is why Idealis
Consulting delivers excellence in HR and SC Management.
- Deco Addict integrates ERP for Global Companies and supports PME
+ Acme Corporation integrates ERP for Global Companies and supports PME
with Open Sources software to manage their businesses. Our
consultants are experts in the following areas:
@@ -62,10 +62,10 @@
- Deco Addict designs, develops, integrates and supports HR and Supply Chain processes in order to make our customers more productive, responsive and profitable.
+ Acme Corporation designs, develops, integrates and supports HR and Supply Chain processes in order to make our customers more productive, responsive and profitable.
- Deco Addict designs, develops, integrates and supports HR and Supply
+ Acme Corporation designs, develops, integrates and supports HR and Supply
Chain processes in order to make our customers more productive,
responsive and profitable.
@@ -75,7 +75,7 @@
installed IT software into account. That is why Idealis
Consulting delivers excellence in HR and SC Management.
- Deco Addict integrates ERP for Global Companies and supports PME
+ Acme Corporation integrates ERP for Global Companies and supports PME
with Open Sources software to manage their businesses. Our
consultants are experts in the following areas:
diff --git a/addons/website_event/data/res_partner_demo.xml b/addons/website_event/data/res_partner_demo.xml
index c6bae7b287e1f..54ff9e77de941 100644
--- a/addons/website_event/data/res_partner_demo.xml
+++ b/addons/website_event/data/res_partner_demo.xml
@@ -23,7 +23,7 @@
True
- Deco Addict brings honesty and seriousness to wood industry while helping customers deal with trees, flowers and fungi.
+ Acme Corporation brings honesty and seriousness to wood industry while helping customers deal with trees, flowers and fungi.
@@ -276,10 +276,10 @@
-
+
True
- Douglas Fletcher is a mighty functional consultant at Deco Addict
+ Douglas Fletcher is a mighty functional consultant at Acme Corporation
Douglas Fletcher works in IT sector since 10 years. He is known
@@ -289,7 +289,7 @@
True
- Floyd Steward is a mighty analyst at Deco Addict.
+ Floyd Steward is a mighty analyst at Acme Corporation.
Floyd Steward works in IT sector since 10 years. He is known
@@ -299,7 +299,7 @@
True
- Addison Olson is a mighty sales representative at Deco Addict.
+ Addison Olson is a mighty sales representative at Acme Corporation.
Addison Olson works in IT sector since 10 years. He is known
diff --git a/addons/website_event/static/src/js/website_event.js b/addons/website_event/static/src/js/website_event.js
index bfd6f75ad85bd..35e85c4d4f722 100644
--- a/addons/website_event/static/src/js/website_event.js
+++ b/addons/website_event/static/src/js/website_event.js
@@ -71,18 +71,7 @@ var EventRegistrationForm = Widget.extend({
$button.attr('disabled', true);
var action = $form.data('action') || $form.attr('action');
var self = this;
- return ajax.jsonRpc(action, 'call', post).then(async function (modal) {
- const tokenObj = await self._recaptcha.getToken('website_event_registration');
- if (tokenObj.error) {
- self.displayNotification({
- type: 'danger',
- title: _t('Error'),
- message: tokenObj.error,
- sticky: true,
- });
- $button.prop('disabled', false);
- return false;
- }
+ return ajax.jsonRpc(action, 'call', post).then(function (modal) {
var $modal = $(modal);
$modal.find('.modal-body > div').removeClass('container'); // retrocompatibility - REMOVE ME in master / saas-19
$modal.appendTo(document.body);
@@ -96,12 +85,29 @@ var EventRegistrationForm = Widget.extend({
$modal.on('click', '.btn-close', function () {
$button.prop('disabled', false);
});
- $modal.on('submit', 'form', function (ev) {
+ $modal.on('submit', 'form', async function (ev) {
+ if (!self._recaptcha._publicKey) {
+ return;
+ }
+
+ ev.preventDefault();
+ const tokenObj = await self._recaptcha.getToken("website_event_registration");
+ if (tokenObj.error) {
+ $button.prop('disabled', false);
+ self.displayNotification({
+ type: 'danger',
+ title: _t('Error'),
+ message: tokenObj.error,
+ sticky: true,
+ });
+ return;
+ }
const tokenInput = document.createElement('input');
tokenInput.setAttribute('name', 'recaptcha_token_response');
tokenInput.setAttribute('type', 'hidden');
tokenInput.setAttribute('value', tokenObj.token);
ev.currentTarget.appendChild(tokenInput);
+ ev.currentTarget.submit();
})
});
}
diff --git a/addons/website_event_exhibitor/data/event_sponsor_demo.xml b/addons/website_event_exhibitor/data/event_sponsor_demo.xml
index 6bb840446eb07..5d0b84a66827b 100644
--- a/addons/website_event_exhibitor/data/event_sponsor_demo.xml
+++ b/addons/website_event_exhibitor/data/event_sponsor_demo.xml
@@ -37,7 +37,7 @@
- http://www.deco-addict.example.com
+ http://www.acme-corp.example.com
You won't believe this sponsor
sponsor
diff --git a/addons/website_sale_loyalty/tests/__init__.py b/addons/website_sale_loyalty/tests/__init__.py
index eeb2997e6de5e..656ff20aa930f 100644
--- a/addons/website_sale_loyalty/tests/__init__.py
+++ b/addons/website_sale_loyalty/tests/__init__.py
@@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_apply_pending_coupon
+from . import test_concurrent_promo_code
from . import test_free_product_reward
from . import test_sale_coupon_multiwebsite
from . import test_shop_loyalty_payment
diff --git a/addons/website_sale_loyalty/tests/test_concurrent_promo_code.py b/addons/website_sale_loyalty/tests/test_concurrent_promo_code.py
new file mode 100644
index 0000000000000..3be37736fd7d1
--- /dev/null
+++ b/addons/website_sale_loyalty/tests/test_concurrent_promo_code.py
@@ -0,0 +1,132 @@
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from psycopg2 import OperationalError
+
+from odoo import SUPERUSER_ID, api, registry
+from odoo.tests import tagged
+from odoo.tests.common import BaseCase, get_db_name
+from odoo.tools import mute_logger
+
+
+@tagged('-standard', '-at_install', 'post_install', 'database_breaking')
+class TestConcurrencyPromoCode(BaseCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.registry = registry(get_db_name())
+
+ with cls.registry.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, {})
+
+ cls.promo_code = "AZERTY123456"
+ cls.promo_code_program = env['loyalty.program'].create({
+ 'name': 'FREE FOR ONE',
+ 'program_type': 'promo_code',
+ 'limit_usage': True,
+ 'max_usage': 1,
+ 'rule_ids': [(0, 0, {
+ 'minimum_qty': 0,
+ 'code': cls.promo_code,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'discount': 100.0,
+ })]
+ })
+
+ cls.partner_1 = env['res.partner'].create([{
+ 'name': 'Mitchel Notadmin',
+ 'email': 'mitch.el@example.com',
+ }])
+ cls.partner_2 = env['res.partner'].create({
+ 'name': 'John Smith',
+ 'email': 'john.smith@example.com',
+ })
+
+ cls.product = env['product.product'].create({
+ 'name': "TEST PRODUCT",
+ 'standard_price': 100,
+ })
+
+ cls.order_partner_1 = env['sale.order'].create({'partner_id': cls.partner_1.id})
+ cls.order_partner_2 = env['sale.order'].create({'partner_id': cls.partner_2.id})
+
+ cls.order_lines = env['sale.order.line'].create([{
+ 'order_id': cls.order_partner_1.id,
+ 'product_id': cls.product.id,
+ 'product_uom_qty': 1,
+ }, {
+ 'order_id': cls.order_partner_2.id,
+ 'product_id': cls.product.id,
+ 'product_uom_qty': 3,
+ },
+ ])
+
+ def setUp(self):
+ super().setUp()
+
+ def reset():
+ with self.registry.cursor() as cr:
+ cr.execute("""
+ DELETE FROM loyalty_card WHERE program_id = %(program_id)s;
+ DELETE FROM loyalty_rule WHERE program_id = %(program_id)s;
+ DELETE FROM loyalty_reward WHERE program_id = %(program_id)s;
+ DELETE FROM loyalty_program WHERE id = %(program_id)s;
+ DELETE FROM sale_order_line WHERE id IN %(sol_ids)s;
+ DELETE FROM sale_order WHERE id IN %(so_ids)s;
+ DELETE FROM res_partner WHERE id IN %(partner_ids)s;
+ DELETE FROM product_product WHERE id = %(product_id)s;
+ """, {
+ 'program_id': self.promo_code_program.id,
+ 'sol_ids': tuple(self.order_lines.ids),
+ 'so_ids': (self.order_partner_1.id, self.order_partner_2.id),
+ 'partner_ids': (self.partner_1.id, self.partner_2.id),
+ 'product_id': self.product.id,
+ })
+ self.addCleanup(reset)
+
+ @mute_logger('odoo.sql_db')
+ def test_locked_update_promo_code(self):
+ """ Test that two cursors cannot lock the same row simultaneously """
+
+ # Commit the orders so that both cursors can read them
+ with self.registry.cursor() as cr:
+ cr.commit()
+
+ # A simple barrier to make sure threads start roughly at the same time
+ start_barrier = threading.Barrier(2)
+
+ def run(order_id):
+ with self.registry.cursor() as cr:
+ cr.execute("SELECT id FROM loyalty_rule WHERE code = %s", (self.promo_code,))
+ self.assertTrue(cr.fetchone())
+
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ order = env['sale.order'].browse(order_id)
+
+ # Wait for the other threads to be ready
+ start_barrier.wait()
+
+ try:
+ order._try_apply_code(self.promo_code)
+ time.sleep(2) # Hold the lock for a moment to ensure overlap
+ return True
+
+ except OperationalError:
+ # This catches the Postgres error when a row is locked
+ return False
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ future_1 = executor.submit(run, self.order_partner_1.id)
+ future_2 = executor.submit(run, self.order_partner_2.id)
+
+ # One should go through, the other should be locked (does not matter
+ # which thread)
+ res_1 = future_1.result(timeout=3)
+ res_2 = future_2.result(timeout=3)
+ self.assertNotEqual(res_1, res_2)
diff --git a/doc/cla/individual/anjeelharia.md b/doc/cla/individual/anjeelharia.md
new file mode 100644
index 0000000000000..a2a6838880902
--- /dev/null
+++ b/doc/cla/individual/anjeelharia.md
@@ -0,0 +1,11 @@
+India, 2025-12-03
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Anjeel bytemeasap@gmail.com https://github.com/ByteMeAsap
diff --git a/odoo/addons/base/data/res_partner_demo.xml b/odoo/addons/base/data/res_partner_demo.xml
index b0be7d4a5df32..9984f0dbf020a 100644
--- a/odoo/addons/base/data/res_partner_demo.xml
+++ b/odoo/addons/base/data/res_partner_demo.xml
@@ -57,7 +57,7 @@
- Deco Addict
+ Acme Corporation
1
77 Santa Barbara Rd
@@ -65,9 +65,9 @@
94523
- deco.addict82@example.com
+ acme.corp82@example.com
(603)-996-3829
- http://www.deco-addict.com
+ http://www.acme-example-company.com
diff --git a/odoo/addons/base/static/img/res_partner_2-image.png b/odoo/addons/base/static/img/res_partner_2-image.png
index 1e9df902f8ae5..e7465708f9cf3 100644
Binary files a/odoo/addons/base/static/img/res_partner_2-image.png and b/odoo/addons/base/static/img/res_partner_2-image.png differ
diff --git a/odoo/addons/base/tests/common.py b/odoo/addons/base/tests/common.py
index 512c3b512f6d6..592513301ae16 100644
--- a/odoo/addons/base/tests/common.py
+++ b/odoo/addons/base/tests/common.py
@@ -182,7 +182,7 @@ def _load_partners_set(cls):
'name': 'Austin Kennedy', # Tom Ruiz
})],
}, {
- 'name': 'Pepper Street', # 'Deco Addict',
+ 'name': 'Pepper Street', # 'Acme Corporation',
'state_id': cls.env.ref('base.state_us_2').id,
'child_ids': [Command.create({
'name': 'Liam King', # 'Douglas Fletcher',
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index f0ea01292c4fb..901d55e8526a7 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -818,14 +818,14 @@ def test_lp1071710(self):
# indirect search via m2o
Partner = self.env['res.partner']
- deco_addict = self._search(Partner, [('name', '=', 'Pepper Street')])
+ acme_corp = self._search(Partner, [('name', '=', 'Pepper Street')])
not_be = self._search(Partner, [('country_id', '!=', 'Belgium')])
- self.assertNotIn(deco_addict, not_be)
+ self.assertNotIn(acme_corp, not_be)
Partner = Partner.with_context(lang='fr_FR')
not_be = self._search(Partner, [('country_id', '!=', 'Belgique')])
- self.assertNotIn(deco_addict, not_be)
+ self.assertNotIn(acme_corp, not_be)
def test_or_with_implicit_and(self):
# Check that when using expression.OR on a list of domains with at least one
diff --git a/odoo/netsvc.py b/odoo/netsvc.py
index fa660ad0a9b0f..a068e98e1ee1f 100644
--- a/odoo/netsvc.py
+++ b/odoo/netsvc.py
@@ -145,6 +145,16 @@ def format(self, record):
record.dbname = getattr(threading.current_thread(), 'dbname', '?')
return logging.Formatter.format(self, record)
+ def formatMessage(self, record):
+ if record.munge_traceback:
+ return super().formatMessage(record).replace(
+ 'Traceback (most recent call last):',
+ '_Traceback_ (most recent call last):',
+ )
+ else:
+ return super().formatMessage(record)
+
+
class ColoredFormatter(DBFormatter):
def format(self, record):
fg_color, bg_color = LEVEL_COLOR_MAPPING.get(record.levelno, (GREEN, DEFAULT))
@@ -162,6 +172,7 @@ def init_logger():
def record_factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
record.perf_info = ""
+ record.munge_traceback = False
return record
logging.setLogRecordFactory(record_factory)
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index 7161a52bde0c0..c1453b985fcd6 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1119,6 +1119,7 @@ def _chrome_start(self):
'--remote-debugging-port': str(self.remote_debugging_port),
'--no-sandbox': '',
'--disable-gpu': '',
+ '--mute-audio': '',
'--remote-allow-origins': '*',
# '--enable-precise-memory-info': '', # uncomment to debug memory leaks in qunit suite
# '--js-flags': '--expose-gc', # uncomment to debug memory leaks in qunit suite
diff --git a/odoo/tests/test_module_operations.py b/odoo/tests/test_module_operations.py
index eb9a216aa7515..bca13263f3fb7 100755
--- a/odoo/tests/test_module_operations.py
+++ b/odoo/tests/test_module_operations.py
@@ -231,5 +231,5 @@ def test_standalone(args):
try:
args.func(args)
except Exception:
- _logger.error("%s tests failed", args.func.__name__[5:])
- raise
+ _logger.exception("%s tests failed", args.func.__name__[5:])
+ exit(1)
diff --git a/odoo/tools/_monkeypatches.py b/odoo/tools/_monkeypatches.py
index fce9ed4c28562..4876272ac1cdc 100644
--- a/odoo/tools/_monkeypatches.py
+++ b/odoo/tools/_monkeypatches.py
@@ -158,10 +158,15 @@ def pool_init(self, *args, **kwargs):
def policy_clone(self, **kwargs):
for arg in kwargs:
- if arg.startswith("_") or "__" in arg:
+ if arg.startswith("_") or "__" in arg or arg == "clone":
raise AttributeError(f"{self.__class__.__name__!r} object has no attribute {arg!r}")
return orig_policy_clone(self, **kwargs)
+def policy_add(self, other):
+ return policy_clone(self, **other.__dict__)
+
+
orig_policy_clone = _PolicyBase.clone
_PolicyBase.clone = policy_clone
+_PolicyBase.__add__ = policy_add
diff --git a/odoo/tools/_monkeypatches_urls.py b/odoo/tools/_monkeypatches_urls.py
index 072e36b087ce8..35cf4bd4ffcb0 100644
--- a/odoo/tools/_monkeypatches_urls.py
+++ b/odoo/tools/_monkeypatches_urls.py
@@ -64,7 +64,6 @@ def _to_str(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789"
"-._~"
- "$!'()*+,;" # RFC3986 sub-delims set, not including query string delimiters &=
)
_always_safe = frozenset(_always_safe_chars.encode("ascii"))
diff --git a/odoo/tools/_vendor/useragents.py b/odoo/tools/_vendor/useragents.py
index 7627ccc973bf6..f0b8730deb484 100644
--- a/odoo/tools/_vendor/useragents.py
+++ b/odoo/tools/_vendor/useragents.py
@@ -75,11 +75,11 @@ class UserAgentParser(object):
)
def __init__(self):
- self.platforms = [(b, re.compile(a, re.I)) for a, b in self.platforms]
- self.browsers = [
+ self.platforms = tuple((b, re.compile(a, re.I)) for a, b in self.platforms)
+ self.browsers = tuple(
(b, re.compile(self._browser_version_re % a, re.I))
for a, b in self.browsers
- ]
+ )
def __call__(self, user_agent):
for platform, regex in self.platforms: # noqa: B007
diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py
index 5dad940be5fca..558c51446c602 100644
--- a/odoo/tools/misc.py
+++ b/odoo/tools/misc.py
@@ -854,8 +854,8 @@ def emit(self, record):
if record.levelno > self.max_level:
record.levelname = f'_{record.levelname}'
record.levelno = self.to_level
+ record.munge_traceback = True
self.had_error_log = True
- record.args = tuple(arg.replace('Traceback (most recent call last):', '_Traceback_ (most recent call last):') if isinstance(arg, str) else arg for arg in record.args)
if logging.getLogger(record.name).isEnabledFor(record.levelno):
for handler in self.old_handlers:
diff --git a/setup/win32/setup.nsi b/setup/win32/setup.nsi
index 7b5ff67a0a52b..1cb1f06a0472b 100644
--- a/setup/win32/setup.nsi
+++ b/setup/win32/setup.nsi
@@ -389,6 +389,7 @@ SectionEnd
!insertmacro MUI_FUNCTION_DESCRIPTION_END
Section "Uninstall"
+ SetRegView 64
# Check if the server is installed
!insertmacro IfKeyExists "HKLM" "${UNINSTALL_REGISTRY_KEY_SERVER}" "UninstallString"
Pop $R0
|