diff options
91 files changed, 689 insertions, 144 deletions
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 91a1d4cf05d..c6933190d78 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -2173,9 +2173,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_base_amount = fields.Monetary(string="Base Amount", store=True, currency_field='company_currency_id') tax_exigible = fields.Boolean(string='Appears in VAT report', default=True, diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 7fbb328c69d..d04a4b05520 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -454,7 +454,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, + }) @api.multi def unlink(self): diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 2834791ec38..108d7b75550 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -580,6 +580,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 f557e54b64d..a58c574020b 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 f457a912011..a662a9ea959 100644 --- a/addons/base_iban/models/res_partner_bank.py +++ b/addons/base_iban/models/res_partner_bank.py @@ -163,6 +163,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 8626d76acb9..04012e07138 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -446,6 +446,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 44b039a06f8..544adbdf4bc 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 @@ -63,6 +70,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', @@ -398,6 +407,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 + def default_compact(self, vat): return vat diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index f7227a99192..dfd6f7a8e49 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 0fc78a8138d..edcf0ddf57e 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -391,7 +391,7 @@ <div t-attf-class="#{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 dfcbcd75aa3..e3ac9ddf655 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -318,7 +318,7 @@ <div t-attf-class="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_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index 86f4f2593da..69c6345cddf 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -192,7 +192,7 @@ <t t-name="kanban-box"> <div class="oe_kanban_card oe_kanban_global_click"> <div class="o_dropdown_kanban dropdown" 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 7dd9b6c206b..881771c7fa1 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 649b2d2c683..3a8d8361b95 100644 --- a/addons/l10n_it_edi/models/account_invoice.py +++ b/addons/l10n_it_edi/models/account_invoice.py @@ -95,7 +95,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: @@ -110,7 +110,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> @@ -730,7 +730,7 @@ 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 ec2a96b93e4..bafdf1cb464 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.")) @api.multi def fetch_mail(self): diff --git a/addons/l10n_it_edi/models/res_company.py b/addons/l10n_it_edi/models/res_company.py index a0cce53b9a5..dc170405d92 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 c68f6cc3f7f..df5455df4d6 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -151,23 +151,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.')) @api.multi def _compute_is_member(self): @@ -254,7 +254,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.")) tools.image_resize_images(vals) result = super(Channel, self).write(vals) @@ -426,9 +426,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!")) @api.multi def _send_guidelines(self, partners): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 3536c4e6001..9d1bd8c4d01 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -937,7 +937,7 @@ class MailThread(models.AbstractModel): # 1. Check if message is a reply on a thread msg_references = [ref for ref in tools.mail_header_msgid_re.findall(thread_references) if 'reply_to' not in ref] - mail_messages = MailMessage.sudo().search([('message_id', 'in', msg_references)], limit=1) + mail_messages = MailMessage.sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id') is_a_reply = bool(mail_messages) # 1.1 Handle forward to an alias with a different model: do not consider it as a reply @@ -1365,7 +1365,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_smtp_header(message.get('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 4a20d24af8d..3ed4031abeb 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 fae95a78c2f..641ff4be1bc 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 6092ab733fc..8284fdc70b5 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 bed4d682d10..4c9d55fcb75 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -1144,6 +1144,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 8c65266a392..226fd1e398a 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -59,6 +59,7 @@ QUnit.module('Discuss', { type: 'integer', }, }, + records: [], }, 'res.partner': { fields: { @@ -1490,6 +1491,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); 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 b2f76feb832..7d24b5e05a4 100644 --- a/addons/mail/static/tests/systray/systray_activity_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_activity_menu_tests.js @@ -153,7 +153,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, { @@ -193,9 +193,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 bcff9dc9840..5235220240e 100644 --- a/addons/mail/wizard/mail_resend_message.py +++ b/addons/mail/wizard/mail_resend_message.py @@ -48,7 +48,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 @api.multi diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py index 3677527e17a..e5fddbd8550 100644 --- a/addons/maintenance/models/maintenance.py +++ b/addons/maintenance/models/maintenance.py @@ -43,7 +43,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) @api.multi diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 332cceeb14d..14f17c71c3e 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -136,7 +136,7 @@ <div t-attf-class="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"> @@ -435,7 +435,7 @@ <div t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click"> <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/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 243fc33af0a..31aaf7844e5 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -252,7 +252,7 @@ class MassMailingContact(models.Model): contacts = self.env['mail.mass_mailing.list_contact_rel'].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/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml index d590f366dec..e0692193572 100644 --- a/addons/mass_mailing/views/mass_mailing_views.xml +++ b/addons/mass_mailing/views/mass_mailing_views.xml @@ -386,7 +386,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" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu"> @@ -685,7 +685,7 @@ <t t-name="kanban-box"> <div t-attf-class="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"> - <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"> @@ -1041,7 +1041,7 @@ <div t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click oe_kanban_mass_mailing_campaign"> <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" 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/res_config_settings_views.xml b/addons/mass_mailing/views/res_config_settings_views.xml index a8a56b27461..63626781ea1 100644 --- a/addons/mass_mailing/views/res_config_settings_views.xml +++ b/addons/mass_mailing/views/res_config_settings_views.xml @@ -58,7 +58,7 @@ </div> </div> </div> - <h2>Mailing Lists</h2> + <h2 id="mailing_list_settings_title" invisible="1">Mailing Lists</h2> <div id="mailing_list" class="row mt16 o_settings_container"/> </div> </xpath> diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index b880b53d6bc..349e18cbcf0 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -249,6 +249,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) + @api.multi def write(self, values): if list(values.keys()) != ['time_ids'] and any(workorder.state == 'done' for workorder in self): diff --git a/addons/note/views/note_views.xml b/addons/note/views/note_views.xml index 73052c91208..636ee773c95 100644 --- a/addons/note/views/note_views.xml +++ b/addons/note/views/note_views.xml @@ -98,7 +98,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 8395223380f..973a4ae309e 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 7cd57158f22..6b5561401ad 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 2efa54b5c17..4af3da9c8d0 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -244,7 +244,7 @@ class CustomerPortal(Controller): document = request.env[model_name].browse([document_id]) document_sudo = document.sudo().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') @@ -279,12 +279,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 94998f98052..2a122fb8d82 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -577,7 +577,7 @@ </small> </div> <div class="o_dropdown_kanban dropdown" 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 336ab7b94f4..fabc0dc4196 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -405,6 +405,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 9a1e06fe644..7678f58c321 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -199,7 +199,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 18b7369a697..4bf9cbb2b9e 100644 --- a/addons/purchase_stock/tests/test_create_picking.py +++ b/addons/purchase_stock/tests/test_create_picking.py @@ -314,6 +314,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 6c741044e78..fef3f226c10 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1083,7 +1083,7 @@ class SaleOrderLine(models.Model): @api.multi 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 fc49b4abc73..4bb7ee0217a 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -305,14 +305,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 1cf3ecf6abf..c120a92cb47 100644 --- a/addons/sale_management/models/sale_order_template.py +++ b/addons/sale_management/models/sale_order_template.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.addons import decimal_precision as dp from odoo.exceptions import UserError @@ -36,7 +36,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) @@ -87,7 +87,7 @@ class SaleOrderTemplateLine(models.Model): @api.multi 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 d67671ab6de..c967616bb23 100644 --- a/addons/sale_management/views/sale_order_views.xml +++ b/addons/sale_management/views/sale_order_views.xml @@ -98,12 +98,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 21972630368..f7342a5fee8 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 e43ea256daa..eee162b7bae 100644 --- a/addons/sale_timesheet/models/account_move.py +++ b/addons/sale_timesheet/models/account_move.py @@ -63,7 +63,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 dd903ba7330..973bea31124 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -90,7 +90,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 7b0ae24677c..6ad314d0e5c 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -751,7 +751,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 df325822e27..c52262e9515 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): @@ -54,13 +53,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 433b5c67e47..1a9f94e1e28 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -54,7 +54,7 @@ class ProductionLot(models.Model): @api.multi 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 4eb4cf2ec62..db86d4dc866 100644 --- a/addons/stock_picking_batch/report/report_picking_batch.xml +++ b/addons/stock_picking_batch/report/report_picking_batch.xml @@ -32,7 +32,7 @@ <span t-field="pick.name"/> </td> <td> - <img t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', pick.name, 300, 50)" alt="Barcode"/> + <img t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', pick.name, 600, 100)" style="width:300px;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 3319f518623..0c2b219c411 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -146,7 +146,7 @@ <div t-name="kanban-box" t-attf-class="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"> - <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 44e17324b85..622050c9626 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -522,7 +522,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 d3165420ef8..6d1b31ea8ea 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1362,9 +1362,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]; @@ -1379,7 +1379,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 3c36720081e..f87268ddb1e 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 @@ -502,16 +502,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 7c766a1cc81..1c148604bc7 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -38,6 +38,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 f9e6b778d42..2677fb13216 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 39a14b6060e..11767887e40 100644 --- a/addons/website/models/mixins.py +++ b/addons/website/models/mixins.py @@ -103,7 +103,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.", + ) @api.multi def can_access_from_current_website(self, website_id=False): diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 3daec56e121..2e10d7d1cc4 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -170,6 +170,19 @@ class Website(models.Model): if 'favicon' in vals: vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO') + @api.multi + 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 4afb9bd63e5..4d3e89c2347 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_mass_mailing/views/res_config_settings_views.xml b/addons/website_mass_mailing/views/res_config_settings_views.xml index 16cbcbeda83..5ba37f54171 100644 --- a/addons/website_mass_mailing/views/res_config_settings_views.xml +++ b/addons/website_mass_mailing/views/res_config_settings_views.xml @@ -5,6 +5,9 @@ <field name="name">res.config.settings.view.form.inherit.website.mass.mailing</field> <field name="model">res.config.settings</field> <field name="arch" type="xml"> + <xpath expr="//h2[@id='mailing_list_settings_title']" position="attributes"> + <attribute name="invisible">0</attribute> + </xpath> <xpath expr="//div[@id='mailing_list']" position="inside"> <div class="col-lg-6 o_setting_box col-12" title="This adds a new Newsletter Popup snippet to drag & drop on your website pages. It triggers a popup when visitors land on the page. This popup prompts them to enter their email address to join a mailing list."> <div class="o_setting_left_pane"> diff --git a/addons/website_payment/models/payment_acquirer.py b/addons/website_payment/models/payment_acquirer.py index fe0c00f9a26..2bd0dca903c 100644 --- a/addons/website_payment/models/payment_acquirer.py +++ b/addons/website_payment/models/payment_acquirer.py @@ -6,4 +6,4 @@ from odoo import fields, models class PaymentAcquirer(models.Model): _inherit = 'payment.acquirer' - website_id = fields.Many2one('website') + website_id = fields.Many2one('website', ondelete='restrict') diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index a0909186eec..1109d11d8d2 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -29,7 +29,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 f6cb6cfc0fe..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._get_valid_product_template_attribute_lines(): + 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 0a67caa597c..d456cecd8fb 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(digits=0, 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') diff --git a/doc/cla/individual/igoris.md b/doc/cla/individual/igoris.md new file mode 100644 index 00000000000..c4db9c166d6 --- /dev/null +++ b/doc/cla/individual/igoris.md @@ -0,0 +1,11 @@ +Ukraine, 2019-08-12 + +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, + +Igor Ishchenko igor@cruxlab.com https://github.com/igoris diff --git a/doc/cla/individual/reinaldoramosarxi.md b/doc/cla/individual/reinaldoramosarxi.md new file mode 100644 index 00000000000..b130c0778a0 --- /dev/null +++ b/doc/cla/individual/reinaldoramosarxi.md @@ -0,0 +1,11 @@ +Portugal, 2019-08-09 + +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, + +Reinaldo Ramos 42942314+reinaldoramosarxi@users.noreply.github.com https://github.com/reinaldoramosarxi diff --git a/doc/reference/qweb.rst b/doc/reference/qweb.rst index f4f464f7aa9..5d910d3b061 100644 --- a/doc/reference/qweb.rst +++ b/doc/reference/qweb.rst @@ -458,12 +458,16 @@ template inheritance '''''''''''''''''''' Template inheritance is used to alter existing templates in-place, e.g. to -add information to templates created by an other modules. +add information to templates created by other modules. Template inheritance is performed via the ``t-extend`` directive which takes the name of the template to alter as parameter. -The alteration is then performed with any number of ``t-jquery`` +When ``t-extend`` is combined with ``t-name`` a new template with the given name +is created. In this case the extended template is not altered, instead the +directives define how to create the new template. + +In both cases the alteration is then performed with any number of ``t-jquery`` sub-directives:: <t t-extend="base.template"> diff --git a/odoo/__init__.py b/odoo/__init__.py index 6bfe00b8499..0080ccd41c9 100644 --- a/odoo/__init__.py +++ b/odoo/__init__.py @@ -63,6 +63,25 @@ if hasattr(time, 'tzset'): time.tzset() #---------------------------------------------------------- +# PyPDF2 hack +# ensure that zlib does not throw error -5 when decompressing +# because some pdf won't fit into allocated memory +# https://docs.python.org/3/library/zlib.html#zlib.decompressobj +# ---------------------------------------------------------- +import PyPDF2 + +try: + import zlib + + def _decompress(data): + zobj = zlib.decompressobj() + return zobj.decompress(data) + + PyPDF2.filters.decompress = _decompress +except ImportError: + pass # no fix required + +#---------------------------------------------------------- # Shortcuts #---------------------------------------------------------- # The hard-coded super-user id (a.k.a. administrator, or root user). diff --git a/odoo/addons/base/data/res.lang.csv b/odoo/addons/base/data/res.lang.csv index 57b42390723..0d7ce5905b9 100644 --- a/odoo/addons/base/data/res.lang.csv +++ b/odoo/addons/base/data/res.lang.csv @@ -51,6 +51,7 @@ base.lang_my,"Burmese / ဗမာစာ",my_MM,my,"Left-to-Right","[3,3]",".",", "base.lang_nb_NO","Norwegian Bokmål / Norsk bokmål","nb_NO","nb_NO","Left-to-Right","[3,0]",","," ","%d. %b %Y","%H:%M:%S","True","1" "base.lang_fa_IR","Persian / فارس","fa_IR","fa","Right-to-Left","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","True","6" "base.lang_pl","Polish / Język polski","pl_PL","pl","Left-to-Right","[]",",",,"%d.%m.%Y","%H:%M:%S","True","1" +"base.lang_pt_AO","Portuguese (AO) / Português (AO)","pt_AO","pt_AO","Left-to-Right","[]",",",,"%d-%m-%Y","%H:%M:%S","True","1" "base.lang_pt_BR","Portuguese (BR) / Português (BR)","pt_BR","pt_BR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","True","7" "base.lang_pt","Portuguese / Português","pt_PT","pt","Left-to-Right","[]",",",,"%d-%m-%Y","%H:%M:%S","True","1" "base.lang_ro","Romanian / română","ro_RO","ro","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","True","1" diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py index 6f6a0d9863f..4ce37aedd18 100644 --- a/odoo/addons/base/models/ir_rule.py +++ b/odoo/addons/base/models/ir_rule.py @@ -48,8 +48,10 @@ class IrRule(models.Model): by the user with the switch company menu. These companies are filtered and trusted. """ + # use an empty context for 'user' to make the domain evaluation + # independent from the context return { - 'user': self.env.user, + 'user': self.env.user.with_context({}), 'time': time, 'company_ids': self.env.companies.ids, } diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index 1df7c1ca38b..204ce97f6fe 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -883,7 +883,7 @@ class Users(models.Model): cfg = self.env['ir.config_parameter'].sudo() min_failures = int(cfg.get_param('base.login_cooldown_after', 5)) if min_failures == 0: - return True + return False delay = int(cfg.get_param('base.login_cooldown_duration', 60)) return failures >= min_failures and (datetime.datetime.now() - previous) < datetime.timedelta(seconds=delay) diff --git a/odoo/addons/base/tests/test_float.py b/odoo/addons/base/tests/test_float.py index 4dbf1f5f139..5bfdbea0a92 100644 --- a/odoo/addons/base/tests/test_float.py +++ b/odoo/addons/base/tests/test_float.py @@ -27,6 +27,8 @@ class TestFloatPrecision(TransactionCase): try_round(0.0049,'0.00') # 0.0049 is closer to 0 than to 0.01, so should round down try_round(0.005,'0.01') # the rule is to round half away from zero try_round(-0.005,'-0.01') # the rule is to round half away from zero + try_round(6.6 * 0.175, '1.16') # 6.6 * 0.175 is rounded to 1.15 with epsilon = 53 + try_round(-6.6 * 0.175, '-1.16') def try_zero(amount, expected): self.assertEqual(currency.is_zero(amount), expected, diff --git a/odoo/addons/base/views/ir_module_views.xml b/odoo/addons/base/views/ir_module_views.xml index d1b84b6e39a..afddfa1f825 100644 --- a/odoo/addons/base/views/ir_module_views.xml +++ b/odoo/addons/base/views/ir_module_views.xml @@ -165,7 +165,7 @@ <div class="oe_module_vignette"> <t t-set="installed" t-value="record.state.raw_value == 'installed'"/> <div class="o_dropdown_kanban dropdown" tabindex="-1"> - <a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" role="button" aria-label="Dropdown menu" title="Dropdown menu"> + <a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" role="button" aria-label="Dropdown menu" title="Dropdown menu"> <span class="fa fa-ellipsis-v"/> </a> <div class="dropdown-menu" role="menu" aria-labelledby="dLabel"> diff --git a/odoo/addons/test_access_rights/ir.model.access.csv b/odoo/addons/test_access_rights/ir.model.access.csv index 20310a5d0da..9edd8503b72 100644 --- a/odoo/addons/test_access_rights/ir.model.access.csv +++ b/odoo/addons/test_access_rights/ir.model.access.csv @@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_test_access_right_some_obj,access_test_access_right_some_obj,model_test_access_right_some_obj,,1,1,1,1 access_test_access_right_container,access_test_access_right_container,model_test_access_right_container,,1,1,1,1 access_test_access_right_parent,access_test_access_right_parent,model_test_access_right_parent,,1,1,1,1 +access_test_access_right_obj_categ,access_test_access_right_obj_categ,model_test_access_right_obj_categ,,1,1,1,1 diff --git a/odoo/addons/test_access_rights/models.py b/odoo/addons/test_access_rights/models.py index 4dd8c034bac..bef14bad2c2 100644 --- a/odoo/addons/test_access_rights/models.py +++ b/odoo/addons/test_access_rights/models.py @@ -8,6 +8,7 @@ class SomeObj(models.Model): _description = 'Object For Test Access Right' val = fields.Integer() + categ_id = fields.Many2one('test_access_right.obj_categ') company_id = fields.Many2one('res.company') forbidden = fields.Integer( groups='test_access_rights.test_group,!base.group_no_one,base.group_user,!base.group_public', @@ -28,3 +29,14 @@ class Parent(models.Model): _inherits = {'test_access_right.some_obj': 'obj_id'} obj_id = fields.Many2one('test_access_right.some_obj', required=True, ondelete='restrict') + +class ObjCateg(models.Model): + _name = 'test_access_right.obj_categ' + _description = "Context dependent searchable model" + + name = fields.Char(required=True) + + def search(self, args, **kwargs): + if self.env.context.get('only_media'): + args += [('name', '=', 'Media')] + return super(ObjCateg, self).search(args, **kwargs) diff --git a/odoo/addons/test_access_rights/tests/test_ir_rules.py b/odoo/addons/test_access_rights/tests/test_ir_rules.py index 9cb2f3ebda2..b10d6c22200 100644 --- a/odoo/addons/test_access_rights/tests/test_ir_rules.py +++ b/odoo/addons/test_access_rights/tests/test_ir_rules.py @@ -8,10 +8,11 @@ class TestRules(TransactionCase): def setUp(self): super(TestRules, self).setUp() - self.id1 = self.env['test_access_right.some_obj']\ - .create({'val': 1}).id - self.id2 = self.env['test_access_right.some_obj']\ - .create({'val': -1}).id + ObjCateg = self.env['test_access_right.obj_categ'] + SomeObj = self.env['test_access_right.some_obj'] + self.categ1 = ObjCateg.create({'name': 'Food'}).id + self.id1 = SomeObj.create({'val': 1, 'categ_id': self.categ1}).id + self.id2 = SomeObj.create({'val': -1, 'categ_id': self.categ1}).id # create a global rule forbidding access to records with a negative # (or zero) val self.env['ir.rule'].create({ @@ -19,6 +20,13 @@ class TestRules(TransactionCase): 'model_id': self.browse_ref('test_access_rights.model_test_access_right_some_obj').id, 'domain_force': "[('val', '>', 0)]" }) + # create a global rule that forbid access to records without + # categories, the search is part of the test + self.env['ir.rule'].create({ + 'name': 'See all categories', + 'model_id': self.browse_ref('test_access_rights.model_test_access_right_some_obj').id, + 'domain_force': "[('categ_id', 'in', user.env['test_access_right.obj_categ'].search([]).ids)]" + }) def test_basic_access(self): env = self.env(user=self.browse_ref('base.public_user')) @@ -82,3 +90,25 @@ class TestRules(TransactionCase): Model = env['test_access_right.some_obj'] with self.assertQueryCount(0): Model._filter_access_rules('read') + + def test_no_context_in_ir_rules(self): + """ The context should not impact the ir rules. """ + env = self.env(user=self.browse_ref('base.public_user')) + ObjCateg = self.env['test_access_right.obj_categ'] + SomeObj = self.env['test_access_right.some_obj'] + + # validate the effect of context on category search, there are + # no existing media category + self.assertTrue(ObjCateg.search([])) + self.assertFalse(ObjCateg.with_context(only_media=True).search([])) + + # record1 is food and is accessible with an empy context + ObjCateg.clear_caches() + records = SomeObj.search([('id', '=', self.id1)]) + self.assertTrue(records) + + # it should also be accessible as the context is not used when + # searching for SomeObjs + ObjCateg.clear_caches() + records = SomeObj.with_context(only_media=True).search([('id', '=', self.id1)]) + self.assertTrue(records) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index c2f02a1304f..80e479005d1 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -706,7 +706,8 @@ class ChromeBrowser(): def set_cookie(self, name, value, path, domain): params = {'name': name, 'value': value, 'path': path, 'domain': domain} - self._websocket_send('Network.setCookie', params=params) + _id = self._websocket_send('Network.setCookie', params=params) + return self._websocket_wait_id(_id) def _wait_ready(self, ready_code, timeout=60): self._logger.info('Evaluate ready code "%s"', ready_code) diff --git a/odoo/tools/float_utils.py b/odoo/tools/float_utils.py index 47b5d72c069..5dc13a7f8de 100644 --- a/odoo/tools/float_utils.py +++ b/odoo/tools/float_utils.py @@ -72,7 +72,7 @@ def float_round(value, precision_digits=None, precision_rounding=None, rounding_ normalized_value = value / rounding_factor # normalize sign = math.copysign(1.0, normalized_value) epsilon_magnitude = math.log(abs(normalized_value), 2) - epsilon = 2**(epsilon_magnitude-53) + epsilon = 2**(epsilon_magnitude-52) # TIE-BREAKING: UP/DOWN (for ceiling[resp. flooring] operations) # When rounding the value up[resp. down], we instead subtract[resp. add] the epsilon value |
