diff --git a/addons/board/static/src/js/dashboard.js b/addons/board/static/src/js/dashboard.js index 7b8230e9e4da9db1884200879b26ca066464d796..b794d0303a32918ffa12decd16a14bcd669f6b4f 100644 --- a/addons/board/static/src/js/dashboard.js +++ b/addons/board/static/src/js/dashboard.js @@ -188,13 +188,9 @@ FormRenderer.include({ var actionID = $(this).attr('data-id'); var newAttrs = _.clone(self.actionsDescr[actionID]); - if (newAttrs.domain) { - newAttrs.domain = newAttrs.domain_string; - delete(newAttrs.domain_string); - } - if (newAttrs.context) { - newAttrs.context = newAttrs.context_string; - delete(newAttrs.context_string); + /* prepare attributes as they should be saved */ + if (newAttrs.modifiers) { + newAttrs.modifiers = JSON.stringify(newAttrs.modifiers); } actions.push(newAttrs); }); diff --git a/addons/calendar/data/calendar_data.xml b/addons/calendar/data/calendar_data.xml index 65ecaacc2f5dbab0c1ff69a6067160dc07f1b751..8f4a4774f0edb78bbf35a3c57797f2bd4223eedf 100644 --- a/addons/calendar/data/calendar_data.xml +++ b/addons/calendar/data/calendar_data.xml @@ -40,13 +40,13 @@ <field name="type">notification</field> </record> <record id="alarm_mail_1" model="calendar.alarm"> - <field name="name">2~3 Hour(s) mail</field> + <field name="name">3 Hour(s), by e-mail</field> <field name="duration" eval="3" /> <field name="interval">hours</field> <field name="type">email</field> </record> <record id="alarm_mail_2" model="calendar.alarm"> - <field name="name">5~6 Hour(s) mail</field> + <field name="name">6 Hour(s), by e-mail</field> <field name="duration" eval="6" /> <field name="interval">hours</field> <field name="type">email</field> diff --git a/addons/calendar/i18n/calendar.pot b/addons/calendar/i18n/calendar.pot index 59bb6681530f7434fe6a73e038254aa916c9a3c0..05c4b992817e0b260308b0bad1438f11f481ed20 100644 --- a/addons/calendar/i18n/calendar.pot +++ b/addons/calendar/i18n/calendar.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 11.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-10 11:35+0000\n" -"PO-Revision-Date: 2017-10-10 11:35+0000\n" +"POT-Creation-Date: 2017-11-08 09:11+0000\n" +"PO-Revision-Date: 2017-11-08 09:11+0000\n" "Last-Translator: <>\n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -317,6 +317,41 @@ msgstr "" msgid "%s has declined invitation" msgstr "" +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_notif_5 +msgid "1 Day(s)" +msgstr "" + +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_notif_3 +msgid "1 Hour(s)" +msgstr "" + +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_notif_1 +msgid "15 Minute(s)" +msgstr "" + +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_notif_4 +msgid "2 Hour(s)" +msgstr "" + +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_mail_1 +msgid "3 Hour(s), by email" +msgstr "" + +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_notif_2 +msgid "30 Minute(s)" +msgstr "" + +#. module: calendar +#: model:calendar.alarm,name:calendar.alarm_mail_2 +msgid "6 Hour(s), by email" +msgstr "" + #. module: calendar #: model:ir.ui.view,arch_db:calendar.view_calendar_event_form #: model:ir.ui.view,arch_db:calendar.view_calendar_event_form_popup diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 478dfc63db7fd83e321a5aad11c767b96f1f78e1..97676349df9dcc02c4e61103b676564fb49185d4 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -484,7 +484,7 @@ class Alarm(models.Model): _interval_selection = {'minutes': 'Minute(s)', 'hours': 'Hour(s)', 'days': 'Day(s)'} - name = fields.Char('Name', required=True) + name = fields.Char('Name', translate=True, required=True) type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], 'Type', required=True, default='email') duration = fields.Integer('Remind Before', required=True, default=1) interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours') diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 31a91c08885c3e012fcdf3157693810ee842757e..08dbd62cc0e8e1237626701201c1d77e9cae7d9d 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -321,6 +321,9 @@ class Lead(models.Model): # Set date_open to today if it is an opp default = default or {} default['date_open'] = fields.Datetime.now() if self.type == 'opportunity' else False + # Do not assign to an archived user + if not self.user_id.active: + default['user_id'] = False return super(Lead, self.with_context(context)).copy(default=default) @api.model diff --git a/addons/fleet/i18n/fleet.pot b/addons/fleet/i18n/fleet.pot index 53b3de82cca5741561800bae90cce5587602832c..a5c648a1f614d04aa7b5c6aa8a8f4a8196c0d8b3 100644 --- a/addons/fleet/i18n/fleet.pot +++ b/addons/fleet/i18n/fleet.pot @@ -226,12 +226,12 @@ msgstr "" #. module: fleet #: model:ir.model.fields,help:fleet.field_fleet_vehicle_log_contract_state -msgid "Choose wheter the contract is still valid or not" +msgid "Choose whether the contract is still valid or not" msgstr "" #. module: fleet #: model:ir.model.fields,help:fleet.field_fleet_service_type_category -msgid "Choose wheter the service refer to contracts, vehicle services or both" +msgid "Choose whether the service refer to contracts, vehicle services or both" msgstr "" #. module: fleet @@ -1563,12 +1563,8 @@ msgstr "" #. module: fleet #: model:ir.actions.act_window,name:fleet.fleet_vehicle_service_types_action #: model:ir.ui.menu,name:fleet.fleet_vehicle_service_types_menu -msgid "Service Types" -msgstr "" - -#. module: fleet #: model:ir.ui.view,arch_db:fleet.fleet_vehicle_service_types_view_tree -msgid "Service types" +msgid "Service Types" msgstr "" #. module: fleet diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py index fd9bffd143317d060c786cb14b3dad8279655614..2d6b59285ec90a12557618feb786e5de6f073b7a 100644 --- a/addons/fleet/models/fleet_vehicle.py +++ b/addons/fleet/models/fleet_vehicle.py @@ -310,4 +310,4 @@ class FleetServiceType(models.Model): category = fields.Selection([ ('contract', 'Contract'), ('service', 'Service') - ], 'Category', required=True, help='Choose wheter the service refer to contracts, vehicle services or both') + ], 'Category', required=True, help='Choose whether the service refer to contracts, vehicle services or both') diff --git a/addons/fleet/models/fleet_vehicle_cost.py b/addons/fleet/models/fleet_vehicle_cost.py index 7ca677d137bd7a7a9636458d8aa7bd7b45c8796d..2a11ab84c4b93e20ff4fb4fc5aa86d4e7281af2a 100644 --- a/addons/fleet/models/fleet_vehicle_cost.py +++ b/addons/fleet/models/fleet_vehicle_cost.py @@ -112,7 +112,7 @@ class FleetVehicleLogContract(models.Model): ('diesoon', 'Expiring Soon'), ('closed', 'Closed') ], 'Status', default='open', readonly=True, - help='Choose wheter the contract is still valid or not', + help='Choose whether the contract is still valid or not', track_visibility="onchange", copy=False) notes = fields.Text('Terms and Conditions', help='Write here all supplementary information relative to this contract', copy=False) diff --git a/addons/fleet/views/fleet_vehicle_views.xml b/addons/fleet/views/fleet_vehicle_views.xml index 1213f7cbefdefa77ab0b188fb034969b8facc8f5..28138f6825c3568c35acb4056607f2a606b9ffbe 100644 --- a/addons/fleet/views/fleet_vehicle_views.xml +++ b/addons/fleet/views/fleet_vehicle_views.xml @@ -351,7 +351,7 @@ <field name="name">fleet.service.type.tree</field> <field name="model">fleet.service.type</field> <field name="arch" type="xml"> - <tree string="Service types" editable="top"> + <tree string="Service Types" editable="top"> <field name="name" /> <field name="category" invisible="1"/> </tree> diff --git a/addons/hr/models/mail_alias.py b/addons/hr/models/mail_alias.py index a88a6a4530fa7a0902c4ef56aaa880ae97eef922..f98fce32f5ae9b9808da7b144723993c52c25890 100644 --- a/addons/hr/models/mail_alias.py +++ b/addons/hr/models/mail_alias.py @@ -13,8 +13,8 @@ class Alias(models.Model): class MailAlias(models.AbstractModel): _inherit = 'mail.alias.mixin' - def _alias_check_contact(self, message, message_dict, alias): - if alias.alias_contact == 'employees' and self.ids: + def _alias_check_contact_on_record(self, record, message, message_dict, alias): + if alias.alias_contact == 'employees' and record.ids: email_from = tools.decode_message_header(message, 'From') email_address = tools.email_split(email_from)[0] employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) @@ -26,4 +26,4 @@ class MailAlias(models.AbstractModel): 'error_template': self.env.ref('hr.mail_template_data_unknown_employee_email_address').body_html, } return True - return super(MailAlias, self)._alias_check_contact(message, message_dict, alias) + return super(MailAlias, self)._alias_check_contact_on_record(record, message, message_dict, alias) diff --git a/addons/hr/models/mail_thread.py b/addons/hr/models/mail_thread.py deleted file mode 100644 index 25ee48bcd6d8edf100cd13eab38cac79e7a09283..0000000000000000000000000000000000000000 --- a/addons/hr/models/mail_thread.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models, tools -from odoo.tools import email_split - - -class MailAlias(models.AbstractModel): - _inherit = 'mail.alias.mixin' - - def _alias_check_contact(self, message, message_dict, alias): - if alias.alias_contact == 'employees' and self.ids: - email_from = tools.decode_message_header(message, 'From') - email_address = email_split(email_from)[0] - employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) - if not employee: - employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1) - if not employee: - return { - 'error_message': 'restricted to employees', - 'error_template': self.env.ref('hr.mail_template_data_unknown_employee_email_address').body_html, - } - return True - return super(MailAlias, self)._alias_check_contact(message, message_dict, alias) diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 8a3f4ab0b29b811dbc7f9f83a5558f6c51cc8e84..f7a72262753a53737b87a2982efd380d47062edb 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -541,6 +541,8 @@ class HrExpenseSheet(models.Model): @api.multi def refuse_sheet(self, reason): + if not self.user_has_groups('hr_expense.group_hr_expense_user'): + raise UserError(_("Only HR Officers can refuse expenses")) self.write({'state': 'cancel'}) for sheet in self: sheet.message_post_with_view('hr_expense.hr_expense_template_refuse_reason', @@ -548,6 +550,8 @@ class HrExpenseSheet(models.Model): @api.multi def approve_expense_sheets(self): + if not self.user_has_groups('hr_expense.group_hr_expense_user'): + raise UserError(_("Only HR Officers can approve expenses")) self.write({'state': 'approve', 'responsible_id': self.env.user.id}) @api.multi diff --git a/addons/hr_timesheet_attendance/models/hr_timesheet_sheet.py b/addons/hr_timesheet_attendance/models/hr_timesheet_sheet.py index 49adf3fed7fe61fde961a3f77ead996c60c954f0..d0c980f9f5fc793f58212ea671104f7e03f085fc 100644 --- a/addons/hr_timesheet_attendance/models/hr_timesheet_sheet.py +++ b/addons/hr_timesheet_attendance/models/hr_timesheet_sheet.py @@ -165,7 +165,7 @@ class hr_timesheet_sheet_sheet_day(models.Model): ON r.user_id = u.id LEFT JOIN res_partner p ON u.partner_id = p.id - WHERE check_out IS NOT NULL + WHERE a.check_out IS NOT NULL group by (a.check_in AT TIME ZONE 'UTC' AT TIME ZONE coalesce(p.tz, 'UTC'))::date, s.id, timezone )) AS foo GROUP BY name, sheet_id, timezone diff --git a/addons/mail/controllers/bus.py b/addons/mail/controllers/bus.py index 2c6d4b835b2d88ffa03d471421f293658564dda5..50f08d8d8debd34e92ffd26ec988aa1c2d262387 100644 --- a/addons/mail/controllers/bus.py +++ b/addons/mail/controllers/bus.py @@ -36,21 +36,19 @@ class MailChatController(BusController): # -------------------------- @route('/mail/chat_post', type="json", auth="none") def mail_chat_post(self, uuid, message_content, **kwargs): - request_uid = self._default_request_uid() # find the author from the user session, which can be None author_id = False # message_post accept 'False' author_id, but not 'None' if request.session.uid: author_id = request.env['res.users'].sudo().browse(request.session.uid).partner_id.id # post a message without adding followers to the channel. email_from=False avoid to get author from email data - mail_channel = request.env["mail.channel"].sudo(request_uid).search([('uuid', '=', uuid)], limit=1) - message = mail_channel.sudo(request_uid).with_context(mail_create_nosubscribe=True).message_post(author_id=author_id, email_from=False, body=message_content, message_type='comment', subtype='mail.mt_comment', content_subtype='plaintext', **kwargs) + mail_channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1) + message = mail_channel.sudo().with_context(mail_create_nosubscribe=True).message_post(author_id=author_id, email_from=False, body=message_content, message_type='comment', subtype='mail.mt_comment', content_subtype='plaintext') return message and message.id or False @route(['/mail/chat_history'], type="json", auth="none") def mail_chat_history(self, uuid, last_id=False, limit=20): - request_uid = self._default_request_uid() - channel = request.env["mail.channel"].sudo(request_uid).search([('uuid', '=', uuid)], limit=1) + channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1) if not channel: return [] else: - return channel.sudo(request_uid).channel_fetch_message(last_id, limit) + return channel.channel_fetch_message(last_id, limit) diff --git a/addons/mail/i18n/mail.pot b/addons/mail/i18n/mail.pot index 84af3669e83dfdb51f526fea2d3eb2f54c86a35f..a53af84ecffcf9b02016feaf491b04a0a34bf9ad 100644 --- a/addons/mail/i18n/mail.pot +++ b/addons/mail/i18n/mail.pot @@ -4408,7 +4408,7 @@ msgstr "" #. module: mail #: model:ir.model.fields,help:mail.field_mail_activity_type_res_model_id -msgid "Specify a model if the activity should be specific to a modeland not available when managing activities for other models." +msgid "Specify a model if the activity should be specific to a model and not available when managing activities for other models." msgstr "" #. module: mail diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index 909474cb109049a728a72701a69ca1b2f278f31e..6ed049833d587cb20b2d062bb63993e7be44395c 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -3,7 +3,7 @@ from datetime import date, datetime, timedelta -from odoo import api, fields, models +from odoo import api, exceptions, fields, models, _ class MailActivityType(models.Model): @@ -26,7 +26,7 @@ class MailActivityType(models.Model): res_model_id = fields.Many2one( 'ir.model', 'Model', index=True, help='Specify a model if the activity should be specific to a model' - 'and not available when managing activities for other models.') + ' and not available when managing activities for other models.') next_type_ids = fields.Many2many( 'mail.activity.type', 'mail_activity_rel', 'activity_id', 'recommended_id', string='Recommended Next Activities') @@ -134,21 +134,61 @@ class MailActivity(models.Model): def _onchange_recommended_activity_type_id(self): self.activity_type_id = self.recommended_activity_type_id + @api.multi + def _check_access(self, operation): + """ Rule to access activities + + * create: check write rights on related document; + * write: rule OR write rights on document; + * unlink: rule OR write rights on document; + """ + self.check_access_rights(operation, raise_exception=True) # will raise an AccessError + + if operation in ('write', 'unlink'): + try: + self.check_access_rule(operation) + except exceptions.AccessError: + pass + else: + return + + doc_operation = 'read' if operation == 'read' else 'write' + activity_to_documents = dict() + for activity in self.sudo(): + activity_to_documents.setdefault(activity.res_model, list()).append(activity.res_id) + for model, res_ids in activity_to_documents.items(): + self.env[model].check_access_rights(doc_operation, raise_exception=True) + try: + self.env[model].browse(res_ids).check_access_rule(doc_operation) + except exceptions.AccessError: + raise exceptions.AccessError( + _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % + (self._description, operation)) + @api.model def create(self, values): - activity = super(MailActivity, self).create(values) - self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[activity.user_id.partner_id.id]) + # already compute default values to be sure those are computed using the current user + values_w_defaults = self.default_get(self._fields.keys()) + values_w_defaults.update(values) + + # continue as sudo because activities are somewhat protected + activity = super(MailActivity, self.sudo()).create(values_w_defaults) + activity_user = activity.sudo(self.env.user) + activity_user._check_access('create') + self.env[activity_user.res_model].browse(activity_user.res_id).message_subscribe(partner_ids=[activity_user.user_id.partner_id.id]) if activity.date_deadline <= fields.Date.today(): self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', activity.user_id.partner_id.id), {'type': 'activity_updated', 'activity_created': True}) - return activity + return activity_user @api.multi def write(self, values): + self._check_access('write') if values.get('user_id'): pre_responsibles = self.mapped('user_id.partner_id') - res = super(MailActivity, self).write(values) + res = super(MailActivity, self.sudo()).write(values) + if values.get('user_id'): for activity in self: self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[activity.user_id.partner_id.id]) @@ -166,12 +206,13 @@ class MailActivity(models.Model): @api.multi def unlink(self): + self._check_access('unlink') for activity in self: if activity.date_deadline <= fields.Date.today(): self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', activity.user_id.partner_id.id), {'type': 'activity_updated', 'activity_deleted': True}) - return super(MailActivity, self).unlink() + return super(MailActivity, self.sudo()).unlink() @api.multi def action_done(self): diff --git a/addons/mail/models/mail_alias.py b/addons/mail/models/mail_alias.py index 8bd0b8f19c9a92210720946ca868062e79bd3a23..dc09f3be13b06d76d8e28cfacce231bd36d8b071 100644 --- a/addons/mail/models/mail_alias.py +++ b/addons/mail/models/mail_alias.py @@ -264,16 +264,27 @@ class AliasMixin(models.AbstractModel): record._name, record.display_name, record.id) def _alias_check_contact(self, message, message_dict, alias): + """ Main mixin method that inheriting models may inherit in order + to implement a specifc behavior. """ + return self._alias_check_contact_on_record(self, message, message_dict, alias) + + def _alias_check_contact_on_record(self, record, message, message_dict, alias): + """ Generic method that takes a record not necessarily inheriting from + mail.alias.mixin. """ author = self.env['res.partner'].browse(message_dict.get('author_id', False)) - if alias.alias_contact == 'followers' and self.ids: - if not hasattr(self, "message_partner_ids") or not hasattr(self, "message_channel_ids"): + if alias.alias_contact == 'followers': + if not record.ids: + return { + 'error_message': _('incorrectly configured alias (unknown reference record)'), + } + if not hasattr(record, "message_partner_ids") or not hasattr(record, "message_channel_ids"): return { - 'error_mesage': _('incorrectly configured alias'), + 'error_message': _('incorrectly configured alias'), } - accepted_partner_ids = self.message_partner_ids | self.message_channel_ids.mapped('channel_partner_ids') + accepted_partner_ids = record.message_partner_ids | record.message_channel_ids.mapped('channel_partner_ids') if not author or author not in accepted_partner_ids: return { - 'error_mesage': _('restricted to followers'), + 'error_message': _('restricted to followers'), } elif alias.alias_contact == 'partners' and not author: return { diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 47918de4966654d85d7ad5b177328d387ed58f5b..86168bf60492d29d93fbb2594efb0a52287fbcb1 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -97,6 +97,7 @@ class Message(models.Model): tracking_value_ids = fields.One2many( 'mail.tracking.value', 'mail_message_id', string='Tracking values', + groups="base.group_no_one", help='Tracked values are stored in a separate model. This field allow to reconstruct ' 'the tracking and to generate statistics on the model.') # mail gateway @@ -280,8 +281,8 @@ class Message(models.Model): # 1. Aggregate partners (author_id and partner_ids), attachments and tracking values partners = self.env['res.partner'].sudo() attachments = self.env['ir.attachment'] - trackings = self.env['mail.tracking.value'] - for key, message in message_tree.items(): + message_ids = list(message_tree.keys()) + for message in message_tree.values(): if message.author_id: partners |= message.author_id if message.subtype_id and message.partner_ids: # take notified people of message with a subtype @@ -292,8 +293,6 @@ class Message(models.Model): partners |= message.needaction_partner_ids if message.attachment_ids: attachments |= message.attachment_ids - if message.tracking_value_ids: - trackings |= message.tracking_value_ids # Read partners as SUPERUSER -> message being browsed as SUPERUSER it is already the case partners_names = partners.name_get() partner_tree = dict((partner[0], partner) for partner in partners_names) @@ -308,13 +307,18 @@ class Message(models.Model): }) for attachment in attachments_data) # 3. Tracking values - tracking_tree = dict((tracking.id, { - 'id': tracking.id, - 'changed_field': tracking.field_desc, - 'old_value': tracking.get_old_display_value()[0], - 'new_value': tracking.get_new_display_value()[0], - 'field_type': tracking.field_type, - }) for tracking in trackings) + tracking_values = self.env['mail.tracking.value'].sudo().search([('mail_message_id', 'in', message_ids)]) + message_to_tracking = dict() + tracking_tree = dict.fromkeys(tracking_values.ids, False) + for tracking in tracking_values: + message_to_tracking.setdefault(tracking.mail_message_id.id, list()).append(tracking.id) + tracking_tree[tracking.id] = { + 'id': tracking.id, + 'changed_field': tracking.field_desc, + 'old_value': tracking.get_old_display_value()[0], + 'new_value': tracking.get_new_display_value()[0], + 'field_type': tracking.field_type, + } # 4. Update message dictionaries for message_dict in messages: @@ -341,9 +345,9 @@ class Message(models.Model): if attachment.id in attachments_tree: attachment_ids.append(attachments_tree[attachment.id]) tracking_value_ids = [] - for tracking_value in message.tracking_value_ids: - if tracking_value.id in tracking_tree: - tracking_value_ids.append(tracking_tree[tracking_value.id]) + for tracking_value_id in message_to_tracking.get(message_id, list()): + if tracking_value_id in tracking_tree: + tracking_value_ids.append(tracking_tree[tracking_value_id]) message_dict.update({ 'author_id': author, @@ -746,7 +750,12 @@ class Message(models.Model): return '%s%s alt="%s"' % (data_to_url[key], match.group(3), name) values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body'])) + # delegate creation of tracking after the create as sudo to avoid access rights issues + tracking_values_cmd = values.pop('tracking_value_ids', False) message = super(Message, self).create(values) + if tracking_values_cmd: + message.sudo().write({'tracking_value_ids': tracking_values_cmd}) + message._invalidate_documents() if not self.env.context.get('message_create_from_mail_mail'): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 1d7d9681ea1da22965e7f78bfbd243e0ffddf52c..b681e18d284f1569595503bda83be10dbebadf4a 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -958,9 +958,10 @@ class MailThread(models.AbstractModel): obj = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id) elif model: obj = self.env[model] - if not hasattr(obj, '_alias_check_contact'): - obj = self.env['mail.alias.mixin'] - check_result = obj._alias_check_contact(message, message_dict, alias) + if hasattr(obj, '_alias_check_contact'): + check_result = obj._alias_check_contact(message, message_dict, alias) + else: + check_result = self.env['mail.alias.mixin']._alias_check_contact_on_record(obj, message, message_dict, alias) if check_result is not True: self._routing_warn(_('alias %s: %s') % (alias.alias_name, check_result.get('error_message', _('unknown error'))), _('skipping'), message_id, route, False) self._routing_create_bounce_email(email_from, check_result.get('error_template', _generic_bounce_body_html), message) diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 3a81423a9b211d89db8e19bcdd4a92c2577cef30..6114adcce7bc1eb280db1c10ae11b8c7eba522c9 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -62,7 +62,7 @@ class Partner(models.Model): record_name = message.record_name tracking = [] - for tracking_value in message.tracking_value_ids: + for tracking_value in self.env['mail.tracking.value'].sudo().search([('mail_message_id', '=', message.id)]): tracking.append((tracking_value.field_desc, tracking_value.get_old_display_value()[0], tracking_value.get_new_display_value()[0])) diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 99f811a363449279f9a8b350846287dc4d7257c7..0a2e9713c4f6cbd826d2612b0102e557c8d7b186 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -23,9 +23,9 @@ access_mail_alias_user,mail.alias.user,model_mail_alias,base.group_user,1,1,1,1 access_mail_alias_system,mail.alias.system,model_mail_alias,base.group_system,1,1,1,1 access_mail_message_subtype_all,mail.message.subtype.all,model_mail_message_subtype,,1,0,0,0 access_mail_message_subtype_user,mail.message.subtype.user,model_mail_message_subtype,,1,1,1,1 -access_mail_tracking_value_all,mail.tracking.value.all,model_mail_tracking_value,,1,0,0,0 -access_mail_tracking_value_portal,mail.tracking.value.portal,model_mail_tracking_value,base.group_portal,1,1,1,1 -access_mail_tracking_value_user,mail.tracking.value.user,model_mail_tracking_value,base.group_user,1,1,1,1 +access_mail_tracking_value_all,mail.tracking.value.all,model_mail_tracking_value,,0,0,0,0 +access_mail_tracking_value_portal,mail.tracking.value.portal,model_mail_tracking_value,base.group_portal,0,0,0,0 +access_mail_tracking_value_user,mail.tracking.value.user,model_mail_tracking_value,base.group_user,0,0,0,0 access_mail_tracking_value_system,mail.tracking.value.system,model_mail_tracking_value,base.group_system,1,1,1,1 access_mail_thread_all,mail.thread.all,model_mail_thread,,1,1,1,1 access_publisher_warranty_contract_all,publisher.warranty.contract.all,model_publisher_warranty_contract,,1,1,1,1 @@ -36,7 +36,7 @@ access_mail_shortcode_portal,mail.shortcode.portal,model_mail_shortcode,base.gro access_mail_test_user,mail.test.all,model_mail_test,base.group_user,1,1,1,1 access_mail_test_portal,mail.test.all,model_mail_test,base.group_portal,1,1,0,0 access_mail_test_simple,mail.test.simple.all,model_mail_test_simple,base.group_user,1,1,1,1 -access_mail_activity_all,mail.activity.all,model_mail_activity,,1,0,0,0 +access_mail_activity_all,mail.activity.all,model_mail_activity,,0,0,0,0 access_mail_activity_user,mail.activity.user,model_mail_activity,base.group_user,1,1,1,1 -access_mail_activity_type_all,mail.activity.type.all,model_mail_activity_type,,1,0,0,0 -access_mail_activity_type_user,mail.activity.type.user,model_mail_activity_type,base.group_user,1,1,1,1 \ No newline at end of file +access_mail_activity_type_all,mail.activity.type.all,model_mail_activity_type,,0,0,0,0 +access_mail_activity_type_user,mail.activity.type.user,model_mail_activity_type,base.group_user,1,1,1,1 diff --git a/addons/mail/security/mail_security.xml b/addons/mail/security/mail_security.xml index d5a5b5422c911d522ecd089e5669f57fc837f431..b9b13d9de3bc1baa8d63dd7ac59065341fa19a81 100644 --- a/addons/mail/security/mail_security.xml +++ b/addons/mail/security/mail_security.xml @@ -40,5 +40,16 @@ <field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_public'))]"/> </record> + <record id="mail_activity_rule_user" model="ir.rule"> + <field name="name">mail.activity: user: own only</field> + <field name="model_id" ref="model_mail_activity"/> + <field name="domain_force">[('user_id', '=', user.id)]</field> + <field name="groups" eval="[(4, ref('base.group_user'))]"/> + <field name="perm_create" eval="False"/> + <field name="perm_read" eval="False"/> + <field name="perm_write" eval="True"/> + <field name="perm_unlink" eval="True"/> + </record> + </data> </odoo> diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 61b06d7929a3473060236ca25299da8cf10fd0c6..8eb0126dd0b84a92809763d5204f956c2164ef91 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -346,7 +346,8 @@ exports.PosModel = Backbone.Model.extend({ }, },{ model: 'product.product', - fields: ['display_name', 'list_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', + // todo remove list_price in master, it is unused + fields: ['display_name', 'list_price', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', 'product_tmpl_id','tracking'], order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }), @@ -1234,7 +1235,7 @@ exports.Product = Backbone.Model.extend({ (! item.date_end || moment(item.date_end).isSameOrAfter(date)); }); - var price = self.list_price; + var price = self.lst_price; _.find(pricelist_items, function (rule) { if (rule.min_quantity && quantity < rule.min_quantity) { return false; diff --git a/addons/point_of_sale/static/src/js/tests.js b/addons/point_of_sale/static/src/js/tests.js index 36fd7a49f8a43b801c9a5e6e3fb923572cb3fe62..6c5c970f545726d755299fa54f32dc3bf0e6a870 100644 --- a/addons/point_of_sale/static/src/js/tests.js +++ b/addons/point_of_sale/static/src/js/tests.js @@ -54,6 +54,7 @@ odoo.define('point_of_sale.tour.pricelist', function (require) { var product_limon = posmodel.db.search_product_in_category(0, 'Stringers')[0]; var product_pamplemousse = posmodel.db.search_product_in_category(0, 'Red grapefruit')[0]; var product_grapes = posmodel.db.search_product_in_category(0, 'Black Grapes')[0]; + var product_poire_conference = posmodel.db.search_product_in_category(0, 'Conference pears')[0]; var product_external_audit = posmodel.db.search_product_in_category(0, 'External Audit')[0]; var product_miscellaneous = posmodel.db.search_product_in_category(0, 'Miscellaneous')[0]; @@ -83,6 +84,7 @@ odoo.define('point_of_sale.tour.pricelist', function (require) { .then(compare_backend_frontend(product_miscellaneous, 'Pricelist base', 1, undefined)) .then(compare_backend_frontend(product_miscellaneous, 'Pricelist base 2', 1, undefined)) .then(compare_backend_frontend(product_papillon_orange, 'Pricelist base rounding', 1, undefined)) + .then(compare_backend_frontend(product_poire_conference, 'Public Pricelist', 1, undefined)) .then(function () { $('.pos').addClass('done-testing'); }); diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py index c69381b0344d7abc15d57a92715f7b91952fdbde..b3dae4e9350908e5efb2d695fd2dd257d5e51421 100644 --- a/addons/point_of_sale/tests/test_frontend.py +++ b/addons/point_of_sale/tests/test_frontend.py @@ -36,6 +36,21 @@ class TestUi(odoo.tests.HttpCase): 'fields_id': field.id, 'value': 'account.account,' + str(account_receivable.id)}) + # test an extra price on an attribute + pear = env.ref('point_of_sale.poire_conference') + attribute_value = env['product.attribute.value'].create({ + 'name': 'add 2', + 'product_ids': [(6, 0, [pear.id])], + 'attribute_id': env['product.attribute'].create({ + 'name': 'add 2', + }).id, + }) + env['product.attribute.price'].create({ + 'product_tmpl_id': pear.product_tmpl_id.id, + 'price_extra': 2, + 'value_id': attribute_value.id, + }) + fixed_pricelist = env['product.pricelist'].create({ 'name': 'Fixed', 'item_ids': [(0, 0, { diff --git a/addons/sale_payment/i18n/sale_payment.pot b/addons/sale_payment/i18n/sale_payment.pot index faa13069f7cb04420b91dfc6e39f4db1d96c8450..e98d32218be39815c4a1ca0b3557825da8a987ea 100644 --- a/addons/sale_payment/i18n/sale_payment.pot +++ b/addons/sale_payment/i18n/sale_payment.pot @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 10.saas~18\n" +"Project-Id-Version: Odoo Server 11.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-02 11:26+0000\n" -"PO-Revision-Date: 2017-10-02 11:26+0000\n" +"POT-Creation-Date: 2017-11-07 15:45+0000\n" +"PO-Revision-Date: 2017-11-07 15:45+0000\n" "Last-Translator: <>\n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -79,7 +79,12 @@ msgid "Awaiting Payment" msgstr "" #. module: sale_payment -#: code:addons/sale_payment/controllers/payment.py:53 +#: model:ir.ui.view,arch_db:sale_payment.crm_team_salesteams_view_kanban_inherit_website_portal_sale +msgid "Awaiting Payments" +msgstr "" + +#. module: sale_payment +#: code:addons/sale_payment/controllers/payment.py:52 #, python-format msgid "If we store your payment information on our server, subscription payments will be made automatically." msgstr "" @@ -126,11 +131,6 @@ msgstr "" msgid "Pay with" msgstr "" -#. module: sale_payment -#: model:ir.ui.view,arch_db:sale_payment.crm_team_salesteams_view_kanban_inherit_website_portal_sale -msgid "Payment" -msgstr "" - #. module: sale_payment #: model:ir.model.fields,field_description:sale_payment.field_sale_order_payment_acquirer_id msgid "Payment Acquirer" @@ -141,6 +141,16 @@ msgstr "" msgid "Payment Transaction" msgstr "" +#. module: sale_payment +#: model:ir.ui.view,arch_db:sale_payment.crm_team_salesteams_view_kanban_inherit_website_portal_sale +msgid "Payment to Capture" +msgstr "" + +#. module: sale_payment +#: model:ir.ui.view,arch_db:sale_payment.crm_team_salesteams_view_kanban_inherit_website_portal_sale +msgid "Payments to Capture" +msgstr "" + #. module: sale_payment #: model:ir.actions.act_window,name:sale_payment.payment_transaction_action_pending msgid "Pending Payment Transactions" @@ -207,8 +217,3 @@ msgstr "" msgid "Transactions" msgstr "" -#. module: sale_payment -#: model:ir.ui.view,arch_db:sale_payment.crm_team_salesteams_view_kanban_inherit_website_portal_sale -msgid "to Capture" -msgstr "" - diff --git a/addons/sale_payment/views/crm_team_views.xml b/addons/sale_payment/views/crm_team_views.xml index 02629ca02dd41134b3af00a0e845573064484e4e..8f2b607a3d72bbfbd43ab24ed8cd0bb7db129443 100644 --- a/addons/sale_payment/views/crm_team_views.xml +++ b/addons/sale_payment/views/crm_team_views.xml @@ -12,7 +12,9 @@ <div class="col-xs-8"> <div> <a name="%(sale_payment.payment_transaction_action_pending)d" type="action"> - <field name="pending_payment_transactions_count"/> Awaiting Payment<t t-if="record.pending_payment_transactions_count.raw_value != 1">s</t> + <field name="pending_payment_transactions_count"/> + <t t-if="record.pending_payment_transactions_count.raw_value == 1">Awaiting Payment</t> + <t t-else="">Awaiting Payments</t> </a> </div> </div> @@ -23,7 +25,9 @@ <div class="row" t-if="record.authorized_payment_transactions_count.raw_value"> <div class="col-xs-8"> <a name="%(sale_payment.payment_transaction_action_authorized)d" type="action"> - <field name="authorized_payment_transactions_count"/> Payment<t t-if="record.authorized_payment_transactions_count.raw_value != 1">s</t> to Capture + <field name="authorized_payment_transactions_count"/> + <t t-if="record.authorized_payment_transactions_count.raw_value == 1">Payment to Capture</t> + <t t-else="">Payments to Capture</t> </a> </div> <div class="col-xs-4 text-right"> diff --git a/addons/sale_timesheet/i18n/sale_timesheet.pot b/addons/sale_timesheet/i18n/sale_timesheet.pot index 813d8661dbd2e72aed0083d31f8badad613b6326..4c3e7c22f09ba4826a8321eb1729038b57ab6eb1 100644 --- a/addons/sale_timesheet/i18n/sale_timesheet.pot +++ b/addons/sale_timesheet/i18n/sale_timesheet.pot @@ -214,7 +214,7 @@ msgstr "" #. module: sale_timesheet #: model:ir.model.fields,help:sale_timesheet.field_product_product_service_tracking #: model:ir.model.fields,help:sale_timesheet.field_product_template_service_tracking -msgid "On Sales order confirmation, this product can generate project and/or task. From thoses, you can track the service you are selling." +msgid "On Sales order confirmation, this product can generate a project and/or task. From those, you can track the service you are selling." msgstr "" #. module: sale_timesheet diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 3fe382701d363e8ff5b898eb8fe0d93fa26cbc00..aeddf1bd2ce5f9bc67d3a0372d25c8956de9d283 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -20,7 +20,8 @@ class ProductTemplate(models.Model): ('task_global_project', 'Create a task in an existing project'), ('task_new_project', 'Create a task in a new project'), ('project_only', 'Create a new project but no task'), - ], string="Service Tracking", default="no", help="On Sales order confirmation, this product can generate project and/or task. From thoses, you can track the service you are selling.") + ], string="Service Tracking", default="no", + help="On Sales order confirmation, this product can generate a project and/or task. From those, you can track the service you are selling.") project_id = fields.Many2one( 'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)], help='Select a non billable project on which tasks can be created. This setting must be set for each company.') diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index b51495cb3d78f14ba9e82ae9b2c60cfd05dc8fa2..5ec43a2728b534d0a6b1aa0bd9ad3bfe51c56d60 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -652,13 +652,14 @@ class StockMove(models.Model): ('printed', '=', False), ('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1) if picking: - # If a picking is found, we'll append `move` to its move list and thus its - # `partner_id` and `ref` field will refer to multiple records. In this - # case, we chose to wipe them. - picking.write({ - 'partner_id': False, - 'origin': False, - }) + if picking.partner_id.id != move.partner_id.id or picking.origin != move.origin: + # If a picking is found, we'll append `move` to its move list and thus its + # `partner_id` and `ref` field will refer to multiple records. In this + # case, we chose to wipe them. + picking.write({ + 'partner_id': False, + 'origin': False, + }) else: recompute = True picking = Picking.create(move._get_new_picking_values()) diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 8c291a7eafedf507876f2cf02fb81a5694efde26..bf2a6dccf01ed6dcde2ed072c1684bff5692416d 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -13,6 +13,7 @@ from odoo.tools.float_utils import float_round, float_compare, float_is_zero class StockMoveLine(models.Model): _name = "stock.move.line" _description = "Packing Operation" + _rec_name = "product_id" _order = "result_package_id desc, id" picking_id = fields.Many2one( @@ -426,9 +427,9 @@ class StockMoveLine(models.Model): if 'lot_id' in vals and vals['lot_id'] != move.lot_id.id: data['lot_name'] = self.env['stock.production.lot'].browse(vals.get('lot_id')).name if 'location_id' in vals: - data['location_name'] = self.env['stock.location_id'].browse(vals.get('location_id')).name + data['location_name'] = self.env['stock.location'].browse(vals.get('location_id')).name if 'location_dest_id' in vals: - data['location_dest_name'] = self.env['stock.location_id'].browse(vals.get('location_dest_id')).name + data['location_dest_name'] = self.env['stock.location'].browse(vals.get('location_dest_id')).name if 'package_id' in vals and vals['package_id'] != move.package_id.id: data['package_name'] = self.env['stock.quant.package'].browse(vals.get('package_id')).name if 'package_result_id' in vals and vals['package_result_id'] != move.package_result_id.id: diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index b16c0c0c2cb9b54384c526beb40f30e77588e675..8232937897555f2f1a8750bff8959642f9bf7f6a 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -612,6 +612,10 @@ class WebClient(http.Controller): def test_suite(self, mod=None, **kwargs): return request.render('web.qunit_suite') + @http.route('/web/tests/mobile', type='http', auth="none") + def test_mobile_suite(self, mod=None, **kwargs): + return request.render('web.qunit_mobile_suite') + @http.route('/web/benchmarks', type='http', auth="none") def benchmarks(self, mod=None, **kwargs): return request.render('web.benchmark_suite') diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 2d2a5d5ea4dfd068635f5f8f3b9b7a8c1bf5e409..e5a7cc3da2f1364c59b41c8a54bffb8cdafd0b47 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -503,9 +503,11 @@ var FieldMany2One = AbstractField.extend({ title: _t("Open: ") + self.string, view_id: view_id, readonly: !self.can_write, - on_saved: function () { - self._setValue(self.value.data, {forceChange: true}); - self.trigger_up('reload', {db_id: self.value.id}); + on_saved: function (record, changed) { + if (changed) { + self._setValue(self.value.data, {forceChange: true}); + self.trigger_up('reload', {db_id: self.value.id}); + } }, }).open(); }); diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index 32d526e160d81dfca77ac4394efee67acc0f7749..e99f9ebb6f2c7650c727a24c250c6f817bdc3f79 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -424,7 +424,7 @@ return core.Class.extend({ var deps = fieldsInfo[node.attrs.name].fieldDependencies; for (var dependency_name in deps) { if (!(dependency_name in fieldsInfo)) { - fieldsInfo[dependency_name] = {'name': dependency_name, 'type': deps[dependency_name].type}; + fieldsInfo[dependency_name] = {'name': dependency_name, 'type': deps[dependency_name].type, 'options': deps[dependency_name].options || {}}; } } } 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 4412d29c455bbefe35b0a5d4412e2fda2131b5a1..4028bff322a9abaf339c3f404b920c613ae28242 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -896,6 +896,9 @@ var BasicModel = AbstractModel.extend({ return def.resolve(changedFields); } + def.then(function () { + record._isDirty = false; + }); // in the case of a write, only perform the RPC if there are changes to save if (method === 'create' || changedFields.length) { var args = method === 'write' ? [[record.data.id], changes] : [changes]; @@ -917,7 +920,6 @@ var BasicModel = AbstractModel.extend({ // Erase changes as they have been applied record._changes = {}; - record._isDirty = false; // Update the data directly or reload them if (shouldReload) { @@ -947,8 +949,8 @@ var BasicModel = AbstractModel.extend({ */ addFieldsInfo: function (recordID, viewInfo) { var record = this.localData[recordID]; - record.fields = _.defaults(record.fields, viewInfo.fields); - record.fieldsInfo = _.defaults(record.fieldsInfo, viewInfo.fieldsInfo); + record.fields = _.extend({}, record.fields, viewInfo.fields); + record.fieldsInfo = _.extend({}, record.fieldsInfo, viewInfo.fieldsInfo); }, /** * Manually sets a resource as dirty. This is used to notify that a field diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index bdc250011f081cc750c2066db9c39e012452ee05..8a91e2b90c74f5c0d8e301be87ccacfd2a1e9d0b 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -47,7 +47,10 @@ var BasicView = AbstractView.extend({ this.loadParams.context = params.context || {}; this.loadParams.limit = parseInt(viewInfo.arch.attrs.limit, 10) || params.limit; this.loadParams.viewType = this.viewType; + this.loadParams.parentID = params.parentID; this.recordID = params.recordID; + + this.model = params.model; }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js index f011a24a32e5d5f6a5d67eb80acf41e4dd452ff0..6283a3539f3b4ac68277090155f63370b8b03b22 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -355,7 +355,7 @@ return AbstractRenderer.extend({ }, // Dirty hack to ensure a correct first render eventAfterAllRender: function () { - window.dispatchEvent(new Event('resize')); + $(window).trigger('resize'); }, viewRender: function (view) { // compute mode from view.name which is either 'month', 'agendaWeek' or 'agendaDay' diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index b48a548884831fc3da65a072ab1aa5558af21305..42cf5de12e452e076a6653460c360ba6ebc11f2c 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -27,7 +27,6 @@ var FormView = BasicView.extend({ var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); this.loadParams.type = 'record'; - this.loadParams.parentID = params.parentID; this.controllerParams.disableAutofocus = params.disable_autofocus; this.controllerParams.hasSidebar = params.sidebar; @@ -41,7 +40,6 @@ var FormView = BasicView.extend({ this.controllerParams.mode = mode; this.rendererParams.mode = mode; - this.model = params.model; }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 460ad5de100073f665757567df60c348c6f90dd5..67cc62715098d037042c345705e2f52387508352 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -355,8 +355,8 @@ var ListController = BasicController.extend({ // trigger a click on the main bus, which would be then caught by the // list editable renderer and would unselect the newly created row event.stopPropagation(); - - if (this.editable) { + var state = this.model.get(this.handle, {raw: true}); + if (this.editable && !state.groupedBy.length) { this._addRecord(); } else { this.trigger_up('switch_view', {view_type: 'form', res_id: undefined}); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index fe38fae753e74664f103c9699faad0b16a7ef77e..a33f6f740b8ca33559031cbb5da4f3425a1d1375 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -81,9 +81,9 @@ var FormViewDialog = ViewDialog.extend({ * @param {Object} [options.fields_view] optional form fields_view * @param {boolean} [options.readonly=false] only applicable when not in * creation mode - * @param {function} [options.on_save] callback to execute when clicking on - * 'Save' (form view's 'saveRecord' by default) - * @param {function} [options.on_saved] callback executed after on_save + * @param {function} [options.on_saved] callback executed after saving a + * record. It will be called with the record data, and a boolean which + * indicates if something was changed * @param {BasicModel} [options.model] if given, it will be used instead of * a new form view model * @param {string} [options.recordID] if given, the model has to be given as @@ -214,24 +214,16 @@ var FormViewDialog = ViewDialog.extend({ _save: function () { var self = this; - var def; - if (this.options.on_save) { - if (this.form_view.canBeSaved()) { - return $.Deferred().reject(); - } - def = this.options.on_save(this.form_view.model.get(this.form_view.handle)); - } else { - def = this.form_view.saveRecord(this.form_view.handle, { + return this.form_view.saveRecord(this.form_view.handle, { stayInEdit: true, reload: false, savePoint: this.shouldSaveLocally, viewType: 'form', - }); - } - return $.when(def).then(function () { + }).then(function (changedFields) { // record might have been changed by the save (e.g. if this was a new record, it has an // id now), so don't re-use the copy obtained before the save - self.on_saved(self.form_view.model.get(self.form_view.handle)); + var record = self.form_view.model.get(self.form_view.handle); + self.on_saved(record, !!changedFields.length); }); }, }); @@ -402,7 +394,8 @@ var SelectCreateDialog = ViewDialog.extend({ var results = pyeval.eval_domains_and_contexts({ domains: [this.domain].concat(domains), contexts: [this.context].concat(contexts), - group_by_seq: groupbys || [] + group_by_seq: groupbys || [], + eval_context: this.getSession().user_context, }); var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; }); return { diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js index a0edf9dba2887bbe65e85999953dfa289bd3f8ec..4160d6f554b967c26a2486c4151f50e177f90007 100644 --- a/addons/web/static/src/js/widgets/debug_manager.js +++ b/addons/web/static/src/js/widgets/debug_manager.js @@ -7,7 +7,6 @@ var config = require('web.config'); var core = require('web.core'); var Dialog = require('web.Dialog'); var field_utils = require('web.field_utils'); -var framework = require('web.framework'); var session = require('web.session'); var SystrayMenu = require('web.SystrayMenu'); var utils = require('web.utils'); @@ -190,6 +189,9 @@ var DebugManager = Widget.extend({ } }).open(); }, + /** + * Runs the JS (desktop) tests + */ perform_js_tests: function () { this.do_action({ name: _t("JS Tests"), @@ -198,6 +200,17 @@ var DebugManager = Widget.extend({ url: '/web/tests?mod=*' }); }, + /** + * Runs the JS mobile tests + */ + perform_js_mobile_tests: function () { + this.do_action({ + name: _t("JS Mobile Tests"), + target: 'new', + type: 'ir.actions.act_url', + url: '/web/tests/mobile?mod=*' + }); + }, split_assets: function() { window.location = $.param.querystring(window.location.href, 'debug=assets'); }, diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js index 782128994db236fd3b8252c71933c282687c63e6..0619cf613c6413eaf6e904b5464f135aa19b0ba3 100644 --- a/addons/web/static/src/js/widgets/domain_selector.js +++ b/addons/web/static/src/js/widgets/domain_selector.js @@ -179,7 +179,16 @@ var DomainTree = DomainNode.extend({ */ init: function (parent, model, domain, options) { this._super.apply(this, arguments); - this._initialize(Domain.prototype.stringToArray(domain)); + try { + domain = Domain.prototype.stringToArray(domain); + } catch (err) { + // TODO: domain could contain `parent` for example, which is + // currently not handled by the DomainSelector + this.invalidDomain = true; + this.children = []; + return; + } + this._initialize(domain); }, /** * @see DomainNode.start @@ -443,6 +452,16 @@ var DomainSelector = DomainTree.extend({ domain_changed: "_onDomainChange", }), + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + if (self.invalidDomain) { + var msg = _t("This domain is not supported."); + self.$el.html(msg); + } + }); + }, + //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 7da3f448378413e78d6723dacad7eb185aa6f712..f1af2e7d76290c917392fa01431e112a519d2c28 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -220,6 +220,7 @@ </t> <t t-name="WebClient.DebugManager.Global"> <li><a href="#" data-action="perform_js_tests">Run JS Tests</a></li> + <li><a href="#" data-action="perform_js_mobile_tests">Run JS Mobile Tests</a></li> <li><a href="#" data-action="select_view">Open View</a></li> <t t-if="manager._events"> <li class="divider"/> diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index f665fa160e4f08efb8a98cd01fd49733e490f0e4..2e8499b31e1e8bc8e00db21ae1ebba61d6062edc 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -137,6 +137,7 @@ QUnit.module('relational_fields', { partner_ids: [], turtle_ref: 'product,37', }], + onchanges: {}, }, user: { fields: { @@ -212,6 +213,98 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('editing a many2one, but not changing anything', function (assert) { + assert.expect(2); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="trululu"/>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>', + }, + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'get_formview_id') { + assert.deepEqual(args.args[0], [4], "should call get_formview_id with correct id"); + return $.when(false); + } + return this._super(route, args); + }, + viewOptions: { + ids: [1, 2], + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + // click on the external button (should do an RPC) + form.$('.o_external_button').click(); + // save and close modal + $('.modal .modal-footer .btn-primary:first').click(); + // save form + form.$buttons.find('.o_form_button_save').click(); + // click next on pager + form.pager.$('.o_pager_next').click(); + + // this checks that the view did not ask for confirmation that the + // record is dirty + assert.strictEqual(form.pager.$el.text().trim(), '2 / 2', + 'pager should be at second page'); + form.destroy(); + }); + + QUnit.test('editing a many2one (with form view opened with external button)', function (assert) { + assert.expect(1); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="trululu"/>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,form': '<form string="Partners"><field name="foo"/></form>', + }, + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'get_formview_id') { + return $.when(false); + } + return this._super(route, args); + }, + viewOptions: { + ids: [1, 2], + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + // click on the external button (should do an RPC) + form.$('.o_external_button').click(); + + $('.modal input[name="foo"]').val('brandon').trigger('input'); + + // save and close modal + $('.modal .modal-footer .btn-primary:first').click(); + // save form + form.$buttons.find('.o_form_button_save').click(); + // click next on pager + form.pager.$('.o_pager_next').click(); + + // this checks that the view did not ask for confirmation that the + // record is dirty + assert.strictEqual(form.pager.$el.text().trim(), '2 / 2', + 'pager should be at second page'); + form.destroy(); + }); + QUnit.test('many2ones in form views with show_adress', function (assert) { assert.expect(4); var form = createView({ @@ -2587,6 +2680,45 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('edition of one2many field, with onchange and not inline sub view', function (assert) { + assert.expect(2); + + this.data.turtle.onchanges.turtle_int = function (obj) { + obj.turtle_foo = String(obj.turtle_int); + }; + this.data.partner.onchanges.turtles = function () {}; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="turtles"/>' + + '</form>', + archs: { + 'turtle,false,list': '<tree><field name="turtle_foo"/></tree>', + 'turtle,false,form': '<form><group><field name="turtle_foo"/><field name="turtle_int"/></group></form>', + }, + mockRPC: function (route, args) { + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_x2many_list_row_add a').click(); + $('input[name="turtle_int"]').val('5').trigger('input'); + $('.modal-footer button.btn-primary').first().click(); + assert.strictEqual(form.$('tbody tr:eq(1) td.o_data_cell').text(), '5', + 'should display 5 in the foo field'); + form.$('tbody tr:eq(1) td.o_data_cell').click(); + + $('input[name="turtle_int"]').val('3').trigger('input'); + $('.modal-footer button.btn-primary').first().click(); + assert.strictEqual(form.$('tbody tr:eq(1) td.o_data_cell').text(), '3', + 'should now display 3 in the foo field'); + form.destroy(); + }); + QUnit.test('sorting one2many fields', function (assert) { assert.expect(4); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index f7d3f2fb3073da647e4ce51985e65818943347c9..3556fb946aa3003d64892a1a12b1bfa559db8cac 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -32,9 +32,6 @@ var MockServer = Class.extend({ if (!('name' in model.fields)) { model.fields.name = {string: "Name", type: "char", default: "name"}; } - for (var fieldName in model.onchanges) { - model.fields[fieldName].onChange = "1"; - } model.records = model.records || []; for (var i = 0; i < model.records.length; i++) { @@ -282,7 +279,7 @@ var MockServer = Class.extend({ // add onchanges if (name in onchanges) { - field.onChange="1"; + node.attrs.on_change="1"; } }); return { diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 65962870be7dc37c833c463d34e88f5bc27ee2c4..492dfd4224cb67d4e4c5ca6a8093fedc55fa2100 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -203,6 +203,7 @@ QUnit.module('Views', { QUnit.test('basic onchange', function (assert) { assert.expect(5); + this.data.partner.fields.foo.onChange = true; this.data.partner.onchanges.foo = function (obj) { obj.bar = obj.foo.length; }; @@ -240,6 +241,7 @@ QUnit.module('Views', { QUnit.test('onchange with a many2one', function (assert) { assert.expect(5); + this.data.partner.fields.product_id.onChange = true; this.data.partner.onchanges.product_id = function (obj) { if (obj.product_id === 37) { obj.foo = "space lollipop"; @@ -280,6 +282,7 @@ QUnit.module('Views', { QUnit.test('onchange on a one2many not in view (fieldNames)', function (assert) { assert.expect(6); + this.data.partner.fields.foo.onChange = true; this.data.partner.onchanges.foo = function (obj) { obj.bar = obj.foo.length; obj.product_ids = []; @@ -425,6 +428,7 @@ QUnit.module('Views', { QUnit.test('onchange on a char with an unchanged many2one', function (assert) { assert.expect(2); + this.data.partner.fields.foo.onChange = true; this.data.partner.onchanges.foo = function (obj) { obj.foo = obj.foo + " alligator"; }; @@ -453,6 +457,7 @@ QUnit.module('Views', { QUnit.test('onchange on a char with another many2one not set to a value', function (assert) { assert.expect(2); this.data.partner.records[0].product_id = false; + this.data.partner.fields.foo.onChange = true; this.data.partner.onchanges.foo = function (obj) { obj.foo = obj.foo + " alligator"; }; @@ -1153,6 +1158,7 @@ QUnit.module('Views', { assert.expect(4); this.data.partner.fields.total.default = 50; + this.data.partner.fields.product_ids.onChange = true; this.data.partner.onchanges.product_ids = function (obj) { obj.total += 100; }; @@ -1497,6 +1503,7 @@ QUnit.module('Views', { assert.expect(6); this.params.fieldNames = ['foo', 'bar']; + this.data.partner.fields.foo.onChange = true; this.data.partner.onchanges.foo = function (obj) { obj.bar = obj.foo.length; }; @@ -1562,6 +1569,7 @@ QUnit.module('Views', { assert.expect(6); this.params.fieldNames = ['foo', 'bar']; + this.data.partner.fields.foo.onChange = true; this.data.partner.onchanges.foo = function (obj) { obj.bar = obj.foo.length; }; @@ -2138,6 +2146,7 @@ QUnit.module('Views', { }; _.extend(this.data.partner.fields, newFields); + this.data.partner.fields.foobool.onChange = true; this.data.partner.onchanges.foobool = function (obj) { if (obj.foobool) { obj.foobool2 = true; diff --git a/addons/web/static/tests/views/kanban_mobile_tests.js b/addons/web/static/tests/views/kanban_mobile_tests.js new file mode 100644 index 0000000000000000000000000000000000000000..d777f5bd87b3e3ba60603a62ddc03a96f4e1493e --- /dev/null +++ b/addons/web/static/tests/views/kanban_mobile_tests.js @@ -0,0 +1,101 @@ +odoo.define('web.kanban_mobile_tests', function (require) { +"use strict"; + +var KanbanView = require('web.KanbanView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + bar: {string: "Bar", type: "boolean"}, + int_field: {string: "int_field", type: "integer", sortable: true}, + qux: {string: "my float", type: "float"}, + product_id: {string: "something_id", type: "many2one", relation: "product"}, + category_ids: { string: "categories", type: "many2many", relation: 'category'}, + state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]}, + date: {string: "Date Field", type: 'date'}, + datetime: {string: "Datetime Field", type: 'datetime'}, + }, + records: [ + {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: []}, + {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]}, + {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]}, + {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []}, + ] + }, + product: { + fields: { + id: {string: "ID", type: "integer"}, + name: {string: "Display Name", type: "char"}, + }, + records: [ + {id: 3, name: "hello"}, + {id: 5, name: "xmo"}, + ] + }, + category: { + fields: { + name: {string: "Category Name", type: "char"}, + color: {string: "Color index", type: "integer"}, + }, + records: [ + {id: 6, name: "gold", color: 2}, + {id: 7, name: "silver", color: 5}, + ] + }, + }; + }, +}, function () { + + QUnit.module('KanbanView Mobile'); + + QUnit.test('mobile grouped rendering', function (assert) { + assert.expect(8); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test o_kanban_small_column" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + // basic rendering tests + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have 2 columns" ); + assert.ok(kanban.$('.o_kanban_mobile_tab:first').hasClass('o_current'), + "first tab is the active tab with class 'o_current'"); + assert.strictEqual(kanban.$('.o_kanban_group:first > div.o_kanban_record').length, 2, + "there are 2 records in active tab"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) > div.o_kanban_record').length, 0, + "there is no records in next tab. Records will be loaded when it will be opened"); + + // quick create in first column + kanban.$buttons.find('.o-kanban-button-new').click(); + assert.ok(kanban.$('.o_kanban_group:nth(0) > div:nth(1)').hasClass('o_kanban_quick_create'), + "clicking on create should open the quick_create in the first column"); + + // move to second column + kanban.$('.o_kanban_mobile_tab:nth(1)').trigger('click'); + assert.ok(kanban.$('.o_kanban_mobile_tab:nth(1)').hasClass('o_current'), + "second tab is now active with class 'o_current'"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) > div.o_kanban_record').length, 2, + "the 2 records of the second group have now been loaded"); + + // quick create in second column + kanban.$buttons.find('.o-kanban-button-new').click(); + assert.ok(kanban.$('.o_kanban_group:nth(1) > div:nth(1)').hasClass('o_kanban_quick_create'), + "clicking on create should open the quick_create in the second column"); + + kanban.destroy(); + }); +}); +}); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index cfd9e1210ba9e82e2134f029ab77f9d7c010c1a2..f614fa9af64ba8462ee75e3977a292317ffdcbfc 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -2129,55 +2129,6 @@ QUnit.module('Views', { "quick create should have been added in the first column"); } }); - - QUnit.skip('mobile grouped rendering', function (assert) { - // Temporarily disable this test until we introduce a mobile test suite, as - // the code of the kanban renderer for mobile is in a specific file which isn't - // executed in desktop (so setting 'isMobile: True' in the test is useless). - // So to re-activate this test, it will need to be moved in another file - // included in the bundle of the mobile test suite. - assert.expect(8); - var done = assert.async(); - - createAsyncView({ - View: KanbanView, - model: 'partner', - data: this.data, - arch: '<kanban class="o_kanban_test o_kanban_small_column" on_create="quick_create">' + - '<templates><t t-name="kanban-box">' + - '<div><field name="foo"/></div>' + - '</t></templates>' + - '</kanban>', - groupBy: ['product_id'], - config: {device: {isMobile: true}}, - }).then(function (kanban) { - - // Dummy dom update trigger for activate mobile tabs and move to first column - core.bus.trigger("DOM_updated"); - - assert.equal(kanban.$el.find('.o_kanban_group').length, 2, "2 colomns are created" ); - - kanban.$buttons.find('.o-kanban-button-new').click(); // Click on 'Create' - assert.ok(kanban.$('.o_kanban_group:nth(0) > div:nth(1)').hasClass('o_kanban_quick_create'), - "clicking on create should open the quick_create in the first column"); - - assert.equal(kanban.$el.find('.o_kanban_mobile_tab.current > span').html(), "hello", "First tab 'hello' is active tab with class 'current'" ); - assert.equal(kanban.$el.find('.o_kanban_group.current > div.o_kanban_record').length, "2", "there is 2 record in active 'hello' tab" ); - assert.equal(kanban.$el.find('.o_kanban_group.next > div.o_kanban_record').length, "0", "there is 0 record in next tab. Records will load when click on next tab"); - - kanban.$el.find('.o_kanban_mobile_tab.next').trigger('click'); // Moving to next tab - assert.equal(kanban.$el.find('.o_kanban_mobile_tab.current > span').html(), "xmo", "Second tab 'xmo' is active with class 'current'" ); - assert.equal(kanban.$el.find('.o_kanban_group.current > div.o_kanban_record').length, "2", "there is 2 record in active 'xmo' tab. Records are loaded after click on tab"); - - kanban.$buttons.find('.o-kanban-button-new').click(); // Click on 'Create' - assert.ok(kanban.$('.o_kanban_group:nth(1) > div:nth(1)').hasClass('o_kanban_quick_create'), - "clicking on create should open the quick_create in the second column"); - - kanban.destroy(); - done(); - }); - }); - }); }); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 32e3ad0046adf54a8fec67e09035461e26a07bd5..242eb2f62d63e1a1188192db843890786c5a2830 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2628,7 +2628,7 @@ QUnit.module('Views', { // Editable grouped list views are not supported, so the purpose of this // test is to check that when a list view is grouped, its editable // attribute is ignored - assert.expect(4); + assert.expect(5); var list = createView({ View: ListView, @@ -2636,8 +2636,9 @@ QUnit.module('Views', { data: this.data, arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>', intercepts: { - switch_view: function () { - assert.step('switch view'); + switch_view: function (event) { + var resID = event.data.res_id || false; + assert.step('switch view ' + event.data.view_type + ' ' + resID); }, }, }); @@ -2649,9 +2650,15 @@ QUnit.module('Views', { // reload with groupBy list.reload({groupBy: ['bar']}); + + // clicking on record should open the form view list.$('.o_group_header:first').click(); list.$('.o_data_cell:first').click(); - assert.verifySteps(['switch view'], 'one switch view should have been requested'); + + // clicking on create button should open the form view + list.$buttons.find('.o_list_button_add').click(); + assert.verifySteps(['switch view form 1', 'switch view form false'], + 'two switch view to form should have been requested'); list.destroy(); }); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 43bf4ee6694c87827d7118bf5f23879f93d339f5..30ab2b8274e364da18d6527212bc5d7d1f336933 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -55,7 +55,7 @@ QUnit.module('Views', { }); - var dialog = new dialogs.FormViewDialog(parent, { + new dialogs.FormViewDialog(parent, { res_model: 'partner', res_id: 1, }).open(); @@ -64,7 +64,7 @@ QUnit.module('Views', { "should not have any button in body"); assert.strictEqual($('div.modal .modal-footer button').length, 1, "should have only one button in footer"); - dialog.destroy(); + parent.destroy(); }); QUnit.test('SelectCreateDialog use domain, group_by and search default', function (assert) { @@ -137,7 +137,45 @@ QUnit.module('Views', { dialog.$('.o_searchview_facet:contains(groupby_bar) .o_facet_remove').click(); dialog.$('.o_searchview_facet .o_facet_remove').click(); - dialog.destroy(); + parent.destroy(); + }); + + QUnit.test('SelectCreateDialog correctly evaluates domains', function (assert) { + assert.expect(1); + + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree string="Partner">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<field name="foo"/>' + + '</search>', + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [['id', '=', 2]], + "should have correctly evaluated the domain"); + } + return this._super.apply(this, arguments); + }, + session: { + user_context: {uid: 2}, + }, + }); + + new dialogs.SelectCreateDialog(parent, { + no_create: true, + readonly: true, + res_model: 'partner', + domain: "[['id', '=', uid]]", + }).open(); + + parent.destroy(); }); QUnit.test('SelectCreateDialog list view in readonly', function (assert) { @@ -166,7 +204,7 @@ QUnit.module('Views', { assert.equal(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first input').length, 0, "list view should not be editable in a SelectCreateDialog"); - dialog.destroy(); + parent.destroy(); }); }); diff --git a/addons/web/static/tests/widgets/domain_selector_tests.js b/addons/web/static/tests/widgets/domain_selector_tests.js index 55ddc971642f167deb291bf36daad1d30d361a9c..aca017e831c8a15b6aa799ff523a662f7138b857 100644 --- a/addons/web/static/tests/widgets/domain_selector_tests.js +++ b/addons/web/static/tests/widgets/domain_selector_tests.js @@ -194,6 +194,25 @@ QUnit.module('DomainSelector', { domainSelector.destroy(); }); + + QUnit.test("editing a domain with `parent` key", function (assert) { + assert.expect(1); + + var $target = $("#qunit-fixture"); + + // Create the domain selector and its mock environment + var domainSelector = new DomainSelector(null, "product", "[['name','=',parent.foo]]", { + debugMode: true, + readonly: false, + }); + testUtils.addMockEnvironment(domainSelector, {data: this.data}); + domainSelector.appendTo($target); + + assert.strictEqual(domainSelector.$el.text(), "This domain is not supported.", + "an error message should be displayed because of the `parent` key"); + + domainSelector.destroy(); + }); }); }); }); diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py index 17eb5c8beb2f4aa28a659c04ec468f162f725fd9..0b1f8ed3763c7068a8285e4448d083431a293d47 100644 --- a/addons/web/tests/test_js.py +++ b/addons/web/tests/test_js.py @@ -11,8 +11,13 @@ class WebSuite(odoo.tests.HttpCase): at_install = False def test_01_js(self): + # webclient desktop test suite self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=300) + def test_02_js(self): + # webclient mobile test suite + self.phantom_js('/web/tests/mobile?mod=web', "", "", login='admin', timeout=300) + def test_check_suite(self): # verify no js test is using `QUnit.only` as it forbid any other test to be executed re_only = re.compile('QUnit\.only\(') diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 9b222f8563d9bef77922187dae6c30d309c5e33f..c3d4e4eceaecab2f59e94c525383894492bfb13e 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -397,60 +397,67 @@ </a> </template> + <template id="web.js_tests_assets"> + <link type="text/css" rel="stylesheet" href="/web/static/lib/qunit/qunit-2.2.1.css"/> + <script type="text/javascript" src="/web/static/lib/qunit/qunit-2.2.1.js"></script> + <script type="text/javascript" src="/web/static/tests/helpers/qunit_config.js"></script> + + <t t-call-assets="web.assets_common" t-js="false"/> + <t t-call-assets="web.assets_backend" t-js="false"/> + <t t-call-assets="web.assets_common" t-css="false"/> + <t t-call-assets="web.assets_backend" t-css="false"/> + + <!-- add lazy-loaded libs to make tests synchronous --> + <link rel="stylesheet" href="/web/static/lib/fullcalendar/css/fullcalendar.css"/> + <script type="text/javascript" src="/web/static/lib/fullcalendar/js/fullcalendar.js"></script> + <link rel="stylesheet" type="text/css" href="/web/static/lib/nvd3/nv.d3.css"/> + <script type="text/javascript" src="/web/static/lib/nvd3/d3.v3.js"></script> + <script type="text/javascript" src="/web/static/lib/nvd3/nv.d3.js"></script> + <script type="text/javascript" src="/web/static/src/js/libs/nvd3.js"></script> + <script type="text/javascript" src="/web/static/lib/ace/ace.odoo-custom.js"></script> + <script type="text/javascript" src="/web/static/lib/ace/mode-python.js"></script> + <script type="text/javascript" src="/web/static/lib/ace/mode-xml.js"></script> + + <script type="text/javascript"> + // define the 'web.web_client' module because some other modules require it + odoo.define('web.web_client', function (require) { + var WebClient = require('web.WebClient'); + var web_client = new WebClient(); + // override _call_service to prevent the web_client from doing RPCs + web_client._call_service = function () {}; + return web_client; + }); + </script> + + <style> + body { + position: relative; // bootstrap-datepicker needs this + } + body:not(.debug) .modal-backdrop, body:not(.debug) .modal, body:not(.debug) .ui-autocomplete { + opacity: 0 !important; + } + #qunit-testrunner-toolbar label { + font-weight: inherit; + margin-bottom: inherit; + } + #qunit-testrunner-toolbar input[type=text] { + width: inherit; + display: inherit; + } + </style> + + <script type="text/javascript" src="/web/static/tests/helpers/test_utils.js"></script> + <script type="text/javascript" src="/web/static/tests/helpers/mock_server.js"></script> + + <script type="text/javascript" src="/web/static/tests/boot_tests.js"></script> + </template> + <template id="web.qunit_suite"> <t t-call="web.layout"> <t t-set="html_data" t-value="{'style': 'height: 100%;'}"/> <t t-set="title">Web Tests</t> <t t-set="head"> - <link type="text/css" rel="stylesheet" href="/web/static/lib/qunit/qunit-2.2.1.css"/> - <script type="text/javascript" src="/web/static/lib/qunit/qunit-2.2.1.js"></script> - <script type="text/javascript" src="/web/static/tests/helpers/qunit_config.js"></script> - - <t t-call-assets="web.assets_common" t-js="false"/> - <t t-call-assets="web.assets_backend" t-js="false"/> - <t t-call-assets="web.assets_common" t-css="false"/> - <t t-call-assets="web.assets_backend" t-css="false"/> - - <!-- add lazy-loaded libs --> - <link rel="stylesheet" href="/web/static/lib/fullcalendar/css/fullcalendar.css"/> - <script type="text/javascript" src="/web/static/lib/fullcalendar/js/fullcalendar.js"></script> - <link rel="stylesheet" type="text/css" href="/web/static/lib/nvd3/nv.d3.css"/> - <script type="text/javascript" src="/web/static/lib/nvd3/d3.v3.js"></script> - <script type="text/javascript" src="/web/static/lib/nvd3/nv.d3.js"></script> - <script type="text/javascript" src="/web/static/src/js/libs/nvd3.js"></script> - <script type="text/javascript" src="/web/static/lib/ace/ace.odoo-custom.js"></script> - <script type="text/javascript" src="/web/static/lib/ace/mode-python.js"></script> - <script type="text/javascript" src="/web/static/lib/ace/mode-xml.js"></script> - - <script type="text/javascript"> - // define the 'web.web_client' module because some other modules require it - odoo.define('web.web_client', function (require) { - var WebClient = require('web.WebClient'); - var web_client = new WebClient(); - // override _call_service to prevent the web_client from doing RPCs - web_client._call_service = function () {}; - return web_client; - }); - </script> - - <style> - body { - position: relative; // bootstrap-datepicker needs this - } - body:not(.debug) .modal-backdrop, body:not(.debug) .modal, body:not(.debug) .ui-autocomplete { - opacity: 0 !important; - } - #qunit-testrunner-toolbar label { - font-weight: inherit; - margin-bottom: inherit; - } - #qunit-testrunner-toolbar input[type=text] { - width: inherit; - display: inherit; - } - </style> - <script type="text/javascript" src="/web/static/tests/helpers/test_utils.js"></script> - <script type="text/javascript" src="/web/static/tests/helpers/mock_server.js"></script> + <t t-call="web.js_tests_assets"/> <script type="text/javascript" src="/web/static/tests/fields/basic_fields_tests.js"></script> <script type="text/javascript" src="/web/static/tests/fields/field_utils_tests.js"></script> @@ -487,8 +494,45 @@ <script type="text/javascript" src="/web/static/tests/widgets/domain_selector_tests.js"/> <script type="text/javascript" src="/web/static/tests/widgets/model_field_selector_tests.js"/> <script type="text/javascript" src="/web/static/tests/widgets/rainbow_man_tests.js"/> + </t> + + <div id="qunit"/> + <div id="qunit-fixture"/> + </t> + </template> + + <template id="web.qunit_mobile_suite"> + <t t-call="web.layout"> + <t t-set="html_data" t-value="{'style': 'height: 100%;'}"/> + <t t-set="title">Web Mobile Tests</t> + <t t-set="head"> + <script> + // force the config.device.isMobile key to be true so that + // mobile specific files aren't rejected + window.odoo = {}; + var odooDefine; + Object.defineProperty(window.odoo, 'define', { + get: function () { + return odooDefine; + }, + set: function (define) { + odooDefine = function () { + define.apply(this, arguments); + if (arguments[0] === 'web.config') { + define.call(this, 'web.config.patch', function (require) { + var config = require('web.config'); + config.device.isMobile = true; + }); + } + }; + }, + }); + </script> + + <t t-call="web.js_tests_assets"/> + <script type="text/javascript" src="/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js"></script> - <script type="text/javascript" src="/web/static/tests/boot_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/views/kanban_mobile_tests.js"></script> </t> <div id="qunit"/> diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 2f457019ae3618ad93007f4ae7ea0a4db5eee764..1c20895662496976b3657f001a856efa5007fcc3 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -227,7 +227,7 @@ var SnippetEditor = Widget.extend({ var optionName = val.option; var $el = val.$el.children('li').clone(true).addClass('snippet-option-' + optionName); - var option = new (options.registry[optionName] || options.Class)(self, self.$target, self.$el); + var option = new (options.registry[optionName] || options.Class)(self, self.$target, self.$el, val.data); self.styles[optionName || _.uniqueId('option')] = option; option.__order = i++; return option.attachTo($el); diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 086948826034c753b1038ac00bf1f6349e1631b8..e1f69e0f88908fdc36f9e6491e1241e9f25bcf63 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -37,10 +37,11 @@ var SnippetOption = Widget.extend({ * * @constructor */ - init: function (parent, $target, $overlay) { + init: function (parent, $target, $overlay, data) { this._super.apply(this, arguments); this.$target = $target; this.$overlay = $overlay; + this.data = data; this.__methodNames = []; }, /** @@ -50,7 +51,6 @@ var SnippetOption = Widget.extend({ * @override */ start: function () { - this.data = this.$el.data(); this._setActive(); return this._super.apply(this, arguments); }, diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index 8b7b51944a592b8882db586744e7261c2217b7b3..4bfe84887d02abcea4b931066f8cd1da56fe0e57 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -114,10 +114,14 @@ <t t-call-assets="web_editor.assets_editor" t-css="false"/> </xpath> </template> -<template id="qunit_suite" inherit_id="web.qunit_suite"> - <xpath expr="//t[@t-call-assets='web.assets_backend'][@t-css='false']" position="after"> +<template id="js_tests_assets" inherit_id="web.js_tests_assets"> + <xpath expr="." position="inside"> <t t-call-assets="web_editor.summernote" t-css="false"/> <t t-call-assets="web_editor.assets_editor" t-css="false"/> + </xpath> +</template> +<template id="qunit_suite" inherit_id="web.qunit_suite"> + <xpath expr="." position="inside"> <script type="text/javascript" src="/web_editor/static/tests/web_editor_tests.js"></script> </xpath> </template> diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 9e934d08ad090ce9eb6867bd87de6f128cc8c552..f7062cc031e7ec3ef7868a9d1260c7c669a9d48d 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -258,9 +258,11 @@ class WebsiteSlides(http.Controller): @http.route(['/slides/add_slide'], type='json', auth='user', methods=['POST'], website=True) def create_slide(self, *args, **post): - file_size = len(post['datas']) * 3 / 4; # base64 - if (file_size / 1024.0 / 1024.0) > 25: - return {'error': _('File is too big. File size cannot exceed 25MB')} + # check the size only when we upload a file. + if post.get('datas'): + file_size = len(post['datas']) * 3 / 4 # base64 + if (file_size / 1024.0 / 1024.0) > 25: + return {'error': _('File is too big. File size cannot exceed 25MB')} values = dict((fname, post[fname]) for fname in [ 'name', 'url', 'tag_ids', 'slide_type', 'channel_id', diff --git a/doc/cla/individual/remi-filament.md b/doc/cla/individual/remi-filament.md new file mode 100644 index 0000000000000000000000000000000000000000..b57242f471f4377cd61738cdd940cdb949c1950d --- /dev/null +++ b/doc/cla/individual/remi-filament.md @@ -0,0 +1,11 @@ +France, 2017-11-06 + +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, + +Rémi CAZENAVE remi@le-filament.com https://github.com/remi-filament diff --git a/odoo/addons/base/i18n/base.pot b/odoo/addons/base/i18n/base.pot index 7051c741b795b7b6cbee1cf030b08fabd3199fb1..ab99d591de7318bae9a91c34578191be050220f7 100644 --- a/odoo/addons/base/i18n/base.pot +++ b/odoo/addons/base/i18n/base.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 11.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-24 09:14+0000\n" -"PO-Revision-Date: 2017-10-24 09:14+0000\n" +"POT-Creation-Date: 2017-11-08 13:21+0000\n" +"PO-Revision-Date: 2017-11-08 13:21+0000\n" "Last-Translator: <>\n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -5868,6 +5868,12 @@ msgstr "" msgid "Before Amount" msgstr "" +#. module: base +#: code:addons/base/res/res_users.py:954 +#, python-format +msgid "Before clicking on 'Change Password', you have to write a new password." +msgstr "" + #. module: base #: model:res.country,name:base.by msgid "Belarus" @@ -6220,6 +6226,7 @@ msgstr "" #: model:ir.ui.view,arch_db:base.view_base_module_update #: model:ir.ui.view,arch_db:base.view_base_module_upgrade #: model:ir.ui.view,arch_db:base.view_base_module_upgrade_install +#: model:ir.ui.view,arch_db:base.view_company_report_form #: model:ir.ui.view,arch_db:base.view_model_menu_create #: model:ir.ui.view,arch_db:base.view_users_form_simple_modif #: model:ir.ui.view,arch_db:base.wizard_lang_export @@ -6558,7 +6565,6 @@ msgstr "" #. module: base #: model:ir.ui.view,arch_db:base.view_base_language_install #: model:ir.ui.view,arch_db:base.view_base_module_update -#: model:ir.ui.view,arch_db:base.view_company_report_form #: model:ir.ui.view,arch_db:base.wizard_lang_export msgid "Close" msgstr "" @@ -7381,11 +7387,6 @@ msgstr "" msgid "Croatia - RRIF 2012 COA - Accounting Reports" msgstr "" -#. module: base -#: model:ir.model.fields,field_description:base.field_ir_cron_user_id -msgid "Cron User" -msgstr "" - #. module: base #: model:res.country,name:base.cu msgid "Cuba" @@ -15835,6 +15836,7 @@ msgstr "" #. module: base #: model:ir.actions.act_window,name:base.ir_cron_act +#: model:ir.model,name:base.model_ir_cron #: model:ir.ui.menu,name:base.menu_ir_cron_act #: model:ir.ui.view,arch_db:base.ir_cron_view_calendar #: model:ir.ui.view,arch_db:base.ir_cron_view_search @@ -15842,6 +15844,11 @@ msgstr "" msgid "Scheduled Actions" msgstr "" +#. module: base +#: model:ir.model.fields,field_description:base.field_ir_cron_user_id +msgid "Scheduler User" +msgstr "" + #. module: base #: model:res.partner.industry,name:base.res_partner_industry_M msgid "Scientific" @@ -19329,11 +19336,6 @@ msgstr "" msgid "ir.config_parameter" msgstr "" -#. module: base -#: model:ir.model,name:base.model_ir_cron -msgid "ir.cron" -msgstr "" - #. module: base #: model:ir.model,name:base.model_ir_default msgid "ir.default" @@ -19704,12 +19706,6 @@ msgstr "" msgid "yes" msgstr "" -#. module: base -#: code:addons/base/res/res_users.py:955 -#, python-format -msgid "Before clicking on 'Change Password', you have to write a new password." -msgstr "" - #. module: base #: model:res.country,name:base.ax msgid "Åland Islands" diff --git a/odoo/addons/base/ir/ir_cron.py b/odoo/addons/base/ir/ir_cron.py index 17b92cae609c0bbf120de26052bea901ac4d6c07..2aa7a1e27735b04d6824b5584d2e4a8978ab53c5 100644 --- a/odoo/addons/base/ir/ir_cron.py +++ b/odoo/addons/base/ir/ir_cron.py @@ -17,6 +17,13 @@ _logger = logging.getLogger(__name__) BASE_VERSION = odoo.modules.load_information_from_description_file('base')['version'] +class BadVersion(Exception): + pass + +class BadModuleState(Exception): + pass + + _intervalTypes = { 'days': lambda interval: relativedelta(days=interval), 'hours': lambda interval: relativedelta(hours=interval), @@ -37,12 +44,13 @@ class ir_cron(models.Model): _name = "ir.cron" _order = 'cron_name' + _description = 'Scheduled Actions' ir_actions_server_id = fields.Many2one( 'ir.actions.server', 'Server action', delegate=True, ondelete='restrict', required=True) cron_name = fields.Char('Name', related='ir_actions_server_id.name', store=True) - user_id = fields.Many2one('res.users', string='Cron User', default=lambda self: self.env.user, required=True) + user_id = fields.Many2one('res.users', string='Scheduler User', default=lambda self: self.env.user, required=True) active = fields.Boolean(default=True) interval_number = fields.Integer(default=1, help="Repeat every x.") interval_type = fields.Selection([('minutes', 'Minutes'), @@ -141,33 +149,102 @@ class ir_cron(models.Model): cron_cr.commit() @classmethod - def _acquire_job(cls, db_name): - # TODO remove 'check' argument from addons/base_automation/base_automation.py - """ Try to process one cron job. + def _process_jobs(cls, db_name): + """ Try to process all cron jobs. This selects in database all the jobs that should be processed. It then tries to lock each of them and, if it succeeds, run the cron job (if it doesn't succeed, it means the job was already locked to be taken care of by another thread) and return. - If a job was processed, returns True, otherwise returns False. + :raise BadVersion: if the version is different from the worker's + :raise BadModuleState: if modules are to install/upgrade/remove """ db = odoo.sql_db.db_connect(db_name) threading.current_thread().dbname = db_name - jobs = [] try: with db.cursor() as cr: - # Make sure the database we poll has the same version as the code of base - cr.execute("SELECT 1 FROM ir_module_module WHERE name=%s AND latest_version=%s", ('base', BASE_VERSION)) - if cr.fetchone(): - # Careful to compare timestamps with 'UTC' - everything is UTC as of v6.1. - cr.execute("""SELECT * FROM ir_cron - WHERE numbercall != 0 - AND active AND nextcall <= (now() at time zone 'UTC') - ORDER BY priority""") - jobs = cr.dictfetchall() - else: - _logger.warning('Skipping database %s as its base version is not %s.', db_name, BASE_VERSION) + # Make sure the database has the same version as the code of + # base and that no module must be installed/upgraded/removed + cr.execute("SELECT latest_version FROM ir_module_module WHERE name=%s", ['base']) + (version,) = cr.fetchone() + cr.execute("SELECT COUNT(*) FROM ir_module_module WHERE state LIKE %s", ['to %']) + (changes,) = cr.fetchone() + if not version or changes: + raise BadModuleState() + elif version != BASE_VERSION: + raise BadVersion() + # Careful to compare timestamps with 'UTC' - everything is UTC as of v6.1. + cr.execute("""SELECT * FROM ir_cron + WHERE numbercall != 0 + AND active AND nextcall <= (now() at time zone 'UTC') + ORDER BY priority""") + jobs = cr.dictfetchall() + + for job in jobs: + lock_cr = db.cursor() + try: + # Try to grab an exclusive lock on the job row from within the task transaction + # Restrict to the same conditions as for the search since the job may have already + # been run by an other thread when cron is running in multi thread + lock_cr.execute("""SELECT * + FROM ir_cron + WHERE numbercall != 0 + AND active + AND nextcall <= (now() at time zone 'UTC') + AND id=%s + FOR UPDATE NOWAIT""", + (job['id'],), log_exceptions=False) + + locked_job = lock_cr.fetchone() + if not locked_job: + _logger.debug("Job `%s` already executed by another process/thread. skipping it", job['cron_name']) + continue + # Got the lock on the job row, run its code + _logger.debug('Starting job `%s`.', job['cron_name']) + job_cr = db.cursor() + try: + registry = odoo.registry(db_name) + registry[cls._name]._process_job(job_cr, job, lock_cr) + except Exception: + _logger.exception('Unexpected exception while processing cron job %r', job) + finally: + job_cr.close() + + except psycopg2.OperationalError as e: + if e.pgcode == '55P03': + # Class 55: Object not in prerequisite state; 55P03: lock_not_available + _logger.debug('Another process/thread is already busy executing job `%s`, skipping it.', job['cron_name']) + continue + else: + # Unexpected OperationalError + raise + finally: + # we're exiting due to an exception while acquiring the lock + lock_cr.close() + + finally: + if hasattr(threading.current_thread(), 'dbname'): + del threading.current_thread().dbname + + @classmethod + def _acquire_job(cls, db_name): + """ Try to process all cron jobs. + + This selects in database all the jobs that should be processed. It then + tries to lock each of them and, if it succeeds, run the cron job (if it + doesn't succeed, it means the job was already locked to be taken care + of by another thread) and return. + + This method hides most exceptions related to the database's version, the + modules' state, and such. + """ + try: + cls._process_jobs(db_name) + except BadVersion: + _logger.warning('Skipping database %s as its base version is not %s.', db_name, BASE_VERSION) + except BadModuleState: + _logger.warning('Skipping database %s because of modules to install/upgrade/remove.', db_name) except psycopg2.ProgrammingError as e: if e.pgcode == '42P01': # Class 42 — Syntax Error or Access Rule Violation; 42P01: undefined_table @@ -178,51 +255,6 @@ class ir_cron(models.Model): except Exception: _logger.warning('Exception in cron:', exc_info=True) - for job in jobs: - lock_cr = db.cursor() - try: - # Try to grab an exclusive lock on the job row from within the task transaction - # Restrict to the same conditions as for the search since the job may have already - # been run by an other thread when cron is running in multi thread - lock_cr.execute("""SELECT * - FROM ir_cron - WHERE numbercall != 0 - AND active - AND nextcall <= (now() at time zone 'UTC') - AND id=%s - FOR UPDATE NOWAIT""", - (job['id'],), log_exceptions=False) - - locked_job = lock_cr.fetchone() - if not locked_job: - _logger.debug("Job `%s` already executed by another process/thread. skipping it", job['cron_name']) - continue - # Got the lock on the job row, run its code - _logger.debug('Starting job `%s`.', job['cron_name']) - job_cr = db.cursor() - try: - registry = odoo.registry(db_name) - registry[cls._name]._process_job(job_cr, job, lock_cr) - except Exception: - _logger.exception('Unexpected exception while processing cron job %r', job) - finally: - job_cr.close() - - except psycopg2.OperationalError as e: - if e.pgcode == '55P03': - # Class 55: Object not in prerequisite state; 55P03: lock_not_available - _logger.debug('Another process/thread is already busy executing job `%s`, skipping it.', job['cron_name']) - continue - else: - # Unexpected OperationalError - raise - finally: - # we're exiting due to an exception while acquiring the lock - lock_cr.close() - - if hasattr(threading.current_thread(), 'dbname'): # cron job could have removed it as side-effect - del threading.current_thread().dbname - @api.multi def _try_lock(self): """Try to grab a dummy exclusive write-lock to the rows with the given ids, diff --git a/odoo/addons/base/ir/report_ir_model.xml b/odoo/addons/base/ir/report_ir_model.xml index b7617a74b172abc2fc108c815ad93fab78f7399c..1d64d246520041685e4f848ffdf0b15281cd7df2 100644 --- a/odoo/addons/base/ir/report_ir_model.xml +++ b/odoo/addons/base/ir/report_ir_model.xml @@ -3,7 +3,7 @@ <template id="report_irmodeloverview"> <t t-call="web.html_container"> <t t-foreach="docs" t-as="o"> - <div class="page"> + <div class="article"> <table class="table table-bordered mb64"> <tr> <td colspan="12"> diff --git a/odoo/addons/test_performance/tests/test_performance.py b/odoo/addons/test_performance/tests/test_performance.py index b2f398f5a712cd8fcee16f930e8d48ae45fda5eb..d2228d583f9217c8cd2fc34c1284e49c96c34495 100644 --- a/odoo/addons/test_performance/tests/test_performance.py +++ b/odoo/addons/test_performance/tests/test_performance.py @@ -141,7 +141,7 @@ class TestPerformance(TransactionCase): records.write({'value': self.int(20)}) - @queryCount(admin=19, demo=29) + @queryCount(admin=20, demo=31) def test_write_mail_with_tracking(self): """ Write records inheriting from 'mail.thread' (with field tracking). """ record = self.env['test_performance.mail'].search([], limit=1) @@ -180,7 +180,7 @@ class TestPerformance(TransactionCase): model = self.env['test_performance.mail'] model.with_context(tracking_disable=True).create({'name': self.str('X')}) - @queryCount(admin=37, demo=53) + @queryCount(admin=38, demo=54) def test_create_mail_with_tracking(self): """ Create records inheriting from 'mail.thread' (with field tracking). """ model = self.env['test_performance.mail'] diff --git a/odoo/service/server.py b/odoo/service/server.py index 0a1c2d71763568225af213066efbca7600ae26a9..70141644b8102ccb9edef55ec528dadc08e472c2 100644 --- a/odoo/service/server.py +++ b/odoo/service/server.py @@ -220,14 +220,11 @@ class ThreadedServer(CommonServer): registries = odoo.modules.registry.Registry.registries _logger.debug('cron%d polling for jobs', number) for db_name, registry in registries.items(): - while registry.ready: + if registry.ready: try: - acquired = ir_cron._acquire_job(db_name) - if not acquired: - break + ir_cron._acquire_job(db_name) except Exception: _logger.warning('cron%d encountered an Exception:', number, exc_info=True) - break def cron_spawn(self): """ Start the above runner function in a daemon thread.