[MERGE] forward port branch saas-12.3 up to 6f55fd65da
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"))
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) + ")]"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
&.o_list_many2one {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
&.o_list_text {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
tfoot {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
# ----------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,6 +5,57 @@ import odoo
|
||||
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):
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -62,6 +62,25 @@ import time
|
||||
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
|
||||
#----------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user