diff options
| author | Christophe Simonis <chs@odoo.com> | 2017-06-09 17:11:47 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2017-06-09 17:11:47 +0200 |
| commit | ab02bf34b0c1d3fa3752ca1f50b098d04dc5b137 (patch) | |
| tree | a29591cae8777390cbeb35d65e23c9f8ae143135 /addons | |
| parent | b350c29e44b4ed1df0c428308c396771b22d73e8 (diff) | |
| parent | cc38b93ab2a6daf9c5badbc387cff20d1a01ddee (diff) | |
[MERGE] forward port branch saas-16 up to cc38b93ab2a
Diffstat (limited to 'addons')
60 files changed, 1051 insertions, 197 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 96ade1cb3d9..4a2846255d2 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -523,7 +523,10 @@ class AccountJournal(models.Model): @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): args = args or [] - recs = self.search(['|', ('code', operator, name), ('name', operator, name)] + args, limit=limit) + connector = '|' + if operator in expression.NEGATIVE_TERM_OPERATORS: + connector = '&' + recs = self.search([connector, ('code', operator, name), ('name', operator, name)] + args, limit=limit) return recs.name_get() @api.multi diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 6c728362378..90223d85923 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -248,7 +248,7 @@ class AccountBankStatement(models.Model): for aml in st_line.journal_entry_ids: moves |= aml.move_id if moves: - moves.post() + moves.filtered(lambda m: m.state != 'posted').post() statement.message_post(body=_('Statement %s confirmed, journal items were created.') % (statement.name,)) statements.link_bank_to_partner() statements.write({'state': 'confirm', 'date_done': time.strftime("%Y-%m-%d %H:%M:%S")}) diff --git a/addons/account/tests/account_test_classes.py b/addons/account/tests/account_test_classes.py index cf37e2493ef..8477f4109d9 100644 --- a/addons/account/tests/account_test_classes.py +++ b/addons/account/tests/account_test_classes.py @@ -28,3 +28,30 @@ class AccountingTestCase(HttpCase): if theorical_lines: raise ValidationError('Remaining theorical line (not found). %s)' % ([(aml[0], aml[1], aml[2]) for aml in theorical_lines])) return True + + def ensure_account_property(self, property_name): + '''Ensure the ir.property targetting an account.account passed as parameter exists. + In case it's not: create it with a random account. This is useful when testing with + partially defined localization (missing stock properties for example) + + :param property_name: The name of the property. + ''' + company_id = self.env.user.company_id + field_id = self.env['ir.model.fields'].search( + [('model', '=', 'product.template'), ('name', '=', property_name)], limit=1) + property_id = self.env['ir.property'].search([ + ('company_id', '=', company_id.id), + ('name', '=', property_name), + ('res_id', '=', None), + ('fields_id', '=', field_id.id)], limit=1) + account_id = self.env['account.account'].search([('company_id', '=', company_id.id)], limit=1) + value_reference = 'account.account,%d' % account_id.id + if property_id and not property_id.value_reference: + property_id.value_reference = value_reference + else: + self.env['ir.property'].create({ + 'name': property_name, + 'company_id': company_id.id, + 'fields_id': field_id.id, + 'value_reference': value_reference, + }) diff --git a/addons/base_geolocalize/models/res_partner.py b/addons/base_geolocalize/models/res_partner.py index 0331d2c9734..855b4d8bbd1 100644 --- a/addons/base_geolocalize/models/res_partner.py +++ b/addons/base_geolocalize/models/res_partner.py @@ -9,8 +9,9 @@ from odoo.exceptions import UserError def geo_find(addr): + if not addr: + return None url = 'https://maps.googleapis.com/maps/api/geocode/json' - try: result = requests.get(url, params={'sensor': 'false', 'address': addr}).json() except Exception as e: diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py index 0ebd1144ada..ec94bc22d27 100644 --- a/addons/base_import_module/models/ir_module.py +++ b/addons/base_import_module/models/ir_module.py @@ -100,18 +100,23 @@ class IrModule(models.Model): raise UserError(_("File '%s' exceed maximum allowed file size") % zf.filename) with tempdir() as module_dir: - z.extractall(module_dir) - dirs = [d for d in os.listdir(module_dir) if os.path.isdir(opj(module_dir, d))] - for mod_name in dirs: - module_names.append(mod_name) - try: - # assert mod_name.startswith('theme_') - path = opj(module_dir, mod_name) - self._import_module(mod_name, path, force=force) - success.append(mod_name) - except Exception as e: - _logger.exception('Error while importing module') - errors[mod_name] = exception_to_unicode(e) + import odoo.modules as addons + try: + addons.module.ad_paths.append(module_dir) + z.extractall(module_dir) + dirs = [d for d in os.listdir(module_dir) if os.path.isdir(opj(module_dir, d))] + for mod_name in dirs: + module_names.append(mod_name) + try: + # assert mod_name.startswith('theme_') + path = opj(module_dir, mod_name) + self._import_module(mod_name, path, force=force) + success.append(mod_name) + except Exception as e: + _logger.exception('Error while importing module') + errors[mod_name] = exception_to_unicode(e) + finally: + addons.module.ad_paths.remove(module_dir) r = ["Successfully imported module '%s'" % mod for mod in success] for mod, error in pycompat.items(errors): r.append("Error while importing module '%s': %r" % (mod, error)) diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 740f6281e9a..9c8b088aeb1 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -567,15 +567,18 @@ class Meeting(models.Model): if not event_date: event_date = datetime.now() - # Convert the event date to saved timezone (or context tz) as it'll - # define the correct hour/day asked by the user to repeat for recurrence. - event_date = event_date.astimezone(timezone) # transform "+hh:mm" timezone - rset1 = rrule.rrulestr(str(self.rrule), dtstart=event_date, forceset=True) + if self.allday and self.rrule and 'UNTIL' in self.rrule and 'Z' not in self.rrule: + rset1 = rrule.rrulestr(str(self.rrule), dtstart=event_date.replace(tzinfo=None), forceset=True, ignoretz=True) + else: + # Convert the event date to saved timezone (or context tz) as it'll + # define the correct hour/day asked by the user to repeat for recurrence. + event_date = event_date.astimezone(timezone) # transform "+hh:mm" timezone + rset1 = rrule.rrulestr(str(self.rrule), dtstart=event_date, forceset=True, tzinfos={}) recurring_meetings = self.search([('recurrent_id', '=', self.id), '|', ('active', '=', False), ('active', '=', True)]) for meeting in recurring_meetings: rset1._exdate.append(todate(meeting.recurrent_id_date)) - return [d.astimezone(pytz.UTC) for d in rset1] + return [d.astimezone(pytz.UTC) if d.tzinfo else d for d in rset1] @api.multi def _get_recurrency_end_date(self): diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 3f5d4511836..ea09ec486b4 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -247,6 +247,7 @@ class Lead(models.Model): team_id = self.env['crm.team']._get_default_team_id(user_id=user_id) return {'team_id': team_id} + @api.constrains('user_id') @api.onchange('user_id') def _onchange_user_id(self): """ When changing the user, also set a team_id or restrict team id to the ones user_id is member of. """ diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py index f48eb8d9835..4a2756d2563 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -226,26 +226,26 @@ class DeliveryCarrier(models.Model): if record.delivery_type == 'fixed': PriceRule.search([('carrier_id', '=', record.id)]).unlink() - line_data = { - 'carrier_id': record.id, - 'variable': 'price', - 'operator': '>=', - } - # Create the delivery price rules - if record.free_if_more_than: - line_data.update({ - 'max_value': record.amount, - 'standard_price': 0.0, - 'list_base_price': 0.0, - }) - PriceRule.create(line_data) - if record.fixed_price is not False: - line_data.update({ - 'max_value': 0.0, - 'standard_price': record.fixed_price, - 'list_base_price': record.fixed_price, - }) - PriceRule.create(line_data) + line_data = { + 'carrier_id': record.id, + 'variable': 'price', + 'operator': '>=', + } + # Create the delivery price rules + if record.free_if_more_than: + line_data.update({ + 'max_value': record.amount, + 'standard_price': 0.0, + 'list_base_price': 0.0, + }) + PriceRule.create(line_data) + if record.fixed_price is not False: + line_data.update({ + 'max_value': 0.0, + 'standard_price': record.fixed_price, + 'list_base_price': record.fixed_price, + }) + PriceRule.create(line_data) return True @api.model diff --git a/addons/hr_holidays/models/hr_holidays.py b/addons/hr_holidays/models/hr_holidays.py index ce3b7d1a827..2061e6be1a0 100644 --- a/addons/hr_holidays/models/hr_holidays.py +++ b/addons/hr_holidays/models/hr_holidays.py @@ -329,9 +329,12 @@ class Holidays(models.Model): res = [] for leave in self: if leave.type == 'remove': - res.append((leave.id, _("%s on %s : %.2f day(s)") % (leave.employee_id.name or leave.category_id.name, leave.holiday_status_id.name, leave.number_of_days_temp))) + if self.env.context.get('short_name'): + res.append((leave.id, _("%s : %.2f day(s)") % (leave.name or leave.holiday_status_id.name, leave.number_of_days_temp))) + else: + res.append((leave.id, _("%s on %s : %.2f day(s)") % (leave.employee_id.name or leave.category_id.name, leave.holiday_status_id.name, leave.number_of_days_temp))) else: - res.append((leave.id, _("Allocation of %s : %.2f day(s) To %s") % (leave.holiday_status_id.name, leave.number_of_days_temp,leave.employee_id.name))) + res.append((leave.id, _("Allocation of %s : %.2f day(s) To %s") % (leave.holiday_status_id.name, leave.number_of_days_temp, leave.employee_id.name))) return res def _check_state_access_right(self, vals): diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 74704a459f2..d8d07c42fd8 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -7,6 +7,13 @@ from odoo import api, fields, models class AccountAnalyticLine(models.Model): _inherit = 'account.analytic.line' + @api.model + def default_get(self, field_list): + result = super(AccountAnalyticLine, self).default_get(field_list) + if 'employee_id' in field_list and result.get('user_id') and result.get('project_id'): + result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id + return result + task_id = fields.Many2one('project.task', 'Task') project_id = fields.Many2one('project.project', 'Project', domain=[('allow_timesheets', '=', True)]) diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 7886e39b870..c84663c2985 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -67,7 +67,7 @@ <field name="planned_hours" widget="float_time"/> </field> <xpath expr="//notebook/page[@name='description_page']" position="after"> - <page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}"> + <page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"> <field name="timesheet_ids" context="{'default_project_id': project_id}"> <tree editable="bottom" string="Timesheet Activities" default_order="date"> <field name="date"/> diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index a797892a4c2..4f728d48698 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -208,8 +208,11 @@ 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: - 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']))) + 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) @api.multi @api.depends('workorder_ids.state', 'move_finished_ids') diff --git a/addons/project/static/src/js/project.js b/addons/project/static/src/js/project.js index 5da3151aa68..a1ca1a60302 100644 --- a/addons/project/static/src/js/project.js +++ b/addons/project/static/src/js/project.js @@ -51,19 +51,23 @@ KanbanRecord.include({ } function open_cover_images_dialog(attachment_ids) { - var cover_id = self.record.displayed_image_id.raw_value[0]; + var coverID = self.record.displayed_image_id && self.record.displayed_image_id.raw_value[0]; var $content = $(QWeb.render("project.SetCoverModal", { - cover_id: cover_id, + cover_id: coverID, attachment_ids: attachment_ids, })); var $imgs = $content.find('img'); - var dialog = new Dialog(self, { title: _t("Set a Cover Image"), - buttons: [{text: _t("Select"), classes: 'btn-primary', close: true, disabled: !cover_id, click: function () { - self._updateRecord({displayed_image_id: $imgs.filter('.o_selected').data('id')}); + buttons: [{text: _t("Select"), classes: 'btn-primary', close: true, disabled: !coverID, click: function () { + var $img = $imgs.filter('.o_selected'); + var data = { + id: $img.data('id'), + display_name: $img.data('name') + }; + self._updateRecord({displayed_image_id: data}); }}, {text: _t("Remove Cover Image"), close: true, click: function () { - self._updateRecord({displayed_image_id: 0}); + self._updateRecord({displayed_image_id: false}); }}, {text: _t("Discard"), close: true}], $content: $content, }).open(); @@ -75,7 +79,12 @@ KanbanRecord.include({ }); $content.on('dblclick', 'img', function (ev) { - self._updateRecord({displayed_image_id: $(ev.currentTarget).data('id')}); + var $img = $(ev.currentTarget); + var data = { + id: $img.data('id'), + display_name: $img.data('name') + }; + self._updateRecord({displayed_image_id: data}); dialog.close(); }); } diff --git a/addons/project/static/src/xml/project.xml b/addons/project/static/src/xml/project.xml index d9c4f3fa675..a2d222784b9 100644 --- a/addons/project/static/src/xml/project.xml +++ b/addons/project/static/src/xml/project.xml @@ -2,7 +2,7 @@ <templates id="template" xml:space="preserve"> <t t-name="project.SetCoverModal"> <div t-if="attachment_ids.length > 0" class="o_kanban_task_cover_image"> - <img t-foreach="attachment_ids" t-as="attachment" t-attf-class="img-responsive#{cover_id == attachment['id'] ? ' o_selected' : ''}" t-attf-src="/web/image/#{attachment['id']}?unique=1" t-att-data-id="attachment['id']"/> + <img t-foreach="attachment_ids" t-as="attachment" t-attf-class="img-responsive#{cover_id == attachment['id'] ? ' o_selected' : ''}" t-attf-src="/web/image/#{attachment['id']}?unique=1" t-att-data-id="attachment['id']" t-att-data-name="attachment['name']"/> </div> <div t-if="attachment_ids.length == 0">There is no available image to be set as cover. Send a message on the task with an attached image.</div> </t> diff --git a/addons/project/static/tests/project_tests.js b/addons/project/static/tests/project_tests.js new file mode 100644 index 00000000000..fa92415684e --- /dev/null +++ b/addons/project/static/tests/project_tests.js @@ -0,0 +1,133 @@ +odoo.define('project.project_kanban_tests', function (require) { +"use strict"; + +var KanbanView = require('web.KanbanView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('project', { + beforeEach: function () { + this.data = { + 'ir.attachment': { + fields: { + name: { + string: "Name", + type: "char" + }, + }, + records: [{ + id: 1, + name: "1.png" + }, + { + id: 2, + name: "2.png" + }, + ] + }, + 'project.task': { + fields: { + name: { + string: "Task Title", + type: "char" + }, + sequence: { + string: "sequence", + type: "integer" + }, + displayed_image_id: { + string: "cover", + type: "many2one", + relation: "ir.attachment" + }, + kanban_state: { + string: "State", + type: "selection", + selection: [ + ["abc", "ABC"], + ["def", "DEF"], + ["ghi", "GHI"] + ] + }, + }, + records: [{ + id: 1, + name: "task1", + sequence: 1, + kanban_state: "abc" + }, + { + id: 2, + name: "task2", + sequence: 2, + kanban_state: "abc" + }, + ] + }, + }; + } +}, function () { + QUnit.module('image test'); + + QUnit.test('cover_image_test', function (assert) { + assert.expect(6); + var kanban = createView({ + View: KanbanView, + model: 'project.task', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '<div class="o_dropdown_kanban dropdown">' + + '<a class="dropdown-toggle btn" data-toggle="dropdown" href="#">' + + '<span class="fa fa-bars fa-lg"/>' + + '</a>' + + '<ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">' + + '<li>' + + '<a type="set_cover">Set Cover Image</a>'+ + '</li>' + + '</ul>' + + '</div>' + + '<div>'+ + '<field name="displayed_image_id" widget="attachment_image"/>'+ + '</div>'+ + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + mockRPC: function(route, args) { + if (args.model === 'ir.attachment' && args.method === 'search_read') { + return $.when([{ + id: 1, + name: "1.png" + },{ + id: 2, + name: "2.png" + }]); + } + if (args.model === 'project.task' && args.method === 'write') { + assert.step(args.args[0][0]); + return this._super(route, args); + } + return this._super(route, args); + }, + }); + assert.strictEqual(kanban.$('img').length, 0, "Initially there is no image."); + kanban.$('.o_dropdown_kanban [data-type=set_cover]').eq(0).click(); + // single click on image + $('.modal').find("img[data-id='1']").click(); + $('.modal-footer .btn-primary').click(); + assert.strictEqual(kanban.$('img[src*="/web/image/1"]').length, 1, "Image inserted in record"); + $('.o_dropdown_kanban [data-type=set_cover]').eq(1).click(); + // double click on image + $('.modal').find("img[data-id='2']").dblclick(); + assert.strictEqual(kanban.$('img[src*="/web/image/2"]').length, 1, "Image inserted after double click"); + // varify write on both kanban record + assert.verifySteps([1,2]); + kanban.destroy(); + }); +}); +}); diff --git a/addons/project/views/project_templates.xml b/addons/project/views/project_templates.xml index 3806674c4b9..8baab3b7657 100644 --- a/addons/project/views/project_templates.xml +++ b/addons/project/views/project_templates.xml @@ -10,4 +10,9 @@ <script type="text/javascript" src="/project/static/src/js/web_planner_project.js"></script> </xpath> </template> + <template id="qunit_suite" name="project_test" inherit_id="web.qunit_suite"> + <xpath expr="//t[@t-set='head']" position="inside"> + <script type="text/javascript" src="/project/static/tests/project_tests.js"></script> + </xpath> + </template> </odoo> diff --git a/addons/project_issue_sheet/views/project_issue_view.xml b/addons/project_issue_sheet/views/project_issue_view.xml index 7bd27cf2c7f..99928d28bdb 100644 --- a/addons/project_issue_sheet/views/project_issue_view.xml +++ b/addons/project_issue_sheet/views/project_issue_view.xml @@ -10,7 +10,7 @@ <field name="progress" widget="progressbar" attrs="{'invisible':[('task_id','=',False)]}" class="oe_inline"/> </field> <xpath expr="//notebook/page[@name='extra_info']" position="before"> - <page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}"> + <page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"> <field name="timesheet_ids" colspan="4" nolabel="1" context="{'default_user_id' : uid, 'default_project_id': project_id}" groups="base.group_user"> <tree editable="top" string="Timesheet Activities"> diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 9ec3ce6b9d5..84dbee6a3fd 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -105,6 +105,9 @@ class TestSaleOrder(TestSale): def test_cost_invoicing(self): """ Test confirming a vendor invoice to reinvoice cost on the so """ + # force the pricelist to have the same currency as the company + self.env.ref('product.list0').currency_id = self.env.ref('base.main_company').currency_id + serv_cost = self.env.ref('product.service_cost_01') prod_gap = self.env.ref('product.product_product_1') so = self.env['sale.order'].create({ diff --git a/addons/sale_expense/tests/test_sale_expense.py b/addons/sale_expense/tests/test_sale_expense.py index bc8d314206e..91a094bf901 100644 --- a/addons/sale_expense/tests/test_sale_expense.py +++ b/addons/sale_expense/tests/test_sale_expense.py @@ -7,6 +7,9 @@ from odoo.addons.sale.tests.test_sale_common import TestSale class TestSaleExpense(TestSale): def test_sale_expense(self): """ Test the behaviour of sales orders when managing expenses """ + # force the pricelist to have the same currency as the company + self.env.ref('product.list0').currency_id = self.env.ref('base.main_company').currency_id + # create a so with a product invoiced on delivery prod = self.env.ref('product.product_product_1') so = self.env['sale.order'].create({ diff --git a/addons/sale_timesheet/models/account_invoice.py b/addons/sale_timesheet/models/account_invoice.py index 64ac5d7a8aa..17ba8ad96e2 100644 --- a/addons/sale_timesheet/models/account_invoice.py +++ b/addons/sale_timesheet/models/account_invoice.py @@ -53,7 +53,7 @@ class AccountInvoice(models.Model): def _compute_timesheet_revenue(self): for invoice in self: - for invoice_line in invoice.invoice_line_ids.filtered(lambda line: line.product_id.type == 'service'): + for invoice_line in invoice.invoice_line_ids.filtered(lambda line: line.product_id.type == 'service').sorted(key=lambda inv_line: (inv_line.invoice_id, inv_line.id)): uninvoiced_timesheet_lines = self.env['account.analytic.line'].sudo().search([ ('so_line', 'in', invoice_line.sale_line_ids.ids), ('project_id', '!=', False), @@ -63,13 +63,12 @@ class AccountInvoice(models.Model): # NOTE JEM : changing quantity (or unit price) of invoice line does not impact the revenue calculation. (FP specs) if uninvoiced_timesheet_lines: - precision = invoice_line.currency_id.decimal_places # delivered : update revenue with the prorata of number of hours on the timesheet line if invoice_line.product_id.invoice_policy == 'delivery': - invoiced_price_per_hour = float_round(invoice_line.price_subtotal / float(sum(uninvoiced_timesheet_lines.mapped('unit_amount'))), precision) + invoiced_price_per_hour = invoice_line.currency_id.round(invoice_line.price_subtotal / float(sum(uninvoiced_timesheet_lines.mapped('unit_amount')))) # invoicing analytic lines of different currency total_revenue_per_currency = dict.fromkeys(uninvoiced_timesheet_lines.mapped('company_currency_id').ids, 0.0) - for index, timesheet_line in enumerate(uninvoiced_timesheet_lines): + for index, timesheet_line in enumerate(uninvoiced_timesheet_lines.sorted(key=lambda ts: ts.date)): if index+1 != len(uninvoiced_timesheet_lines): line_revenue = invoice_line.currency_id.compute(invoiced_price_per_hour, timesheet_line.company_currency_id) * timesheet_line.unit_amount total_revenue_per_currency[timesheet_line.company_currency_id.id] += line_revenue @@ -92,7 +91,7 @@ class AccountInvoice(models.Model): # invoicing analytic lines of different currency total_revenue_per_currency = dict.fromkeys(no_zero_timesheet_revenue.mapped('company_currency_id').ids, 0.0) - for index, timesheet_line in enumerate(no_zero_timesheet_revenue): + for index, timesheet_line in enumerate(no_zero_timesheet_revenue.sorted(key=lambda ts: ts.date)): if index+1 != len(no_zero_timesheet_revenue): price_subtotal_inv = invoice_line.currency_id.compute(invoice_line.price_subtotal, timesheet_line.company_currency_id) price_subtotal_sol = timesheet_line.so_line.currency_id.compute(timesheet_line.so_line.price_subtotal, timesheet_line.company_currency_id) diff --git a/addons/stock/i18n/stock.pot b/addons/stock/i18n/stock.pot index 1296f083384..8e86883b4aa 100644 --- a/addons/stock/i18n/stock.pot +++ b/addons/stock/i18n/stock.pot @@ -6384,6 +6384,12 @@ msgid "You cannot set a negative product quantity in an inventory line:\n" msgstr "" #. module: stock +#: code:addons/stock/models/stock_production_lot.py:37 +#, python-format +msgid "You are not allowed to create a lot for this picking type" +msgstr "" + +#. module: stock #: code:addons/stock/models/stock_move.py:927 #, python-format msgid "You cannot split a draft move. It needs to be confirmed first." diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 54b4a877fd7..6e26cf6dc77 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -419,6 +419,9 @@ 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: diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index 60f370fff51..a568cd80fe3 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ +from odoo.exceptions import UserError class ProductionLot(models.Model): @@ -27,6 +28,15 @@ class ProductionLot(models.Model): ('name_ref_uniq', 'unique (name, product_id)', 'The combination of serial number and product must be unique !'), ] + @api.model + def create(self, vals): + pack_id = self.env.context.get('active_pack_operation', False) + if pack_id: + pack = self.env['stock.pack.operation'].browse(pack_id) + if pack.picking_id and not pack.picking_id.picking_type_id.use_create_lots: + raise UserError(_("You are not allowed to create a lot for this picking type")) + return super(ProductionLot, self).create(vals) + @api.one @api.depends('quant_ids.qty') def _product_qty(self): diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index 20de2d25775..8453a49b4e3 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -66,6 +66,12 @@ class StockScrap(models.Model): scrap.do_scrap() return scrap + @api.multi + def unlink(self): + if 'done' in self.mapped('state'): + raise UserError(_('You cannot delete a scrap which is done.')) + return super(StockScrap, self).unlink() + def _get_origin_moves(self): return self.picking_id and self.picking_id.move_lines.filtered(lambda x: x.product_id == self.product_id) diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv index dc8f1756134..6b13597199d 100644 --- a/addons/stock/security/ir.model.access.csv +++ b/addons/stock/security/ir.model.access.csv @@ -78,4 +78,8 @@ access_barcode_rule_stock_manager,barcode.rule.stock.manager,barcodes.model_barc access_stock_forecast_user,report.stock.forecast.user,model_report_stock_forecast,stock.group_stock_user,1,0,0,0 access_stock_forecast_manager,report.stock.forecast.manager,model_report_stock_forecast,stock.group_stock_manager,1,1,1,1 access_stock_scrap_user,stock.scrap.user,model_stock_scrap,stock.group_stock_user,1,1,1,0 -access_procurement_order,procurement.order,stock.model_procurement_order,base.group_portal,1,0,0,0
\ No newline at end of file +access_procurement_order,procurement.order,stock.model_procurement_order,base.group_portal,1,0,0,0 +access_product_attribute_manager,product.attribute manager,product.model_product_attribute,stock.group_stock_manager,1,1,1,1 +access_product_attribute_value_manager,product.attribute manager value,product.model_product_attribute_value,stock.group_stock_manager,1,1,1,1 +access_product_attribute_price_manager,product.attribute manager price,product.model_product_attribute_price,stock.group_stock_manager,1,1,1,1 +access_product_attribute_line_manager,product.attribute manager line,product.model_product_attribute_line,stock.group_stock_manager,1,1,1,1
\ No newline at end of file diff --git a/addons/stock/views/stock_pack_operation_views.xml b/addons/stock/views/stock_pack_operation_views.xml index 5b6919dab83..cd8469d4672 100644 --- a/addons/stock/views/stock_pack_operation_views.xml +++ b/addons/stock/views/stock_pack_operation_views.xml @@ -85,7 +85,7 @@ <field name="lot_name" invisible="not context.get('only_create', False)"/> <field name="lot_id" invisible="context.get('only_create', False)" domain="[('product_id','=', parent.product_id)]" - context="{'default_product_id': parent.product_id}"/> + context="{'default_product_id': parent.product_id, 'active_pack_operation': parent.id}"/> <field name="qty_todo" invisible="not context.get('show_reserved') or context.get('serial') or context.get('state_done')" readonly="1"/> <field name="qty" invisible="context.get('serial')"/> <button name="do_minus" type="object" icon="fa-minus-square" attrs="{'invisible': [('qty', '<=', 0.99)]}" invisible="not context.get('show_reserved') or context.get('state_done')"/> diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 1e4ac7ffbe1..1b15d2aa066 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -51,6 +51,9 @@ 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}) diff --git a/addons/stock_landed_costs/tests/common.py b/addons/stock_landed_costs/tests/common.py index cdd99228dab..205d2171853 100644 --- a/addons/stock_landed_costs/tests/common.py +++ b/addons/stock_landed_costs/tests/common.py @@ -59,6 +59,9 @@ class TestStockLandedCostsCommon(AccountingTestCase): self.brokerage_quantity = self._create_services('Brokerage Cost') self.transportation_weight = self._create_services('Transportation Cost') self.packaging_volume = self._create_services('Packaging Cost') + # Ensure the account properties exists. + self.ensure_account_property('property_stock_account_input') + self.ensure_account_property('property_stock_account_output') def _create_services(self, name): return self.Product.create({ diff --git a/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js b/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js index 807bdff70dd..8f0724bbb00 100644 --- a/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js +++ b/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js @@ -421,6 +421,12 @@ element.children().first().after(widget); } + // !! ODOO FIX START !! + var parentOffset = parent.offset(); + position.top = offset.top - parentOffset.top; + position.left = offset.left - parentOffset.left; + // !! ODOO FIX END !! + // Top and bottom logic if (vertical === 'auto') { if (offset.top + widget.height() * 1.5 >= $(window).height() + $(window).scrollTop() && diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 9f56cd01ef4..481e9a7fc54 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -82,6 +82,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { } }); }, + show_wow: '_onShowWow', }, init: function (parent) { this.client_options = {}; @@ -319,6 +320,22 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { this.notification_manager.warn(e.data.title, e.data.message, e.data.sticky); } }, + /** + * Displays a thumb up, heart or peace image (randomly) for a moment (e.g. + * used when an opportunity is won) + * + * @private + */ + _onShowWow: function () { + var className = 'o_wow_thumbs'; + if (Math.random() > 0.9) { + var otherClasses = ['o_wow_peace', 'o_wow_heart']; + className = otherClasses[Math.floor(Math.random()*otherClasses.length)]; + } + var $body = $('body'); + $body.addClass(className); + setTimeout($body.removeClass.bind($body, className), 1000); + }, }); return AbstractWebClient; diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 5908f7f5a8b..492d9f26552 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -176,8 +176,8 @@ var AbstractField = Widget.extend({ * focusable element was not found or invisible */ activate: function (options) { - var $focusable = this.getFocusableElement(); - if ($focusable.length && $focusable.is(':visible')) { + if (this.isFocusable()) { + var $focusable = this.getFocusableElement(); $focusable.focus(); if ($focusable.is('input[type="text"], textarea')) { $focusable[0].selectionStart = $focusable[0].selectionEnd = $focusable[0].value.length; @@ -212,6 +212,15 @@ var AbstractField = Widget.extend({ return $(); }, /** + * Returns true iff the widget has a visible element that can take the focus + * + * @returns {boolean} + */ + isFocusable: function () { + var $focusable = this.getFocusableElement(); + return $focusable.length && $focusable.is(':visible'); + }, + /** * this method is used to determine if the field value is set to a meaningful * value. This is useful to determine if a field should be displayed as empty * @@ -351,12 +360,13 @@ var AbstractField = Widget.extend({ * @param {Object} [options] * @param {boolean} [options.forceChange=false] if true, the change event will be * triggered even if the new value is the same as the old one + * @returns {Deferred} */ _setValue: function (value, options) { // we try to avoid doing useless work, if the value given has not // changed. Note that we compare the unparsed values. if (this.lastSetValue === value || (this.value === false && value === '')) { - return; + return $.when(); } this.lastSetValue = value; try { @@ -364,18 +374,22 @@ var AbstractField = Widget.extend({ this._isValid = true; } catch (e) { this._isValid = false; - return; + return $.Deferred().reject(); } if (!(options && options.forceChange) && this._isSameValue(value)) { - return; + return $.when(); } + var def = $.Deferred(); var changes = {}; changes[this.name] = value; this.trigger_up('field_changed', { dataPointID: this.dataPointID, changes: changes, viewType: this.viewType, + onSuccess: def.resolve.bind(def), + onFailure: def.reject.bind(def), }); + return def; }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index a5ceba50d3f..d4013a8bcdb 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1281,8 +1281,26 @@ var PriorityWidget = AbstractField.extend({ }, }); -var AttachmentImage = AbstractField.extend({ - template: 'AttachmentImage', +var AttachmentImage = AbstractField.extend({ + className: 'o_attachment_image', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Reset cover image when widget value change + * + * @private + */ + _render: function () { + if (this.value) { + this.$el.empty().append($('<img>/', { + src: "/web/image/" + this.value.data.id + "?unique=1", + title: this.value.data.display_name + })); + } + } }); var StateSelectionWidget = AbstractField.extend({ diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index e13c3d29004..ae3b06d3a02 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -213,17 +213,20 @@ var FieldMany2One = AbstractField.extend({ var self = this; var slowCreate = this._searchCreatePopup.bind(this, "form", false, this._createContext(name)); if (this.nodeOptions.quick_create) { - return this._rpc({ - model: this.field.relation, - method: 'name_create', - args: [name], - context: this.record.getContext(this.recordParams), - }) - .then(function (result) { - if (self.mode === "edit") { - self.reinitialize({id: result[0], display_name: result[1]}); - } - }, slowCreate); + this.trigger_up('mutexify', { + action: function () { + return self._rpc({ + model: self.field.relation, + method: 'name_create', + args: [name], + context: self.record.getContext(self.recordParams), + }).then(function (result) { + if (self.mode === "edit") { + self.reinitialize({id: result[0], display_name: result[1]}); + } + }, slowCreate); + }, + }); } else { slowCreate(); } @@ -964,7 +967,6 @@ var FieldOne2Many = FieldX2Many.extend({ var index = self.editable === 'top' ? 0 : self.value.data.length - 1; var newID = self.value.data[index].id; self.renderer.editRecord(newID); - self.creatingRecord = false; } } }); @@ -1014,6 +1016,7 @@ var FieldOne2Many = FieldX2Many.extend({ * in the kanban view */ _onAddRecord: function (ev) { + var self = this; // we don't want interference with the components upstream. ev.stopPropagation(); @@ -1023,10 +1026,11 @@ var FieldOne2Many = FieldX2Many.extend({ this._setValue({ operation: 'CREATE', position: this.editable, + }).always(function () { + self.creatingRecord = false; }); } } else { - var self = this; this._openFormDialog({ on_saved: function (record) { self._setValue({ operation: 'ADD', id: record.id }); diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index 5131db19028..41fc293e383 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -11,6 +11,7 @@ var utils = require('web.utils'); return core.Class.extend({ init: function () { this._init_cache(); + core.bus.on('clear_cache', this, this.invalidate.bind(this)); }, _init_cache: function () { diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 53e5be8ff6e..16a066db495 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -46,6 +46,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @returns {Deferred} */ start: function () { + // add classname to reflect the (absence of) access rights (used to + // correctly display the nocontent helper) + this.$el.toggleClass('o_cannot_create', !this.activeActions.create); return this._super.apply(this, arguments) .then(this._updateEnv.bind(this)); }, @@ -186,8 +189,12 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { // user if some discarding has to be made). This operation must also be // mutex-protected as commitChanges function of x2m has to be aware of // all final changes made to a row. - this.mutex.exec(this.renderer.commitChanges.bind(this.renderer, recordID || this.handle)); // TODO write a test for this - return this.mutex.exec(this._saveRecord.bind(this, recordID, options)); + var self = this; + return this.mutex + .exec(this.renderer.commitChanges.bind(this.renderer, recordID || this.handle)) + .then(function () { + return self.mutex.exec(self._saveRecord.bind(self, recordID, options)); + }); }, /** * @override diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 60abfde4344..61c285d6581 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1841,7 +1841,6 @@ var BasicModel = AbstractModel.extend({ return d.res_id === res_id; }); m2mList.data.push(dataPoint.id); - m2mList.count++; }); }); }); @@ -2476,7 +2475,10 @@ var BasicModel = AbstractModel.extend({ // REPLACE_WITH x2manyList.res_ids = value[2]; x2manyList.count = x2manyList.res_ids.length; - defs.push(self._readUngroupedList(x2manyList)); + var def = self._readUngroupedList(x2manyList).then(function () { + return self._fetchX2ManysBatched(x2manyList); + }); + defs.push(def); } }); diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js index 9578e568558..422af121d96 100644 --- a/addons/web/static/src/js/views/field_manager_mixin.js +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -99,7 +99,9 @@ var FieldManagerMixin = { // subrecord's form view), otherwise it bubbles up to the main form view // but its model doesn't have any data related to the given dataPointID event.stopPropagation(); - this._applyChanges(event.data.dataPointID, event.data.changes, event); + this._applyChanges(event.data.dataPointID, event.data.changes, event) + .done(event.data.onSuccess || function () {}) + .fail(event.data.onFailure || function () {}); }, /** * Some widgets need to trigger a reload of their data. For example, a diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index ce449f2dcc9..8eef70a916c 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -39,12 +39,10 @@ var FormController = BasicController.extend({ //-------------------------------------------------------------------------- /** - * This method is supposed to focus the first active control, I think. It - * is currently only called by the FormViewDialog. - * - * @todo To be implemented + * Calls autofocus on the renderer */ autofocus: function () { + this.renderer.autofocus(); }, /** * This method switches the form view in edit mode, with a new record. @@ -93,6 +91,14 @@ var FormController = BasicController.extend({ return dataPoint.data.display_name || _t('New'); }, /** + * Called each time the form view is attached into the DOM + * + * @todo convert to new style + */ + on_attach_callback: function () { + this.autofocus(); + }, + /** * Render buttons for the control panel. The form view can be rendered in * a dialog, and in that case, if we have buttons defined in the footer, we * have to use them instead of the standard buttons. @@ -374,10 +380,8 @@ var FormController = BasicController.extend({ }); } - if (event.data.show_wow) { - def.then(function () { - self.show_wow(); - }); + if (event.data.showWow) { + def.then(this.trigger_up.bind(this, 'show_wow')); } def.always(this._enableButtons.bind(this)); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index ddd77ee5dc6..08f28438368 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -26,6 +26,29 @@ var FormRenderer = BasicRenderer.extend({ //-------------------------------------------------------------------------- /** + * Focuses the field having attribute 'default_focus' set, if any, or the + * first focusable field otherwise. + */ + autofocus: function () { + if (this.mode === 'readonly') { + return; + } + var focusWidget = this.defaultFocusField; + if (!focusWidget || !focusWidget.isFocusable()) { + var widgets = this.allFieldWidgets[this.state.id]; + for (var i = 0; i < (widgets ? widgets.length : 0); i++) { + var widget = widgets[i]; + if (widget.isFocusable()) { + focusWidget = widget; + break; + } + } + } + if (focusWidget) { + focusWidget.activate({noselect: true}); + } + }, + /** * Extend the method so that labels also receive the 'o_field_invalid' class * if necessary. * @@ -183,7 +206,7 @@ var FormRenderer = BasicRenderer.extend({ self.trigger_up('button_clicked', { attrs: node.attrs, record: self.state, - show_wow: self.$el.hasClass('o_wow'), // TODO: implement this (in view) + showWow: $el.hasClass('o_wow'), }); }); }, @@ -709,7 +732,10 @@ var FormRenderer = BasicRenderer.extend({ * @returns {jQueryElement} */ _renderTagSeparator: function (node) { - return $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + var $separator = $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + this._handleAttributes($separator, node); + this._registerModifiers(node, this.state, $separator); + return $separator; }, /** * @private @@ -769,20 +795,16 @@ var FormRenderer = BasicRenderer.extend({ core.bus.trigger('DOM_updated'); // Attach the tooltips on the fields' label - var focusWidget = this.defaultFocusField; _.each(this.allFieldWidgets[this.state.id], function (widget) { - if (!focusWidget) { - focusWidget = widget; - } if (core.debug || widget.attrs.help || widget.field.help) { var idForLabel = self.idsForLabels[widget.name]; var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); self._addFieldTooltip(widget, $label); } }); - if (focusWidget) { - focusWidget.activate({noselect: true}); - } + + // Focus the default field (in edit mode) + self.autofocus(); }, /** * Sets id attribute of given widget to idForLabel diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 9364d1ba9fe..08b9e7f1602 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -155,7 +155,7 @@ ListRenderer.include({ var self = this; var rowIndex = _.findIndex(this.state.data, {id: recordID}); if (rowIndex < 0) { - return; + return $.when(); } var editMode = (mode === 'edit'); var record = this.state.data[rowIndex]; diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index b84d60d5b7c..a19e440913e 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -4,6 +4,7 @@ odoo.define('web.view_dialogs', function (require) { var core = require('web.core'); var data = require('web.data'); var Dialog = require('web.Dialog'); +var dom = require('web.dom'); var ListController = require('web.ListController'); var ListView = require('web.ListView'); var pyeval = require('web.pyeval'); @@ -194,8 +195,10 @@ var FormViewDialog = ViewDialog.extend({ if ($buttons.children().length) { self.$footer.empty().append($buttons.contents()); } - _super().$el.append(fragment); - self.form_view.autofocus(); + dom.append(_super().$el, fragment, { + callbacks: [{widget: self.form_view}], + in_DOM: true, + }); }); }); @@ -281,6 +284,7 @@ var SelectCreateDialog = ViewDialog.extend({ if(this.options.initial_view !== "search") { return this.create_edit_record(); } + var self = this; var user_context = this.getSession().user_context; var _super = this._super.bind(this); @@ -298,7 +302,10 @@ var SelectCreateDialog = ViewDialog.extend({ this.loadViews(this.dataset.model, this.dataset.get_context(), [[false, 'list'], [false, 'search']], {}) .then(this.setup.bind(this, search_defaults)) .then(function (fragment) { - _super().$el.append(fragment); + dom.append(_super().$el, fragment, { + callbacks: [{widget: self.list_controller}], + in_DOM: true, + }); }); return this; }, diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index 353a5dc78f2..76281d9366a 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -42,6 +42,7 @@ var DateWidget = Widget.extend({ locale : moment.locale(), allowInputToggle: true, keyBinds: null, + widgetParent: 'body', }); }, /** diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js index 279428e8939..6607561a2bb 100644 --- a/addons/web/static/src/js/widgets/model_field_selector.js +++ b/addons/web/static/src/js/widgets/model_field_selector.js @@ -16,6 +16,11 @@ var modelFieldsCache = { cacheDefs: {}, }; +core.bus.on('clear_cache', null, function () { + modelFieldsCache.cache = {}; + modelFieldsCache.cacheDefs = {}; +}); + /** * The ModelFieldSelector widget can be used to display/select a particular * field chain from a given model. diff --git a/addons/web/static/src/less/model_field_selector.less b/addons/web/static/src/less/model_field_selector.less index 124c15cb8e5..aee5bd17867 100644 --- a/addons/web/static/src/less/model_field_selector.less +++ b/addons/web/static/src/less/model_field_selector.less @@ -7,6 +7,10 @@ .o-flex-flow(row, wrap); .o-align-items(center); height: 100%; + min-height: 20px; // needed when there is no value in it and used standalone + &:active, &:focus, &:active:focus { + outline: none; + } > .o_field_selector_chain_part { padding: 0px 1px; diff --git a/addons/web/static/src/less/webclient.less b/addons/web/static/src/less/webclient.less index 2f9878fbdc3..5e84d74b599 100644 --- a/addons/web/static/src/less/webclient.less +++ b/addons/web/static/src/less/webclient.less @@ -12,6 +12,7 @@ a { // General // ------------------------------------------------------------------ .o_web_client { + position: relative; // normally useless but required by bootstrap-datepicker background-color: @odoo-webclient-background-color; } .o_text_overflow { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 394abe18c7c..20e38e98912 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -24,7 +24,7 @@ <div t-name="CrashManager.warning" class="o_dialog_warning"> <t t-js="d"> - var message = d.message ? d.message : d.error.data.message; + var message = (d.message !== undefined) ? d.message : d.error.data.message; d.html_error = context.engine.tools.html_escape(message).replace(/\n/g, '<br/>'); </t> <t t-raw="html_error"/> @@ -836,11 +836,6 @@ t-att-width="widget.node.attrs.img_width || widget.node.attrs.width" t-att-height="widget.node.attrs.img_height || widget.node.attrs.height"/> </t> -<t t-name="AttachmentImage"> - <div class="o_attachment_image"> - <img t-att-src="'/web/image/' + widget.value + '?unique=1'"/> - </div> -</t> <t t-name="FieldBinaryFile"> <a t-if="widget.mode === 'readonly'" href="javascript:void(0)" class="o_form_uri"/> diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 4f326e14f98..127d5761fd2 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -482,6 +482,183 @@ QUnit.module('relational_fields', { // assert.strictEqual(form.$('input').eq(1).val(), 'xphone', "onchange should have been applied"); // }); + QUnit.test('form: quick create then save directly', function (assert) { + var done = assert.async(); + assert.expect(5); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="trululu"/>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + assert.step('name_create'); + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].trululu, newRecordID, + "should create with the correct m2o id"); + } + return result; + }, + }); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + form.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['name_create'], + "should wait for the name_create before creating the record"); + + def.resolve(); + + assert.verifySteps(['name_create', 'create']); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + + QUnit.test('list: quick create then save directly', function (assert) { + var done = assert.async(); + assert.expect(8); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="top">' + + '<field name="trululu"/>' + + '</tree>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + assert.step('name_create'); + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].trululu, newRecordID, + "should create with the correct m2o id"); + } + return result; + }, + }); + + list.$buttons.find('.o_list_button_add').click(); + + var $dropdown = list.$('.o_field_many2one input').autocomplete('widget'); + list.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + list.$buttons.find('.o_list_button_add').click(); + + assert.verifySteps(['name_create'], + "should wait for the name_create before creating the record"); + assert.strictEqual(list.$('.o_data_row').length, 4, + "should wait for the name_create before adding the new row"); + + def.resolve(); + + assert.verifySteps(['name_create', 'create']); + assert.strictEqual(list.$('.o_data_row:nth(1) .o_data_cell').text(), 'b', + "created row should have the correct m2o value"); + assert.strictEqual(list.$('.o_data_row').length, 5, + "should have added the fifth row"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + list.destroy(); + done(); + }); + }); + + QUnit.test('list in form: quick create then save directly', function (assert) { + var done = assert.async(); + assert.expect(6); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="trululu"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + assert.step('name_create'); + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].p[0][2].trululu, newRecordID, + "should create with the correct m2o id"); + } + return result; + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + form.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['name_create'], + "should wait for the name_create before creating the record"); + + def.resolve(); + + assert.verifySteps(['name_create', 'create']); + assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b', + "first row should have the correct m2o value"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) { assert.expect(1); @@ -3852,6 +4029,115 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('editable list: value reset by an onchange', function (assert) { + // this test reproduces a subtle behavior that may occur in a form view: + // the user adds a record in a one2many field, and directly clicks on a + // datetime field of the form view which has an onchange, which totally + // overrides the value of the one2many (commands 5 and 0). The handler + // that switches the edited row to readonly is then called after the + // new value of the one2many field is applied (the one returned by the + // onchange), so the row that must go to readonly doesn't exist anymore. + assert.expect(2); + + this.data.partner.onchanges = { + datetime: function (obj) { + obj.turtles = [[5], [0, 0, {display_name: 'new'}]]; + }, + }; + + var def; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="datetime"/>' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + // trigger the two onchanges + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_data_row .o_field_widget').val('a name').trigger('input'); + def = $.Deferred(); + form.$('.o_datepicker_input').click(); // focusout o2m and set value to today + + // resolve the onchange def + def.resolve(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have one record in the o2m"); + assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new', + "should be the record created by the onchange"); + + form.destroy(); + }); + + QUnit.test('editable list: onchange that returns a warning', function (assert) { + assert.expect(5); + + this.data.turtle.onchanges = { + display_name: function () {}, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.step(args.method); + return $.when({ + value: {}, + warning: { + title: "Warning", + message: "You must first select a partner" + }, + }); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + intercepts: { + warning: function () { + assert.step('warning'); + }, + }, + }); + + // add a line (this should trigger an onchange and a warning) + form.$('.o_field_x2many_list_row_add a').click(); + + // check if 'Add an item' still works (this should trigger an onchange + // and a warning again) + form.$('.o_field_x2many_list_row_add a').click(); + + assert.verifySteps(['onchange', 'warning', 'onchange', 'warning']); + + form.destroy(); + }); + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index eae31e68e80..bc2cf05f360 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -13,6 +13,7 @@ odoo.define('web.test_utils', function (require) { var basic_fields = require('web.basic_fields'); var config = require('web.config'); var core = require('web.core'); +var dom = require('web.dom'); var session = require('web.session'); var MockServer = require('web.MockServer'); var Widget = require('web.Widget'); @@ -172,7 +173,14 @@ function createAsyncView(params) { widget.destroy(); $('#qunit-fixture').off('DOMNodeInserted.removeSRC'); }; - return view.appendTo($view_manager).then(function () { + // render the view in a fragment as they must be able to render correctly + // without being in the DOM + var fragment = document.createDocumentFragment(); + return view.appendTo(fragment).then(function () { + dom.append($view_manager, fragment, { + callbacks: [{widget: view}], + in_DOM: true, + }); view.$el.on('click', 'a', function (ev) { ev.preventDefault(); }); @@ -244,52 +252,53 @@ function addMockEnvironment(widget, params) { logLevel: params.logLevel, currentDate: params.currentDate, }); - var widgetDestroy; - // make sure the debounce value for input fields is set to 0 var initialDebounce = DebouncedField.prototype.DEBOUNCE; DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; - + var initialSession, initialConfig, initialParameters; if ('session' in params) { - var initialSession = _.extend({}, session); + initialSession = _.extend({}, session); _.extend(session, params.session); - widgetDestroy = widget.destroy; - widget.destroy = function () { - for (var key in session) { - delete session[key]; - } - _.extend(session, initialSession); - widgetDestroy.call(this); - }; } - if ('config' in params) { - var initialConfig = _.extend({}, config); + initialConfig = _.extend({}, config); _.extend(config, params.config); - widgetDestroy = widget.destroy; - widget.destroy = function () { - for (var key in config) { - delete config[key]; - } - _.extend(config, initialConfig); - DebouncedField.prototype.DEBOUNCE = initialDebounce; - widgetDestroy.call(this); - }; } - if ('translateParameters' in params) { - var initialParameters = _.extend({}, core._t.database.parameters); + initialParameters = _.extend({}, core._t.database.parameters); _.extend(core._t.database.parameters, params.translateParameters); - var oldDestroy = widget.destroy; - widget.destroy = function () { - for (var key in core._t.database.parameters) { + } + + var widgetDestroy = widget.destroy; + widget.destroy = function () { + // clear the caches (e.g. data_manager, ModelFieldSelector) when the + // widget is destroyed, at the end of each test to avoid collisions + core.bus.trigger('clear_cache'); + + DebouncedField.prototype.DEBOUNCE = initialDebounce; + + var key; + if ('session' in params) { + for (key in session) { + delete session[key]; + } + _.extend(session, initialSession); + } + if ('config' in params) { + for (key in config) { + delete config[key]; + } + _.extend(config, initialConfig); + } + if ('translateParameters' in params) { + for (key in core._t.database.parameters) { delete core._t.database.parameters[key]; } _.extend(core._t.database.parameters, initialParameters); - oldDestroy.call(this); - }; - } + } + widgetDestroy.call(this); + }; intercept(widget, 'call_service', function (event) { if (event.data.service === 'ajax') { diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 582e06ba62b..fefc7231e5b 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -1165,6 +1165,54 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('default_get: fetch x2manys inside x2manys', function (assert) { + assert.expect(3); + + this.data.partner.fields.o2m = { + string: "O2M", type: 'one2many', relation: 'partner', default: [[6, 0, [1]]], + }; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['o2m'], + fields: this.data.partner.fields, + fieldsInfo: { + form: { + o2m: { + relatedFields: this.data.partner.fields, + fieldsInfo: { + list: { + category: { + relatedFields: { display_name: {} }, + }, + }, + }, + viewType: 'list', + }, + }, + }, + modelName: 'partner', + type: 'record', + viewType: 'form', + }; + + model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.o2m.count, 1, "o2m field should contain 1 record"); + var categoryList = record.data.o2m.data[0].data.category; + assert.strictEqual(categoryList.count, 1, + "category field should contain 1 record"); + assert.strictEqual(categoryList.data[0].data.display_name, + 'gold', "category records should have been fetched"); + }); + + model.destroy(); + }); + QUnit.test('contexts and domains can be properly fetched', function (assert) { assert.expect(8); @@ -1448,6 +1496,7 @@ QUnit.module('Views', { fieldsInfo: { form: { product_ids: { + relatedFields: this.data.product.fields, fieldsInfo: { list: { name: {}, date: {} } }, viewType: 'list', } diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index a07f022eb74..3f829a654b7 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -837,6 +837,28 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('invisible attrs on separators', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<separator string="Geolocation" attrs=\'{"invisible": [["bar", "=", True]]}\'/>'+ + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + assert.strictEqual(form.$('div.o_horizontal_separator').hasClass('o_invisible_modifier'), true, + "separator div should be hidden"); + form.destroy(); + }); + QUnit.test('buttons in form view', function (assert) { assert.expect(7); @@ -3770,7 +3792,7 @@ QUnit.module('Views', { }); form.$buttons.find('.o_form_button_edit').click(); - assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], "foo field should have focus"); assert.strictEqual(form.$('input[name="foo"]')[0].selectionStart, 3, "cursor should be at the end"); @@ -3792,7 +3814,25 @@ QUnit.module('Views', { res_id: 1, }); form.$buttons.find('.o_form_button_edit').click(); - assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + + QUnit.test('in create mode, autofocus fields are focused', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="foo" default_focus="1"/>' + + '</form>', + }); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], "foo field should have focus"); form.destroy(); @@ -3821,6 +3861,24 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('autofocus first visible field', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field" invisible="1"/>' + + '<field name="foo"/>' + + '</form>', + }); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + QUnit.test('open one2many form containing many2many_tags', function (assert) { assert.expect(4); @@ -4893,5 +4951,40 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('wow effect', function (assert) { + assert.expect(3); + + this.data.partner.fields.model_name = { string: "Model name", type: "char" }; + + var clickCount = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<button name="test" string="Mark Won" type="object" class="o_wow"/>' + + '</form>', + intercepts: { + execute_action: function (event) { + if (clickCount === 0) { + event.data.on_success(); + } else { + event.data.on_fail(); + } + clickCount++; + }, + show_wow: function () { + assert.step('wow'); + }, + }, + }); + + form.$('button.o_wow').click(); + assert.verifySteps(['wow']); + form.$('button.o_wow').click(); + assert.verifySteps(['wow']); + + form.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index e1cfef2b23b..332ba6687f9 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -84,7 +84,7 @@ QUnit.module('Views', { QUnit.module('ListView'); QUnit.test('simple readonly list', function (assert) { - assert.expect(9); + assert.expect(10); var list = createView({ View: ListView, @@ -93,6 +93,9 @@ QUnit.module('Views', { arch: '<tree><field name="foo"/><field name="int_field"/></tree>', }); + assert.notOk(list.$el.hasClass('o_cannot_create'), + "should not have className 'o_cannot_create'"); + // 3 th (1 for checkbox, 2 for columns) assert.strictEqual(list.$('th').length, 3, "should have 3 columns"); @@ -114,6 +117,24 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('list with create="0"', function (assert) { + assert.expect(2); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree create="0"><field name="foo"/></tree>', + }); + + assert.ok(list.$el.hasClass('o_cannot_create'), + "should have className 'o_cannot_create'"); + assert.strictEqual(list.$buttons.find('.o_list_button_add').length, 0, + "should not have the 'Create' button"); + + list.destroy(); + }); + QUnit.test('simple editable rendering', function (assert) { assert.expect(9); diff --git a/addons/website/models/website.py b/addons/website/models/website.py index fe79a8ac1a3..0b407e4d753 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -397,9 +397,10 @@ class Website(models.Model): @api.model def get_current_website(self): - domain_name = request.httprequest.environ.get('HTTP_HOST', '').split(':')[0] + domain_name = request and request.httprequest.environ.get('HTTP_HOST', '').split(':')[0] or None website_id = self._get_current_website_id(domain_name) - request.context = dict(request.context, website_id=website_id) + if request: + request.context = dict(request.context, website_id=website_id) return self.browse(website_id) @tools.cache('domain_name') diff --git a/addons/website/static/src/js/website.ace.js b/addons/website/static/src/js/website.ace.js index 6e0e4437487..2ce486fc1c8 100644 --- a/addons/website/static/src/js/website.ace.js +++ b/addons/website/static/src/js/website.ace.js @@ -3,18 +3,23 @@ odoo.define('website.ace', function (require) { var Widget = require('web.Widget'); var website = require('website.website'); +var mixins = require('web.mixins'); var ViewEditor = require('web_editor.ace'); var hash = "#advanced-view-editor"; -var Ace = Widget.extend({ +var Ace = Widget.extend(mixins.ServiceProvider, { events: { "click a[data-action=ace]": function (e) { e.preventDefault(); this.launchAce(); }, }, + init: function () { + mixins.ServiceProvider.init.apply(this, arguments); + this._super.apply(this, arguments); + }, start: function () { if (window.location.hash.substr(0, hash.length) === hash) { this.launchAce(); diff --git a/addons/website/static/src/js/website.seo.js b/addons/website/static/src/js/website.seo.js index 219728aa552..a05fc4a5883 100644 --- a/addons/website/static/src/js/website.seo.js +++ b/addons/website/static/src/js/website.seo.js @@ -6,6 +6,7 @@ var ajax = require('web.ajax'); var Class = require('web.Class'); var Dialog = require('web.Dialog'); var mixins = require('web.mixins'); +var rpc = require("web.rpc"); var Widget = require('web.Widget'); var base = require('web_editor.base'); var website = require('website.website'); @@ -490,22 +491,21 @@ var Configurator = Dialog.extend({ def.resolve(null); } else { var fields = ['website_meta_title', 'website_meta_description', 'website_meta_keywords']; - this._rpc({ - model: obj.model, - method: 'read', - args: [[obj.id], fields, base.get_context()], - }) - .then(function (data) { - if (data.length) { - var meta = data[0]; - meta.model = obj.model; - def.resolve(meta); - } else { - def.resolve(null); - } - }).fail(function () { - def.reject(); - }); + rpc.query({ + model: obj.model, + method: 'read', + args: [[obj.id], fields, base.get_context()], + }).then(function (data) { + if (data.length) { + var meta = data[0]; + meta.model = obj.model; + def.resolve(meta); + } else { + def.resolve(null); + } + }).fail(function () { + def.reject(); + }); } return def; }, @@ -514,11 +514,11 @@ var Configurator = Dialog.extend({ if (!obj) { return $.Deferred().reject(); } else { - return this._rpc({ - model: obj.model, - method: 'write', - args: [[obj.id], data, base.get_context()], - }); + return rpc.query({ + model: obj.model, + method: 'write', + args: [[obj.id], data, base.get_context()], + }); } }, titleChanged: function () { diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py index f501a64eefa..f1f002d5dd0 100644 --- a/addons/website_crm_partner_assign/models/crm_lead.py +++ b/addons/website_crm_partner_assign/models/crm_lead.py @@ -48,8 +48,6 @@ class CrmLead(models.Model): for salesman_id, leads_ids in pycompat.items(salesmans_leads): leads = self.browse(leads_ids) leads.write({'user_id': salesman_id}) - for lead in leads: - lead._onchange_user_id() @api.multi def action_assign_partner(self): diff --git a/addons/website_event_sale/static/src/js/website.tour.event_sale.js b/addons/website_event_sale/static/src/js/website.tour.event_sale.js index 8a29796dab4..895e6191f86 100644 --- a/addons/website_event_sale/static/src/js/website.tour.event_sale.js +++ b/addons/website_event_sale/static/src/js/website.tour.event_sale.js @@ -75,7 +75,7 @@ tour.register('event_buy_tickets', { trigger: 'a[href="/shop/confirm_order"]:contains("Confirm")', }, { - content: "Check that the subtotal is 5,500.00", + content: "Check that the subtotal is 5,500.00 USD", // this test will fail if the currency of the main company is not USD trigger: '#order_total_untaxed .oe_currency_value:contains("5,500.00")', run: function () {}, // it's a check }, diff --git a/addons/website_forum/i18n/website_forum.pot b/addons/website_forum/i18n/website_forum.pot index 5fddf1e74a5..2f70c77241b 100644 --- a/addons/website_forum/i18n/website_forum.pot +++ b/addons/website_forum/i18n/website_forum.pot @@ -604,6 +604,7 @@ msgid "Bad Request" msgstr "" #. module: website_forum +#: model:ir.model.fields,field_description:website_forum.field_res_users_badge_ids #: model:ir.ui.view,arch_db:website_forum.badge #: model:ir.ui.view,arch_db:website_forum.header #: model:ir.ui.view,arch_db:website_forum.user_detail_full @@ -782,6 +783,7 @@ msgstr "" #. openerp-web #: code:addons/website_forum/static/src/xml/website_forum_share_templates.xml:46 #: selection:forum.post,state:0 +#: model:ir.ui.view,arch_db:website_forum.post_description_full #, python-format msgid "Close" msgstr "" @@ -884,6 +886,16 @@ msgid "Convert all answers to comments and vice versa" msgstr "" #. module: website_forum +#: model:ir.ui.view,arch_db:website_forum.post_answers +msgid "Convert as a comment" +msgstr "" + +#. module: website_forum +#: model:ir.ui.view,arch_db:website_forum.post_comment +msgid "Convert as an answer" +msgstr "" + +#. module: website_forum #: model:ir.model.fields,field_description:website_forum.field_forum_post_karma_accept msgid "Convert comment to answer" msgstr "" @@ -969,6 +981,12 @@ msgid "Default Post" 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 "Delete" +msgstr "" + +#. module: website_forum #: model:ir.model.fields,field_description:website_forum.field_forum_forum_karma_unlink_all msgid "Delete all posts" msgstr "" @@ -1038,6 +1056,8 @@ msgstr "" #. module: website_forum #: model:ir.ui.view,arch_db:website_forum.edit_post +#: model:ir.ui.view,arch_db:website_forum.post_answers +#: model:ir.ui.view,arch_db:website_forum.post_description_full msgid "Edit" msgstr "" @@ -1185,6 +1205,11 @@ msgid "First upvote" msgstr "" #. module: website_forum +#: model:ir.ui.view,arch_db:website_forum.post_description_full +msgid "Flag" +msgstr "" + +#. module: website_forum #: model:ir.model.fields,field_description:website_forum.field_forum_forum_karma_flag #: model:ir.model.fields,field_description:website_forum.field_forum_post_karma_flag msgid "Flag a post as offensive" @@ -1193,6 +1218,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_description_full msgid "Flagged" msgstr "" @@ -2016,6 +2042,11 @@ msgid "Re: %s" msgstr "" #. module: website_forum +#: model:ir.ui.view,arch_db:website_forum.post_description_full +msgid "Reactivate" +msgstr "" + +#. module: website_forum #: model:ir.ui.view,arch_db:website_forum.header msgid "Read Guidelines" msgstr "" @@ -2068,6 +2099,11 @@ msgid "Relevance Computation" msgstr "" #. module: website_forum +#: model:ir.ui.view,arch_db:website_forum.post_description_full +msgid "Reopen" +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 "Reply" diff --git a/addons/website_forum/static/src/xml/website_forum_share_templates.xml b/addons/website_forum/static/src/xml/website_forum_share_templates.xml index ab44aa48fcb..670e143ced8 100644 --- a/addons/website_forum/static/src/xml/website_forum_share_templates.xml +++ b/addons/website_forum/static/src/xml/website_forum_share_templates.xml @@ -33,7 +33,7 @@ <t t-name="website_forum.social_message_answer"> <p>By sharing you answer, you will get additional <b>karma points</b> if your answer is selected as the right one. See what you can do with karma - <a href="https://www.odoo.com/forum/how-to/miscellaneous-14/how-does-karma-work-odoo-v8-forum-software-57845" target="_blank">here</a>.</p> + <a href="/forum/help-1/faq" target="_blank">here</a>.</p> </t> <t t-name="website_forum.social_message_default"> <p>Share this content to increase your chances to be featured on the front page and attract more visitors.</p> diff --git a/addons/website_forum/views/website_forum.xml b/addons/website_forum/views/website_forum.xml index 74df06b10cb..3608b9b47c7 100644 --- a/addons/website_forum/views/website_forum.xml +++ b/addons/website_forum/views/website_forum.xml @@ -793,9 +793,10 @@ </div> </div> <div class="text-muted mb16"> - <i t-if="question.post_type == 'question'" t-attf-class="fa fa-question-circle" t-att-title="question.post_type.capitalize()" /> - <i t-if="question.post_type == 'discussion'" t-attf-class="fa fa-comments-o" t-att-title="question.post_type.capitalize()" /> - <i t-if="question.post_type == 'link'" t-attf-class="fa fa-link" t-att-title="question.post_type.capitalize()" /> + <t t-set="type_labels" t-value="dict(question._fields['post_type']._description_selection(question.env))"/> + <i t-if="question.post_type == 'question'" t-attf-class="fa fa-question-circle" t-att-title="type_labels[question.post_type]" /> + <i t-if="question.post_type == 'discussion'" t-attf-class="fa fa-comments-o" t-att-title="type_labels[question.post_type]" /> + <i t-if="question.post_type == 'link'" t-attf-class="fa fa-link" t-att-title="type_labels[question.post_type]" /> <span>By </span> <a t-att-href="(question.create_uid.id == request.session.uid or question.create_uid.sudo().website_published) and '/forum/%s/user/%s' % (slug(forum), question.create_uid.id) or None" t-field="question.create_uid" @@ -819,7 +820,7 @@ <div class="mt16 mb24 text-center"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) + '/question/' + slug(question) + '/reopen'"/> - <t t-set="label" t-value="'Reopen'"/> + <t t-set="label">Reopen</t> <t t-set="classes" t-value="'fa-arrow-right'"/> <t t-set="karma" t-value="not question.can_close and question.karma_close or 0"/> </t> @@ -832,7 +833,7 @@ <li t-if="question.state == 'close'"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) +'/question/' + slug(question) + '/reopen'"/> - <t t-set="label" t-value="'Reopen'"/> + <t t-set="label">Reopen</t> <t t-set="classes" t-value="'fa-undo'"/> <t t-set="karma" t-value="not question.can_close and question.karma_close or 0"/> </t> @@ -840,7 +841,7 @@ <li> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) +'/post/' + slug(question) + '/edit'"/> - <t t-set="label" t-value="'Edit'"/> + <t t-set="label">Edit</t> <t t-set="classes" t-value="'fa-pencil-square-o'"/> <t t-set="karma" t-value="not question.can_edit and question.karma_edit or 0"/> </t> @@ -848,7 +849,7 @@ <li t-if="question.state != 'close'"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) +'/question/' + slug(question) + '/ask_for_close'"/> - <t t-set="label" t-value="'Close'"/> + <t t-set="label">Close</t> <t t-set="classes" t-value="'fa-times'"/> <t t-set="karma" t-value="not question.can_close and question.karma_close or 0"/> </t> @@ -856,7 +857,7 @@ <li t-if="question.active"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) +'/question/' + slug(question) + '/delete'"/> - <t t-set="label" t-value="'Delete'"/> + <t t-set="label">Delete</t> <t t-set="classes" t-value="'fa-trash-o'"/> <t t-set="karma" t-value="not question.can_unlink and question.karma_unlink or 0"/> </t> @@ -864,7 +865,7 @@ <li t-if="not question.active and question.state != 'offensive'"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) +'/question/' + slug(question) + '/undelete'"/> - <t t-set="label" t-value="'Reactivate'"/> + <t t-set="label">Reactivate</t> <t t-set="classes" t-value="'fa-trash-o'"/> <t t-set="karma" t-value="not question.can_unlink and question.karma_unlink or 0"/> </t> @@ -872,7 +873,7 @@ <li t-if="not question.active and question.state == 'offensive'"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) +'/post/' + slug(question) + '/validate'"/> - <t t-set="label" t-value="'Reactivate'"/> + <t t-set="label">Reactivate</t> <t t-set="classes" t-value="'fa-trash-o'"/> <t t-set="karma" t-value="not question.can_moderate and question.forum_id.karma_moderate or 0"/> </t> @@ -883,7 +884,12 @@ t-attf-data-karma="#{not question.can_flag and question.forum_id.karma_flag or 0}" t-attf-data-href="/forum/#{slug(forum)}/post/#{slug(question)}/flag" > - <t class="flag_flagged_label" t-esc="question.state == 'flagged' and 'Flagged' or 'Flag'"/> + <t t-if="question.state == 'flagged'"> + Flagged + </t> + <t t-if="question.state != 'flagged'"> + Flag + </t> </a> </li> </ul> @@ -982,7 +988,7 @@ <li> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) + '/post/' + slug(answer) + '/edit'"/> - <t t-set="label" t-value="'Edit'"/> + <t t-set="label">Edit</t> <t t-set="classes" t-value="'fa fa-pencil-square-o'"/> <t t-set="karma" t-value="not answer.can_edit and answer.karma_edit or 0"/> </t> @@ -990,7 +996,7 @@ <li> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) + '/post/' + slug(answer) + '/delete'"/> - <t t-set="label" t-value="'Delete'"/> + <t t-set="label">Delete</t> <t t-set="classes" t-value="'fa-trash-o'"/> <t t-set="karma" t-value="not answer.can_unlink and answer.karma_unlink or 0"/> </t> @@ -1007,7 +1013,7 @@ <li t-if="question.post_type == 'question'"> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) + '/post/' + slug(answer) + '/convert_to_comment'"/> - <t t-set="label" t-value="'Convert as a comment'"/> + <t t-set="label">Convert as a comment</t> <t t-set="classes" t-value="'fa-magic'"/> <t t-set="karma" t-value="not answer.can_comment_convert and answer.karma_comment_convert or 0"/> </t> @@ -1081,7 +1087,7 @@ <t t-set="required_karma" t-value="message.author_id.id == user.partner_id.id and object.forum_id.karma_comment_unlink_own or object.forum_id.karma_comment_unlink_all"/> <t t-call="website_forum.link_button"> <t t-set="url" t-value="'/forum/' + slug(forum) + '/post/' + slug(object) + '/comment/' + slug(message) + '/delete'"/> - <t t-set="label" t-value="' '"/> + <t t-set="label"> </t> <t t-set="karma" t-value="user.karma<required_karma and required_karma or 0"/> <t t-set="classes" t-value="'close comment_delete fa-times'"/> </t> @@ -1094,7 +1100,7 @@ <t t-if="allow_post_comment"> <t t-call="website_forum.link_button" > <t t-set="url" t-value="'/forum/' + slug(forum) + '/post/' + slug(object) + '/comment/' + slug(message) + '/convert_to_answer'"/> - <t t-set="label" t-value="'Convert as an answer'"/> + <t t-set="label">Convert as an answer</t> <t t-set="karma" t-value="user.karma<required_karma and required_karma or 0"/> <t t-set="classes" t-value="'fa-magic pull-right'"/> </t> diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index 2878c0bfc20..e7415b137fa 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -77,19 +77,19 @@ class Website(models.Model): :param bool show_visible: if True, we don't display pricelist where selectable is False (Eg: Code promo) :returns: pricelist recordset """ - website = request.website - if not request.website: + website = request and request.website or None + if not website: if self.env.context.get('website_id'): website = self.browse(self.env.context['website_id']) else: website = self.search([], limit=1) - isocountry = request.session.geoip and request.session.geoip.get('country_code') or False + isocountry = request and request.session.geoip and request.session.geoip.get('country_code') or False partner = self.env.user.partner_id order_pl = partner.last_website_so_id and partner.last_website_so_id.state == 'draft' and partner.last_website_so_id.pricelist_id partner_pl = partner.property_product_pricelist pricelists = website._get_pl_partner_order(isocountry, show_visible, website.user_id.sudo().partner_id.property_product_pricelist.id, - request.session.get('website_sale_current_pl'), + request and request.session.get('website_sale_current_pl') or None, website.pricelist_ids, partner_pl=partner_pl and partner_pl.id or None, order_pl=order_pl and order_pl.id or None) @@ -113,7 +113,7 @@ class Website(models.Model): available_pricelists = self.get_pricelist_available() pl = None partner = self.env.user.partner_id - if request.session.get('website_sale_current_pl'): + if request and request.session.get('website_sale_current_pl'): # `website_sale_current_pl` is set only if the user specifically chose it: # - Either, he chose it from the pricelist selection # - Either, he entered a coupon code |
