[MERGE] forward port branch saas-12.3 up to 6f55fd65da

This commit is contained in:
Christophe Simonis
2019-08-20 12:17:04 +02:00
91 changed files with 689 additions and 144 deletions
+2 -2
View File
@@ -2173,9 +2173,9 @@ class AccountMoveLine(models.Model):
help="The bank statement used for bank reconciliation") help="The bank statement used for bank reconciliation")
# ==== Tax fields ==== # ==== 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, 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, tax_base_amount = fields.Monetary(string="Base Amount", store=True,
currency_field='company_currency_id') currency_field='company_currency_id')
tax_exigible = fields.Boolean(string='Appears in VAT report', default=True, tax_exigible = fields.Boolean(string='Appears in VAT report', default=True,
+4 -1
View File
@@ -454,7 +454,10 @@ class account_payment(models.Model):
move.line_ids.remove_move_reconcile() move.line_ids.remove_move_reconcile()
move.button_cancel() move.button_cancel()
move.unlink() move.unlink()
rec.state = 'cancelled' rec.write({
'state': 'cancelled',
'move_name': False,
})
@api.multi @api.multi
def unlink(self): def unlink(self):
@@ -580,6 +580,8 @@ class AccountReconcileModel(models.Model):
''' '''
if not self.match_total_amount: if not self.match_total_amount:
return True return True
if not candidates:
return False
# Match total residual amount. # Match total residual amount.
total_residual = 0.0 total_residual = 0.0
@@ -331,7 +331,7 @@
<field name="module_account_sepa_direct_debit" class="oe_inline" widget="upgrade_boolean"/> <field name="module_account_sepa_direct_debit" class="oe_inline" widget="upgrade_boolean"/>
</div> </div>
<div class="o_setting_right_pane" name="sepa_direct_debit_right_pane"> <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"/> <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"> <div class="text-muted">
Collect customer payments in one-click using Euro SEPA Service 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 'sm': 'SMkk KBBB BBSS SSSC CCCC CCCC CCC', # San Marino
'tn': 'TNkk BBSS SCCC CCCC CCCC CCCC', # Tunisia 'tn': 'TNkk BBSS SCCC CCCC CCCC CCCC', # Tunisia
'tr': 'TRkk BBBB BRCC CCCC CCCC CCCC CC', # Turkey '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 'vg': 'VGkk BBBB CCCC CCCC CCCC CCCC', # Virgin Islands
'xk': 'XKkk BBBB CCCC CCCC CCCC', # Kosovo 'xk': 'XKkk BBBB CCCC CCCC CCCC', # Kosovo
} }
+3
View File
@@ -446,6 +446,9 @@ class Import(models.TransientModel):
# Or a date/datetime if it matches the pattern # Or a date/datetime if it matches the pattern
date_patterns = [options['date_format']] if options.get( date_patterns = [options['date_format']] if options.get(
'date_format') else [] '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) date_patterns.extend(DATE_PATTERNS)
match = check_patterns(date_patterns, preview_values) match = check_patterns(date_patterns, preview_values)
if match: if match:
+15
View File
@@ -14,6 +14,13 @@ except ImportError:
"Install it to support more countries, for example with `easy_install vatnumber`.") "Install it to support more countries, for example with `easy_install vatnumber`.")
vatnumber = None 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 import api, models, _
from odoo.tools.misc import ustr from odoo.tools.misc import ustr
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
@@ -63,6 +70,8 @@ _ref_vat = {
'be': 'BE0477472701', 'be': 'BE0477472701',
'bg': 'BG1234567892', 'bg': 'BG1234567892',
'ch': 'CHE-123.456.788 TVA or CH TVA 123456', # Swiss by Yannick Vaucher @ Camptocamp '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', 'cy': 'CY12345678F',
'cz': 'CZ12345679', 'cz': 'CZ12345679',
'de': 'DE123456788', 'de': 'DE123456788',
@@ -398,6 +407,12 @@ class ResPartner(models.Model):
except ImportError: except ImportError:
return True 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): def default_compact(self, vat):
return vat return vat
+3 -3
View File
@@ -122,8 +122,8 @@
<field name="stop" attrs="{'invisible': True}"/> <field name="stop" attrs="{'invisible': True}"/>
<field name="id" 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="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)]}"/> <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="start_datetime" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/>
<field name="stop_datetime" invisible="1"/> <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)]}"/> <field name="duration" widget="float_time" string="Duration" class="oe_inline" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/>
<span> hours</span> <span> hours</span>
</div> </div>
<field name="allday" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <field name="allday" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/>
</group> </group>
<group> <group>
<field name="categ_ids" widget="many2many_tags" options=" <field name="categ_ids" widget="many2many_tags" options="
+1 -1
View File
@@ -391,7 +391,7 @@
<div t-attf-class="#{kanban_color(record.color.raw_value)} oe_kanban_global_click"> <div t-attf-class="#{kanban_color(record.color.raw_value)} oe_kanban_global_click">
<div class="o_dropdown_kanban dropdown"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
+1 -1
View File
@@ -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 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"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
@@ -192,7 +192,7 @@
<t t-name="kanban-box"> <t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click"> <div class="oe_kanban_card oe_kanban_global_click">
<div class="o_dropdown_kanban dropdown" groups="base.group_user"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
+11 -14
View File
@@ -893,24 +893,21 @@ class Escpos:
self._raw(PAPER_FULL_CUT) self._raw(PAPER_FULL_CUT)
def cashdraw(self, pin, tries=5): def cashdraw(self, pin):
""" Send pulse to kick the cash drawer """ Send pulse to kick the cash drawer
With some printers the drawer will not open after one pulse, for this reason we will check For some reason, with some printers (ex: Epson TM-m30), the cash drawer
the drawer status up to 'tries' times and send a new pulse if the drawer is still closed. If the only opens 50% of the time if you just send the pulse. But if you read
drawer status is open, we will stop sending pulses. the status afterwards, it opens all the time.
""" """
for i in range(tries): if pin == 2:
if pin == 2: self._raw(CD_KICK_2)
self._raw(CD_KICK_2) elif pin == 5:
elif pin == 5: self._raw(CD_KICK_5)
self._raw(CD_KICK_5) else:
else: raise CashDrawerError()
raise CashDrawerError()
if i != tries - 1 and not self.get_printer_status()['printer']['drawer_pin_high']:
break
self.get_printer_status()
def hw(self, hw): def hw(self, hw):
""" Hardware operations """ """ Hardware operations """
+4 -2
View File
@@ -7,7 +7,7 @@ import uuid
import werkzeug.urls import werkzeug.urls
import requests import requests
from odoo import api, fields, models, exceptions from odoo import api, fields, models, exceptions, _
from odoo.tools import pycompat from odoo.tools import pycompat
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -68,7 +68,9 @@ def jsonrpc(url, method='call', params=None, timeout=15):
raise e raise e
return response.get('result') return response.get('result')
except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e: 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 # Helpers for proxy
@@ -828,6 +828,17 @@
<field name="tax_group_id" ref="ae_tax_group_5"/> <field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="chart_template_id" ref="uae_chart_template_standard"/>
</record> </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"> <record id="uae_purchase_tax_reverse_charge" model="account.tax.template">
<field name="name">Reverse Charge Provision</field> <field name="name">Reverse Charge Provision</field>
<field name="type_tax_use">purchase</field> <field name="type_tax_use">purchase</field>
+3 -3
View File
@@ -95,7 +95,7 @@ class AccountMove(models.Model):
# <1.2.1.8> # <1.2.1.8>
if not seller.l10n_it_tax_system: 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> # <1.2.2>
if not seller.street and not seller.street2: 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)) raise UserError(_("%s must have a country.") % (seller.display_name))
# <1.4.1> # <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)) 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> # <1.4.2>
@@ -730,7 +730,7 @@ class AccountTax(models.Model):
for tax in self: for tax in self:
if tax.l10n_it_has_exoneration: if tax.l10n_it_has_exoneration:
if not tax.l10n_it_kind_exoneration or not tax.l10n_it_law_reference or tax.amount != 0: 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': 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'")) raise UserError(_("'Scissione dei pagamenti' is not compatible with exoneration of kind 'N6'"))
+1 -1
View File
@@ -35,7 +35,7 @@ class FetchmailServer(models.Model):
def _check_pec(self): def _check_pec(self):
for record in self: for record in self:
if record.l10n_it_is_pec and record.type != 'imap': 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 @api.multi
def fetch_mail(self): def fetch_mail(self):
+5 -5
View File
@@ -1,7 +1,7 @@
# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # 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 from odoo.exceptions import ValidationError
TAX_SYSTEM = [ 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_share_capital\
or not record.l10n_it_eco_index_sole_shareholder\ or not record.l10n_it_eco_index_sole_shareholder\
or not record.l10n_it_eco_index_liquidation_state: 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', @api.constrains('l10n_it_has_tax_representative',
'l10n_it_tax_representative_partner_id') 'l10n_it_tax_representative_partner_id')
@@ -103,8 +103,8 @@ class ResCompany(models.Model):
if not record.l10n_it_has_tax_representative: if not record.l10n_it_has_tax_representative:
continue continue
if not record.l10n_it_tax_representative_partner_id: 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: 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: 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."))
+7 -7
View File
@@ -151,23 +151,23 @@ class Channel(models.Model):
@api.constrains('moderator_ids') @api.constrains('moderator_ids')
def _check_moderator_email(self): def _check_moderator_email(self):
if any(not moderator.email for channel in self for moderator in channel.moderator_ids): 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') @api.constrains('moderator_ids', 'channel_partner_ids', 'channel_last_seen_partner_ids')
def _check_moderator_is_member(self): def _check_moderator_is_member(self):
for channel in self: for channel in self:
if not (channel.mapped('moderator_ids.partner_id') <= channel.sudo().channel_partner_ids): 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') @api.constrains('moderation', 'email_send')
def _check_moderation_parameters(self): def _check_moderation_parameters(self):
if any(not channel.email_send and channel.moderation for channel in 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') @api.constrains('moderator_ids')
def _check_moderator_existence(self): def _check_moderator_existence(self):
if any(not channel.moderator_ids for channel in self if channel.moderation): 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 @api.multi
def _compute_is_member(self): 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. # 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 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'): 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) tools.image_resize_images(vals)
result = super(Channel, self).write(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'): 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) success = self._send_guidelines(self.channel_partner_ids)
if not success: 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: 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 @api.multi
def _send_guidelines(self, partners): def _send_guidelines(self, partners):
+2 -2
View File
@@ -937,7 +937,7 @@ class MailThread(models.AbstractModel):
# 1. Check if message is a reply on a thread # 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] 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) is_a_reply = bool(mail_messages)
# 1.1 Handle forward to an alias with a different model: do not consider it as a reply # 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 # Very unusual situation, be we should be fault-tolerant here
message_id = "<%s@localhost>" % time.time() message_id = "<%s@localhost>" % time.time()
_logger.debug('Parsing Message without message-id, generating a random one: %s', message_id) _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'): if message.get('Subject'):
msg_dict['subject'] = tools.decode_smtp_header(message.get('Subject')) msg_dict['subject'] = tools.decode_smtp_header(message.get('Subject'))
@@ -97,7 +97,6 @@ var DocumentThread = Thread.extend({
fetchMessages: function (options) { fetchMessages: function (options) {
var self = this; var self = this;
return this._fetchMessages(options).then(function () { return this._fetchMessages(options).then(function () {
self.call('mail_service', 'markMessagesAsRead', self._messageIDs);
return self._messages; return self._messages;
}); });
}, },
@@ -85,7 +85,7 @@ var MailManager = AbstractService.extend({
addMessage: function (data, options) { addMessage: function (data, options) {
options = options || {}; options = options || {};
var message = this.getMessage(data.id); var message = this.getMessage(data.id);
var prom = Promise.resolve(); var prom;
if (!message) { if (!message) {
prom = this._addNewMessage(data, options); prom = this._addNewMessage(data, options);
} else { } else {
@@ -95,6 +95,7 @@ var MailManager = AbstractService.extend({
}); });
} }
this._addMessageToThreads(message, options); this._addMessageToThreads(message, options);
prom = Promise.resolve(message);
} }
return prom; return prom;
}, },
@@ -106,7 +106,7 @@ MailManager.include({
// This may happen due to concurrent calls to this method from // This may happen due to concurrent calls to this method from
// messaging menu preview click and handling of longpolling chat // messaging menu preview click and handling of longpolling chat
// window state. // window state.
return; return Promise.resolve();
} }
var threadWindow = this._getThreadWindow(threadID); var threadWindow = this._getThreadWindow(threadID);
var prom = Promise.resolve(); var prom = Promise.resolve();
@@ -108,6 +108,7 @@ var ActivityMenu = Widget.extend({
*/ */
_onActivityActionClick: function (ev) { _onActivityActionClick: function (ev) {
ev.stopPropagation(); ev.stopPropagation();
this.$('.dropdown-toggle').dropdown('toggle');
var targetAction = $(ev.currentTarget); var targetAction = $(ev.currentTarget);
var actionXmlid = targetAction.data('action_xmlid'); var actionXmlid = targetAction.data('action_xmlid');
if (actionXmlid) { if (actionXmlid) {
+1
View File
@@ -110,6 +110,7 @@
} }
.o_mail_thread { .o_mail_thread {
position: relative;
flex: 1 0 0; flex: 1 0 0;
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
+65
View File
@@ -1144,6 +1144,71 @@ QUnit.test('chatter: access document with some notifs', async function (assert)
form.destroy(); 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) { QUnit.test('chatter: post a message and switch in edit mode', async function (assert) {
assert.expect(5); assert.expect(5);
+65
View File
@@ -59,6 +59,7 @@ QUnit.module('Discuss', {
type: 'integer', type: 'integer',
}, },
}, },
records: [],
}, },
'res.partner': { 'res.partner': {
fields: { fields: {
@@ -1490,6 +1491,70 @@ QUnit.test('custom-named DM conversation', async function (assert) {
discuss.destroy(); 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) { QUnit.test('input not cleared on unresolved message_post rpc', async function (assert) {
assert.expect(2); 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) { QUnit.test('activity menu widget: activity view icon', async function (assert) {
assert.expect(10); assert.expect(12);
var self = this; var self = this;
var activityMenu = new ActivityMenu(); var activityMenu = new ActivityMenu();
testUtils.mock.addMockEnvironment(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 // click on the "Issue" activity icon
await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); 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']")); 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 // 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']")); await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Note']"));
assert.verifySteps([ assert.verifySteps([
@@ -920,5 +920,62 @@ QUnit.test('messaging menu widget: expand on thread preview', async function (as
messagingMenu.destroy(); 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();
});
}); });
}); });
+1 -1
View File
@@ -48,7 +48,7 @@ class MailResendMessage(models.TransientModel):
rec['mail_message_id'] = mail_message_id.id rec['mail_message_id'] = mail_message_id.id
rec['partner_ids'] = partner_ids rec['partner_ids'] = partner_ids
else: else:
raise UserError('No message_id found in context') raise UserError(_('No message_id found in context'))
return rec return rec
@api.multi @api.multi
+1 -1
View File
@@ -43,7 +43,7 @@ class MaintenanceEquipmentCategory(models.Model):
alias_id = fields.Many2one( alias_id = fields.Many2one(
'mail.alias', 'Alias', ondelete='restrict', required=True, 'mail.alias', 'Alias', ondelete='restrict', required=True,
help="Email alias for this equipment category. New emails will automatically " 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) fold = fields.Boolean(string='Folded in Maintenance Pipe', compute='_compute_fold', store=True)
@api.multi @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 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"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <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 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"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
+1 -1
View File
@@ -252,7 +252,7 @@ class MassMailingContact(models.Model):
contacts = self.env['mail.mass_mailing.list_contact_rel'].search([('list_id', '=', active_list_id)]) 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])] return [('id', 'in', [record.contact_id.id for record in contacts if record.opt_out == value])]
else: 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') @api.depends('subscription_list_ids')
def _compute_opt_out(self): def _compute_opt_out(self):
@@ -386,7 +386,7 @@
</div> </div>
<div class="o_kanban_card_manage_pane"> <div class="o_kanban_card_manage_pane">
<div class="o_kanban_card_manage_section o_dropdown_kanban dropdown"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
@@ -685,7 +685,7 @@
<t t-name="kanban-box"> <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 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"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <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 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"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
@@ -58,7 +58,7 @@
</div> </div>
</div> </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 id="mailing_list" class="row mt16 o_settings_container"/>
</div> </div>
</xpath> </xpath>
+5
View File
@@ -249,6 +249,11 @@ class MrpWorkorder(models.Model):
for order in (self - late_orders): for order in (self - late_orders):
order.color = 2 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 @api.multi
def write(self, values): def write(self, values):
if list(values.keys()) != ['time_ids'] and any(workorder.state == 'done' for workorder in self): if list(values.keys()) != ['time_ids'] and any(workorder.state == 'done' for workorder in self):
+1 -1
View File
@@ -98,7 +98,7 @@
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<div class="o_dropdown_kanban dropdown"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
@@ -110,7 +110,7 @@ class ResPartner(models.Model):
if suggestions: if suggestions:
results = [] results = []
for suggestion in suggestions: for suggestion in suggestions:
results.append(suggestion) results.append(self._format_data_company(suggestion))
return results return results
else: else:
return [] return []
@@ -76,7 +76,10 @@ var ScreenWidget = PosBaseWidget.extend({
barcode_client_action: function(code){ barcode_client_action: function(code){
var partner = this.pos.db.get_partner_by_barcode(code.code); var partner = this.pos.db.get_partner_by_barcode(code.code);
if(partner){ 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; return true;
} }
this.barcode_error_action(code); this.barcode_error_action(code);
+1 -1
View File
@@ -10,7 +10,7 @@
<field name="end_date"/> <field name="end_date"/>
</group> </group>
<group> <group>
<field name="pos_config_ids" colspan="4" nolabel="1" /> <field name="pos_config_ids" mode="tree" colspan="4" nolabel="1" />
</group> </group>
<footer> <footer>
<button name="generate_report" string="Print" type="object" class="btn-primary"/> <button name="generate_report" string="Print" type="object" class="btn-primary"/>
+3 -3
View File
@@ -244,7 +244,7 @@ class CustomerPortal(Controller):
document = request.env[model_name].browse([document_id]) document = request.env[model_name].browse([document_id])
document_sudo = document.sudo().exists() document_sudo = document.sudo().exists()
if not document_sudo: if not document_sudo:
raise MissingError("This document does not exist.") raise MissingError(_("This document does not exist."))
try: try:
document.check_access_rights('read') document.check_access_rights('read')
document.check_access_rule('read') document.check_access_rule('read')
@@ -279,12 +279,12 @@ class CustomerPortal(Controller):
def _show_report(self, model, report_type, report_ref, download=False): def _show_report(self, model, report_type, report_ref, download=False):
if report_type not in ('html', 'pdf', 'text'): 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() report_sudo = request.env.ref(report_ref).sudo()
if not isinstance(report_sudo, type(request.env['ir.actions.report'])): 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) method_name = 'render_qweb_%s' % (report_type)
report = getattr(report_sudo, method_name)([model.id], data={'report_type': report_type})[0] 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"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view"> <record id="res_config_view_form_inherit_pos_mercury" model="ir.ui.view">
<field name="name">pos.config.form.inherit.mercury</field> <field name="name">res.config.form.inherit.mercury</field>
<field name="model">res.config.settings</field> <field name="model">res.config.settings</field>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/> <field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <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="name">pos.config.form.view.inherit.mercury</field>
<field name="model">pos.config</field> <field name="model">pos.config</field>
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/> <field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
+1 -1
View File
@@ -577,7 +577,7 @@
</small> </small>
</div> </div>
<div class="o_dropdown_kanban dropdown" groups="base.group_user"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
+2
View File
@@ -405,6 +405,8 @@ class PurchaseOrder(models.Model):
'default_type': 'in_invoice', 'default_type': 'in_invoice',
'default_purchase_id': self.id, 'default_purchase_id': self.id,
} }
if self.user_id:
result['context']['default_user_id'] = self.user_id.id
# choose the view_mode accordingly # choose the view_mode accordingly
if len(self.invoice_ids) > 1 and not create_bill: if len(self.invoice_ids) > 1 and not create_bill:
result['domain'] = "[('id', 'in', " + str(self.invoice_ids.ids) + ")]" result['domain'] = "[('id', 'in', " + str(self.invoice_ids.ids) + ")]"
+2 -1
View File
@@ -199,7 +199,8 @@ class ProductionLot(models.Model):
stock_moves = self.env['stock.move.line'].search([ stock_moves = self.env['stock.move.line'].search([
('lot_id', '=', lot.id), ('lot_id', '=', lot.id),
('state', '=', 'done') ('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') 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_ids = stock_moves.mapped('purchase_line_id.order_id')
lot.purchase_order_count = len(lot.purchase_order_ids) 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.') 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): 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') 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') customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers')
+1 -1
View File
@@ -1083,7 +1083,7 @@ class SaleOrderLine(models.Model):
@api.multi @api.multi
def write(self, values): def write(self, values):
if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): 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: if 'product_uom_qty' in values:
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+2 -2
View File
@@ -305,14 +305,14 @@ class SaleOrder(models.Model):
# Check commit 6bb42904a03 for next if/else # Check commit 6bb42904a03 for next if/else
# Remove reward line if price or qty equal to 0 # Remove reward line if price or qty equal to 0
if values['product_uom_qty'] and values['price_unit']: 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: else:
if program.reward_type != 'free_shipping': if program.reward_type != 'free_shipping':
# Can't remove the lines directly as we might be in a recordset loop # Can't remove the lines directly as we might be in a recordset loop
lines_to_remove += lines lines_to_remove += lines
else: else:
values.update(price_unit=0.0) 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 return lines_to_remove
self.ensure_one() self.ensure_one()
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # 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.addons import decimal_precision as dp
from odoo.exceptions import UserError 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') template_id = self.env['ir.default'].get('sale.order', 'sale_order_template_id')
for template in self: for template in self:
if template_id and template_id == template.id: 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) return super(SaleOrderTemplate, self).write(vals)
@@ -87,7 +87,7 @@ class SaleOrderTemplateLine(models.Model):
@api.multi @api.multi
def write(self, values): def write(self, values):
if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): 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) return super(SaleOrderTemplateLine, self).write(values)
_sql_constraints = [ _sql_constraints = [
@@ -98,12 +98,13 @@
</field> </field>
</record> </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" <menuitem id="menu_product_settings" name="Products" sequence="80"
parent="sale.menu_sale_config"/> parent="sale.menu_sale_config"/>
<menuitem id="menu_product_attribute_action" <menuitem id="menu_product_attribute_action"
action="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" <menuitem id="menu_catalog_variants_action"
action="product.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> </odoo>
+2 -1
View File
@@ -123,7 +123,8 @@ class ProductionLot(models.Model):
stock_moves = self.env['stock.move.line'].search([ stock_moves = self.env['stock.move.line'].search([
('lot_id', '=', lot.id), ('lot_id', '=', lot.id),
('state', '=', 'done') ('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') 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_ids = stock_moves.mapped('sale_line_id.order_id')
lot.sale_order_count = len(lot.sale_order_ids) lot.sale_order_count = len(lot.sale_order_ids)
+1 -1
View File
@@ -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') 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: if sale_line_delivery:
domain = self._timesheet_domain_get_invoiced_lines(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({ timesheets.write({
'timesheet_invoice_id': line.move_id.id, 'timesheet_invoice_id': line.move_id.id,
}) })
+2 -1
View File
@@ -90,7 +90,8 @@ class SaleOrder(models.Model):
task_projects = self.tasks_ids.mapped('project_id') 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, ...) 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'): if action.get('context'):
eval_context = self.env['ir.actions.actions']._get_eval_context() eval_context = self.env['ir.actions.actions']._get_eval_context()
eval_context.update({'active_id': task_projects.id}) eval_context.update({'active_id': task_projects.id})
+3 -1
View File
@@ -751,7 +751,9 @@ class UoM(models.Model):
procurement_uom = self procurement_uom = self
computed_qty = qty computed_qty = qty
get_param = self.env['ir.config_parameter'].sudo().get_param 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') computed_qty = self._compute_quantity(qty, quant_uom, rounding_method='HALF-UP')
procurement_uom = quant_uom procurement_uom = quant_uom
else:
computed_qty = self._compute_quantity(qty, procurement_uom, rounding_method='HALF-UP')
return (computed_qty, procurement_uom) return (computed_qty, procurement_uom)
+1 -9
View File
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # 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 UserError
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):
@@ -54,13 +53,6 @@ class ResConfigSettings(models.TransientModel):
def _onchange_group_stock_production_lot(self): def _onchange_group_stock_production_lot(self):
if not self.group_stock_production_lot: if not self.group_stock_production_lot:
self.group_lot_on_delivery_slip = False 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') @api.onchange('group_stock_adv_location')
def onchange_adv_location(self): def onchange_adv_location(self):
+1 -1
View File
@@ -54,7 +54,7 @@ class ProductionLot(models.Model):
@api.multi @api.multi
def write(self, vals): def write(self, vals):
if 'product_id' in vals and any([vals['product_id'] != lot.product_id.id for lot in self]): 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: if move_lines:
raise UserError(_( raise UserError(_(
'You are not allowed to change the product linked to a serial or lot number ' + 'You are not allowed to change the product linked to a serial or lot number ' +
@@ -32,7 +32,7 @@
<span t-field="pick.name"/> <span t-field="pick.name"/>
</td> </td>
<td> <td>
<img t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', pick.name, 300, 50)" alt="Barcode"/> <img t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', pick.name, 600, 100)" style="width:300px;height:50px" alt="Barcode"/>
</td> </td>
<td> <td>
<span t-field="pick.state"/> <span t-field="pick.state"/>
@@ -15,12 +15,15 @@
<h2> <h2>
<span t-esc="user_input.partner_id.name or user_input.email"/> <span t-esc="user_input.partner_id.name or user_input.email"/>
</h2> </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> <h3>
<span t-field="user_input.survey_id.display_name"/> <span t-field="user_input.survey_id.display_name"/>
</h3> </h3>
<p> <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"/> <span t-field="user_input.create_date"/>
</p> </p>
</div> </div>
+1 -1
View File
@@ -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 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"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu"> <div class="dropdown-menu" role="menu">
+1 -1
View File
@@ -522,7 +522,7 @@ var utils = {
} }
var normalized_value = value / precision; var normalized_value = value / precision;
var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2); 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; normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
/** /**
@@ -1362,9 +1362,9 @@ var BasicModel = AbstractModel.extend({
// apply changes to local data // apply changes to local data
for (var fieldName in changes) { for (var fieldName in changes) {
field = record.fields[fieldName]; 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)); 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])); defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName]));
} else { } else {
record._changes[fieldName] = changes[fieldName]; 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 var onChangeFields = []; // the fields that have changed and that have an on_change
for (var fieldName in changes) { for (var fieldName in changes) {
field = record.fields[fieldName]; field = record.fields[fieldName];
if (field.onChange) { if (field && field.onChange) {
var isX2Many = field.type === 'one2many' || field.type === 'many2many'; var isX2Many = field.type === 'one2many' || field.type === 'many2many';
if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) {
onChangeFields.push(fieldName); onChangeFields.push(fieldName);
@@ -91,10 +91,15 @@ KanbanRenderer.include({
var index = _.findIndex(this.widgets, {db_id: localID}); var index = _.findIndex(this.widgets, {db_id: localID});
var $column = this.widgets[index].$el; var $column = this.widgets[index].$el;
var left = $column.css('left'); var left = $column.css('left');
var right = $column.css('right');
var scrollTop = $column.scrollTop(); var scrollTop = $column.scrollTop();
return this._super.apply(this, arguments).then(function () { return this._super.apply(this, arguments).then(function () {
$column = self.widgets[index].$el; $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' $column.scrollTop(scrollTop); // required when clicking on 'Load More'
self._enableSwipe(); self._enableSwipe();
}); });
@@ -115,19 +120,40 @@ KanbanRenderer.include({
var self = this; var self = this;
var moveToIndex = this.activeColumnIndex; var moveToIndex = this.activeColumnIndex;
var updateFunc = animate ? 'animate' : 'css'; var updateFunc = animate ? 'animate' : 'css';
var rtl = _t.database.parameters.direction === 'rtl';
_.each(this.widgets, function (column, index) { _.each(this.widgets, function (column, index) {
var columnID = column.id || column.db_id; var columnID = column.id || column.db_id;
var $column = self.$('.o_kanban_group[data-id="' + columnID + '"]'); var $column = self.$('.o_kanban_group[data-id="' + columnID + '"]');
if (index === moveToIndex - 1) { if (index === moveToIndex - 1) {
$column[updateFunc]({left: '-100%'}); if (rtl) {
$column[updateFunc]({right: '-100%'});
} else {
$column[updateFunc]({left: '-100%'});
}
} else if (index === moveToIndex + 1) { } else if (index === moveToIndex + 1) {
$column[updateFunc]({left: '100%'}); if (rtl) {
$column[updateFunc]({right: '100%'});
} else {
$column[updateFunc]({left: '100%'});
}
} else if (index === moveToIndex) { } else if (index === moveToIndex) {
$column[updateFunc]({left: '0%'}); if (rtl) {
$column[updateFunc]({right: '0%'});
} else {
$column[updateFunc]({left: '0%'});
}
} else if (index < moveToIndex) { } else if (index < moveToIndex) {
$column.css({left: '-100%'}); if (rtl) {
$column.css({right: '-100%'});
} else {
$column.css({left: '-100%'});
}
} else if (index > moveToIndex) { } 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 // Apply the scroll x on the tabs
// XXX in case of RTL, should we use scrollRight?
this.$('.o_kanban_mobile_tabs').scrollLeft(scrollToLeft); this.$('.o_kanban_mobile_tabs').scrollLeft(scrollToLeft);
} }
}, },
@@ -213,16 +240,17 @@ KanbanRenderer.include({
*/ */
_enableSwipe: function () { _enableSwipe: function () {
var self = this; var self = this;
var step = _t.database.parameters.direction === 'rtl' ? -1 : 1;
this.$el.swipe({ this.$el.swipe({
excludedElements: ".o_kanban_mobile_tabs", excludedElements: ".o_kanban_mobile_tabs",
swipeLeft: function () { swipeLeft: function () {
var moveToIndex = self.activeColumnIndex + 1; var moveToIndex = self.activeColumnIndex + step;
if (moveToIndex < self.widgets.length) { if (moveToIndex < self.widgets.length) {
self._moveToGroup(moveToIndex, self.ANIMATE); self._moveToGroup(moveToIndex, self.ANIMATE);
} }
}, },
swipeRight: function () { swipeRight: function () {
var moveToIndex = self.activeColumnIndex - 1; var moveToIndex = self.activeColumnIndex - step;
if (moveToIndex > -1) { if (moveToIndex > -1) {
self._moveToGroup(moveToIndex, self.ANIMATE); self._moveToGroup(moveToIndex, self.ANIMATE);
} }
@@ -502,16 +502,16 @@ ListRenderer.include({
switch (field.type) { switch (field.type) {
case 'binary': return 1; case 'binary': return 1;
case 'boolean': return '40px'; case 'boolean': return '40px';
case 'char': return 1; case 'char': return 2;
case 'date': return '100px'; case 'date': return '92px';
case 'datetime': return '150px'; case 'datetime': return '146px';
case 'float': return '100px'; case 'float': return '92px';
case 'html': return 3; case 'html': return 3;
case 'integer': return '80px'; case 'integer': return '74px';
case 'many2many': return 2; case 'many2many': return 2;
case 'many2one': return 2; case 'many2one': return 2;
case 'monetary': return 1.2; case 'monetary': return '104px';
case 'one2many': return 2.5; case 'one2many': return 2;
case 'reference': return 1.5; case 'reference': return 1.5;
case 'selection': return 1.5; case 'selection': return 1.5;
case 'text': return 2.5; case 'text': return 2.5;
@@ -38,6 +38,9 @@
&.o_list_many2one { &.o_list_many2one {
white-space: pre-wrap; white-space: pre-wrap;
} }
&.o_list_text {
word-break: break-word;
}
} }
tfoot { tfoot {
@@ -1,10 +1,12 @@
odoo.define('web.kanban_mobile_tests', function (require) { odoo.define('web.kanban_mobile_tests', function (require) {
"use strict"; "use strict";
var core = require('web.core');
var KanbanView = require('web.KanbanView'); var KanbanView = require('web.KanbanView');
var testUtils = require('web.test_utils'); var testUtils = require('web.test_utils');
var createView = testUtils.createView; var createView = testUtils.createView;
var _t = core._t;
QUnit.module('Views', { QUnit.module('Views', {
beforeEach: function () { beforeEach: function () {
@@ -119,6 +121,35 @@ QUnit.module('Views', {
kanban.destroy(); 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) { QUnit.test('mobile grouped with undefined column', async function (assert) {
assert.expect(5); assert.expect(5);
+6 -7
View File
@@ -1432,10 +1432,10 @@ QUnit.module('Views', {
QUnit.test('width of some of the fields should be hardcoded', async function (assert) { QUnit.test('width of some of the fields should be hardcoded', async function (assert) {
const assertions = [ const assertions = [
{ field: 'bar', expected: 40, type: 'Boolean' }, { field: 'bar', expected: 40, type: 'Boolean' },
{ field: 'int_field', expected: 80, type: 'Integer' }, { field: 'int_field', expected: 74, type: 'Integer' },
{ field: 'qux', expected: 100, type: 'Float' }, { field: 'qux', expected: 92, type: 'Float' },
{ field: 'date', expected: 100, type: 'Date' }, { field: 'date', expected: 92, type: 'Date' },
{ field: 'datetime', expected: 150, type: 'Datetime' }, { field: 'datetime', expected: 146, type: 'Datetime' },
]; ];
assert.expect(assertions.length + 1); assert.expect(assertions.length + 1);
@@ -1497,9 +1497,8 @@ QUnit.module('Views', {
}); });
// the width is hardcoded to make sure we have the same condition // the width is hardcoded to make sure we have the same condition
// between debug mode and non debug mode (#qunit-fixture is limited to // between debug mode and non debug mode
// 1000px) list.$el.width('1200px');
list.$el.width('1000px');
var startHeight = list.$('.o_data_row:first').height(); var startHeight = list.$('.o_data_row:first').height();
// start edition of first row // start edition of first row
+6 -1
View File
@@ -103,7 +103,12 @@ class WebsiteMultiMixin(models.AbstractModel):
_name = 'website.multi.mixin' _name = 'website.multi.mixin'
_description = 'Multi Website 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 @api.multi
def can_access_from_current_website(self, website_id=False): def can_access_from_current_website(self, website_id=False):
+13
View File
@@ -170,6 +170,19 @@ class Website(models.Model):
if 'favicon' in vals: if 'favicon' in vals:
vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO') 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 # Page Management
# ---------------------------------------------------------- # ----------------------------------------------------------
+1 -1
View File
@@ -21,7 +21,7 @@ class Menu(models.Model):
page_id = fields.Many2one('website.page', 'Related Page', ondelete='cascade') page_id = fields.Many2one('website.page', 'Related Page', ondelete='cascade')
new_window = fields.Boolean('New Window') new_window = fields.Boolean('New Window')
sequence = fields.Integer(default=_default_sequence) 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") parent_id = fields.Many2one('website.menu', 'Parent Menu', index=True, ondelete="cascade")
child_id = fields.One2many('website.menu', 'parent_id', string='Child Menus') child_id = fields.One2many('website.menu', 'parent_id', string='Child Menus')
parent_path = fields.Char(index=True) parent_path = fields.Char(index=True)
+51
View File
@@ -5,6 +5,57 @@ import odoo
import odoo.tests 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') @odoo.tests.tagged('-at_install', 'post_install')
class TestUiHtmlEditor(odoo.tests.HttpCase): class TestUiHtmlEditor(odoo.tests.HttpCase):
def test_html_editor_multiple_templates(self): def test_html_editor_multiple_templates(self):
@@ -12,12 +12,22 @@ publicWidget.registry.visitor = publicWidget.Widget.extend({
start: function () { start: function () {
var defs = [this._super.apply(this, arguments)]; var defs = [this._super.apply(this, arguments)];
var self = this; 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) { defs.push(this._rpc({route: '/event/get_country_event_list'}).then(function (data) {
if (data) { if (data) {
self.$('.country_events_list').replaceWith(data); self._$loadedContent = $(data);
$eventList.replaceWith(self._$loadedContent);
} }
})); }));
return Promise.all(defs); 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="name">res.config.settings.view.form.inherit.website.mass.mailing</field>
<field name="model">res.config.settings</field> <field name="model">res.config.settings</field>
<field name="arch" type="xml"> <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"> <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 &amp; 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="col-lg-6 o_setting_box col-12" title="This adds a new Newsletter Popup snippet to drag &amp; 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"> <div class="o_setting_left_pane">
@@ -6,4 +6,4 @@ from odoo import fields, models
class PaymentAcquirer(models.Model): class PaymentAcquirer(models.Model):
_inherit = 'payment.acquirer' _inherit = 'payment.acquirer'
website_id = fields.Many2one('website') website_id = fields.Many2one('website', ondelete='restrict')
+1 -1
View File
@@ -29,7 +29,7 @@ class ProductPricelist(models.Model):
domain = [('company_id', '=', company_id)] domain = [('company_id', '=', company_id)]
return self.env['website'].search(domain, limit=1) 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") 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") 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)]) products = request.env['product.product'].search([('id', 'in', product_ids)])
values['products'] = products.with_context(display_default_code=False) 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 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') cat_name = var.attribute_id.category_id.name or _('Uncategorized')
att_name = var.attribute_id.name 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) val = product.attribute_value_ids.filtered(lambda x: x.attribute_id == var.attribute_id)
if val: if val:
res[cat_name][att_name][num] = val[0].name 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) 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') 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') 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) active = fields.Boolean(default=True, required=True)
@api.model @api.model
@@ -8,7 +8,7 @@ class WebsiteTwitterTweet(models.Model):
_name = 'website.twitter.tweet' _name = 'website.twitter.tweet'
_description = 'Website Twitter' _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') screen_name = fields.Char(string='Screen Name')
tweet = fields.Text(string='Tweets') tweet = fields.Text(string='Tweets')
+11
View File
@@ -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
+11
View File
@@ -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
+6 -2
View File
@@ -458,12 +458,16 @@ template inheritance
'''''''''''''''''''' ''''''''''''''''''''
Template inheritance is used to alter existing templates in-place, e.g. to 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 Template inheritance is performed via the ``t-extend`` directive which takes
the name of the template to alter as parameter. 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:: sub-directives::
<t t-extend="base.template"> <t t-extend="base.template">
+19
View File
@@ -62,6 +62,25 @@ import time
if hasattr(time, 'tzset'): if hasattr(time, 'tzset'):
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 # Shortcuts
#---------------------------------------------------------- #----------------------------------------------------------
+1
View File
@@ -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_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_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_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_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_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" "base.lang_ro","Romanian / română","ro_RO","ro","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","True","1"
1 id name code iso_code direction grouping decimal_point thousands_sep date_format time_format translatable week_start
51 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
52 base.lang_fa_IR Persian / فارس fa_IR fa Right-to-Left [3,0] . , %Y/%m/%d %H:%M:%S True 6
53 base.lang_pl Polish / Język polski pl_PL pl Left-to-Right [] , %d.%m.%Y %H:%M:%S True 1
54 base.lang_pt_AO Portuguese (AO) / Português (AO) pt_AO pt_AO Left-to-Right [] , %d-%m-%Y %H:%M:%S True 1
55 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
56 base.lang_pt Portuguese / Português pt_PT pt Left-to-Right [] , %d-%m-%Y %H:%M:%S True 1
57 base.lang_ro Romanian / română ro_RO ro Left-to-Right [3,0] , . %d.%m.%Y %H:%M:%S True 1
+3 -1
View File
@@ -48,8 +48,10 @@ class IrRule(models.Model):
by the user with the switch company menu. These companies are by the user with the switch company menu. These companies are
filtered and trusted. filtered and trusted.
""" """
# use an empty context for 'user' to make the domain evaluation
# independent from the context
return { return {
'user': self.env.user, 'user': self.env.user.with_context({}),
'time': time, 'time': time,
'company_ids': self.env.companies.ids, 'company_ids': self.env.companies.ids,
} }
+1 -1
View File
@@ -883,7 +883,7 @@ class Users(models.Model):
cfg = self.env['ir.config_parameter'].sudo() cfg = self.env['ir.config_parameter'].sudo()
min_failures = int(cfg.get_param('base.login_cooldown_after', 5)) min_failures = int(cfg.get_param('base.login_cooldown_after', 5))
if min_failures == 0: if min_failures == 0:
return True return False
delay = int(cfg.get_param('base.login_cooldown_duration', 60)) delay = int(cfg.get_param('base.login_cooldown_duration', 60))
return failures >= min_failures and (datetime.datetime.now() - previous) < datetime.timedelta(seconds=delay) return failures >= min_failures and (datetime.datetime.now() - previous) < datetime.timedelta(seconds=delay)
+2
View File
@@ -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.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(-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): def try_zero(amount, expected):
self.assertEqual(currency.is_zero(amount), expected, self.assertEqual(currency.is_zero(amount), expected,
+1 -1
View File
@@ -165,7 +165,7 @@
<div class="oe_module_vignette"> <div class="oe_module_vignette">
<t t-set="installed" t-value="record.state.raw_value == 'installed'"/> <t t-set="installed" t-value="record.state.raw_value == 'installed'"/>
<div class="o_dropdown_kanban dropdown" tabindex="-1"> <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"/> <span class="fa fa-ellipsis-v"/>
</a> </a>
<div class="dropdown-menu" role="menu" aria-labelledby="dLabel"> <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_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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_test_access_right_some_obj access_test_access_right_some_obj model_test_access_right_some_obj 1 1 1 1
3 access_test_access_right_container access_test_access_right_container model_test_access_right_container 1 1 1 1
4 access_test_access_right_parent access_test_access_right_parent model_test_access_right_parent 1 1 1 1
5 access_test_access_right_obj_categ access_test_access_right_obj_categ model_test_access_right_obj_categ 1 1 1 1
+12
View File
@@ -8,6 +8,7 @@ class SomeObj(models.Model):
_description = 'Object For Test Access Right' _description = 'Object For Test Access Right'
val = fields.Integer() val = fields.Integer()
categ_id = fields.Many2one('test_access_right.obj_categ')
company_id = fields.Many2one('res.company') company_id = fields.Many2one('res.company')
forbidden = fields.Integer( forbidden = fields.Integer(
groups='test_access_rights.test_group,!base.group_no_one,base.group_user,!base.group_public', 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'} _inherits = {'test_access_right.some_obj': 'obj_id'}
obj_id = fields.Many2one('test_access_right.some_obj', required=True, ondelete='restrict') 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): def setUp(self):
super(TestRules, self).setUp() super(TestRules, self).setUp()
self.id1 = self.env['test_access_right.some_obj']\ ObjCateg = self.env['test_access_right.obj_categ']
.create({'val': 1}).id SomeObj = self.env['test_access_right.some_obj']
self.id2 = self.env['test_access_right.some_obj']\ self.categ1 = ObjCateg.create({'name': 'Food'}).id
.create({'val': -1}).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 # create a global rule forbidding access to records with a negative
# (or zero) val # (or zero) val
self.env['ir.rule'].create({ 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, 'model_id': self.browse_ref('test_access_rights.model_test_access_right_some_obj').id,
'domain_force': "[('val', '>', 0)]" '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): def test_basic_access(self):
env = self.env(user=self.browse_ref('base.public_user')) env = self.env(user=self.browse_ref('base.public_user'))
@@ -82,3 +90,25 @@ class TestRules(TransactionCase):
Model = env['test_access_right.some_obj'] Model = env['test_access_right.some_obj']
with self.assertQueryCount(0): with self.assertQueryCount(0):
Model._filter_access_rules('read') 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)
+2 -1
View File
@@ -706,7 +706,8 @@ class ChromeBrowser():
def set_cookie(self, name, value, path, domain): def set_cookie(self, name, value, path, domain):
params = {'name': name, 'value': value, 'path': path, 'domain': 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): def _wait_ready(self, ready_code, timeout=60):
self._logger.info('Evaluate ready code "%s"', ready_code) self._logger.info('Evaluate ready code "%s"', ready_code)
+1 -1
View File
@@ -72,7 +72,7 @@ def float_round(value, precision_digits=None, precision_rounding=None, rounding_
normalized_value = value / rounding_factor # normalize normalized_value = value / rounding_factor # normalize
sign = math.copysign(1.0, normalized_value) sign = math.copysign(1.0, normalized_value)
epsilon_magnitude = math.log(abs(normalized_value), 2) 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) # TIE-BREAKING: UP/DOWN (for ceiling[resp. flooring] operations)
# When rounding the value up[resp. down], we instead subtract[resp. add] the epsilon value # When rounding the value up[resp. down], we instead subtract[resp. add] the epsilon value