diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-08-21 10:10:11 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-08-21 10:10:11 +0200 |
| commit | 3faea8fbf810224ad4e03cfa1dc36f186800c501 (patch) | |
| tree | 5e05c40d62dacc5951c70b3686f30a7bbb86f399 /addons | |
| parent | cc2d78beeaf61fa0b8c9543196d1b4fce5cd5da6 (diff) | |
| parent | f26de445e5579913e27ea6a630bcee6d40eefc7d (diff) | |
[MERGE] forward port branch saas-12.4 up to f26de445e55
Diffstat (limited to 'addons')
79 files changed, 593 insertions, 143 deletions
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index a7abbf8ab82..647043e8d81 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -2146,9 +2146,9 @@ class AccountMoveLine(models.Model): help="The bank statement used for bank reconciliation") # ==== Tax fields ==== - tax_ids = fields.Many2many('account.tax', string='Taxes') + tax_ids = fields.Many2many('account.tax', string='Taxes', help="Taxes that apply on the base amount") tax_line_id = fields.Many2one('account.tax', string='Originator Tax', ondelete='restrict', store=True, - compute='_compute_tax_line_id') + compute='_compute_tax_line_id', help="Indicates that this journal item is a tax line") tax_group_id = fields.Many2one(related='tax_line_id.tax_group_id', string='Originator tax group', readonly=True, store=True, help='technical field for widget tax-group-custom-field') diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 15cc5bf499e..de720f64d51 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -450,7 +450,10 @@ class account_payment(models.Model): move.line_ids.remove_move_reconcile() move.button_cancel() move.unlink() - rec.state = 'cancelled' + rec.write({ + 'state': 'cancelled', + 'move_name': False, + }) def unlink(self): if any(bool(rec.move_line_ids) for rec in self): diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index f3b9973adc6..1b68ed9cac1 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -590,6 +590,8 @@ class AccountReconcileModel(models.Model): ''' if not self.match_total_amount: return True + if not candidates: + return False # Match total residual amount. total_residual = 0.0 diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index e6410dfcc68..411696d151f 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -331,7 +331,7 @@ <field name="module_account_sepa_direct_debit" class="oe_inline" widget="upgrade_boolean"/> </div> <div class="o_setting_right_pane" name="sepa_direct_debit_right_pane"> - <label string="SEPA Direct Debit (SDD)" for="module_account_sepa"/> + <label string="SEPA Direct Debit (SDD)" for="module_account_sepa_direct_debit"/> <span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/> <div class="text-muted"> Collect customer payments in one-click using Euro SEPA Service diff --git a/addons/base_iban/models/res_partner_bank.py b/addons/base_iban/models/res_partner_bank.py index c750af9aae7..2c9f4f868b9 100644 --- a/addons/base_iban/models/res_partner_bank.py +++ b/addons/base_iban/models/res_partner_bank.py @@ -168,6 +168,7 @@ _map_iban_template = { 'sm': 'SMkk KBBB BBSS SSSC CCCC CCCC CCC', # San Marino 'tn': 'TNkk BBSS SCCC CCCC CCCC CCCC', # Tunisia 'tr': 'TRkk BBBB BRCC CCCC CCCC CCCC CC', # Turkey + 'ua': 'UAkk BBBB BBCC CCCC CCCC CCCC CCCC C', # Ukraine 'vg': 'VGkk BBBB CCCC CCCC CCCC CCCC', # Virgin Islands 'xk': 'XKkk BBBB CCCC CCCC CCCC', # Kosovo } diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 76ad4683e28..a9cc382871e 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -442,6 +442,9 @@ class Import(models.TransientModel): # Or a date/datetime if it matches the pattern date_patterns = [options['date_format']] if options.get( 'date_format') else [] + user_date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format + if user_date_format: + date_patterns.append(user_date_format) date_patterns.extend(DATE_PATTERNS) match = check_patterns(date_patterns, preview_values) if match: diff --git a/addons/base_vat/models/res_partner.py b/addons/base_vat/models/res_partner.py index 983f616f1f3..e06f1ac8f02 100644 --- a/addons/base_vat/models/res_partner.py +++ b/addons/base_vat/models/res_partner.py @@ -14,6 +14,13 @@ except ImportError: "Install it to support more countries, for example with `easy_install vatnumber`.") vatnumber = None +# Although stdnum is a dependency of vatnumber, the import of the latter is surrounded by a try/except +# if it is not installed. Therefore, we cannot be sure stdnum is installed in all cases. +try: + import stdnum +except ImportError: + stdnum = None + from odoo import api, models, _ from odoo.tools.misc import ustr from odoo.exceptions import ValidationError @@ -64,6 +71,8 @@ _ref_vat = { 'be': 'BE0477472701', 'bg': 'BG1234567892', 'ch': 'CHE-123.456.788 TVA or CH TVA 123456', # Swiss by Yannick Vaucher @ Camptocamp + 'cl': 'CL76086428-5', + 'co': 'CO213123432-1 or CO213.123.432-1', 'cy': 'CY12345678F', 'cz': 'CZ12345679', 'de': 'DE123456788', @@ -399,6 +408,12 @@ class ResPartner(models.Model): except ImportError: return True + def check_vat_cl(self, vat): + return stdnum.util.get_cc_module('cl', 'vat').is_valid(vat) if stdnum else True + + def check_vat_co(self, vat): + return stdnum.util.get_cc_module('co', 'vat').is_valid(vat) if stdnum else True + # Argentinian VAT validation, contributed by ADHOC def check_vat_ar(self, vat): try: diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index 01c3b571cd2..aae443953ee 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -122,8 +122,8 @@ <field name="stop" attrs="{'invisible': True}"/> <field name="id" attrs="{'invisible': True}"/> - <field name="start_date" string="Starting at" attrs="{'required': [('allday','=',True)], 'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> - <field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> + <field name="start_date" string="Starting at" attrs="{'required': [('allday','=',True)], 'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/> + <field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/> <field name="start_datetime" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <field name="stop_datetime" invisible="1"/> @@ -132,7 +132,7 @@ <field name="duration" widget="float_time" string="Duration" class="oe_inline" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <span> hours</span> </div> - <field name="allday" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> + <field name="allday" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/> </group> <group> <field name="categ_ids" widget="many2many_tags" options=" diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 135fa611402..2a14dc42b87 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -466,7 +466,7 @@ <div t-attf-class="#{!selection_mode ? kanban_color(record.color.raw_value) : ''} oe_kanban_global_click"> <div class="o_dropdown_kanban dropdown"> - <a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index 4307550b2b9..aaf2df32a89 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -318,7 +318,7 @@ <div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click"> <div class="o_dropdown_kanban dropdown"> - <a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/hr/models/hr_employee_base.py b/addons/hr/models/hr_employee_base.py index c86a1411e23..4548cb4382f 100644 --- a/addons/hr/models/hr_employee_base.py +++ b/addons/hr/models/hr_employee_base.py @@ -26,6 +26,9 @@ class HrEmployeeBase(models.AbstractModel): user_id = fields.Many2one('res.users') resource_id = fields.Many2one('resource.resource') resource_calendar_id = fields.Many2one('resource.calendar', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + tz = fields.Selection( + string='Timezone', related='resource_id.tz', readonly=False, + help="This field is used in order to define in which timezone the resources will work.") hr_presence_state = fields.Selection([ ('present', 'Present'), ('absent', 'Absent'), diff --git a/addons/hr/models/hr_employee_public.py b/addons/hr/models/hr_employee_public.py index 62c221ee472..3d1fe02cc4f 100644 --- a/addons/hr/models/hr_employee_public.py +++ b/addons/hr/models/hr_employee_public.py @@ -28,6 +28,7 @@ class HrEmployeePublic(models.Model): user_id = fields.Many2one(readonly=True) resource_id = fields.Many2one(readonly=True) resource_calendar_id = fields.Many2one(readonly=True) + tz = fields.Selection(readonly=True) color = fields.Integer(readonly=True) # hr.employee.public specific fields diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index 5ab887b44d0..ea4b5bf6cef 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -242,7 +242,7 @@ <t t-name="kanban-box"> <div class="oe_kanban_card oe_kanban_global_click"> <div class="o_dropdown_kanban dropdown" t-if="!selection_mode" groups="base.group_user"> - <a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/hw_escpos/escpos/escpos.py b/addons/hw_escpos/escpos/escpos.py index d9f40d24816..59c276f82d9 100644 --- a/addons/hw_escpos/escpos/escpos.py +++ b/addons/hw_escpos/escpos/escpos.py @@ -893,24 +893,21 @@ class Escpos: self._raw(PAPER_FULL_CUT) - def cashdraw(self, pin, tries=5): + def cashdraw(self, pin): """ Send pulse to kick the cash drawer - With some printers the drawer will not open after one pulse, for this reason we will check - the drawer status up to 'tries' times and send a new pulse if the drawer is still closed. If the - drawer status is open, we will stop sending pulses. + For some reason, with some printers (ex: Epson TM-m30), the cash drawer + only opens 50% of the time if you just send the pulse. But if you read + the status afterwards, it opens all the time. """ - for i in range(tries): - if pin == 2: - self._raw(CD_KICK_2) - elif pin == 5: - self._raw(CD_KICK_5) - else: - raise CashDrawerError() - - if i != tries - 1 and not self.get_printer_status()['printer']['drawer_pin_high']: - break + if pin == 2: + self._raw(CD_KICK_2) + elif pin == 5: + self._raw(CD_KICK_5) + else: + raise CashDrawerError() + self.get_printer_status() def hw(self, hw): """ Hardware operations """ diff --git a/addons/iap/models/iap.py b/addons/iap/models/iap.py index b90bfff5ccb..608aa52c788 100644 --- a/addons/iap/models/iap.py +++ b/addons/iap/models/iap.py @@ -7,7 +7,7 @@ import uuid import werkzeug.urls import requests -from odoo import api, fields, models, exceptions +from odoo import api, fields, models, exceptions, _ from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -68,7 +68,9 @@ def jsonrpc(url, method='call', params=None, timeout=15): raise e return response.get('result') except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e: - raise exceptions.AccessError('The url that this service requested returned an error. Please contact the author of the app. The url it tried to contact was ' + url) + raise exceptions.AccessError( + _('The url that this service requested returned an error. Please contact the author of the app. The url it tried to contact was %s') % url + ) #---------------------------------------------------------- # Helpers for proxy diff --git a/addons/l10n_ae/data/account_tax_template_data.xml b/addons/l10n_ae/data/account_tax_template_data.xml index a9ffc836bdb..b3cec92c1c5 100644 --- a/addons/l10n_ae/data/account_tax_template_data.xml +++ b/addons/l10n_ae/data/account_tax_template_data.xml @@ -828,6 +828,17 @@ <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> </record> + <record id="uae_purchase_tax_5_child_reverse" model="account.tax.template"> + <field name="name">Reverse Charge TVA +5%(Purchase)</field> + <field name="type_tax_use">none</field> + <field name="amount">5</field> + <field name="amount_type">percent</field> + <field name="description">TVA 5%</field> + <field name="account_id" ref="uae_account_3726"/> + <field name="refund_account_id" ref="uae_account_3726"/> + <field name="tax_group_id" ref="ae_tax_group_5"/> + <field name="chart_template_id" ref="uae_chart_template_standard"/> + </record> <record id="uae_purchase_tax_reverse_charge" model="account.tax.template"> <field name="name">Reverse Charge Provision</field> <field name="type_tax_use">purchase</field> diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py index ed6cf716493..2938c6630ff 100644 --- a/addons/l10n_it_edi/models/account_invoice.py +++ b/addons/l10n_it_edi/models/account_invoice.py @@ -94,7 +94,7 @@ class AccountMove(models.Model): # <1.2.1.8> if not seller.l10n_it_tax_system: - raise UserError("The seller's company must have a tax system.") + raise UserError(_("The seller's company must have a tax system.")) # <1.2.2> if not seller.street and not seller.street2: @@ -109,7 +109,7 @@ class AccountMove(models.Model): raise UserError(_("%s must have a country.") % (seller.display_name)) # <1.4.1> - if not buyer.vat and not buyer.l10n_it_codice_fiscale: + if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code != 'IT': raise UserError(_("The buyer, %s, or his company must have either a VAT number either a tax code (Codice Fiscale).") % (buyer.display_name)) # <1.4.2> @@ -737,6 +737,6 @@ class AccountTax(models.Model): for tax in self: if tax.l10n_it_has_exoneration: if not tax.l10n_it_kind_exoneration or not tax.l10n_it_law_reference or tax.amount != 0: - raise ValidationError("If the tax has exoneration, you must enter a kind of exoneration, a law reference and the amount of the tax must be 0.0.") + raise ValidationError(_("If the tax has exoneration, you must enter a kind of exoneration, a law reference and the amount of the tax must be 0.0.")) if tax.l10n_it_kind_exoneration == 'N6' and tax.l10n_it_vat_due_date == 'S': raise UserError(_("'Scissione dei pagamenti' is not compatible with exoneration of kind 'N6'")) diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py index 176b89f68d7..7b030b32234 100644 --- a/addons/l10n_it_edi/models/ir_mail_server.py +++ b/addons/l10n_it_edi/models/ir_mail_server.py @@ -35,7 +35,7 @@ class FetchmailServer(models.Model): def _check_pec(self): for record in self: if record.l10n_it_is_pec and record.type != 'imap': - raise ValidationError("PEC mail server must be of type IMAP.") + raise ValidationError(_("PEC mail server must be of type IMAP.")) def fetch_mail(self): """ WARNING: meant for cron usage only - will commit() after each email! """ diff --git a/addons/l10n_it_edi/models/res_company.py b/addons/l10n_it_edi/models/res_company.py index e37b451d36b..71c651f84e9 100644 --- a/addons/l10n_it_edi/models/res_company.py +++ b/addons/l10n_it_edi/models/res_company.py @@ -1,7 +1,7 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ from odoo.exceptions import ValidationError TAX_SYSTEM = [ @@ -94,7 +94,7 @@ class ResCompany(models.Model): or not record.l10n_it_eco_index_share_capital\ or not record.l10n_it_eco_index_sole_shareholder\ or not record.l10n_it_eco_index_liquidation_state: - raise ValidationError("All fields about the Economic and Administrative Index must be completed.") + raise ValidationError(_("All fields about the Economic and Administrative Index must be completed.")) @api.constrains('l10n_it_has_tax_representative', 'l10n_it_tax_representative_partner_id') @@ -103,8 +103,8 @@ class ResCompany(models.Model): if not record.l10n_it_has_tax_representative: continue if not record.l10n_it_tax_representative_partner_id: - raise ValidationError("You must select a tax representative.") + raise ValidationError(_("You must select a tax representative.")) if not record.l10n_it_tax_representative_partner_id.vat: - raise ValidationError("Your tax representative partner must have a tax number.") + raise ValidationError(_("Your tax representative partner must have a tax number.")) if not record.l10n_it_tax_representative_partner_id.country_id: - raise ValidationError("Your tax representative partner must have a country.") + raise ValidationError(_("Your tax representative partner must have a country.")) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index e94174b7591..91d40bed9de 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -139,23 +139,23 @@ class Channel(models.Model): @api.constrains('moderator_ids') def _check_moderator_email(self): if any(not moderator.email for channel in self for moderator in channel.moderator_ids): - raise ValidationError("Moderators must have an email address.") + raise ValidationError(_("Moderators must have an email address.")) @api.constrains('moderator_ids', 'channel_partner_ids', 'channel_last_seen_partner_ids') def _check_moderator_is_member(self): for channel in self: if not (channel.mapped('moderator_ids.partner_id') <= channel.sudo().channel_partner_ids): - raise ValidationError("Moderators should be members of the channel they moderate.") + raise ValidationError(_("Moderators should be members of the channel they moderate.")) @api.constrains('moderation', 'email_send') def _check_moderation_parameters(self): if any(not channel.email_send and channel.moderation for channel in self): - raise ValidationError('Only mailing lists can be moderated.') + raise ValidationError(_('Only mailing lists can be moderated.')) @api.constrains('moderator_ids') def _check_moderator_existence(self): if any(not channel.moderator_ids for channel in self if channel.moderation): - raise ValidationError('Moderated channels must have moderators.') + raise ValidationError(_('Moderated channels must have moderators.')) def _compute_is_member(self): memberships = self.env['mail.channel.partner'].sudo().search([ @@ -239,7 +239,7 @@ class Channel(models.Model): # First checks if user tries to modify moderation fields and has not the right to do it. if any(key for key in MODERATION_FIELDS if vals.get(key)) and any(self.env.user not in channel.moderator_ids for channel in self if channel.moderation): if not self.env.user.has_group('base.group_system'): - raise UserError("You do not possess the rights to modify fields related to moderation on one of the channels you are modifying.") + raise UserError(_("You do not have the rights to modify fields related to moderation on one of the channels you are modifying.")) result = super(Channel, self).write(vals) @@ -400,9 +400,9 @@ class Channel(models.Model): if self.env.user in self.moderator_ids or self.env.user.has_group('base.group_system'): success = self._send_guidelines(self.channel_partner_ids) if not success: - raise UserError('View "mail.mail_channel_send_guidelines" was not found. No email has been sent. Please contact an administrator to fix this issue.') + raise UserError(_('View "mail.mail_channel_send_guidelines" was not found. No email has been sent. Please contact an administrator to fix this issue.')) else: - raise UserError("Only an administrator or a moderator can send guidelines to channel members!") + raise UserError(_("Only an administrator or a moderator can send guidelines to channel members!")) def _send_guidelines(self, partners): """ Send guidelines of a given channel. Returns False if template used for guidelines diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 2016f07e0c3..80b4db5131b 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -878,7 +878,7 @@ class MailThread(models.AbstractModel): # compute references to find if message is a reply to an existing thread thread_references = message_dict['references'] or message_dict['in_reply_to'] msg_references = [ref for ref in tools.mail_header_msgid_re.findall(thread_references) if 'reply_to' not in ref] - mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1) + mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id') is_a_reply = bool(mail_messages) reply_model, reply_thread_id = mail_messages.model, mail_messages.res_id @@ -1366,7 +1366,7 @@ class MailThread(models.AbstractModel): # Very unusual situation, be we should be fault-tolerant here message_id = "<%s@localhost>" % time.time() _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id) - msg_dict['message_id'] = message_id + msg_dict['message_id'] = message_id.strip() if message.get('Subject'): msg_dict['subject'] = tools.decode_message_header(message, 'Subject') diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js index b4f1df097cf..0ef02e5f57a 100644 --- a/addons/mail/static/src/js/models/threads/document_thread.js +++ b/addons/mail/static/src/js/models/threads/document_thread.js @@ -97,7 +97,6 @@ var DocumentThread = Thread.extend({ fetchMessages: function (options) { var self = this; return this._fetchMessages(options).then(function () { - self.call('mail_service', 'markMessagesAsRead', self._messageIDs); return self._messages; }); }, diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js index 2aa083a56d2..27208f0edfc 100644 --- a/addons/mail/static/src/js/services/mail_manager.js +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -85,7 +85,7 @@ var MailManager = AbstractService.extend({ addMessage: function (data, options) { options = options || {}; var message = this.getMessage(data.id); - var prom = Promise.resolve(); + var prom; if (!message) { prom = this._addNewMessage(data, options); } else { @@ -95,6 +95,7 @@ var MailManager = AbstractService.extend({ }); } this._addMessageToThreads(message, options); + prom = Promise.resolve(message); } return prom; }, diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index 5cc965b5c7a..a832b0b0793 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -106,7 +106,7 @@ MailManager.include({ // This may happen due to concurrent calls to this method from // messaging menu preview click and handling of longpolling chat // window state. - return; + return Promise.resolve(); } var threadWindow = this._getThreadWindow(threadID); var prom = Promise.resolve(); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js index 1f84918f36d..f624e5ae308 100644 --- a/addons/mail/static/src/js/systray/systray_activity_menu.js +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -108,6 +108,7 @@ var ActivityMenu = Widget.extend({ */ _onActivityActionClick: function (ev) { ev.stopPropagation(); + this.$('.dropdown-toggle').dropdown('toggle'); var targetAction = $(ev.currentTarget); var actionXmlid = targetAction.data('action_xmlid'); if (actionXmlid) { diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index f777efd785e..b56d7747e8e 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -110,6 +110,7 @@ } .o_mail_thread { + position: relative; flex: 1 0 0; overflow: auto; -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index f0f3394c1e1..fd59686aeb5 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -1233,6 +1233,71 @@ QUnit.test('chatter: access document with some notifs', async function (assert) form.destroy(); }); +QUnit.test('chatter: new message notification from document', async function (assert) { + // when receiving new messages from a document that is open, it should + // not mark the messages are read + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '<div class="oe_chatter">' + + '<field name="message_ids" widget="mail_thread"/>' + + '</div>' + + '</form>', + res_id: 2, + session: { + partner_id: 3, + }, + mockRPC: function (route, args) { + if (args.method === 'set_message_done') { + throw new Error("should not mark message as read"); + } + return this._super.apply(this, arguments); + }, + }); + + var thread = form.call('mail_service', 'getDocumentThread', 'partner', 2); + assert.strictEqual( + thread.getUnreadCounter(), + 0, + "document thread should have no unread messages initially"); + assert.containsNone( + form, + '.o_thread_message', + "should have no message in the chatter"); + + // Simulate received needaction`` message on this document + var message = { + author_id: [1, "Me"], + body: '<p>test</p>', + channel_ids: [], + id: 2, + model: 'partner', + needaction_partner_ids: [3], + res_id: 2, + }; + const notifications = [ [['myDB', 'ir.needaction'], message] ]; + form.call('bus_service', 'trigger', 'notification', notifications); + await testUtils.nextTick(); + assert.strictEqual( + thread.getUnreadCounter(), + 1, + "document thread should have one unread message (not marked as read)"); + assert.containsOnce( + form, + '.o_thread_message', + "should have one message in the chatter"); + + form.destroy(); +}); + QUnit.test('chatter: post a message and switch in edit mode', async function (assert) { assert.expect(5); diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index 2cad78eed8b..9949af7c28c 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -64,6 +64,7 @@ QUnit.module('Discuss', { type: 'integer', }, }, + records: [], }, 'res.partner': { fields: { @@ -1517,6 +1518,70 @@ QUnit.test('custom-named DM conversation', async function (assert) { discuss.destroy(); }); +QUnit.test('receive channel message notification then delayed needaction notification', async function (assert) { + assert.expect(3); + + const message = { + author_id: [5, 'Demo User'], + body: '<p>test</p>', + channel_ids: [1], + id: 100, + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + res_id: 1, + }; + this.data.initMessaging = { + channel_slots: { + channel_channel: [{ + id: 1, + channel_type: 'channel', + name: "general", + }], + }, + }; + + const discuss = await createDiscuss({ + context: {}, + params: {}, + data: this.data, + services: this.services, + session: { + partner_id: 3 + }, + }); + + const $inbox = discuss.$('.o_mail_discuss_item[data-thread-id="mailbox_inbox"]'); + assert.hasClass( + $inbox, + 'o_active', + "'Inbox' should be the currently active thread"); + assert.containsNone( + discuss, + '.o_thread_message', + "inbox should contain no messages initially"); + + // simulate new needaction message posted on channnel + this.data['mail.message'].records.push(message); + // simulate receiving channel notification + discuss.call('bus_service', 'trigger', 'notification', [ + [['myDB', 'mail.channel', 1], message] + ]); + // short delay after receiving needaction notification + await testUtils.nextTick(); + // simulate receiving needaction message notification after a short delay + discuss.call('bus_service', 'trigger', 'notification', [ + [['myDB', 'ir.needaction', 3], message] + ]); + await testUtils.nextTick(); + assert.containsOnce( + discuss, + '.o_thread_message', + "inbox should contain one message"); + + discuss.destroy(); +}); + QUnit.test('input not cleared on unresolved message_post rpc', async function (assert) { assert.expect(2); // Promise to simulate late server response on message post diff --git a/addons/mail/static/tests/systray/systray_activity_menu_tests.js b/addons/mail/static/tests/systray/systray_activity_menu_tests.js index cc5b099a949..a256267fd9c 100644 --- a/addons/mail/static/tests/systray/systray_activity_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_activity_menu_tests.js @@ -156,7 +156,7 @@ QUnit.test('activity menu widget: activity menu with 3 records', async function }); QUnit.test('activity menu widget: activity view icon', async function (assert) { - assert.expect(10); + assert.expect(12); var self = this; var activityMenu = new ActivityMenu(); testUtils.mock.addMockEnvironment(activityMenu, { @@ -197,9 +197,15 @@ QUnit.test('activity menu widget: activity view icon', async function (assert) { // click on the "Issue" activity icon await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass(activityMenu.$('.dropdown-menu'), 'show', + "dropdown should be expanded"); + await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Issue']")); + assert.doesNotHaveClass(activityMenu.$('.dropdown-menu'), 'show', + "dropdown should be collapsed"); // click on the "Note" activity icon + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Note']")); assert.verifySteps([ diff --git a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js index b31b12f15b3..610e689ae4a 100644 --- a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js @@ -920,5 +920,62 @@ QUnit.test('messaging menu widget: expand on thread preview', async function (as messagingMenu.destroy(); }); +QUnit.test('messaging menu widget: click twice preview on slow message_fetch should open chat window once', async function (assert) { + // This test assumes that a condition for opening chat window is to + // successfully fetch messages beforehand. + assert.expect(1); + + const self = this; + // Used to pause `message_fetch` after opening the messaging menu. + // This is necessary `message_fetch` on mailbox_inbox is required to + // display the previews. + let lockMessageFetch = false; + const messageFetchProm = testUtils.makeTestPromise(); + + const messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + services: this.services, + data: this.data, + session: { partner_id: 1 }, + async mockRPC(route, args) { + if (args.method === 'message_fetch' && lockMessageFetch) { + const _super = this._super.bind(this); + await messageFetchProm; + return _super(route, args); + } + if (args.method === 'channel_minimize') { + // called to detach thread in chat window + // simulate longpolling response with new chat window state + const channelInfo = { + ...self.data['mail.channel'].records[0], + is_minimized: true, + state: 'open', + }; + const notifications = [ [['myDB', 'res.partner'], channelInfo] ]; + messagingMenu.call('bus_service', 'trigger', 'notification', notifications); + } + return this._super(...arguments); + }, + }); + await messagingMenu.appendTo($('#qunit-fixture')); + + // Opening chat window 1st time from messaging menu (pending from `messageFetchDef`) + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + lockMessageFetch = true; + await testUtils.dom.click(messagingMenu.$('.o_mail_preview')); + // Click again on preview to open chat window + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + await testUtils.dom.click(messagingMenu.$('.o_mail_preview')); + messageFetchProm.resolve(); + await testUtils.nextTick(); + + assert.containsOnce( + $, + '.o_thread_window', + "should only display a single chat window"); + + messagingMenu.destroy(); +}); + }); }); diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py index dc659681dac..e68144af84d 100644 --- a/addons/mail/wizard/mail_resend_message.py +++ b/addons/mail/wizard/mail_resend_message.py @@ -46,7 +46,7 @@ class MailResendMessage(models.TransientModel): rec['mail_message_id'] = mail_message_id.id rec['partner_ids'] = partner_ids else: - raise UserError('No message_id found in context') + raise UserError(_('No message_id found in context')) return rec def resend_mail_action(self): diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py index c6d5da099d3..2ab6df51909 100644 --- a/addons/maintenance/models/maintenance.py +++ b/addons/maintenance/models/maintenance.py @@ -46,7 +46,7 @@ class MaintenanceEquipmentCategory(models.Model): alias_id = fields.Many2one( 'mail.alias', 'Alias', ondelete='restrict', required=True, help="Email alias for this equipment category. New emails will automatically " - "create new maintenance request for this equipment category.") + "create a new equipment under this category.") fold = fields.Boolean(string='Folded in Maintenance Pipe', compute='_compute_fold', store=True) def _compute_equipment_count(self): diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 48b5d767780..9d1c2c06ebc 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -137,7 +137,7 @@ <div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click oe_semantic_html_override"> <div class="o_dropdown_kanban dropdown"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> @@ -438,7 +438,7 @@ <div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click"> <div class="o_dropdown_kanban dropdown" t-if="!selection_mode"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py index 5c0e6a3a860..e50b36534e4 100644 --- a/addons/mass_mailing/models/mailing_contact.py +++ b/addons/mass_mailing/models/mailing_contact.py @@ -78,7 +78,7 @@ class MassMailingContact(models.Model): contacts = self.env['mailing.contact.subscription'].search([('list_id', '=', active_list_id)]) return [('id', 'in', [record.contact_id.id for record in contacts if record.opt_out == value])] else: - raise UserError('Search opt out cannot be executed without a unique and valid active mailing list context.') + raise UserError(_('Search opt out cannot be executed without a unique and valid active mailing list context.')) @api.depends('subscription_list_ids') def _compute_opt_out(self): diff --git a/addons/mass_mailing/views/mailing_list_views.xml b/addons/mass_mailing/views/mailing_list_views.xml index 6ee9811931f..4f57c5d8501 100644 --- a/addons/mass_mailing/views/mailing_list_views.xml +++ b/addons/mass_mailing/views/mailing_list_views.xml @@ -114,7 +114,7 @@ </div> <div class="o_kanban_card_manage_pane"> <div class="o_kanban_card_manage_section o_dropdown_kanban dropdown"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" data-display="static" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/mass_mailing/views/mailing_mailing_views.xml b/addons/mass_mailing/views/mailing_mailing_views.xml index fe5b5ad2f1b..d6672f4bab2 100644 --- a/addons/mass_mailing/views/mailing_mailing_views.xml +++ b/addons/mass_mailing/views/mailing_mailing_views.xml @@ -236,7 +236,7 @@ <t t-name="kanban-box"> <div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click oe_kanban_mass_mailing"> <div class="o_dropdown_kanban dropdown" t-if="!selection_mode"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" data-display="static" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/mass_mailing_sale/models/mailing_mailing.py b/addons/mass_mailing_sale/models/mailing_mailing.py index 08716037a55..4d4081da489 100644 --- a/addons/mass_mailing_sale/models/mailing_mailing.py +++ b/addons/mass_mailing_sale/models/mailing_mailing.py @@ -20,16 +20,11 @@ class MassMailing(models.Model): @api.depends('mailing_domain') def _compute_sale_invoiced_amount(self): - has_so_access = self.env['sale.order'].check_access_rights('read', raise_exception=False) - has_invoice_report_access = self.env['account.invoice.report'].check_access_rights('read', raise_exception=False) for mass_mailing in self: - if has_so_access and has_invoice_report_access: - invoices = self.env['sale.order'].search(self._get_sale_utm_domain()).mapped('invoice_ids') - res = self.env['account.invoice.report'].search_read( - [('move_id', 'in', invoices.ids), ('state', 'not in', ['draft', 'cancel'])], - ['price_subtotal'] - ) - mass_mailing.sale_invoiced_amount = sum(r['price_subtotal'] for r in res) + if self.user_has_groups('sales_team.group_sale_salesman') and self.user_has_groups('account.group_account_invoice'): + domain = self._get_sale_utm_domain() + [('state', 'not in', ['draft', 'cancel'])] + moves = self.env['account.move'].search_read(domain, ['amount_untaxed']) + mass_mailing.sale_invoiced_amount = sum(i['amount_untaxed'] for i in moves) else: mass_mailing.sale_invoiced_amount = 0 @@ -40,15 +35,15 @@ class MassMailing(models.Model): return action def action_redirect_to_invoiced(self): - action = self.env.ref('account.action_move_journal_line').read()[0] - invoices = self.env['sale.order'].search(self._get_sale_utm_domain()).mapped('invoice_ids') + action = self.env.ref('account.action_move_out_invoice_type').read()[0] + moves = self.env['account.move'].search(self._get_sale_utm_domain()) action['context'] = { 'create': False, 'edit': False, 'view_no_maturity': True } action['domain'] = [ - ('id', 'in', invoices.ids), + ('id', 'in', moves.ids), ('type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')), ('state', 'not in', ['draft', 'cancel']) ] diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 1766293e0e1..6fe33d5170e 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -259,6 +259,11 @@ class MrpWorkorder(models.Model): for order in (self - late_orders): order.color = 2 + @api.onchange('date_planned_start', 'duration_expected') + def _onchange_date_planned_finished(self): + if self.date_planned_start and self.duration_expected: + self.date_planned_finished = self.date_planned_start + relativedelta(minutes=self.duration_expected) + def write(self, values): if list(values.keys()) != ['time_ids'] and any(workorder.state == 'done' for workorder in self): raise UserError(_('You can not change the finished work order.')) diff --git a/addons/note/views/note_views.xml b/addons/note/views/note_views.xml index 051b7abe0e2..cb1ba5cb4ae 100644 --- a/addons/note/views/note_views.xml +++ b/addons/note/views/note_views.xml @@ -97,7 +97,7 @@ <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <div class="o_dropdown_kanban dropdown"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/partner_autocomplete/models/res_partner.py b/addons/partner_autocomplete/models/res_partner.py index 4a38ffb7855..1821dfd20f1 100644 --- a/addons/partner_autocomplete/models/res_partner.py +++ b/addons/partner_autocomplete/models/res_partner.py @@ -110,7 +110,7 @@ class ResPartner(models.Model): if suggestions: results = [] for suggestion in suggestions: - results.append(suggestion) + results.append(self._format_data_company(suggestion)) return results else: return [] diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index d5d149a02e4..3c1747a1d01 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -76,7 +76,10 @@ var ScreenWidget = PosBaseWidget.extend({ barcode_client_action: function(code){ var partner = this.pos.db.get_partner_by_barcode(code.code); if(partner){ - this.pos.get_order().set_client(partner); + if (this.pos.get_order().get_client() !== partner) { + this.pos.get_order().set_client(partner); + this.pos.get_order().set_pricelist(_.findWhere(this.pos.pricelists, {'id': partner.property_product_pricelist[0]}) || this.pos.default_pricelist); + } return true; } this.barcode_error_action(code); diff --git a/addons/point_of_sale/wizard/pos_details.xml b/addons/point_of_sale/wizard/pos_details.xml index 596ba509825..6a10896b6fd 100644 --- a/addons/point_of_sale/wizard/pos_details.xml +++ b/addons/point_of_sale/wizard/pos_details.xml @@ -10,7 +10,7 @@ <field name="end_date"/> </group> <group> - <field name="pos_config_ids" colspan="4" nolabel="1" /> + <field name="pos_config_ids" mode="tree" colspan="4" nolabel="1" /> </group> <footer> <button name="generate_report" string="Print" type="object" class="btn-primary"/> diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 4425266df38..0b6ea4c7983 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -323,7 +323,7 @@ class CustomerPortal(Controller): document = request.env[model_name].browse([document_id]) document_sudo = document.with_user(SUPERUSER_ID).exists() if not document_sudo: - raise MissingError("This document does not exist.") + raise MissingError(_("This document does not exist.")) try: document.check_access_rights('read') document.check_access_rule('read') @@ -359,12 +359,12 @@ class CustomerPortal(Controller): def _show_report(self, model, report_type, report_ref, download=False): if report_type not in ('html', 'pdf', 'text'): - raise UserError("Invalid report type: %s" % report_type) + raise UserError(_("Invalid report type: %s") % report_type) report_sudo = request.env.ref(report_ref).sudo() if not isinstance(report_sudo, type(request.env['ir.actions.report'])): - raise UserError("%s is not the reference of a report" % report_ref) + raise UserError(_("%s is not the reference of a report") % report_ref) method_name = 'render_qweb_%s' % (report_type) report = getattr(report_sudo, method_name)([model.id], data={'report_type': report_type})[0] diff --git a/addons/pos_mercury/views/pos_config_setting_views.xml b/addons/pos_mercury/views/pos_config_setting_views.xml index 99f0f9d786f..cc92de56eea 100644 --- a/addons/pos_mercury/views/pos_config_setting_views.xml +++ b/addons/pos_mercury/views/pos_config_setting_views.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view"> - <field name="name">pos.config.form.inherit.mercury</field> + <record id="res_config_view_form_inherit_pos_mercury" model="ir.ui.view"> + <field name="name">res.config.form.inherit.mercury</field> <field name="model">res.config.settings</field> <field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/> <field name="arch" type="xml"> diff --git a/addons/pos_mercury/views/pos_config_views.xml b/addons/pos_mercury/views/pos_config_views.xml index 8da1d16bd8a..63aa77de208 100644 --- a/addons/pos_mercury/views/pos_config_views.xml +++ b/addons/pos_mercury/views/pos_config_views.xml @@ -1,6 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view"> + <!-- TODO the inherit for + point_of_sale.res_config_settings_view_form in this module + uses the pos_config_view_form_inherit_pos_mercury xml id + already. It should be renamed to + res_config_settings_view_form_inherit_pos_mercury. --> + <record id="pos_config_view_form_inherit_pos_mercury_2" model="ir.ui.view"> <field name="name">pos.config.form.view.inherit.mercury</field> <field name="model">pos.config</field> <field name="inherit_id" ref="point_of_sale.pos_config_view_form"/> diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 3c92ea27f6b..e7f09273aae 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -574,7 +574,7 @@ </small> </div> <div class="o_dropdown_kanban dropdown" t-if="!selection_mode" groups="base.group_user"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index a00aa4e0606..3f5beb0ff1f 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -393,6 +393,8 @@ class PurchaseOrder(models.Model): 'default_type': 'in_invoice', 'default_purchase_id': self.id, } + if self.user_id: + result['context']['default_user_id'] = self.user_id.id # choose the view_mode accordingly if len(self.invoice_ids) > 1 and not create_bill: result['domain'] = "[('id', 'in', " + str(self.invoice_ids.ids) + ")]" diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index b54abba7fd2..3598a06645d 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -196,7 +196,8 @@ class ProductionLot(models.Model): stock_moves = self.env['stock.move.line'].search([ ('lot_id', '=', lot.id), ('state', '=', 'done') - ]).mapped('move_id').filtered( + ]).mapped('move_id') + stock_moves = stock_moves.search([('id', 'in', stock_moves.ids)]).filtered( lambda move: move.picking_id.location_id.usage == 'supplier' and move.state == 'done') lot.purchase_order_ids = stock_moves.mapped('purchase_line_id.order_id') lot.purchase_order_count = len(lot.purchase_order_ids) diff --git a/addons/purchase_stock/tests/test_create_picking.py b/addons/purchase_stock/tests/test_create_picking.py index a727fea2371..de209d06319 100644 --- a/addons/purchase_stock/tests/test_create_picking.py +++ b/addons/purchase_stock/tests/test_create_picking.py @@ -315,6 +315,68 @@ class TestCreatePicking(common.TestProductCommon): self.assertEqual(sum(customer_picking.move_lines.mapped('reserved_availability')), 100.0, 'The total quantity for the customer move should be available and reserved.') + def test_04_rounding(self): + """ We set the Unit(s) rounding to 1.0 and ensure buying 1.2 units in a PO is rounded to 1.0 + at reception. + """ + uom_unit = self.env.ref('uom.product_uom_unit') + uom_unit.rounding = 1.0 + + # buy a dozen + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 1.2 + po.button_confirm() + + # the move should be 1.0 units + move1 = po.picking_ids.move_lines[0] + self.assertEqual(move1.product_uom_qty, 1.0) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 1.0) + + # edit the so line, buy 2.4 units, the move should now be 2.0 units + po.order_line.product_qty = 2.0 + self.assertEqual(move1.product_uom_qty, 2.0) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 2.0) + + # deliver everything + move1.quantity_done = 2.0 + po.picking_ids.button_validate() + + # check the delivered quantity + self.assertEqual(po.order_line.qty_received, 2.0) + + def test_05_uom_rounding(self): + """ We set the Unit(s) and Dozen(s) rounding to 1.0 and ensure buying 1.3 dozens in a PO is + rounded to 1.0 at reception. + """ + uom_unit = self.env.ref('uom.product_uom_unit') + uom_dozen = self.env.ref('uom.product_uom_dozen') + uom_unit.rounding = 1.0 + uom_dozen.rounding = 1.0 + + # buy 1.3 dozen + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 1.3 + po.order_line.product_uom = uom_dozen.id + po.button_confirm() + + # the move should be 16.0 units + move1 = po.picking_ids.move_lines[0] + self.assertEqual(move1.product_uom_qty, 16.0) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 16.0) + + # force the propagation of the uom, buy 2.6 dozens, the move 2 should have 2 dozens + self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1') + po.order_line.product_qty = 2.6 + move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id) + self.assertEqual(move2.product_uom_qty, 2) + self.assertEqual(move2.product_uom.id, uom_dozen.id) + self.assertEqual(move2.product_qty, 24) + def create_delivery_order(self, propagate_date, propagate_date_minimum_delta): stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 0e93cf69cec..cb293cd5d97 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1073,7 +1073,7 @@ class SaleOrderLine(models.Model): def write(self, values): if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): - raise UserError("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type.") + raise UserError(_("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type.")) if 'product_uom_qty' in values: precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py index 636f5e65281..e1249c86abc 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -303,14 +303,14 @@ class SaleOrder(models.Model): # Check commit 6bb42904a03 for next if/else # Remove reward line if price or qty equal to 0 if values['product_uom_qty'] and values['price_unit']: - order.write({'order_line': [(1, line.id, values) for line in lines]}) + lines.write(values) else: if program.reward_type != 'free_shipping': # Can't remove the lines directly as we might be in a recordset loop lines_to_remove += lines else: values.update(price_unit=0.0) - order.write({'order_line': [(1, line.id, values) for line in lines]}) + lines.write(values) return lines_to_remove self.ensure_one() diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py index 745ac2b3ff4..71ca1f9acdd 100644 --- a/addons/sale_management/models/sale_order_template.py +++ b/addons/sale_management/models/sale_order_template.py @@ -50,7 +50,7 @@ class SaleOrderTemplate(models.Model): template_id = self.env['ir.default'].get('sale.order', 'sale_order_template_id') for template in self: if template_id and template_id == template.id: - raise UserError('Before archiving "%s" please select another default template in the settings.' % template.name) + raise UserError(_('Before archiving "%s" please select another default template in the settings.') % template.name) return super(SaleOrderTemplate, self).write(vals) @@ -99,7 +99,7 @@ class SaleOrderTemplateLine(models.Model): def write(self, values): if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): - raise UserError("You cannot change the type of a sale quote line. Instead you should delete the current line and create a new line of the proper type.") + raise UserError(_("You cannot change the type of a sale quote line. Instead you should delete the current line and create a new line of the proper type.")) return super(SaleOrderTemplateLine, self).write(values) _sql_constraints = [ diff --git a/addons/sale_management/views/sale_order_views.xml b/addons/sale_management/views/sale_order_views.xml index feb8162110b..54b8bc35716 100644 --- a/addons/sale_management/views/sale_order_views.xml +++ b/addons/sale_management/views/sale_order_views.xml @@ -103,12 +103,13 @@ </field> </record> + <!-- TODO remove me after saas-12.4 to be replaced by sale.prod_config_main --> <menuitem id="menu_product_settings" name="Products" sequence="80" parent="sale.menu_sale_config"/> <menuitem id="menu_product_attribute_action" action="product.attribute_action" - parent="menu_product_settings" groups="product.group_product_variant" sequence="1"/> + parent="sale.prod_config_main" groups="product.group_product_variant" sequence="1"/> <menuitem id="menu_catalog_variants_action" action="product.variants_action" - parent="menu_product_settings" groups="product.group_product_variant" sequence="2"/> + parent="sale.prod_config_main" groups="product.group_product_variant" sequence="2"/> </odoo> diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index 325650aedd9..3e4eb8c9180 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -123,7 +123,8 @@ class ProductionLot(models.Model): stock_moves = self.env['stock.move.line'].search([ ('lot_id', '=', lot.id), ('state', '=', 'done') - ]).mapped('move_id').filtered( + ]).mapped('move_id') + stock_moves = stock_moves.search([('id', 'in', stock_moves.ids)]).filtered( lambda move: move.picking_id.location_dest_id.usage == 'customer' and move.state == 'done') lot.sale_order_ids = stock_moves.mapped('sale_line_id.order_id') lot.sale_order_count = len(lot.sale_order_ids) diff --git a/addons/sale_timesheet/models/account_move.py b/addons/sale_timesheet/models/account_move.py index 5ef655898be..7e316a523e4 100644 --- a/addons/sale_timesheet/models/account_move.py +++ b/addons/sale_timesheet/models/account_move.py @@ -61,7 +61,7 @@ class AccountMoveLine(models.Model): sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet') if sale_line_delivery: domain = self._timesheet_domain_get_invoiced_lines(sale_line_delivery) - timesheets = self.env['account.analytic.line'].search(domain) + timesheets = self.env['account.analytic.line'].search(domain).sudo() timesheets.write({ 'timesheet_invoice_id': line.move_id.id, }) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 9ca508870da..4f06e298a00 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -85,7 +85,8 @@ class SaleOrder(models.Model): task_projects = self.tasks_ids.mapped('project_id') if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...) - action = self.env.ref('project.act_project_project_2_project_task_all').read()[0] + action = self.with_context(active_id=task_projects.id).env.ref( + 'project.act_project_project_2_project_task_all').read()[0] if action.get('context'): eval_context = self.env['ir.actions.actions']._get_eval_context() eval_context.update({'active_id': task_projects.id}) diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 6571459ee20..56844ddb5b1 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -830,7 +830,9 @@ class UoM(models.Model): procurement_uom = self computed_qty = qty get_param = self.env['ir.config_parameter'].sudo().get_param - if procurement_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1': + if get_param('stock.propagate_uom') != '1': computed_qty = self._compute_quantity(qty, quant_uom, rounding_method='HALF-UP') procurement_uom = quant_uom + else: + computed_qty = self._compute_quantity(qty, procurement_uom, rounding_method='HALF-UP') return (computed_qty, procurement_uom) diff --git a/addons/stock/models/res_config_settings.py b/addons/stock/models/res_config_settings.py index 9dda679f4e6..a6675a97c0e 100644 --- a/addons/stock/models/res_config_settings.py +++ b/addons/stock/models/res_config_settings.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo import api, fields, models class ResConfigSettings(models.TransientModel): @@ -55,13 +54,6 @@ class ResConfigSettings(models.TransientModel): def _onchange_group_stock_production_lot(self): if not self.group_stock_production_lot: self.group_lot_on_delivery_slip = False - tracked_products = self.env['product.template'].search([('tracking', 'in', ['lot', 'serial']),]) - if not self.group_stock_production_lot and tracked_products: - names = ", ".join(tracked_products.mapped('display_name') if len(tracked_products) <= 10 - else tracked_products[:10].mapped('display_name') + ["..."]) - raise UserError(_("You should not remove the 'lots and serial numbers' " - "option while the following products are still tracked by lot " - "or serial number:\n %s") % names) @api.onchange('group_stock_adv_location') def onchange_adv_location(self): diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index 1307272edda..82c50565c39 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -53,7 +53,7 @@ class ProductionLot(models.Model): def write(self, vals): if 'product_id' in vals and any([vals['product_id'] != lot.product_id.id for lot in self]): - move_lines = self.env['stock.move.line'].search([('lot_id', 'in', self.ids)]) + move_lines = self.env['stock.move.line'].search([('lot_id', 'in', self.ids), ('product_id', '!=', vals['product_id'])]) if move_lines: raise UserError(_( 'You are not allowed to change the product linked to a serial or lot number ' + diff --git a/addons/stock_picking_batch/report/report_picking_batch.xml b/addons/stock_picking_batch/report/report_picking_batch.xml index 4502df325f0..c1c1df41ccb 100644 --- a/addons/stock_picking_batch/report/report_picking_batch.xml +++ b/addons/stock_picking_batch/report/report_picking_batch.xml @@ -31,7 +31,7 @@ <span t-field="pick.name"/> </td> <td> - <img t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&quiet=%s' % ('Code128', pick.name, 200, 50, 0)" alt="Barcode"/> + <img t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&quiet=%s' % ('Code128', pick.name, 400, 100, 0)" style="width:200px;height:50px" alt="Barcode"/> </td> <td> <span t-field="pick.state"/> diff --git a/addons/survey/views/survey_report_templates.xml b/addons/survey/views/survey_report_templates.xml index 55b209079f7..df969747027 100644 --- a/addons/survey/views/survey_report_templates.xml +++ b/addons/survey/views/survey_report_templates.xml @@ -15,12 +15,15 @@ <h2> <span t-esc="user_input.partner_id.name or user_input.email"/> </h2> - <p>Successfully achieved</p> + <!-- You should not print a certificate if failed :) --> + <p t-if="user_input.quizz_passed">Successfully achieved</p> + <p t-if="not user_input.quizz_passed">Successfully failed</p> <h3> <span t-field="user_input.survey_id.display_name"/> </h3> <p> - <strong>Date of Certification:</strong> + <strong t-if="user_input.quizz_passed">Date of Certification:</strong> + <strong t-if="not user_input.quizz_passed">Date of Failure:</strong> <span t-field="user_input.create_date"/> </p> </div> diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index c41aa840223..99f46580fc1 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -159,7 +159,7 @@ <div t-name="kanban-box" t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click o_kanban_card_survey"> <div class="o_dropdown_kanban dropdown" t-if="widget.editable and !selection_mode"> - <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 945b63e9a37..29c2ff04c40 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -534,7 +534,7 @@ var utils = { } var normalized_value = value / precision; var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2); - var epsilon = Math.pow(2, epsilon_magnitude - 53); + var epsilon = Math.pow(2, epsilon_magnitude - 52); normalized_value += normalized_value >= 0 ? epsilon : -epsilon; /** diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 13ea270870f..289e8a3a0c2 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1434,9 +1434,9 @@ var BasicModel = AbstractModel.extend({ // apply changes to local data for (var fieldName in changes) { field = record.fields[fieldName]; - if (field.type === 'one2many' || field.type === 'many2many') { + if (field && (field.type === 'one2many' || field.type === 'many2many')) { defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options)); - } else if (field.type === 'many2one' || field.type === 'reference') { + } else if (field && (field.type === 'many2one' || field.type === 'reference')) { defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName])); } else { record._changes[fieldName] = changes[fieldName]; @@ -1451,7 +1451,7 @@ var BasicModel = AbstractModel.extend({ var onChangeFields = []; // the fields that have changed and that have an on_change for (var fieldName in changes) { field = record.fields[fieldName]; - if (field.onChange) { + if (field && field.onChange) { var isX2Many = field.type === 'one2many' || field.type === 'many2many'; if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { onChangeFields.push(fieldName); diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js b/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js index eae16968833..dfc86042aca 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js @@ -91,10 +91,15 @@ KanbanRenderer.include({ var index = _.findIndex(this.widgets, {db_id: localID}); var $column = this.widgets[index].$el; var left = $column.css('left'); + var right = $column.css('right'); var scrollTop = $column.scrollTop(); return this._super.apply(this, arguments).then(function () { $column = self.widgets[index].$el; - $column.css({left: left}); + if (_t.database.parameters.direction === 'rtl') { + $column.css({right: right}); + } else { + $column.css({left: left}); + } $column.scrollTop(scrollTop); // required when clicking on 'Load More' self._enableSwipe(); }); @@ -115,19 +120,40 @@ KanbanRenderer.include({ var self = this; var moveToIndex = this.activeColumnIndex; var updateFunc = animate ? 'animate' : 'css'; + var rtl = _t.database.parameters.direction === 'rtl'; _.each(this.widgets, function (column, index) { var columnID = column.id || column.db_id; var $column = self.$('.o_kanban_group[data-id="' + columnID + '"]'); if (index === moveToIndex - 1) { - $column[updateFunc]({left: '-100%'}); + if (rtl) { + $column[updateFunc]({right: '-100%'}); + } else { + $column[updateFunc]({left: '-100%'}); + } } else if (index === moveToIndex + 1) { - $column[updateFunc]({left: '100%'}); + if (rtl) { + $column[updateFunc]({right: '100%'}); + } else { + $column[updateFunc]({left: '100%'}); + } } else if (index === moveToIndex) { - $column[updateFunc]({left: '0%'}); + if (rtl) { + $column[updateFunc]({right: '0%'}); + } else { + $column[updateFunc]({left: '0%'}); + } } else if (index < moveToIndex) { - $column.css({left: '-100%'}); + if (rtl) { + $column.css({right: '-100%'}); + } else { + $column.css({left: '-100%'}); + } } else if (index > moveToIndex) { - $column.css({left: '100%'}); + if (rtl) { + $column.css({right: '100%'}); + } else { + $column.css({left: '100%'}); + } } }); } @@ -184,6 +210,7 @@ KanbanRenderer.include({ } } // Apply the scroll x on the tabs + // XXX in case of RTL, should we use scrollRight? this.$('.o_kanban_mobile_tabs').scrollLeft(scrollToLeft); } }, @@ -213,16 +240,17 @@ KanbanRenderer.include({ */ _enableSwipe: function () { var self = this; + var step = _t.database.parameters.direction === 'rtl' ? -1 : 1; this.$el.swipe({ excludedElements: ".o_kanban_mobile_tabs", swipeLeft: function () { - var moveToIndex = self.activeColumnIndex + 1; + var moveToIndex = self.activeColumnIndex + step; if (moveToIndex < self.widgets.length) { self._moveToGroup(moveToIndex, self.ANIMATE); } }, swipeRight: function () { - var moveToIndex = self.activeColumnIndex - 1; + var moveToIndex = self.activeColumnIndex - step; if (moveToIndex > -1) { self._moveToGroup(moveToIndex, self.ANIMATE); } diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index df0ad782b60..e4dd682ed51 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -516,16 +516,16 @@ ListRenderer.include({ switch (field.type) { case 'binary': return 1; case 'boolean': return '40px'; - case 'char': return 1; - case 'date': return '100px'; - case 'datetime': return '150px'; - case 'float': return '100px'; + case 'char': return 2; + case 'date': return '92px'; + case 'datetime': return '146px'; + case 'float': return '92px'; case 'html': return 3; - case 'integer': return '80px'; + case 'integer': return '74px'; case 'many2many': return 2; case 'many2one': return 2; - case 'monetary': return 1.2; - case 'one2many': return 2.5; + case 'monetary': return '104px'; + case 'one2many': return 2; case 'reference': return 1.5; case 'selection': return 1.5; case 'text': return 2.5; diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 6932628b9e9..f2fc83902a4 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -37,6 +37,9 @@ &.o_list_many2one { white-space: pre-wrap; } + &.o_list_text { + word-break: break-word; + } } tfoot { diff --git a/addons/web/static/tests/views/kanban_mobile_tests.js b/addons/web/static/tests/views/kanban_mobile_tests.js index 9a9cd3cf15f..184ac985e80 100644 --- a/addons/web/static/tests/views/kanban_mobile_tests.js +++ b/addons/web/static/tests/views/kanban_mobile_tests.js @@ -1,10 +1,12 @@ odoo.define('web.kanban_mobile_tests', function (require) { "use strict"; +var core = require('web.core'); var KanbanView = require('web.KanbanView'); var testUtils = require('web.test_utils'); var createView = testUtils.createView; +var _t = core._t; QUnit.module('Views', { beforeEach: function () { @@ -119,6 +121,35 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('mobile grouped rendering in rtl direction', async function (assert) { + assert.expect(2); + + var direction = _t.database.parameters.direction; + _t.database.parameters.direction = 'rtl'; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test o_kanban_small_column" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + domain: [['product_id', '!=', false]], + groupBy: ['product_id'], + }); + + assert.strictEqual(kanban.$('.o_kanban_group:first')[0].style.right, '0%', + "first tab should have 50% right"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1)')[0].style.right, '100%', + "second tab should have 100% right"); + + kanban.destroy(); + _t.database.parameters.direction = direction; + }); + + QUnit.test('mobile grouped with undefined column', async function (assert) { assert.expect(5); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index d74bda7578b..f4eb264241d 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -1432,10 +1432,10 @@ QUnit.module('Views', { QUnit.test('width of some of the fields should be hardcoded', async function (assert) { const assertions = [ { field: 'bar', expected: 40, type: 'Boolean' }, - { field: 'int_field', expected: 80, type: 'Integer' }, - { field: 'qux', expected: 100, type: 'Float' }, - { field: 'date', expected: 100, type: 'Date' }, - { field: 'datetime', expected: 150, type: 'Datetime' }, + { field: 'int_field', expected: 74, type: 'Integer' }, + { field: 'qux', expected: 92, type: 'Float' }, + { field: 'date', expected: 92, type: 'Date' }, + { field: 'datetime', expected: 146, type: 'Datetime' }, ]; assert.expect(assertions.length + 1); @@ -1497,9 +1497,8 @@ QUnit.module('Views', { }); // the width is hardcoded to make sure we have the same condition - // between debug mode and non debug mode (#qunit-fixture is limited to - // 1000px) - list.$el.width('1000px'); + // between debug mode and non debug mode + list.$el.width('1200px'); var startHeight = list.$('.o_data_row:first').height(); // start edition of first row diff --git a/addons/website/models/mixins.py b/addons/website/models/mixins.py index b6d8887cfe3..1dad8168161 100644 --- a/addons/website/models/mixins.py +++ b/addons/website/models/mixins.py @@ -102,7 +102,12 @@ class WebsiteMultiMixin(models.AbstractModel): _name = 'website.multi.mixin' _description = 'Multi Website Mixin' - website_id = fields.Many2one('website', string='Website', help='Restrict publishing to this website.') + website_id = fields.Many2one( + "website", + string="Website", + ondelete="restrict", + help="Restrict publishing to this website.", + ) def can_access_from_current_website(self, website_id=False): can_access = True diff --git a/addons/website/models/website.py b/addons/website/models/website.py index a81721d9f10..8ce3d46568a 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -174,6 +174,18 @@ class Website(models.Model): if 'favicon' in vals: vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO') + def unlink(self): + # Do not delete invoices, delete what's strictly necessary + attachments_to_unlink = self.env['ir.attachment'].search([ + ('website_id', 'in', self.ids), + '|', '|', + ('key', '!=', False), # theme attachment + ('url', 'ilike', '.custom.'), # customized theme attachment + ('url', 'ilike', '.assets\\_'), + ]) + attachments_to_unlink.unlink() + return super(Website, self).unlink() + # ---------------------------------------------------------- # Page Management # ---------------------------------------------------------- diff --git a/addons/website/models/website_menu.py b/addons/website/models/website_menu.py index 274cb2bb5a3..e710979015f 100644 --- a/addons/website/models/website_menu.py +++ b/addons/website/models/website_menu.py @@ -21,7 +21,7 @@ class Menu(models.Model): page_id = fields.Many2one('website.page', 'Related Page', ondelete='cascade') new_window = fields.Boolean('New Window') sequence = fields.Integer(default=_default_sequence) - website_id = fields.Many2one('website', 'Website') + website_id = fields.Many2one('website', 'Website', ondelete='cascade') parent_id = fields.Many2one('website.menu', 'Parent Menu', index=True, ondelete="cascade") child_id = fields.One2many('website.menu', 'parent_id', string='Child Menus') parent_path = fields.Char(index=True) diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index 0660d232752..2db952cc62c 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -6,6 +6,57 @@ import odoo.tests @odoo.tests.tagged('-at_install', 'post_install') +class TestUiCustomizeTheme(odoo.tests.HttpCase): + def test_01_attachment_website_unlink(self): + ''' Some ir.attachment needs to be unlinked when a website is unlink, + otherwise some flows will just crash. That's the case when 2 website + have their theme color customized. Removing a website will make its + customized attachment generic, thus having 2 attachments with the + same URL available for other websites, leading to singleton errors + (among other). + + But no all attachment should be deleted, eg we don't want to delete + a SO or invoice PDF coming from an ecommerce order. + ''' + Website = self.env['website'] + Page = self.env['website.page'] + Attachment = self.env['ir.attachment'] + + website_default = Website.browse(1) + website_test = Website.create({'name': 'Website Test'}) + + # simulate attachment state when editing 2 theme through customize + custom_url = '/TEST/website/static/src/scss/options/colors/user_theme_color_palette.custom.web.assets_common.scss' + scss_attachment = Attachment.create({ + 'name': custom_url, + 'type': 'binary', + 'mimetype': 'text/scss', + 'datas': '', + 'url': custom_url, + 'website_id': website_default.id + }) + scss_attachment.copy({'website_id': website_test.id}) + + # simulate PDF from ecommerce order + # Note: it will only have its website_id flag if the website has a domain + # equal to the current URL (fallback or get_current_website()) + so_attachment = Attachment.create({ + 'name': 'SO036.pdf', + 'type': 'binary', + 'mimetype': 'application/pdf', + 'datas': '', + 'website_id': website_test.id + }) + + # avoid sql error on page website_id restrict + Page.search([('website_id', '=', website_test.id)]).unlink() + website_test.unlink() + self.assertEqual(Attachment.search_count([('url', '=', custom_url)]), 1, 'Should not left duplicates when deleting a website') + self.assertTrue(so_attachment.exists(), 'Most attachment should not be deleted') + self.assertFalse(so_attachment.website_id, 'Website should be removed') + + +@odoo.tests.tagged('-at_install', 'post_install') class TestUiHtmlEditor(odoo.tests.HttpCase): def test_html_editor_multiple_templates(self): Website = self.env['website'] diff --git a/addons/website_event/static/src/js/website_geolocation.js b/addons/website_event/static/src/js/website_geolocation.js index 19ee2f87033..5e81eb0adce 100644 --- a/addons/website_event/static/src/js/website_geolocation.js +++ b/addons/website_event/static/src/js/website_geolocation.js @@ -12,12 +12,22 @@ publicWidget.registry.visitor = publicWidget.Widget.extend({ start: function () { var defs = [this._super.apply(this, arguments)]; var self = this; + var $eventList = this.$('.country_events_list'); + this._originalContent = $eventList[0].outerHTML; defs.push(this._rpc({route: '/event/get_country_event_list'}).then(function (data) { if (data) { - self.$('.country_events_list').replaceWith(data); + self._$loadedContent = $(data); + $eventList.replaceWith(self._$loadedContent); } })); return Promise.all(defs); }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this._$loadedContent.replaceWith(this._originalContent); + }, }); }); diff --git a/addons/website_payment/models/payment_acquirer.py b/addons/website_payment/models/payment_acquirer.py index 6702aad9115..5f7c31542a4 100644 --- a/addons/website_payment/models/payment_acquirer.py +++ b/addons/website_payment/models/payment_acquirer.py @@ -4,6 +4,10 @@ from odoo import fields, models class PaymentAcquirer(models.Model): - _inherit = 'payment.acquirer' + _inherit = "payment.acquirer" - website_id = fields.Many2one('website', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + website_id = fields.Many2one( + "website", + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + ondelete="restrict", + ) diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index 630b3d44146..d3f80b639d4 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -30,7 +30,7 @@ class ProductPricelist(models.Model): domain = [('company_id', '=', company_id)] return self.env['website'].search(domain, limit=1) - website_id = fields.Many2one('website', string="Website", default=_default_website) + website_id = fields.Many2one('website', string="Website", ondelete='restrict', default=_default_website) code = fields.Char(string='E-commerce Promotional Code', groups="base.group_user") selectable = fields.Boolean(help="Allow the end user to choose this price list") diff --git a/addons/website_sale_comparison/controllers/main.py b/addons/website_sale_comparison/controllers/main.py index 09a5984d64a..a2dac3d4904 100644 --- a/addons/website_sale_comparison/controllers/main.py +++ b/addons/website_sale_comparison/controllers/main.py @@ -18,14 +18,15 @@ class WebsiteSaleProductComparison(WebsiteSale): products = request.env['product.product'].search([('id', 'in', product_ids)]) values['products'] = products.with_context(display_default_code=False) - res = {} + res = OrderedDict() + attrs = products.mapped('product_tmpl_id.valid_product_template_attribute_line_ids.attribute_id').filtered(lambda x: x.create_variant) + for attr in attrs.sorted(lambda att: (att.category_id.sequence, att.sequence)): + cat_name = attr.category_id.name or _('Uncategorized') + res.setdefault(cat_name, OrderedDict()).setdefault(attr.name, [' - '] * len(products)) for num, product in enumerate(products): - for var in product.product_tmpl_id.valid_product_template_attribute_line_ids: + for var in product.product_tmpl_id.valid_product_template_attribute_line_ids.filtered(lambda x: x.attribute_id.create_variant): cat_name = var.attribute_id.category_id.name or _('Uncategorized') att_name = var.attribute_id.name - if not var.attribute_id.create_variant: - continue - res.setdefault(cat_name, OrderedDict()).setdefault(att_name, [' - '] * len(products)) val = product.attribute_value_ids.filtered(lambda x: x.attribute_id == var.attribute_id) if val: res[cat_name][att_name][num] = val[0].name diff --git a/addons/website_sale_wishlist/models/product_wishlist.py b/addons/website_sale_wishlist/models/product_wishlist.py index 3530168efa8..4914aae6ea2 100644 --- a/addons/website_sale_wishlist/models/product_wishlist.py +++ b/addons/website_sale_wishlist/models/product_wishlist.py @@ -18,7 +18,7 @@ class ProductWishlist(models.Model): currency_id = fields.Many2one('res.currency', related='pricelist_id.currency_id', readonly=True) pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', help='Pricelist when added') price = fields.Monetary(currency_field='currency_id', string='Price', help='Price of the product when it has been added in the wishlist') - website_id = fields.Many2one('website', required=True) + website_id = fields.Many2one('website', ondelete='cascade', required=True) active = fields.Boolean(default=True, required=True) @api.model diff --git a/addons/website_twitter/models/website_twitter_tweet.py b/addons/website_twitter/models/website_twitter_tweet.py index 4e80e439296..6fa6fb1564c 100644 --- a/addons/website_twitter/models/website_twitter_tweet.py +++ b/addons/website_twitter/models/website_twitter_tweet.py @@ -8,7 +8,7 @@ class WebsiteTwitterTweet(models.Model): _name = 'website.twitter.tweet' _description = 'Website Twitter' - website_id = fields.Many2one('website', string='Website') + website_id = fields.Many2one('website', string='Website', ondelete='cascade') screen_name = fields.Char(string='Screen Name') tweet = fields.Text(string='Tweets') |
