diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index d0698de25caebafdcb5a0125e2273049309ed84b..627d480f242ef7ebe85bb8f1b2d4e71d094af965 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/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 8ee0401b12f17ba03e5cf598f0f12692e72f1760..84f8e41c703f80ffcc5d2d9ac8e5d79ba4135a75 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -530,6 +530,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', @@ -537,6 +539,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/models/mail_activity.py b/addons/mail/models/mail_activity.py index 1e7c0729ba4234cdca1043cbc99f595fe759f87c..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): @@ -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/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/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/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/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..7a107c70346ed2a443c171f8b6868ce8fe73e940 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -140,6 +140,44 @@ QUnit.module('Views', { dialog.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}, + }, + }); + + var dialog = new dialogs.SelectCreateDialog(parent, { + no_create: true, + readonly: true, + res_model: 'partner', + domain: "[['id', '=', uid]]", + }).open(); + + dialog.destroy(); + }); + QUnit.test('SelectCreateDialog list view in readonly', function (assert) { assert.expect(1); 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/ir/ir_cron.py b/odoo/addons/base/ir/ir_cron.py index 4a4d0bc6f5f44c04ae687d24ad3daf8cb01839ff..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), @@ -142,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 @@ -179,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/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.