[MERGE] forward port branch saas-16 up to 278e478d55

This commit is contained in:
Christophe Simonis
2017-06-27 14:19:31 +02:00
123 changed files with 1671 additions and 755 deletions
+5
View File
@@ -272,6 +272,11 @@ file_filter = addons/hr_expense/i18n/<lang>.po
source_file = addons/hr_expense/i18n/hr_expense.pot
source_lang = en
[odoo-master.hr_expense_check]
file_filter = addons/hr_expense_check/i18n/<lang>.po
source_file = addons/hr_expense_check/i18n/hr_expense_check.pot
source_lang = en
[odoo-master.hr_gamification]
file_filter = addons/hr_gamification/i18n/<lang>.po
source_file = addons/hr_gamification/i18n/hr_gamification.pot
+6 -1
View File
@@ -897,6 +897,11 @@ class AccountMoveLine(models.Model):
@api.multi
def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False):
# Empty self can happen if the user tries to reconcile entries which are already reconciled.
# The calling method might have filtered out reconciled lines.
if not self:
return True
#Perform all checks on lines
company_ids = set()
all_accounts = []
@@ -1425,7 +1430,7 @@ class AccountPartialReconcile(models.Model):
help='Utility field to express amount currency')
company_id = fields.Many2one('res.company', related='debit_move_id.company_id', store=True, string='Currency')
full_reconcile_id = fields.Many2one('account.full.reconcile', string="Full Reconcile", copy=False)
max_date = fields.Datetime(string='Max Date of Matched Lines', compute='_compute_max_date',
max_date = fields.Date(string='Max Date of Matched Lines', compute='_compute_max_date',
readonly=True, copy=False, stored=True,
help='Technical field used to determine at which date this reconciliation needs to be shown on the aged receivable/payable reports.')
@@ -2,6 +2,7 @@
import time
from odoo import api, models, _
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
from datetime import datetime
from dateutil.relativedelta import relativedelta
@@ -196,6 +197,9 @@ class ReportAgedPartnerBalance(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model') or not self.env.context.get('active_id'):
raise UserError(_("Some data are missing, this report cannot be printed."))
total = []
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_id'))
+7 -1
View File
@@ -2,6 +2,7 @@
import time
from odoo import api, models
from odoo.exceptions import UserError
class ReportTrialBalance(models.AbstractModel):
@@ -50,13 +51,18 @@ class ReportTrialBalance(models.AbstractModel):
res['balance'] = account_result[account.id].get('balance')
if display_account == 'all':
account_res.append(res)
if display_account in ['movement', 'not_zero'] and not currency.is_zero(res['balance']):
if display_account == 'not_zero' and not currency.is_zero(res['balance']):
account_res.append(res)
if display_account == 'movement' and (not currency.is_zero(res['debit']) or not currency.is_zero(res['credit'])):
account_res.append(res)
return account_res
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model'):
raise UserError(_("Some data are missing, this report cannot be printed."))
self.model = self.env.context.get('active_model')
docs = self.env[self.model].browse(self.env.context.get('active_ids', []))
display_account = data['form'].get('display_account')
@@ -2,6 +2,7 @@
import time
from odoo import api, models
from odoo.exceptions import UserError
class ReportGeneralLedger(models.AbstractModel):
@@ -109,6 +110,9 @@ class ReportGeneralLedger(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model'):
raise UserError(_("Some data are missing, this report cannot be printed."))
self.model = self.env.context.get('active_model')
docs = self.env[self.model].browse(self.env.context.get('active_ids', []))
+4
View File
@@ -2,6 +2,7 @@
import time
from odoo import api, models
from odoo.exceptions import UserError
class ReportJournal(models.AbstractModel):
@@ -95,6 +96,9 @@ class ReportJournal(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Some data are missing, this report cannot be printed."))
target_move = data['form'].get('target_move', 'all')
sort_selection = data['form'].get('sort_selection', 'date')
@@ -3,6 +3,7 @@
from datetime import datetime
import time
from odoo import api, models
from odoo.exceptions import UserError
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
@@ -69,6 +70,9 @@ class ReportPartnerLedger(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Some data are missing, this report cannot be printed."))
data['computed'] = {}
obj_partner = self.env['res.partner']
@@ -2,6 +2,7 @@
import time
from odoo import api, models
from odoo.exceptions import UserError
from odoo.tools import pycompat
@@ -145,6 +146,9 @@ class ReportFinancial(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model') or not self.env.context.get('active_id'):
raise UserError(_("Some data are missing, this report cannot be printed."))
self.model = self.env.context.get('active_model')
docs = self.env[self.model].browse(self.env.context.get('active_id'))
report_lines = self.get_account_lines(data.get('form'))
+1 -1
View File
@@ -729,7 +729,7 @@
<field name="name">Bank Statement Lines</field>
<field name="res_model">account.bank.statement.line</field>
<field name="view_type">form</field>
<field name="view_mode">tree</field>
<field name="view_mode">tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="search_view_id" ref="view_bank_statement_line_search"/>
@@ -109,7 +109,7 @@ var BarcodeParser = Class.extend({
ean = ean.substr(0,13);
for(var n = 0, count = (13 - ean.length); n < count; n++){
ean = ean + '0';
ean = '0' + ean;
}
return ean.substr(0,12) + this.ean_checksum(ean);
},
+5
View File
@@ -281,6 +281,11 @@ class Lead(models.Model):
if vals.get('user_id') and 'date_open' not in vals:
vals['date_open'] = fields.Datetime.now()
if context.get('default_partner_id') and not vals.get('email_from'):
partner = self.env['res.partner'].browse(context['default_partner_id'])
vals['email_from'] = partner.email
# context: no_log, because subtype already handle this
return super(Lead, self.with_context(context, mail_create_nolog=True)).create(vals)
@@ -6,6 +6,7 @@ import calendar
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
class HrHolidaySummaryReport(models.AbstractModel):
@@ -107,6 +108,9 @@ class HrHolidaySummaryReport(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Some data are missing, this report cannot be printed."))
holidays_report = self.env['ir.actions.report']._get_report_from_name('hr_holidays.report_holidayssummary')
holidays = self.env['hr.holidays'].browse(self.ids)
return {
@@ -6,6 +6,7 @@ from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
class ContributionRegisterReport(models.AbstractModel):
@@ -29,6 +30,9 @@ class ContributionRegisterReport(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Some data are missing, this report cannot be printed."))
register_ids = self.env.context.get('active_ids', [])
contrib_registers = self.env['hr.contribution.register'].browse(register_ids)
date_from = data['form'].get('date_from', fields.Date.today())
+1 -1
View File
@@ -13,7 +13,7 @@
</record>
<record id="l10nch_chart_template" model="account.chart.template">
<field name="name">Plan comptable 2015</field>
<field name="code_digits">1</field>
<field name="code_digits">4</field>
<field name="bank_account_code_prefix">102</field>
<field name="cash_account_code_prefix">100</field>
<field name="currency_id" ref="base.CHF"/>
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@
from datetime import date
from odoo import api, models
from odoo.exceptions import UserError
class ReportHrSalaryEmployeeBymonth(models.AbstractModel):
@@ -93,6 +94,9 @@ class ReportHrSalaryEmployeeBymonth(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model') or not self.env.context.get('active_id'):
raise UserError(_("Some data are missing, this report cannot be printed."))
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_id'))
get_periods, months, total_mnths = self.get_periods(data['form'])
@@ -4,6 +4,7 @@
from datetime import date
from odoo import api, models
from odoo.exceptions import UserError
from odoo.tools import pycompat
@@ -128,6 +129,9 @@ class EmployeesYearlySalaryReport(models.AbstractModel):
@api.model
def get_report_values(self, docids, data=None):
if not self.env.context.get('active_model') or not self.env.context.get('active_id'):
raise UserError(_("Some data are missing, this report cannot be printed."))
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_id'))
return {
+6 -5
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from operator import itemgetter
from collections import OrderedDict
import json
import datetime
@@ -94,9 +94,9 @@ class LunchOrder(models.Model):
}))
if self.previous_order_ids:
lunch_data = []
lunch_data = {}
for line in self.previous_order_ids:
lunch_data.append({
lunch_data[line.id] = {
'line_id': line.id,
'product_id': line.product_id.id,
'product_name': line.product_id.name,
@@ -105,8 +105,9 @@ class LunchOrder(models.Model):
'price': line.price,
'date': line.date,
'currency_id': line.currency_id.id,
})
lunch_data.sort(key=itemgetter('date', 'line_id'), reverse=True)
}
# sort the old lunch orders by (date, id)
lunch_data = OrderedDict(sorted(lunch_data.items(), key=lambda t: (t[1]['date'], t[0]), reverse=True))
self.previous_order_widget = json.dumps(lunch_data)
@api.one
+7 -1
View File
@@ -75,8 +75,14 @@ class MailTracking(models.Model):
assert type in ('new', 'old')
result = []
for record in self:
if record.field_type in ['integer', 'float', 'char', 'text', 'datetime', 'monetary']:
if record.field_type in ['integer', 'float', 'char', 'text', 'monetary']:
result.append(getattr(record, '%s_value_%s' % (type, record.field_type)))
elif record.field_type == 'datetime':
if record['%s_value_datetime' % type]:
new_datetime = getattr(record, '%s_value_datetime' % type)
result.append('%sZ' % new_datetime)
else:
result.append(record['%s_value_datetime' % type])
elif record.field_type == 'date':
if record['%s_value_datetime' % type]:
new_date = datetime.strptime(record['%s_value_datetime' % type], tools.DEFAULT_SERVER_DATETIME_FORMAT).date()
+10 -2
View File
@@ -217,14 +217,22 @@ function make_message (data) {
// can not be done in preprocess, since it alter the original value
if (msg.tracking_value_ids && msg.tracking_value_ids.length) {
_.each(msg.tracking_value_ids, function(f) {
if (_.contains(['date', 'datetime'], f.field_type)) {
var format = (f.field_type === 'date') ? 'LL' : 'LLL';
if (f.field_type === 'datetime') {
var format = 'LLL';
if (f.old_value) {
f.old_value = moment.utc(f.old_value).local().format(format);
}
if (f.new_value) {
f.new_value = moment.utc(f.new_value).local().format(format);
}
} else if (f.field_type === 'date') {
var format = 'LL';
if (f.old_value) {
f.old_value = moment(f.old_value).local().format(format);
}
if (f.new_value) {
f.new_value = moment(f.new_value).local().format(format);
}
}
});
}
+1 -1
View File
@@ -312,7 +312,7 @@ class MassMailing(models.Model):
create_date = fields.Datetime(string='Creation Date')
sent_date = fields.Datetime(string='Sent Date', oldname='date', copy=False)
schedule_date = fields.Datetime(string='Schedule in the Future')
body_html = fields.Html(string='Body', translate=html_translate, sanitize_attributes=False)
body_html = fields.Html(string='Body', sanitize_attributes=False)
attachment_ids = fields.Many2many('ir.attachment', 'mass_mailing_ir_attachments_rel',
'mass_mailing_id', 'attachment_id', string='Attachments')
keep_archives = fields.Boolean(string='Keep Archives')
@@ -32,17 +32,13 @@ KanbanColumn.include({
});
FieldTextHtml.include({
get_datarecord: function () {
getDatarecord: function () {
/* Avoid extremely long URIs by whitelisting fields in the datarecord
that get set as a get parameter */
var datarecord = this._super();
if (this.view.model === 'mail.mass_mailing') {
if (this.model === 'mail.mass_mailing') {
// these fields can potentially get very long, let's remove them
var blacklist = ['mailing_domain', 'contact_list_ids'];
for (var k in blacklist) {
delete datarecord[blacklist[k]];
}
delete datarecord[this.name];
datarecord = _.omit(datarecord, ['mailing_domain', 'contact_list_ids', 'body_html']);
}
return datarecord;
},
@@ -170,6 +170,7 @@ snippets_editor.Class.include({
className: classname || "",
img: $theme.data("img") || "",
template: $theme.html().trim(),
nowrap: !!$theme.data('nowrap'),
get_image_info: function (filename) {
if (images_info[filename]) {
return images_info[filename];
@@ -331,13 +332,20 @@ snippets_editor.Class.include({
var $old_layout = $editable_area.find(".o_layout");
// This wrapper structure is the only way to have a responsive and
// centered fixed-width content column on all mail clients
var $new_wrapper = $('<table/>', {class: 'o_mail_wrapper'});
var $new_wrapper_content = $("<td/>", {class: 'o_mail_no_resize o_mail_wrapper_td oe_structure'});
$new_wrapper.append($('<tr/>').append(
$("<td/>", {class: 'o_mail_no_resize'}),
$new_wrapper_content,
$("<td/>", {class: 'o_mail_no_resize'})
));
var $new_wrapper, $new_wrapper_content;
if (theme_params.nowrap) {
$new_wrapper = $new_wrapper_content = $("<div/>", {"class": "oe_structure"});
}
else {
$new_wrapper = $('<table/>', {class: 'o_mail_wrapper'});
$new_wrapper_content = $("<td/>", {class: 'o_mail_no_resize o_mail_wrapper_td oe_structure'});
$new_wrapper.append($('<tr/>').append(
$("<td/>", {class: 'o_mail_no_resize'}),
$new_wrapper_content,
$("<td/>", {class: 'o_mail_no_resize'})
));
}
var $new_layout = $("<div/>", {"class": "o_layout " + theme_params.className}).append($new_wrapper);
var $contents;
+26 -4
View File
@@ -117,26 +117,48 @@ class MrpBom(models.Model):
Quantity describes the number of times you need the BoM: so the quantity divided by the number created by the BoM
and converted into its UoM
"""
from collections import defaultdict
graph = defaultdict(list)
V = set()
def check_cycle(v, visited, recStack, graph):
visited[v] = True
recStack[v] = True
for neighbour in graph[v]:
if visited[neighbour] == False:
if check_cycle(neighbour, visited, recStack, graph) == True:
return True
elif recStack[neighbour] == True:
return True
recStack[v] = False
return False
boms_done = [(self, {'qty': quantity, 'product': product, 'original_qty': quantity, 'parent_line': False})]
lines_done = []
templates_done = product.product_tmpl_id
V |= set([product.product_tmpl_id.id])
bom_lines = [(bom_line, product, quantity, False) for bom_line in self.bom_line_ids]
for bom_line in self.bom_line_ids:
V |= set([bom_line.product_id.product_tmpl_id.id])
graph[product.product_tmpl_id.id].append(bom_line.product_id.product_tmpl_id.id)
while bom_lines:
current_line, current_product, current_qty, parent_line = bom_lines[0]
bom_lines = bom_lines[1:]
if current_line._skip_bom_line(current_product):
continue
if current_line.product_id.product_tmpl_id in templates_done:
raise UserError(_('Recursion error! A product with a Bill of Material should not have itself in its BoM or child BoMs!'))
line_quantity = current_qty * current_line.product_qty
bom = self._bom_find(product=current_line.product_id, picking_type=picking_type or self.picking_type_id, company_id=self.company_id.id)
if bom.type == 'phantom':
converted_line_quantity = current_line.product_uom_id._compute_quantity(line_quantity / bom.product_qty, bom.product_uom_id)
bom_lines = [(line, current_line.product_id, converted_line_quantity, current_line) for line in bom.bom_line_ids] + bom_lines
templates_done |= current_line.product_id.product_tmpl_id
for bom_line in bom.bom_line_ids:
graph[current_line.product_id.product_tmpl_id.id].append(bom_line.product_id.product_tmpl_id.id)
if bom_line.product_id.product_tmpl_id.id in V and check_cycle(bom_line.product_id.product_tmpl_id.id, {key: False for key in V}, {key: False for key in V}, graph):
raise UserError(_('Recursion error! A product with a Bill of Material should not have itself in its BoM or child BoMs!'))
V |= set([bom_line.product_id.product_tmpl_id.id])
boms_done.append((bom, {'qty': converted_line_quantity, 'product': current_product, 'original_qty': quantity, 'parent_line': current_line}))
else:
# We round up here because the user expects that if he has to consume a little more, the whole UOM unit
+6 -6
View File
@@ -208,11 +208,8 @@ class MrpProduction(models.Model):
@api.depends('move_raw_ids.quantity_done', 'move_finished_ids.quantity_done')
def _compute_post_visible(self):
for order in self:
if order.product_tmpl_id._is_cost_method_standard():
order.post_visible = any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_raw_ids) or \
any((x.quantity_done > 0 and x.state not in ['done' 'cancel']) for x in order.move_finished_ids)
else:
order.post_visible = any((x.quantity_done > 0 and x.state not in ['done' 'cancel']) for x in order.move_finished_ids)
order.post_visible = any(order.move_raw_ids.filtered(lambda x: (x.quantity_done) > 0 and (x.state not in ['done', 'cancel']))) or \
any(order.move_finished_ids.filtered(lambda x: (x.quantity_done) > 0 and (x.state not in ['done', 'cancel'])))
@api.multi
@api.depends('workorder_ids.state', 'move_finished_ids')
@@ -269,7 +266,10 @@ class MrpProduction(models.Model):
@api.model
def create(self, values):
if not values.get('name', False) or values['name'] == _('New'):
values['name'] = self.env['ir.sequence'].next_by_code('mrp.production') or _('New')
if values.get('picking_type_id'):
values['name'] = self.env['stock.picking.type'].browse(values['picking_type_id']).sequence_id.next_by_id()
else:
values['name'] = self.env['ir.sequence'].next_by_code('mrp.production') or _('New')
if not values.get('procurement_group_id'):
values['procurement_group_id'] = self.env["procurement.group"].create({'name': values['name']}).id
production = super(MrpProduction, self).create(values)
+10 -5
View File
@@ -391,9 +391,12 @@ class MrpWorkorder(models.Model):
domain = [('workorder_id', 'in', self.ids), ('date_end', '=', False)]
if not doall:
domain.append(('user_id', '=', self.env.user.id))
not_productive_timelines = timeline_obj.browse()
for timeline in timeline_obj.search(domain, limit=None if doall else 1):
wo = timeline.workorder_id
if timeline.loss_type != 'productive':
if wo.duration_expected <= wo.duration:
if timeline.loss_type == 'productive':
not_productive_timelines += timeline
timeline.write({'date_end': fields.Datetime.now()})
else:
maxdate = fields.Datetime.from_string(timeline.date_start) + relativedelta(minutes=wo.duration_expected - wo.duration)
@@ -402,10 +405,12 @@ class MrpWorkorder(models.Model):
timeline.write({'date_end': enddate})
else:
timeline.write({'date_end': maxdate})
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one unactive productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
timeline.copy({'date_start': maxdate, 'date_end': enddate, 'loss_id': loss_id.id})
not_productive_timelines += timeline.copy({'date_start': maxdate, 'date_end': enddate})
if not_productive_timelines:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one unactive productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
not_productive_timelines.write({'loss_id': loss_id.id})
return True
@api.multi
+26 -3
View File
@@ -131,7 +131,30 @@ class TestBoM(TestMrpCommon):
self.assertEqual(set((test_bom_2 | self.bom_2).ids), set([b[0].id for b in boms]))
self.assertEqual(set((test_bom_2_l1 | test_bom_2_l4 | self.bom_2.bom_line_ids).ids), set([l[0].id for l in lines]))
# check recursion
test_bom_2_l3.write({'attribute_value_ids': [(6, 0, [self.prod_attr1_v1.id])]})
#check recursion
test_bom_3 = self.env['mrp.bom'].create({
'product_id': self.product_9.id,
'product_tmpl_id': self.product_9.product_tmpl_id.id,
'product_uom_id': self.product_9.uom_id.id,
'product_qty': 1.0,
'type': 'normal'
})
test_bom_4 = self.env['mrp.bom'].create({
'product_id': self.product_10.id,
'product_tmpl_id': self.product_10.product_tmpl_id.id,
'product_uom_id': self.product_10.uom_id.id,
'product_qty': 1.0,
'type': 'phantom'
})
test_bom_3_l1 = self.env['mrp.bom.line'].create({
'bom_id': test_bom_3.id,
'product_id': self.product_10.id,
'product_qty': 1.0,
})
test_bom_4_l1 = self.env['mrp.bom.line'].create({
'bom_id': test_bom_4.id,
'product_id': self.product_9.id,
'product_qty': 1.0,
})
with self.assertRaises(exceptions.UserError):
test_bom_2.explode(self.product_7_1, 4)
test_bom_3.explode(self.product_9, 1)
+1 -1
View File
@@ -48,7 +48,7 @@
<button name="button_plan" attrs="{'invisible': ['|', ('state', '!=', 'confirmed'), ('routing_id', '=', False)]}" type="object" string="Create Workorders" class="oe_highlight"/>
<button name="open_produce_product" attrs="{'invisible': ['|', '|', '|', ('check_to_done', '=', True), ('availability', 'not in', ['partially_available', 'assigned']), ('state', 'not in', ('confirmed','progress')), ('routing_id', '!=', False)]}" string="Produce" type="object" class="oe_highlight"/>
<button name="open_produce_product" attrs="{'invisible': ['|', '|', '|', ('check_to_done', '=', True), ('availability', '!=', 'waiting'), ('state', 'not in', ('confirmed','progress')), ('routing_id', '!=', False)]}" string="Produce" type="object"/>
<button name="post_inventory" string="Post Inventory" type="object" attrs="{'invisible': [('post_visible', '=', False)]}"/>
<button name="post_inventory" string="Post Inventory" type="object" attrs="{'invisible': [('post_visible', '=', False)]}" groups="base.group_no_one"/>
<button name="action_cancel" type="object" string="Cancel" attrs="{'invisible': ['|', ('state', 'in', ('done','cancel')), ('check_to_done', '=', True)]}"/>
<button name="button_scrap" type="object" string="Scrap" attrs="{'invisible': ['|', ('availability', '=', 'none'), ('state', 'in', ('cancel'))]}"/>
<button name="button_unreserve" type="object" string="Unreserve" attrs="{'invisible': [('unreserve_visible', '=', False)]}"/>
+1 -1
View File
@@ -150,7 +150,7 @@
<tree editable="bottom" create="0" delete="0">
<field name="product_id"/>
<field name="quantity" readonly="1"/>
<field name="lot_id" context="{'default_product_id': product_id}"/>
<field name="lot_id" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}"/>
<field name="quantity_done"/>
<field name="move_id" invisible="1"/>
<field name="done_wo" invisible="1"/>
-1
View File
@@ -13,7 +13,6 @@ var FieldPad = AbstractField.extend({
events: {
'click .oe_pad_switch': '_onToggleFullScreen',
},
supportedFieldTypes: ['char'],
/**
* @override
+11 -3
View File
@@ -527,7 +527,9 @@ class PaymentTransaction(models.Model):
tx.write({'reference': str(tx.id)})
# Generate callback hash if it is configured on the tx; avoid generating unnecessary stuff
if tx.callback_model_id and tx.callback_res_id and tx.sudo().callback_method:
# (limited sudo env for checking callback presence, must work for manual transactions too)
tx_sudo = tx.sudo()
if tx_sudo.callback_model_id and tx_sudo.callback_res_id and tx_sudo.callback_method:
tx.write({'callback_hash': tx._generate_callback_hash()})
return tx
@@ -657,7 +659,13 @@ class PaymentTransaction(models.Model):
@api.multi
def execute_callback(self):
res = None
for transaction in self.filtered(lambda tx: tx.callback_model_id and tx.callback_res_id and tx.sudo().callback_method):
for transaction in self:
# limited sudo env, only for checking callback presence, not for running it!
# manual transactions have no callback, and can pass without being run by admin user
tx_sudo = transaction.sudo()
if not (tx_sudo.callback_model_id and tx_sudo.callback_res_id and tx_sudo.callback_method):
continue
valid_token = transaction._generate_callback_hash()
if not consteq(ustr(valid_token), transaction.callback_hash):
_logger.warning("Invalid callback signature for transaction %d" % (transaction.id))
@@ -665,7 +673,7 @@ class PaymentTransaction(models.Model):
record = self.env[transaction.callback_model_id.model].browse(transaction.callback_res_id).exists()
if record:
res = getattr(record, transaction.sudo().callback_method)(transaction)
res = getattr(record, transaction.callback_method)(transaction)
else:
_logger.warning("Did not found record %s.%s for callback of transaction %d" % (transaction.callback_model_id.model, transaction.callback_res_id, transaction.id))
return res
@@ -5,7 +5,9 @@ from StringIO import StringIO
from xml.etree import ElementTree as ET
from uuid import uuid4
from odoo import _
from odoo.exceptions import ValidationError, UserError
from odoo import _
XMLNS = 'AnetApi/xml/v1/schema/AnetApiSchema.xsd'
+1 -1
View File
@@ -8,7 +8,7 @@ import hmac
import logging
import time
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.addons.payment.models.payment_acquirer import ValidationError
from odoo.addons.payment_authorize.controllers.main import AuthorizeController
from odoo.tools.float_utils import float_compare
@@ -8,10 +8,10 @@ from odoo.addons.phone_validation.tools import phone_validation
class PhoneValidationMixin(models.AbstractModel):
_name = 'phone.validation.mixin'
def _phone_get_country_code(self):
def _phone_get_country(self):
if 'country_id' in self:
return self.country_id.code
return self.env.user.company_id.country_id.code
return self.country_id
return self.env.user.company_id.country_id
def _phone_get_always_international(self):
if 'company_id' in self:
@@ -19,11 +19,12 @@ class PhoneValidationMixin(models.AbstractModel):
return self.env.user.company_id.phone_international_format
def phone_format(self, number, country=None, company=None):
country_code = country.code if country else self._phone_get_country_code()
country = country or self._phone_get_country()
always_international = company.phone_international_format if company else self._phone_get_always_international()
return phone_validation.phone_format(
number, country_code if country_code else None,
number,
country.code if country else None,
country.phone_code if country else None,
always_international=always_international,
raise_exception=True
)
@@ -9,7 +9,7 @@ class ResCompany(models.Model):
phone_international_format = fields.Boolean(
string="Enforce International Format", default=False,
help="Always encore phone numbers using international format. Otherwise"
help="Always encode phone numbers using international format. Otherwise"
"numbers coming from the company's country are nationaly formatted."
"International numbers are always using international format."
)
@@ -26,7 +26,15 @@ try:
return phone_nbr
def phone_format(number, country_code, always_international=True, raise_exception=True):
def phone_format(number, country_code, country_phone_code, always_international=True, raise_exception=True):
""" Format the given phone number according to the localisation and international options.
:param number: number to convert
:param country_code: the ISO country code in two chars
:type country_code: str
:param country_phone_code: country dial in codes, defined by the ITU-T (Ex: 32 for Belgium)
:type country_phone_code: int
:rtype: str
"""
try:
phone_nbr = phone_parse(number, country_code)
except (phonenumbers.phonenumberutil.NumberParseException, UserError) as e:
@@ -35,8 +43,7 @@ try:
else:
_logger.warning(_('Unable to format %s:\n%s') % number, e)
return number
if always_international and phone_nbr.country_code != country_code:
if always_international and phone_nbr.country_code != country_phone_code:
phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
else:
phone_fmt = phonenumbers.PhoneNumberFormat.NATIONAL
@@ -48,7 +55,7 @@ except ImportError:
def phone_parse(number, country_code):
return False
def phone_format(number, country_code, always_international=True, raise_exception=True):
def phone_format(number, country_code, country_phone_code, always_international=True, raise_exception=True):
global _phonenumbers_lib_warning
if not _phonenumbers_lib_warning:
_logger.warning(
+2 -1
View File
@@ -886,7 +886,8 @@ class PosOrderLine(models.Model):
fiscal_position_id = line.order_id.fiscal_position_id
if fiscal_position_id:
taxes = fiscal_position_id.map_tax(taxes, line.product_id, line.order_id.partner_id)
price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
price = self.env['account.tax']._fix_tax_included_price(
line.price_unit * (1 - (line.discount or 0.0) / 100.0), line.product_id.taxes_id, taxes)
line.price_subtotal = line.price_subtotal_incl = price * line.qty
if taxes:
taxes = taxes.compute_all(price, currency, line.qty, product=line.product_id, partner=line.order_id.partner_id or False)
@@ -2352,7 +2352,6 @@ td {
}
.pos .popup-password .popup-input {
width: 70%;
-webkit-text-security: disc;
}
.pos .popup .body ul,
@@ -0,0 +1,20 @@
odoo.define('point_of_sale.fieldtexthtml', function (require) {
'use strict';
var FieldTextHtml = require('web_editor.backend').FieldTextHtml;
FieldTextHtml.include({
// avoid '414 Request-URI Too Large' errors to
// /point_of_sale/field/customer_facing_display_template by
// filtering out biggest fields
getDatarecord: function () {
var datarecord = this._super();
if (this.model === 'pos.config') {
datarecord = _.omit(datarecord, function (val, key) {
return _.isObject(val) || key === 'customer_facing_display_html';
});
}
return datarecord;
},
});
});
@@ -308,6 +308,11 @@ var PasswordPopupWidget = NumberPopupWidget.extend({
this._super();
this.$('.popup').addClass('popup-password');
},
click_numpad: function(event){
this._super.apply(this, arguments);
var $value = this.$('.value');
$value.text($value.text().replace(/./g, '•'));
},
});
gui.define_popup({name:'password', widget: PasswordPopupWidget});
@@ -179,22 +179,23 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon):
'price_unit': 450,
'discount': 0.0,
'qty': 2.0,
'tax_ids': [(6, 0, self.product3.taxes_id.ids)],
}), (0, 0, {
'name': "OL/0002",
'product_id': self.product4.id,
'price_unit': 300,
'discount': 0.0,
'qty': 3.0,
'tax_ids': [(6, 0, self.product4.taxes_id.ids)],
})]
})
# I click on the "Make Payment" wizard to pay the PoS order with the total amount (2*450 + 3*300 = 1800)
context_make_payment = {
"active_ids": [self.pos_order_pos1.id],
"active_id": self.pos_order_pos1.id
}
self.pos_make_payment_2 = self.PosMakePayment.with_context(context_make_payment).create({
'amount': 1800
'amount': 1845
})
# I click on the validate button to register the payment.
@@ -241,22 +242,23 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon):
'price_unit': 450,
'discount': 0.0,
'qty': (-2.0),
'tax_ids': [(6, 0, self.product3.taxes_id.ids)],
}), (0, 0, {
'name': "OL/0004",
'product_id': self.product4.id,
'price_unit': 300,
'discount': 0.0,
'qty': (-3.0),
'tax_ids': [(6, 0, self.product4.taxes_id.ids)],
})]
})
# I click on the "Make Payment" wizard to pay the PoS order with the total amount (-2*450 + -3*300 = -1800)
context_make_payment = {
"active_ids": [self.pos_order_pos2.id],
"active_id": self.pos_order_pos2.id
}
self.pos_make_payment_3 = self.PosMakePayment.with_context(context_make_payment).create({
'amount': (-1800)
'amount': (-1845)
})
# I click on the validate button to register the payment.
@@ -303,22 +305,23 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon):
'price_unit': 450,
'discount': 0.0,
'qty': (-2.0),
'tax_ids': [(6, 0, self.product3.taxes_id.ids)],
}), (0, 0, {
'name': "OL/0006",
'product_id': self.product4.id,
'price_unit': 300,
'discount': 0.0,
'qty': 3.0,
'tax_ids': [(6, 0, self.product4.taxes_id.ids)],
})]
})
# I click on the "Make Payment" wizard to pay the PoS order with the total amount (-2*450 + 3*300 = 0)
context_make_payment = {
"active_ids": [self.pos_order_pos3.id],
"active_id": self.pos_order_pos3.id
}
self.pos_make_payment_4 = self.PosMakePayment.with_context(context_make_payment).create({
'amount': 0
'amount': 45,
})
# I click on the validate button to register the payment.
@@ -72,6 +72,12 @@
</script>
</template>
<template id="pos_editor_fieldtexthtml_assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/point_of_sale/static/src/js/field_text_html.js"/>
</xpath>
</template>
<template id="pos_editor_assets" inherit_id="web_editor.assets_editor">
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/point_of_sale/static/src/js/pos.web_editor.js"/>
+4
View File
@@ -57,6 +57,10 @@ class ProductCategory(models.Model):
raise ValidationError(_('Error ! You cannot create recursive categories.'))
return True
@api.model
def name_create(self, name):
return self.create({'name': name}).name_get()[0]
class ProductPriceHistory(models.Model):
""" Keep track of the ``product.template`` standard prices as they are changed. """
+10
View File
@@ -90,3 +90,13 @@ class TestProductCommon(common.SavepointCase):
'name': 'House',
'uom_id': cls.uom_unit.id,
'uom_po_id': cls.uom_unit.id})
cls.product_9 = Product.create({
'name': 'Paper',
'uom_id': cls.uom_unit.id,
'uom_po_id': cls.uom_unit.id})
cls.product_10 = Product.create({
'name': 'Stone',
'uom_id': cls.uom_unit.id,
'uom_po_id': cls.uom_unit.id})
@@ -61,5 +61,6 @@ class Holidays(models.Model):
def action_refuse(self):
""" Remove the timesheets linked to the refused holidays """
result = super(Holidays, self).action_refuse()
self.mapped('timesheet_ids').write({'holiday_id': False})
self.mapped('timesheet_ids').unlink()
return result
@@ -72,6 +72,10 @@ class TestTimesheetHolidays(TestTimesheet):
holiday.sudo().action_validate()
self.assertEquals(len(holiday.timesheet_ids), number_of_days, 'Number of generated timesheets should be the same as the leave duration (1 per day)')
# manager refuse the leave
holiday.sudo().action_refuse()
self.assertEquals(len(holiday.timesheet_ids), 0, 'Number of linked timesheets should be zero, since the leave is refused.')
def test_validate_without_timesheet(self):
# employee creates a leave request
number_of_days = (self.leave_end_datetime - self.leave_start_datetime).days
+2 -2
View File
@@ -143,7 +143,7 @@ class PurchaseOrder(models.Model):
picking_ids = fields.Many2many('stock.picking', compute='_compute_picking', string='Receptions', copy=False, store=True)
# There is no inverse function on purpose since the date may be different on each line
date_planned = fields.Datetime(string='Scheduled Date', compute='_compute_date_planned', store=True, index=True, oldname='minimum_planned_date')
date_planned = fields.Datetime(string='Scheduled Date', compute='_compute_date_planned', store=True, index=True)
amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', track_visibility='always')
amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all')
@@ -670,7 +670,7 @@ class PurchaseOrderLine(models.Model):
}
# Fullfill all related procurements with this po line
diff_quantity = self.product_qty - qty
for procurement in self.procurement_ids:
for procurement in self.procurement_ids.filtered(lambda p: p.state != 'cancel'):
# If the procurement has some moves already, we should deduct their quantity
sum_existing_moves = sum(x.product_qty for x in procurement.move_ids if x.state != 'cancel')
existing_proc_qty = procurement.product_id.uom_id._compute_quantity(sum_existing_moves, procurement.product_uom)
@@ -33,7 +33,14 @@ class PurchaseRequisition(models.Model):
_order = "id desc"
def _get_picking_in(self):
return self.env.ref('stock.picking_type_in')
pick_in = self.env.ref('stock.picking_type_in')
if not pick_in:
company = self.env['res.company']._company_default_get('purchase.requisition')
pick_in = self.env['stock.picking.type'].search(
[('warehouse_id.company_id', '=', company.id), ('code', '=', 'incoming')],
limit=1,
)
return pick_in
def _get_type_id(self):
return self.env['purchase.requisition.type'].search([], limit=1)
-3
View File
@@ -35,9 +35,6 @@ class Rating(http.Controller):
return request.not_found()
record_sudo = request.env[rating.res_model].sudo().browse(rating.res_id)
record_sudo.rating_apply(rate, token=token, feedback=kwargs.get('feedback'))
# redirect to the form view if logged person
if request.session.uid:
return werkzeug.utils.redirect('/web#model=%s&id=%s&view_type=form' % (record_sudo._name, record_sudo.id))
lang = rating.partner_id.lang or 'en_US'
return request.env['ir.ui.view'].with_context(lang=lang).render_template('rating.rating_external_page_view', {
'web_base_url': request.env['ir.config_parameter'].sudo().get_param('web.base.url'),
+29
View File
@@ -307,6 +307,35 @@ class ResourceWorkingHours(TestResourceCommon):
to_naive_utc(Datetime.from_string('2013-02-12 09:25:00'), self.env.user))
self.assertEqual(res, to_naive_utc(Datetime.from_string('2013-02-26 11:25:00'), self.env.user))
def test_calendar_hours_scheduling_timezone_2(self):
# Call schedule_hours for a user in Autralia, Sydney (GMT+10)
# Two cases:
# - start at 2013-02-15 08:00:00 => 2013-02-14 21:00:00 in UTC
# - start at 2013-02-15 11:00:00 => 2013-02-15 00:00:00 in UTC
self.env.user.tz = 'Australia/Sydney'
self.env['resource.calendar.attendance'].create({
'name': 'Day3 - 1',
'dayofweek': '3',
'hour_from': 8,
'hour_to': 12,
'calendar_id': self.calendar.id,
})
self.env['resource.calendar.attendance'].create({
'name': 'Day3 - 2',
'dayofweek': '3',
'hour_from': 13,
'hour_to': 17,
'calendar_id': self.calendar.id,
})
hours = 1.0/60.0
for test_date in ['2013-02-15 08:00:00', '2013-02-15 11:00:00']:
start_dt = Datetime.from_string(test_date)
start_dt_utc = to_naive_utc(start_dt, self.env.user)
res = self.calendar._schedule_hours(hours, start_dt_utc)
self.assertEqual(
[(start_dt_utc, start_dt_utc.replace(minute=1))], res,
'resource_calendar: wrong schedule_hours computation')
def test_calendar_hours_scheduling_forward_leaves_resource(self):
res = self.calendar._schedule_hours(
40, day_dt=Datetime.from_string('2013-02-12 09:00:00'),
+1 -1
View File
@@ -127,7 +127,7 @@ class SaleOrder(models.Model):
('cancel', 'Cancelled'),
], string='Status', readonly=True, copy=False, index=True, track_visibility='onchange', default='draft')
date_order = fields.Datetime(string='Order Date', required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now)
validity_date = fields.Date(string='Expiration Date', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
validity_date = fields.Date(string='Expiration Date', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
help="Manually set the expiration date of your quotation (offer), or it will set the date automatically based on the template if online quotation is installed.")
create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.")
confirmation_date = fields.Datetime(string='Confirmation Date', readonly=True, index=True, help="Date on which the sales order is confirmed.", oldname="date_confirm")
+1 -1
View File
@@ -55,7 +55,7 @@ class SaleOrderLine(models.Model):
return super(SaleOrderLine, self).create(vals)
@api.depends('product_id', 'purchase_price', 'product_uom_qty', 'price_unit')
@api.depends('product_id', 'purchase_price', 'product_uom_qty', 'price_unit', 'price_subtotal')
def _product_margin(self):
for line in self:
currency = line.order_id.pricelist_id.currency_id
@@ -17,7 +17,7 @@
<field name="arch" type="xml">
<xpath expr="//label" position="before">
<div class="oe_button_box" name="button_box" groups="base.group_user">
<button name="%(sale_timesheet.action_timesheet_from_invoice)d" type="action" class="oe_stat_button" icon="fa-calendar">
<button name="%(sale_timesheet.action_timesheet_from_invoice)d" type="action" class="oe_stat_button" icon="fa-calendar" attrs="{'invisible':[('timesheet_count','=', 0)]}">
<field name="timesheet_count" widget="statinfo" string="Timesheets"/>
</button>
</div>
-3
View File
@@ -419,9 +419,6 @@ class ProductTemplate(models.Model):
relation="stock.location.route", string="Category Routes",
related='categ_id.total_route_ids')
def _is_cost_method_standard(self):
return True
def _compute_quantities(self):
res = self._compute_quantities_dict()
for template in self:
+4 -3
View File
@@ -110,10 +110,11 @@ class PackOperation(models.Model):
qty -= record.qty
self.remaining_qty = float_round(qty, precision_rounding=self.product_id.uom_id.rounding)
@api.one
@api.multi
def _compute_location_description(self):
self.from_loc = '%s%s' % (self.location_id.name, self.product_id and self.package_id.name or '')
self.to_loc = '%s%s' % (self.location_dest_id.name, self.result_package_id.name or '')
for operation, operation_sudo in zip(self, self.sudo()):
operation.from_loc = '%s%s' % (operation_sudo.location_id.name, operation.product_id and operation_sudo.package_id.name or '')
operation.to_loc = '%s%s' % (operation_sudo.location_dest_id.name, operation_sudo.result_package_id.name or '')
@api.one
def _compute_lots_visible(self):
+9 -3
View File
@@ -328,11 +328,11 @@ class Picking(models.Model):
.filtered(lambda move: move.state not in ['cancel', 'done'])\
.sorted(key=lambda move: (move.state == 'assigned' and 2) or (move.state == 'waiting' and 1) or 0)
if self.move_type == 'one':
self.state = moves_todo[0].state
self.state = moves_todo[0].state or 'draft'
elif moves_todo[0].state != 'assigned' and any(x.partially_available or x.state == 'assigned' for x in moves_todo):
self.state = 'partially_available'
else:
self.state = moves_todo[-1].state
self.state = moves_todo[-1].state or 'draft'
@api.one
@api.depends('move_lines.priority')
@@ -610,9 +610,13 @@ class Picking(models.Model):
forced_qties[move.product_id] = forced_qty
for vals in picking._prepare_pack_ops(picking_quants, forced_qties):
vals['fresh_record'] = False
PackOperation.create(vals)
PackOperation |= PackOperation.create(vals)
# recompute the remaining quantities all at once
self.do_recompute_remaining_quantities()
for pack in PackOperation:
pack.ordered_qty = sum(
pack.mapped('linked_move_operation_ids').mapped('move_id').filtered(lambda r: r.state != 'cancel').mapped('ordered_qty')
)
self.write({'recompute_pack_op': False})
@api.multi
@@ -782,6 +786,8 @@ class Picking(models.Model):
@api.multi
def do_new_transfer(self):
for pick in self:
if pick.state == 'done':
raise UserError(_('The pick is already validated'))
pack_operations_delete = self.env['stock.pack.operation']
if not pick.move_lines and not pick.pack_operation_ids:
raise UserError(_('Please create some Initial Demand or Mark as Todo and create some Operations. '))
-1
View File
@@ -1576,7 +1576,6 @@ class TestStockFlow(TestStockCommon):
'picking_id': picking_out.id,
'location_id': self.stock_location,
'location_dest_id': self.customer_location})
# validate this delivery order, it should be in the waiting state
picking_out.action_assign()
self.assertEquals(picking_out.state, "confirmed")
+1
View File
@@ -201,6 +201,7 @@
<header>
<field name="state" widget="statusbar"/>
</header>
<field name="scrapped" invisible="1"/>
<group>
<group string="#Products">
<field name="product_id"/>
@@ -19,11 +19,10 @@ class StockBackorderConfirmation(models.TransientModel):
@api.one
def _process(self, cancel_backorder=False):
for pack in self.pick_id.pack_operation_ids:
if pack.qty_done > 0:
pack.product_qty = pack.qty_done
else:
pack.unlink()
operations_to_delete = self.pick_id.pack_operation_ids.filtered(lambda o: o.qty_done <= 0)
for pack in self.pick_id.pack_operation_ids - operations_to_delete:
pack.product_qty = pack.qty_done
operations_to_delete.unlink()
self.pick_id.do_transfer()
if cancel_backorder:
backorder_pick = self.env['stock.picking'].search([('backorder_id', '=', self.pick_id.id)])
-3
View File
@@ -51,9 +51,6 @@ class ProductTemplate(models.Model):
def _compute_cost_method(self):
self.cost_method = self.property_cost_method or self.categ_id.property_cost_method
def _is_cost_method_standard(self):
return self.property_cost_method == 'standard'
@api.one
def _set_cost_method(self):
return self.write({'property_cost_method': self.cost_method})
+1 -1
View File
@@ -41,7 +41,7 @@ class StockHistory(models.Model):
self._cr.execute("""SELECT DISTINCT ON (product_id, company_id) product_id, company_id, cost
FROM product_price_history
WHERE product_id in %s AND datetime <= %s
ORDER BY product_id, company_id, datetime DESC""", (tuple(not_real_cost_method_products.ids), date))
ORDER BY product_id, company_id, datetime DESC, id DESC""", (tuple(not_real_cost_method_products.ids), date))
for history in self._cr.dictfetchall():
histories_dict[(history['product_id'], history['company_id'])] = history['cost']
@@ -155,6 +155,7 @@ ExtendedSearchProposition.Field = Widget.extend({
ExtendedSearchProposition.Char = ExtendedSearchProposition.Field.extend({
tagName: 'input',
className: 'o_input',
attributes: {
type: 'text'
},
@@ -186,7 +187,7 @@ ExtendedSearchProposition.DateTime = ExtendedSearchProposition.Field.extend({
{value: "∄", text: _lt("is not set")}
],
get_value: function (index) {
return this["datewidget_" + (index || 0)].get_value();
return this["datewidget_" + (index || 0)].getValue();
},
get_domain: function (field, operator) {
switch (operator.value) {
@@ -251,6 +252,7 @@ ExtendedSearchProposition.Date = ExtendedSearchProposition.DateTime.extend({
ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({
tagName: 'input',
className: 'o_input',
attributes: {
type: 'number',
value: '0',
@@ -307,7 +307,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
self.trigger('switch_mode', view_type, view_options);
}).fail(function(e) {
if (!(e && e.code === 200 && e.data.exception_type)) {
self.do_warn(_t("Error"), view.controller.display_name + _t(" view couldn't be loaded"));
self.do_warn(_t("Error"), view.label + _t(" view couldn't be loaded"));
}
// Restore internal state
self.active_view = old_view;
+16
View File
@@ -435,6 +435,22 @@ datetime.datetime = py.type('datetime', null, {
this.second,
this.microsecond / 1000);
},
__add__: function (other) {
if (!py.PY_isInstance(other, datetime.timedelta)) {
return py.NotImplemented;
}
var s = tmxxx(this.year, this.month, this.day + other.days, this.hour, this.minute, this.second + other.seconds);
return datetime.datetime.fromJSON(s.year, s.month, s.day, s.hour, s.minute, s.second);
},
__sub__: function (other) {
if (py.PY_isInstance(other, datetime.timedelta)) {
return py.PY_add(this, py.PY_negative(other));
}
return py.NotImplemented;
},
fromJSON: function (year, month, day, hour, minute, second) {
return py.PY_call(datetime.datetime, [year, month, day, hour, minute, second]);
},
});
datetime.date = py.type('date', null, {
@@ -142,6 +142,21 @@ var AbstractField = Widget.extend({
// this is the last value that was set by the user, unparsed. This is
// used to avoid setting the value twice in a row with the exact value.
this.lastSetValue = undefined;
// formatType is used to determine which format (and parse) functions
// to call to format the field's value to insert into the DOM (typically
// put into a span or an input), and to parse the value from the input
// to send it to the server. These functions are chosen according to
// the 'widget' attrs if is is given, and if it is a valid key, with a
// fallback on the field type, ensuring that the value is formatted and
// displayed according to the choosen widget, if any.
this.formatType = this.attrs.widget in field_utils.format ?
this.attrs.widget :
this.field.type;
// formatOptions (resp. parseOptions) is a dict of options passed to
// calls to the format (resp. parse) function.
this.formatOptions = {};
this.parseOptions = {};
},
/**
* When a field widget is appended to the DOM, its start method is called,
@@ -266,15 +281,15 @@ var AbstractField = Widget.extend({
//--------------------------------------------------------------------------
/**
* convert the value from the field to a string representation
* Converts the value from the field to a string representation.
*
* @private
* @param {any} value (from the field type)
* @returns {string}
*/
_formatValue: function (value) {
var options = _.extend({}, this.nodeOptions, { data: this.recordData });
return field_utils.format[this.field.type](value, this.field, options);
var options = _.extend({}, this.nodeOptions, { data: this.recordData }, this.formatOptions);
return field_utils.format[this.formatType](value, this.field, options);
},
/**
* This method check if a value is the same as the current value of the
@@ -291,15 +306,14 @@ var AbstractField = Widget.extend({
return this.value === value;
},
/**
* convert a string representation to a valid value, depending on the field
* type.
* Converts a string representation to a valid value.
*
* @private
* @param {string} value
* @returns {any}
*/
_parseValue: function (value) {
return field_utils.parse[this.field.type](value, this.field);
return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
},
/**
* main rendering function. Override this if your widget has the same render
@@ -426,22 +440,18 @@ var AbstractField = Widget.extend({
this.trigger_up('navigation_move', {direction: 'cancel'});
break;
case $.ui.keyCode.UP:
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'up'});
break;
case $.ui.keyCode.RIGHT:
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'right'});
break;
case $.ui.keyCode.DOWN:
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'down'});
break;
case $.ui.keyCode.LEFT:
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'left'});
break;
+79 -70
View File
@@ -285,11 +285,15 @@ var InputField = DebouncedField.extend({
*/
_onNavigationMove: function (ev) {
this._super.apply(this, arguments);
var input = this.$input[0];
var selecting = (input.selectionEnd !== input.selectionStart);
if ((ev.data.direction === "left" && (selecting || input.selectionStart !== 0))
|| (ev.data.direction === "right" && (selecting || input.selectionStart !== input.value.length))) {
ev.stopPropagation();
// the following code only makes sense in edit mode, with an input
if (this.mode === 'edit') {
var input = this.$input[0];
var selecting = (input.selectionEnd !== input.selectionStart);
if ((ev.data.direction === "left" && (selecting || input.selectionStart !== 0))
|| (ev.data.direction === "right" && (selecting || input.selectionStart !== input.value.length))) {
ev.stopPropagation();
}
}
},
});
@@ -321,6 +325,14 @@ var FieldDate = InputField.extend({
tagName: "span",
supportedFieldTypes: ['date'],
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
// use the session timezone when formatting dates
this.formatOptions.timezone = true;
},
/**
* In edit mode, instantiates a DateWidget datepicker and listen to changes.
*
@@ -396,17 +408,6 @@ var FieldDate = InputField.extend({
this.datewidget.setValue(this.value);
this.$input = this.datewidget.$input;
},
/**
* FieldDateTime overrides _formatValue to use the session timezone
*
* @override
* @private
* @param {Moment} value
*/
_formatValue: function (value) {
return field_utils.format.date(value, {timezone: true});
},
});
var FieldDateTime = FieldDate.extend({
@@ -448,17 +449,6 @@ var FieldDateTime = FieldDate.extend({
this.datewidget.setValue(value);
this.$input = this.datewidget.$input;
},
/**
* FieldDateTime overrides _formatValue to use the session timezone
*
* @override
* @private
* @param {Moment} value
*/
_formatValue: function (value) {
return field_utils.format.datetime(value, {timezone: true});
},
});
var FieldMonetary = InputField.extend({
@@ -490,6 +480,13 @@ var FieldMonetary = InputField.extend({
this.tagName = 'div';
this.className += ' o_input';
}
// use the formatFloat function in edit
if (this.mode === 'edit') {
this.formatType = 'float';
}
this.formatOptions.currency = this.currency;
this.formatOptions.digits = [16, 2];
},
//--------------------------------------------------------------------------
@@ -562,22 +559,6 @@ var FieldMonetary = InputField.extend({
var currencyID = this.record.data[currencyField] && this.record.data[currencyField].res_id;
this.currency = session.get_currency(currencyID);
},
/**
* FieldMonetary overrides _formatValue to use the format monetary method
* in readonly.
*
* @override
* @private
* @param {float} value
* @returns {string}
*/
_formatValue: function (value) {
var format = field_utils.format[this.mode === 'edit' ? "float" : "monetary"];
return format(value, this.field, {
digits: [16, 2],
currency: this.currency,
});
},
});
var FieldBoolean = AbstractField.extend({
@@ -686,6 +667,11 @@ var FieldBoolean = AbstractField.extend({
this.$input.prop('checked', !this.value);
this._setValue(!this.value);
return;
case $.ui.keyCode.UP:
case $.ui.keyCode.RIGHT:
case $.ui.keyCode.DOWN:
case $.ui.keyCode.LEFT:
ev.preventDefault();
}
this._super.apply(this, arguments);
},
@@ -734,7 +720,7 @@ var FieldInteger = InputField.extend({
}
return value;
}
return field_utils.format.integer(value, this.field);
return this._super.apply(this, arguments);
},
});
@@ -771,31 +757,11 @@ var FieldFloat = InputField.extend({
});
var FieldFloatTime = FieldFloat.extend({
supportedFieldTypes: ['float'],
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Format the float time value into a human-readable time format.
*
* @override
* @private
*/
_formatValue: function (value) {
return field_utils.format.float_time(value);
},
/**
* Parse the human-readable formatted time value into a float.
*
* @override
* @private
*/
_parseValue: function (value) {
return field_utils.parse.float_time(value);
},
// this is not strictly necessary, as for this widget to be used, the 'widget'
// attrs must be set to 'float_time', so the formatType is automatically
// 'float_time', but for the sake of clarity, we explicitely define a
// FieldFloatTime widget with formatType = 'float_time'.
formatType: 'float_time',
});
var FieldText = InputField.extend(TranslatableFieldMixin, {
@@ -852,6 +818,19 @@ var FieldEmail = InputField.extend({
this.tagName = this.mode === 'readonly' ? 'a' : 'input';
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns the associated link.
*
* @override
*/
getFocusableElement: function () {
return this.mode === 'readonly' ? this.$el : this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -887,6 +866,17 @@ var FieldPhone = FieldEmail.extend({
this.tagName = 'span';
}
},
/**
* Returns the associated link only if there is one.
*
* @override
*/
getFocusableElement: function () {
if (this._canCall()) {
return this._super.apply(this, arguments);
}
return $();
},
//--------------------------------------------------------------------------
// Private
@@ -940,6 +930,19 @@ var UrlWidget = InputField.extend({
this.tagName = this.mode === 'readonly' ? 'a' : 'input';
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns the associated link.
*
* @override
*/
getFocusableElement: function () {
return this.mode === 'readonly' ? this.$el : this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -1041,7 +1044,13 @@ var AbstractFieldBinary = AbstractField.extend({
set_filename: function (value) {
var filename = this.attrs.filename;
if (filename && filename in this.fields) {
this.trigger_up('update_field', { name: filename, value: value });
var changes = {};
changes[filename] = value;
this.trigger_up('field_changed', {
dataPointID: this.dataPointID,
changes: changes,
viewType: this.viewType,
});
}
},
on_clear: function () {
@@ -124,7 +124,7 @@ var FieldMany2One = AbstractField.extend({
* @returns {jQuery}
*/
getFocusableElement: function () {
return this.$input || $();
return this.mode === 'edit' && this.$input || this.$el;
},
/**
* TODO
@@ -205,6 +205,17 @@ var FieldMany2One = AbstractField.extend({
_getSearchBlacklist: function () {
return [];
},
/**
* Returns the display_name from a string which contains it but was altered
* as a result of the show_address option using a horrible hack.
*
* @private
* @param {string} value
* @returns {string} display_name without show_address mess
*/
_getDisplayName: function (value) {
return value.split('\n')[0];
},
/**
* @private
* @param {string} name
@@ -244,7 +255,7 @@ var FieldMany2One = AbstractField.extend({
// and hope for the best that noone tries to uses this mechanism to do
// something else.
if (this.nodeOptions.always_reload) {
value = value.split('\n')[0];
value = this._getDisplayName(value);
}
this.$input.val(value);
if (!this.autocomplete_bound) {
@@ -302,7 +313,7 @@ var FieldMany2One = AbstractField.extend({
.then(function (result) {
// possible selections for the m2o
var values = _.map(result, function (x) {
x[1] = x[1].split("\n")[0];
x[1] = self._getDisplayName(x[1]);
return {
label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent,
value: x[1],
@@ -478,7 +489,7 @@ var FieldMany2One = AbstractField.extend({
_onInputKeyup: function () {
if (this.$input.val() === "") {
this.reinitialize(false);
} else if (this.m2o_value !== this.$input.val()) {
} else if (this._getDisplayName(this.m2o_value) !== this.$input.val()) {
this.floating = true;
this._updateExternalButton();
}
@@ -1585,7 +1596,7 @@ var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
var KanbanFieldMany2ManyTags = FieldMany2ManyTags.extend({
_render: function () {
var self = this;
this.$el.addClass('o_field_many2manytags o_kanban_tags');
this.$el.empty().addClass('o_field_many2manytags o_kanban_tags');
_.each(this.value.data, function (m2m) {
// 10th color is invisible
if ('color' in m2m.data && m2m.data.color !== 10) {
@@ -1763,6 +1774,7 @@ var FieldSelection = AbstractField.extend({
this._super.apply(this, arguments);
if (this.field.type === 'many2one') {
this.values = this.record.specialData[this.name];
this.formatType = 'many2one';
} else {
this.values = _.reject(this.field.selection, function (v) {
return v[0] === false && v[1] === '';
@@ -6,6 +6,7 @@ odoo.define('web.upgrade_widgets', function (require) {
* When checked, an upgrade popup is showed to the user.
*/
var AbstractField = require('web.AbstractField');
var basic_fields = require('web.basic_fields');
var core = require('web.core');
var Dialog = require('web.Dialog');
@@ -58,6 +59,7 @@ var AbstractFieldUpgrade = {
* Opens the Upgrade dialog.
*
* @private
* @returns {Dialog} the instance of the opened Dialog
*/
_openDialog: function () {
var message = $(QWeb.render('EnterpriseUpgrade'));
@@ -75,7 +77,7 @@ var AbstractFieldUpgrade = {
},
];
new Dialog(this, {
return new Dialog(this, {
size: 'medium',
buttons: buttons,
$content: $('<div>', {
@@ -122,7 +124,7 @@ var AbstractFieldUpgrade = {
var UpgradeBoolean = FieldBoolean.extend(AbstractFieldUpgrade, {
supportedFieldTypes: [],
events: _.extend({}, FieldBoolean.prototype.events, {
events: _.extend({}, AbstractField.prototype.events, {
'click input': '_onInputClicked',
}),
@@ -423,7 +423,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
});
if (!options.stayInEdit) {
saveDef = saveDef.then(function (fieldNames) {
var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly');
var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly', recordID);
return def.then(function () {
return fieldNames;
});
@@ -534,6 +534,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
* @param {function} ev.data.action the function to execute in the mutex
*/
_onMutexify: function (ev) {
ev.stopPropagation(); // prevent other controllers from handling this request
this.mutex.exec(ev.data.action);
},
/**
@@ -578,6 +579,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
* @param {OdooEvent} event
*/
_onTranslate: function (event) {
event.stopPropagation();
var record = this.model.get(event.data.id, {raw: true});
this._rpc({
route: '/web/dataset/call_button',
@@ -1399,7 +1399,8 @@ var BasicModel = AbstractModel.extend({
* @param {string} [optinos.viewType] the type of view for which the record
* is fetched (usefull to load the adequate fields), by defaults, uses
* record.viewType
* @returns {Deferred -> Object} resolves to the record
* @returns {Deferred -> Object} resolves to the record or is rejected in
* case no id given were valid ids
*/
_fetchRecord: function (record, options) {
var self = this;
@@ -1412,6 +1413,9 @@ var BasicModel = AbstractModel.extend({
context: _.extend({}, record.context, {bin_size: true}),
})
.then(function (result) {
if (result.length === 0) {
return $.Deferred().reject();
}
result = result[0];
record.data = _.extend({}, record.data, result);
})
@@ -2194,12 +2198,6 @@ var BasicModel = AbstractModel.extend({
if (!field || field.name === 'id') {
continue;
}
if (field.type === 'float' ||
field.type === 'integer' ||
field.type === 'monetary') {
context[fieldName] = context[fieldName] || 0;
continue;
}
if (field.type === 'date' || field.type === 'datetime') {
if (context[fieldName]) {
context[fieldName] = JSON.parse(JSON.stringify(context[fieldName]));
@@ -2445,7 +2443,11 @@ var BasicModel = AbstractModel.extend({
var fieldName = fieldNames[i];
if (!(fieldName in result)) {
var field = params.fields[fieldName];
if (field.type === 'one2many' || field.type === 'many2many') {
if (field.type === 'float' ||
field.type === 'integer' ||
field.type === 'monetary') {
result[fieldName] = 0;
} else if (field.type === 'one2many' || field.type === 'many2many') {
result[fieldName] = [];
} else {
result[fieldName] = null;
@@ -828,6 +828,7 @@ var FormRenderer = BasicRenderer.extend({
*/
_onNavigationMove: function (ev) {
ev.stopPropagation();
var index;
if (ev.data.direction === "next") {
index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
@@ -40,8 +40,6 @@ var KanbanColumn = Widget.extend({
var value = data.value;
this.id = data.res_id || value;
// todo: handle group_by_m2o (nameget)
this.title = value === undefined ? _t('Undefined') : value;
this.folded = !data.isOpen;
this.has_active_field = 'active' in data.fields;
this.size = data.count;
@@ -62,6 +60,14 @@ var KanbanColumn = Widget.extend({
this.record_options = _.clone(recordOptions);
if (options.grouped_by_m2o) {
// For many2one, a false value means that the field is not set.
this.title = value ? value : _t('Undefined');
} else {
// False and 0 might be valid values for these fields.
this.title = value === undefined ? _t('Undefined') : value;
}
if (options.group_by_tooltip) {
this.tooltipInfo = _.map(options.group_by_tooltip, function (help, field) {
return (data.tooltipData && data.tooltipData[field] && "<div>" + help + "<br>" + data.tooltipData[field] + "</div>") || '';
@@ -153,7 +153,7 @@ var KanbanModel = BasicModel.extend({
}).then(function () {
// Remove record from its current group
var old_group;
for (var i = 0; i < parent.count; i++) {
for (var i = 0; i < parent.data.length; i++) {
old_group = self.localData[parent.data[i]];
var index = _.indexOf(old_group.data, recordID);
if (index >= 0) {
@@ -244,9 +244,14 @@ var KanbanModel = BasicModel.extend({
if (groupedByField.type !== 'many2one') {
return $.when();
}
var groupIds = _.map(list.data, function (id) {
return self.get(id, {raw: true}).res_id;
});
var groupIds = _.reduce(list.data, function (groupIds, id) {
var res_id = self.get(id, {raw: true}).res_id;
// The field on which we are grouping might not be set on all records
if (res_id) {
groupIds.push(res_id);
}
return groupIds;
}, []);
var tooltipFields = [];
var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]];
if (groupedByFieldInfo && groupedByFieldInfo.options) {
@@ -271,10 +271,14 @@ var ListRenderer = BasicRenderer.extend({
_renderButton: function (record, node) {
var $button = $('<button>', {
type: 'button',
class: 'o_icon_button',
title: node.attrs.string,
});
$button.append($('<i>', {class: 'fa ' + node.attrs.icon}));
if (node.attrs.icon) {
$button.addClass('o_icon_button');
$button.append($('<i>', {class: 'fa ' + node.attrs.icon}));
} else {
$button.text(node.attrs.string);
}
this._registerModifiers(node, record, $button);
+8 -1
View File
@@ -28,6 +28,13 @@
}
}
// Need to put datetimepicker widget above everything else
// The '.dropdown-menu' part is needed to override default bootstrap rule as
// this file is unfortunately placed in common assets.
.bootstrap-datetimepicker-widget.dropdown-menu {
z-index: @zindex-modal + 1;
}
.datepicker {
.table-condensed {
> thead {
@@ -66,5 +73,5 @@
}
}
}
}
}
}
+11 -7
View File
@@ -961,7 +961,8 @@
<span t-if="attrs.help">?</span>
</label>
<div t-att-style="style">
<input type="text" size="15" t-att-name="attrs.name"
<input type="text" size="15" class="o_input"
t-att-name="attrs.name"
t-att-autofocus="attrs.default_focus === '1' ? 'autofocus' : undefined"
t-att-id="element_id"
t-att-value="defaults[attrs.name] || ''"/>
@@ -990,7 +991,9 @@
<span t-if="attrs.help">?</span>
</label>
<div t-att-style="style">
<select t-att-name="attrs.name" t-att-id="element_id"
<select class="o_input"
t-att-name="attrs.name"
t-att-id="element_id"
t-att-autofocus="attrs.default_focus === '1' || undefined">
<option t-if="prepend_empty"/>
<t t-foreach="attrs.selection" t-as="option">
@@ -1012,7 +1015,7 @@
<li class="o_filter_condition">
<span class="o_or_filter">or</span>
<span>
<select class="o_searchview_extended_prop_field">
<select class="o_input o_searchview_extended_prop_field">
<t t-foreach="widget.attrs.fields" t-as="field">
<option t-att="{'selected': field === widget.attrs.selected ? 'selected' : null}"
t-att-value="field.name">
@@ -1022,18 +1025,19 @@
</select>
<span class="o_searchview_extended_delete_prop fa fa-trash-o"/>
</span>
<select class="o_searchview_extended_prop_op"/>
<select class="o_input o_searchview_extended_prop_op"/>
<span class="o_searchview_extended_prop_value"/>
</li>
</t>
<t t-name="SearchView.extended_search.proposition.float">
<input t-att-type="widget.decimal_point === '.' ? 'number' : 'text'"
<input class="o_input"
t-att-type="widget.decimal_point === '.' ? 'number' : 'text'"
t-attf-title="Number using #{widget.decimal_point || '.' } as decimal separator."
t-attf-pattern="[0-9]+([\\#{widget.decimal_point || '.' }][0-9]+)?"
t-attf-value="0#{widget.decimal_point || '.' }0" step="0.01"/>
</t>
<t t-name="SearchView.extended_search.proposition.selection">
<select>
<select class="o_input">
<option t-foreach="widget.field.selection" t-as="element" t-att-value="element[0]">
<t t-esc="element[1]"/>
</option>
@@ -1051,7 +1055,7 @@
</ul>
</div>
<t t-name="GroupByMenuSelector">
<li><select class="o_add_group o_group_selector">
<li><select class="o_input o_add_group o_group_selector">
<t t-foreach="groupableFields" t-as="field">
<option t-att-data-name="field.name"><t t-esc="field.string"/></option>
</t>
@@ -343,6 +343,27 @@ QUnit.module('core', function () {
'2015-02-02');
});
QUnit.test('timedelta', function (assert) {
assert.expect(4);
assert.strictEqual(
py.eval("(datetime.datetime(2017, 2, 15, 1, 7, 31) + datetime.timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')",
pyEval.context()),
"2017-02-16 01:07:31");
assert.strictEqual(
py.eval("(datetime.datetime(2012, 2, 15, 1, 7, 31) - datetime.timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')",
pyEval.context()),
"2012-02-15 00:07:31");
assert.strictEqual(
py.eval("(datetime.datetime(2012, 2, 15, 1, 7, 31) + datetime.timedelta(hours=-1)).strftime('%Y-%m-%d %H:%M:%S')",
pyEval.context()),
"2012-02-15 00:07:31");
assert.strictEqual(
py.eval("(datetime.datetime(2012, 2, 15, 1, 7, 31) + datetime.timedelta(minutes=100)).strftime('%Y-%m-%d %H:%M:%S')",
pyEval.context()),
"2012-02-15 02:47:31");
});
QUnit.test('datetime.tojson', function (assert) {
assert.expect(7);
@@ -394,7 +394,7 @@ QUnit.module('basic_fields', {
QUnit.module('FieldFloat');
QUnit.test('float field when unset', function (assert) {
assert.expect(1);
assert.expect(2);
var form = createView({
View: FormView,
@@ -408,8 +408,10 @@ QUnit.module('basic_fields', {
res_id: 4,
});
assert.ok(form.$('.o_field_widget').hasClass('o_field_empty'),
'Non-set float field should be recognized as unset.');
assert.notOk(form.$('.o_field_widget').hasClass('o_field_empty'),
'Non-set float field should be considered as 0.');
assert.strictEqual(form.$('.o_field_widget').text(), "0.000",
'Non-set float field should be considered as 0.');
form.destroy();
});
@@ -536,6 +538,35 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.test('float widget on monetary field', function (assert) {
assert.expect(1);
this.data.partner.fields.monetary = {string: "Monetary", type: 'monetary'};
this.data.partner.records[0].monetary = 9.99;
this.data.partner.records[0].currency_id = 1;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<field name="monetary" widget="float"/>' +
'<field name="currency_id" invisible="1"/>' +
'</sheet>' +
'</form>',
res_id: 1,
session: {
currencies: _.indexBy(this.data.currency.records, 'id'),
},
});
assert.strictEqual(form.$('.o_field_widget[name=monetary]').text(), '9.99',
'value should be correctly formatted (with the float formatter)');
form.destroy();
});
QUnit.module('FieldEmail');
QUnit.test('email field in form view', function (assert) {
@@ -1015,7 +1046,7 @@ QUnit.module('basic_fields', {
QUnit.module('FieldBinary');
QUnit.test('binary fields are correctly rendered', function (assert) {
assert.expect(9);
assert.expect(16);
// save the session function
var oldGetFile = session.get_file;
@@ -1028,18 +1059,24 @@ QUnit.module('basic_fields', {
return $.when();
};
this.data.partner.records[0].foo = 'coucou.txt';
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="document"/>' +
'<field name="document" filename="foo"/>' +
'<field name="foo"/>' +
'</form>',
res_id: 1,
});
assert.strictEqual(form.$('a.o_field_widget[name="document"] > .fa-download').length, 1,
"the binary field should be rendered as a downloadable link in readonly");
assert.strictEqual(form.$('a.o_field_widget[name="document"]').text().trim(), 'coucou.txt',
"the binary field should display the name of the file in the link");
assert.strictEqual(form.$('.o_field_char').text(), 'coucou.txt',
"the filename field should have the file name as value");
form.$('a.o_field_widget[name="document"]').click();
@@ -1047,12 +1084,15 @@ QUnit.module('basic_fields', {
assert.strictEqual(form.$('a.o_field_widget[name="document"] > .fa-download').length, 0,
"the binary field should not be rendered as a downloadable link in edit");
assert.strictEqual(form.$('div.o_field_binary_file[name="document"]').length, 1,
"the binary field should be correctly rendered in edit");
assert.strictEqual(form.$('div.o_field_binary_file[name="document"] > input').val(), 'coucou.txt',
"the binary field should display the file name in the input edit mode");
assert.strictEqual(form.$('.o_field_binary_file > input').attr('readonly'), 'readonly',
"the input should be readonly");
assert.strictEqual(form.$('.o_field_binary_file > .o_clear_file_button').length, 1,
"there shoud be a button to clear the file");
assert.strictEqual(form.$('input.o_field_char').val(), 'coucou.txt',
"the filename field should have the file name as value");
form.$('.o_field_binary_file > .o_clear_file_button').click();
@@ -1060,6 +1100,16 @@ QUnit.module('basic_fields', {
"the input should be hidden");
assert.strictEqual(form.$('.o_field_binary_file > .o_select_file_button:not(.o_hidden)').length, 1,
"there shoud be a button to upload the file");
assert.strictEqual(form.$('input.o_field_char').val(), '',
"the filename field should be empty since we removed the file");
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('a.o_field_widget[name="document"] > .fa-download').length, 0,
"the binary field should not render as a downloadable link since we removed the file");
assert.strictEqual(form.$('a.o_field_widget[name="document"]').text().trim(), '',
"the binary field should not display a filename in the link since we removed the file");
assert.strictEqual(form.$('.o_field_char').text().trim(), '',
"the filename field should be empty since we removed the file");
form.destroy();
@@ -1926,7 +1976,7 @@ QUnit.module('basic_fields', {
QUnit.module('FieldInteger');
QUnit.test('integer field when unset', function (assert) {
assert.expect(1);
assert.expect(2);
var form = createView({
View: FormView,
@@ -1936,8 +1986,10 @@ QUnit.module('basic_fields', {
res_id: 4,
});
assert.ok(form.$('.o_field_widget').hasClass('o_field_empty'),
'Non-set integer field should be recognized as unset.');
assert.notOk(form.$('.o_field_widget').hasClass('o_field_empty'),
'Non-set integer field should be recognized as 0.');
assert.strictEqual(form.$('.o_field_widget').text(), "0",
'Non-set integer field should be recognized as 0.');
form.destroy();
});
@@ -199,6 +199,115 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('many2ones in form views with show_adress', function (assert) {
assert.expect(4);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field ' +
'name="trululu" ' +
'string="custom label" ' +
'context="{\'search_default_customer\':1, \'show_address\': 1}" ' +
'options="{\'always_reload\': True}"' +
'/>' +
'</group>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'name_get') {
return this._super(route, args).then(function (result) {
result[0][1] += '\nStreet\nCity ZIP';
return result;
});
}
return this._super(route, args);
},
res_id: 1,
});
assert.strictEqual($('a.o_form_uri').html(), 'aaa<br>Street<br>City ZIP',
"input should have a multi-line content in readonly due to show_address");
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('button.o_external_button:visible').length, 1,
"should have an open record button");
form.$('input.o_input').click();
form.$('input.o_input').trigger($.Event('keyup', {
which: $.ui.keyCode.ESC,
keyCode: $.ui.keyCode.ESC,
}));
assert.strictEqual(form.$('button.o_external_button:visible').length, 1,
"should still have an open record button");
form.$('input.o_input').trigger('focusout');
assert.strictEqual($('.modal button:contains(Create and edit)').length, 0,
"there should not be a quick create modal");
form.destroy();
});
QUnit.test('many2ones in form views with search more', function (assert) {
assert.expect(3);
this.data.partner.records.push({
id: 5,
display_name: "Partner 4",
}, {
id: 6,
display_name: "Partner 5",
}, {
id: 7,
display_name: "Partner 6",
}, {
id: 8,
display_name: "Partner 7",
}, {
id: 9,
display_name: "Partner 8",
}, {
id: 10,
display_name: "Partner 9",
});
this.data.partner.fields.datetime.searchable = true;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="trululu"/>' +
'</group>' +
'</sheet>' +
'</form>',
archs: {
'partner,false,list': '<tree><field name="display_name"/></tree>',
'partner,false,search': '<search><field name="datetime"/></search>',
},
res_id: 1,
});
form.$buttons.find('.o_form_button_edit').click();
var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
form.$('.o_field_many2one input').click();
$dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click(); // Open Search More
assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records");
$('.o_searchview_more').click(); // Magnifying class for more filters
$('button:contains(Filters)').click();
$('.o_add_filter').click(); // Add a custom filter, datetime field is selected
assert.strictEqual($('li.o_filter_condition select.o_searchview_extended_prop_field').val(), 'datetime',
"datetime field should be selected");
$('.o_apply_filter').click();
assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records");
form.destroy();
});
QUnit.test('onchanges on many2ones trigger when editing record in form view', function (assert) {
assert.expect(9);
@@ -2927,8 +3036,8 @@ QUnit.module('relational_fields', {
assert.strictEqual(form.$('.o_data_row').length, 3,
"sould have 3 records in one2many list");
assert.strictEqual(form.$('.o_data_row').text(), "blip1.59yop1.5tototo1.550xphone",
"sould display the record values in one2many list");
assert.strictEqual(form.$('.o_data_row').text(), "blip1.59yop1.50tototo1.550xphone",
"should display the record values in one2many list");
$('.o_form_button_save').click();
@@ -5153,7 +5262,7 @@ QUnit.module('relational_fields', {
});
QUnit.test('widget selection, edition and on many2one field', function (assert) {
assert.expect(15);
assert.expect(18);
this.data.partner.onchanges = {product_id: function () {}};
this.data.partner.records[0].product_id = 37;
@@ -5178,6 +5287,13 @@ QUnit.module('relational_fields', {
});
assert.ok(!form.$('select').length, "should not have a select tag in dom");
assert.strictEqual(form.$('.o_field_widget[name=product_id]').text(), 'xphone',
"should have rendered the many2one field correctly");
assert.strictEqual(form.$('.o_field_widget[name=trululu]').text(), '',
"should have rendered the unset many2one field correctly");
assert.strictEqual(form.$('.o_field_widget[name=color]').text(), 'Red',
"should have rendered the selection field correctly");
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('select').length, 3,
@@ -5811,6 +5927,52 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('name_create in form dialog', function (assert) {
var done = assert.async();
assert.expect(2);
var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<group>' +
'<field name="p">' +
'<tree>' +
'<field name="bar"/>' +
'</tree>' +
'<form>' +
'<field name="product_id"/>' +
'</form>' +
'</field>' +
'</group>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'name_create') {
assert.step('name_create');
}
return this._super.apply(this, arguments);
},
});
form.$buttons.find('.o_form_button_edit').click();
form.$('.o_field_x2many_list_row_add a').click();
var $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
$('.modal input').val('new record').trigger('keydown');
concurrency.delay(0).then(function () {
$dropdown.find('li:first()').click(); // quick create 'new record'
assert.verifySteps(['name_create']);
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
form.destroy();
done();
});
});
});
});
});
@@ -0,0 +1,44 @@
odoo.define('web.upgrade_fields_tests', function (require) {
"use strict";
var FormView = require('web.FormView');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
QUnit.module('fields', {}, function () {
QUnit.module('upgrade_fields', {
beforeEach: function () {
this.data = {
partner: {
fields: {
bar: {string: "Bar", type: "boolean"},
},
}
};
},
}, function () {
QUnit.module('UpgradeBoolean');
QUnit.test('widget upgrade_boolean in a form view', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form><field name="bar" widget="upgrade_boolean"/></form>',
});
form.$('input:checkbox').click();
assert.strictEqual($('.modal').length, 1,
"the 'Upgrade to Enterprise' dialog should be opened");
form.destroy();
});
});
});
});
+13 -5
View File
@@ -537,11 +537,14 @@ var MockServer = Class.extend({
ids = [ids];
}
var fields = args[1] && args[1].length ? _.uniq(args[1].concat(['id'])) : Object.keys(this.data[model].fields);
var results = _.map(ids, function (id) {
var record = _.findWhere(self.data[model].records, {id: id});
if (!record) {
throw "mock read: id does not exist...";
var records = _.reduce(ids, function (records, id) {
if (!id) {
throw "mock read: falsy value given as id, would result in an access error in actual server !";
}
var record = _.findWhere(self.data[model].records, {id: id});
return record ? records.concat(record) : records;
}, []);
var results = _.map(records, function (record) {
var result = {};
for (var i = 0; i < fields.length; i++) {
var field = self.data[model].fields[fields[i]];
@@ -549,7 +552,12 @@ var MockServer = Class.extend({
// the field doens't exist on the model, so skip it
continue;
}
if (field.type === 'many2one') {
if (field.type === 'float' ||
field.type === 'integer' ||
field.type === 'monetary') {
// read should return 0 for unset numeric fields
result[fields[i]] = record[fields[i]] || 0;
} else if (field.type === 'many2one') {
var relatedRecord = _.findWhere(self.data[field.relation].records, {
id: record[fields[i]]
});
@@ -92,6 +92,23 @@ QUnit.module('Views', {
model.destroy();
});
QUnit.test('rejects loading a record with invalid id', function (assert) {
assert.expect(1);
this.params.res_id = 99;
var model = createModel({
Model: BasicModel,
data: this.data,
});
model.load(this.params).always(function (error) {
assert.strictEqual(this.state(), 'rejected',
"load should return a rejected deferred for an invalid id")
});
model.destroy();
});
QUnit.test('notify change with many2one', function (assert) {
assert.expect(2);
@@ -1347,8 +1364,8 @@ QUnit.module('Views', {
delete this.params.res_id;
model.load(this.params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.bar, false,
"should be initialized with correct value");
assert.strictEqual(record.data.bar, 0,
"should be initialized with correct value (0 as integer)");
model.notifyChanges(resultID, {foo: "anotherverylongstring"});
+130 -14
View File
@@ -625,10 +625,10 @@ QUnit.module('Views', {
res_id: 2,
});
assert.strictEqual(form.$('.o_field_widget.o_field_empty').length, 2,
"should have 2 empty fields with correct class");
assert.strictEqual(form.$('.o_form_label_empty').length, 2,
"should have 2 muted labels (for the empty fieds) in readonly");
assert.strictEqual(form.$('.o_field_widget.o_field_empty').length, 1,
"should have 1 empty field with correct class");
assert.strictEqual(form.$('.o_form_label_empty').length, 1,
"should have 1 muted label (for the empty fied) in readonly");
form.$buttons.find('.o_form_button_edit').click();
@@ -771,7 +771,7 @@ QUnit.module('Views', {
});
QUnit.test('required float fields works as expected', function (assert) {
assert.expect(8);
assert.expect(10);
this.data.partner.fields.qux.required = true;
var form = createView({
@@ -793,24 +793,26 @@ QUnit.module('Views', {
assert.ok(form.$('input[name="qux"]').hasClass('o_required_modifier'),
"qux input is flagged as required");
assert.strictEqual(form.$('input[name="qux"]').val(), "",
"qux input is empty");
assert.strictEqual(form.$('input[name="qux"]').val(), "0.0",
"qux input is 0 by default (float field)");
form.$buttons.find('.o_form_button_save').click();
assert.ok(form.$('input[name="qux"]').hasClass('o_field_invalid'),
"qux input is displayed as invalid");
assert.notOk(form.$('input[name="qux"]').hasClass('o_field_invalid'),
"qux input is not displayed as invalid");
form.$('input[name="qux"]').val("0").trigger('input');
form.$buttons.find('.o_form_button_edit').click();
form.$('input[name="qux"]').val("1").trigger('input');
form.$buttons.find('.o_form_button_save').click();
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('input[name="qux"]').val(), "0.0",
assert.strictEqual(form.$('input[name="qux"]').val(), "1.0",
"qux input is properly formatted");
assert.verifySteps(['default_get', 'create', 'read']);
assert.verifySteps(['default_get', 'create', 'read', 'write', 'read']);
form.destroy();
});
@@ -3278,7 +3280,7 @@ QUnit.module('Views', {
});
QUnit.test('navigation with tab key in form view', function (assert) {
assert.expect(2);
assert.expect(3);
var form = createView({
View: FormView,
@@ -3287,8 +3289,9 @@ QUnit.module('Views', {
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="foo"/>' +
'<field name="foo" widget="email"/>' +
'<field name="bar"/>' +
'<field name="display_name" widget="url"/>' +
'</group>' +
'</sheet>' +
'</form>',
@@ -3300,18 +3303,73 @@ QUnit.module('Views', {
// focus first input, trigger tab
form.$('input[name="foo"]').focus();
form.$('input[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.ok($.contains(form.$('div[name="bar"]')[0], document.activeElement),
"bar checkbox should be focused");
form.$('div[name="bar"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
"display_name should be focused");
// simulate shift+tab on active element
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
"first input should be focused");
form.destroy();
});
QUnit.test('navigation with tab key in readonly form view', function (assert) {
// The behavior of the phone widget is completely altered by crm_voip so
// this test fails if crm_voip is installed. The enterprise module is
// responsible for testing its own behavior in its own tests.
if ('crm.voip' in odoo.__DEBUG__.services) {
assert.expect(0);
return;
}
assert.expect(3);
this.data.partner.records[1].product_id = 37;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="trululu"/>' +
'<field name="foo"/>' +
'<field name="product_id"/>' +
'<field name="foo" widget="phone"/>' +
'<field name="display_name" widget="url"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 2,
});
// focus first field, trigger tab
form.$('[name="trululu"]').focus();
form.$('[name="trululu"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('[name="product_id"]')[0], document.activeElement,
"product_id should be focused");
form.$('[name="product_id"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('[name="display_name"]')[0], document.activeElement,
"display_name should be focused (emails are focusable but phone aren't)");
// simulate shift+tab on active element
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
assert.strictEqual(document.activeElement, form.$('[name="trululu"]')[0],
"first many2one should be focused");
form.destroy();
});
QUnit.test('skip invisible fields when navigating with TAB', function (assert) {
assert.expect(1);
@@ -4640,6 +4698,64 @@ QUnit.module('Views', {
_t.database.multi_lang = multi_lang;
});
QUnit.test('translate event correctly handled with multiple controllers', function (assert) {
assert.expect(3);
this.data.product.fields.name.translate = true;
this.data.partner.records[0].product_id = 37;
var nbTranslateCalls = 0;
var multi_lang = _t.database.multi_lang;
_t.database.multi_lang = true;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="product_id"/>' +
'</group>' +
'</sheet>' +
'</form>',
archs: {
'product,false,form': '<form>' +
'<sheet>' +
'<group>' +
'<field name="name"/>' +
'<field name="partner_type_id"/>' +
'</group>' +
'</sheet>' +
'</form>',
},
res_id: 1,
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/product/get_formview_id') {
return $.when(false);
} else if (route === "/web/dataset/call_button" && args.method === 'translate_fields') {
assert.deepEqual(args.args, ["product",37,"name",{}], 'should call "call_button" route');
nbTranslateCalls++;
return $.when();
}
return this._super.apply(this, arguments);
},
});
form.$buttons.find('.o_form_button_edit').click();
form.$('[name="product_id"] .o_external_button').click();
assert.strictEqual($('.modal-body .o_field_translate').length, 1,
"there should be a translate button in the modal");
$('.modal-body .o_field_translate').click();
assert.strictEqual(nbTranslateCalls, 1, "should call_button translate once");
form.destroy();
_t.database.multi_lang = multi_lang;
});
QUnit.test('buttons are disabled until action is resolved', function (assert) {
assert.expect(3);
+92 -6
View File
@@ -445,7 +445,7 @@ QUnit.module('Views', {
});
QUnit.test('many2many_tags are correctly fetched and displayed', function (assert) {
assert.expect(5);
assert.expect(10);
this.data.partner.records[0].category_ids = [6, 7];
this.data.partner.records[1].category_ids = [7];
@@ -459,6 +459,7 @@ QUnit.module('Views', {
'<div>' +
'<field name="category_ids"/>' +
'<field name="foo"/>' +
'<field name="state" widget="priority"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
@@ -474,7 +475,20 @@ QUnit.module('Views', {
assert.ok($first_record.find('.o_tag:first()').hasClass('o_tag_color_2'),
'first tag should have color 2');
assert.verifySteps(['/web/dataset/search_read', '/web/dataset/call_kw/category/read'],
'two RPC should have been done(one search read and one read for the m2m');
'two RPC should have been done (one search read and one read for the m2m)');
// Write on the record using the priority widget to trigger a re-render in readonly
kanban.$('.o_field_widget.o_priority a.o_priority_star.fa-star-o').first().click();
assert.verifySteps([
'/web/dataset/search_read',
'/web/dataset/call_kw/category/read',
'/web/dataset/call_kw/partner/write',
'/web/dataset/call_kw/partner/read',
'/web/dataset/call_kw/category/read'
], 'five RPCs should have been done (previous 2, 1 write (triggers a re-render), same 2 at re-render');
assert.strictEqual(kanban.$('.o_kanban_record:first()').find('.o_field_many2manytags .o_tag').length, 2,
'first record should still contain only 2 tags');
kanban.destroy();
});
@@ -1505,7 +1519,8 @@ QUnit.module('Views', {
});
QUnit.test('group_by_tooltip option when grouping on a many2one', function (assert) {
assert.expect(5);
assert.expect(12);
delete this.data.partner.records[3].product_id;
var kanban = createView({
View: KanbanView,
model: 'partner',
@@ -1528,18 +1543,89 @@ QUnit.module('Views', {
},
});
assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped'),
"should have classname 'o_kanban_grouped'");
assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have " + 2 + " columns");
// simulate an update coming from the searchview, with another groupby given
kanban.update({groupBy: ['product_id']});
assert.ok(kanban.$('.o_kanban_group:first span.o_column_title:contains(hello)').length,
"first column should have a title with a value from the many2one");
assert.strictEqual(kanban.$('.o_kanban_group').length, 3, "should have " + 3 + " columns");
assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 1,
"column should contain 1 record(s)");
assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2,
"column should contain 2 record(s)");
assert.strictEqual(kanban.$('.o_kanban_group:nth-child(3) .o_kanban_record').length, 1,
"column should contain 1 record(s)");
assert.ok(kanban.$('.o_kanban_group:first span.o_column_title:contains(Undefined)').length,
"first column should have a default title for when no value is provided");
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_header').data('original-title'),
"<p>1 records</p>",
"first column should have a tooltip with the number of records, but not" +
"the group_by_tooltip title and the many2one field value since it has no value");
assert.ok(kanban.$('.o_kanban_group:eq(1) span.o_column_title:contains(hello)').length,
"second column should have a title with a value from the many2one");
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_header').data('original-title'),
"<p>2 records</p><div>Kikou<br>hello</div>",
"first column should have a tooltip with the number of records, the group_by_tooltip title and many2one field value");
"second column should have a tooltip with the number of records, the group_by_tooltip title and many2one field value");
kanban.destroy();
});
QUnit.test('move a record then put it again in the same column', function (assert) {
assert.expect(6);
this.data.partner.records = [];
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban>' +
'<field name="product_id"/>' +
'<templates><t t-name="kanban-box">' +
'<div><field name="display_name"/></div>' +
'</t></templates></kanban>',
groupBy: ['product_id'],
});
kanban.$('.o_column_quick_create').click();
kanban.$('.o_column_quick_create input').val('column1');
kanban.$('.o_column_quick_create button.o_kanban_add').click();
kanban.$('.o_column_quick_create').click();
kanban.$('.o_column_quick_create input').val('column2');
kanban.$('.o_column_quick_create button.o_kanban_add').click();
kanban.$('.o_kanban_group:eq(1) .o_kanban_quick_add i').click();
var $quickCreate = kanban.$('.o_kanban_group:eq(1) .o_kanban_quick_create');
$quickCreate.find('input').val('new partner');
$quickCreate.find('button.o_kanban_add').click();
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 0,
"column should contain 0 record");
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 1,
"column should contain 1 records");
var $record = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(0)');
var $group = kanban.$('.o_kanban_group:eq(0)');
testUtils.dragAndDrop($record, $group);
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 1,
"column should contain 1 records");
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 0,
"column should contain 0 records");
$record = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)');
$group = kanban.$('.o_kanban_group:eq(1)');
testUtils.dragAndDrop($record, $group);
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 0,
"column should contain 0 records");
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 1,
"column should contain 1 records");
kanban.destroy();
});
});
});
+28 -1
View File
@@ -136,7 +136,7 @@ QUnit.module('Views', {
});
QUnit.test('simple editable rendering', function (assert) {
assert.expect(9);
assert.expect(12);
var list = createView({
View: ListView,
@@ -164,6 +164,15 @@ QUnit.module('Views', {
"should have a visible save button");
assert.ok(list.$buttons.find('.o_list_button_discard').is(':visible'),
"should have a visible discard button");
list.$buttons.find('.o_list_button_save').click();
assert.ok(list.$buttons.find('.o_list_button_add').is(':visible'),
"should have a visible Create button");
assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'),
"should not have a visible save button");
assert.ok(!list.$buttons.find('.o_list_button_discard').is(':visible'),
"should not have a visible discard button");
list.destroy();
});
@@ -1046,6 +1055,24 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('list view with a button without icon', function (assert) {
assert.expect(1);
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree string="Phonecalls" editable="top">' +
'<field name="foo"/>' +
'<button string="abc" type="object" name="schedule_another_phonecall"/>' +
'</tree>',
});
assert.strictEqual(list.$('table button').first().text(), 'abc',
"should have rendered a button with string attribute as label");
list.destroy();
});
QUnit.test('list view, editable, can discard', function (assert) {
assert.expect(5);
+1
View File
@@ -438,6 +438,7 @@
<script type="text/javascript" src="/web/static/tests/fields/field_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/relational_fields_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/special_fields.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/upgrade_fields_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/form_tests.js"></script>
+9 -2
View File
@@ -6,7 +6,7 @@ import logging
from lxml import etree, html
from odoo.exceptions import AccessError
from odoo import api, models
from odoo import api, fields, models
from odoo.tools import pycompat
_logger = logging.getLogger(__name__)
@@ -34,6 +34,10 @@ class IrUiView(models.Model):
def extract_embedded_fields(self, arch):
return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
@api.model
def get_default_lang_code(self):
return False
@api.model
def save_embedded_field(self, el):
Model = self.env[el.get('data-oe-model')]
@@ -45,7 +49,10 @@ class IrUiView(models.Model):
if value is not None:
# TODO: batch writes?
Model.browse(int(el.get('data-oe-id'))).write({field: value})
if self.get_default_lang_code() and field != 'arch' and not isinstance(Model._fields[field], fields.Html):
Model.browse(int(el.get('data-oe-id'))).with_context(lang=self.get_default_lang_code()).write({field: value})
else:
Model.browse(int(el.get('data-oe-id'))).write({field: value})
def _pretty_arch(self, arch):
# remove_blank_string does not seem to work on HTMLParser, and
+6 -2
View File
@@ -192,6 +192,7 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({
var FieldTextHtml = AbstractField.extend({
template: 'web_editor.FieldTextHtml',
supportedFieldTypes: ['html'],
start: function () {
var self = this;
@@ -206,7 +207,7 @@ var FieldTextHtml = AbstractField.extend({
};
window.odoo[this.callback+"_updown"] = null;
window.odoo[this.callback+"_downup"] = function (value) {
self.set_value(value);
self._setValue(value);
self.resize();
};
@@ -236,10 +237,13 @@ var FieldTextHtml = AbstractField.extend({
// this.$translate = $();
return def;
},
getDatarecord: function () {
return this.recordData;
},
get_url: function (_attr) {
var src = this.nodeOptions.editor_url || "/mass_mailing/field/email_template";
var k;
var datarecord = this.recordData;
var datarecord = this.getDatarecord();
var attr = {
'model': this.model,
'field': this.name,
+10
View File
@@ -145,3 +145,13 @@ class View(models.Model):
menu_data=self.env['ir.ui.menu'].load_menus_root() if request.website.is_user() else None,
)
return qcontext
@api.model
def get_default_lang_code(self):
website_id = self.env.context.get('website_id')
if website_id:
lang_code = self.env['website'].browse(website_id).default_lang_code
return lang_code
else:
return super(View, self).get_default_lang_code()
@@ -7,6 +7,12 @@ class TestUi(odoo.tests.HttpCase):
at_install = False
def test_admin(self):
# Seen that:
# - this test relies on demo data that are entirely in USD (pricelists)
# - that main demo company is gelocated in US
# - that this test awaits for hardcoded USDs amount
# we have to force company currency as USDs only for this test
self.env.ref('base.main_company').write({'currency_id': self.env.ref('base.USD').id})
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('event_buy_tickets')", "odoo.__DEBUG__.services['web_tour.tour'].tours.event_buy_tickets.ready", login="admin")
def test_demo(self):
@@ -788,6 +788,11 @@ msgstr ""
msgid "Close"
msgstr ""
#. module: website_forum
#: model:ir.ui.view,arch_db:website_forum.close_post
msgid "Close Post"
msgstr ""
#. module: website_forum
#: model:ir.model.fields,field_description:website_forum.field_forum_forum_karma_close_all
msgid "Close all posts"
@@ -798,6 +803,11 @@ msgstr ""
msgid "Close own posts"
msgstr ""
#. module: website_forum
#: model:ir.ui.view,arch_db:website_forum.close_post
msgid "Close post"
msgstr ""
#. module: website_forum
#: model:ir.model.fields,field_description:website_forum.field_forum_post_closed_uid
msgid "Closed by"
@@ -1205,6 +1215,7 @@ msgid "First upvote"
msgstr ""
#. module: website_forum
#: model:ir.ui.view,arch_db:website_forum.post_answers
#: model:ir.ui.view,arch_db:website_forum.post_description_full
msgid "Flag"
msgstr ""
@@ -1218,6 +1229,7 @@ msgstr ""
#. module: website_forum
#: selection:forum.post,state:0
#: model:ir.ui.view,arch_db:website_forum.moderation_queue
#: model:ir.ui.view,arch_db:website_forum.post_answers
#: model:ir.ui.view,arch_db:website_forum.post_description_full
msgid "Flagged"
msgstr ""
@@ -1591,6 +1603,11 @@ msgstr ""
msgid "Links"
msgstr ""
#. module: website_forum
#: model:ir.ui.view,arch_db:website_forum.close_post
msgid "Mark as offensive"
msgstr ""
#. module: website_forum
#: model:ir.model.fields,field_description:website_forum.field_forum_forum_karma_moderate
msgid "Moderate posts"
@@ -1757,6 +1774,11 @@ msgstr ""
msgid "Offensive"
msgstr ""
#. module: website_forum
#: model:ir.ui.view,arch_db:website_forum.close_post
msgid "Offensive Post"
msgstr ""
#. module: website_forum
#: model:ir.ui.view,arch_db:website_forum.post_answers
msgid "On"
+11 -5
View File
@@ -631,7 +631,10 @@
<!-- Moderation: close a post -->
<template id="close_post">
<t t-call="website_forum.header">
<h1 class="mt0"><t t-esc="offensive and 'Offensive' or 'Close'"/> Post</h1>
<h1 class="mt0">
<t t-if="offensive">Offensive Post</t>
<t t-if="not offensive">Close Post</t>
</h1>
<p class="text-muted" t-if="not offensive">
If you close this post, it will be hidden for most users. Only
users having a high karma can see closed posts to moderate
@@ -668,7 +671,10 @@
</div>
<div class="form-group">
<div class="col-md-offset-3 col-md-8">
<button type="submit" class="btn btn-primary"><t t-esc="offensive and 'Mark as offensive' or 'Close post'"/></button>
<button type="submit" class="btn btn-primary">
<t t-if="offensive">Mark as offensive</t>
<t t-if="not offensive">Close post</t>
</button>
<span class="text-muted">or</span>
<a class="btn btn-link" t-attf-href="/forum/#{ slug(forum) }/question/#{ slug(question) }">back to post</a>
</div>
@@ -1005,9 +1011,9 @@
<a t-attf-style="cursor: pointer;#{not answer.can_flag and 'color:#999;' or ''}"
t-attf-class="flag fa fa-flag #{not answer.can_flag and 'karma_required' or ''}"
t-attf-data-karma="#{not answer.can_flag and answer.forum_id.karma_flag or 0}"
t-attf-data-href="/forum/#{slug(forum)}/post/#{slug(answer)}/flag"
>
<t class="flag_flagged_label" t-esc="answer.state == 'flagged' and 'Flagged' or 'Flag'"/>
t-attf-data-href="/forum/#{slug(forum)}/post/#{slug(answer)}/flag">
<t t-if="answer.state == 'flagged'">Flagged</t>
<t t-if="answer.state != 'flagged'">Flag</t>
</a>
</li>
<li t-if="question.post_type == 'question'">
@@ -88,6 +88,7 @@ class WebsiteSaleBackend(WebsiteBackend):
order_carts_abandoned_count=request.env['sale.order'].search_count(sale_order_domain + [
('state', '=', 'draft'),
('order_line', '!=', 'False'),
('partner_id', '!=', request.env.ref('base.public_partner').id),
('date_order', '<=', fields.Datetime.to_string(datetime.now() - timedelta(hours=1))),
]),
payment_to_capture_count=request.env['payment.transaction'].search_count([
+8 -10
View File
@@ -7,8 +7,6 @@
<field name="arch" type="xml">
<search>
<filter string="Confirmed Orders" name="order_confirmed" domain="[('state', 'in', ('sale', 'done'))]"/>
<separator/>
<filter string="From Website" name="from_website" domain="[('team_id.team_type', '=', 'website')]"/>
</search>
</field>
</record>
@@ -17,8 +15,8 @@
<field name="name">Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form</field>
<field name="domain">[]</field>
<field name="context">{'show_sale': True, 'search_default_confirm_order': 1, 'search_default_from_website': 1}</field>
<field name="domain">[('team_id.team_type', '=', 'website')]</field>
<field name="context">{'show_sale': True, 'search_default_order_confirmed': 1}</field>
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
<field name="help">You don't have any confirmed order from the website.</field>
</record>
@@ -27,8 +25,8 @@
<field name="name">Unpaid Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('state', '=', 'sent')]</field>
<field name="context">{'show_sale': True, 'search_default_from_website': 1, 'create': False}</field>
<field name="domain">[('state', '=', 'sent'), ('team_id.team_type', '=', 'website')]</field>
<field name="context">{'show_sale': True, 'create': False}</field>
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
<field name="help">You don't have any unpaid order from the website.</field>
</record>
@@ -37,8 +35,8 @@
<field name="name">Abandoned Carts</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('state', '=', 'draft'), ('order_line', '!=', False), ('date_order', '&lt;=', (context_today() - datetime.timedelta(hours=1)).strftime('%Y-%m-%d'))]</field>
<field name="context">{'show_sale': True, 'search_default_from_website': 1, 'create': False}</field>
<field name="domain">[('state', '=', 'draft'), ('order_line', '!=', False), ('partner_id','!=', context.get('public_partner_id')), ('team_id.team_type', '=', 'website'), ('date_order', '&lt;=', (datetime.datetime.utcnow() - datetime.timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S'))]</field>
<field name="context" eval="{'show_sale': True, 'create': False, 'public_partner_id': ref('base.public_partner')}"/>
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
<field name="help" type="xml">
<p>Find here all the abandoned carts, i.e. the carts generated by your website's visitors more than one hour ago and not confirmed.</p>
@@ -49,8 +47,8 @@
<field name="name">Orders To Invoice</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('state', 'in', ('sale', 'done')), ('invoice_status', '=', 'to invoice')]</field>
<field name="context">{'show_sale': True, 'search_default_from_website': 1, 'create': False}</field>
<field name="domain">[('state', 'in', ('sale', 'done')), ('invoice_status', '=', 'to invoice'), ('team_id.team_type', '=', 'website')]</field>
<field name="context">{'show_sale': True, 'search_default_order_confirmed': 1, 'create': False}</field>
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
<field name="help">You don't have any order from the website.</field>
</record>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

-9
View File
@@ -1,9 +0,0 @@
==================
Business Mementoes
==================
.. toctree::
Accounting Memento (US GAAP) <https://odoo.com/documentation/functional/accounting.html>
Double-Entry Inventory <https://odoo.com/documentation/functional/double-entry.html>
Inventory Valuations <https://odoo.com/documentation/functional/valuation.html>
+9
View File
@@ -0,0 +1,9 @@
Poland, 2017-06-25
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,
Marcin Pieczyński marcin-pieczynski@wp.pl https://github.com/piemar1
+11
View File
@@ -0,0 +1,11 @@
Belgium, 22/06/2017
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,
Ward Marissen ward.marissen@gmail.com https://github.com/wardm95

Some files were not shown because too many files have changed in this diff Show More