class jointpiece(models.Model): _inherit = 'ir.attachment' joindre_mail = fields.Boolean(string="Joindre au Mail") datas = fields.Binary(string='File Content', compute='_compute_datas', inverse='_inverse_datas', readonly=False) db_datas = fields.Binary('Database Data', attachment=False, readonly=False) store_fname = fields.Char('Stored Filename', readonly=False) file_size = fields.Integer('File Size', readonly=False) checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=False) mimetype = fields.Char('Mime Type', readonly=False) index_content = fields.Text('Indexed Content', readonly=False, prefetch=False) res_name = fields.Char('Resource Name', compute='_compute_res_name', readonly=False) res_model = fields.Char( 'Resource Model', readonly=False, help="The database object this attachment will be attached to.") res_field = fields.Char('Resource Field', readonly=False) res_id = fields.Many2oneReference( 'Resource ID', model_field='res_model', readonly=False, help="The record id this is attached to.")
class ProductConfiguratorAttribute(models.Model): _name = "product.configurator.attribute" _description = "Product Configurator Attribute" owner_id = fields.Many2oneReference( string="Owner", required=True, index=True, model_field="owner_model", # ondelete is required since the owner_id is declared as inverse # of the field product_attribute_ids of the abstract model # product.configurator ondelete="cascade", ) owner_model = fields.Char(required=True) product_tmpl_id = fields.Many2one(comodel_name="product.template", string="Product Template", required=True) attribute_id = fields.Many2one(comodel_name="product.attribute", string="Attribute", readonly=False) value_id = fields.Many2one( comodel_name="product.attribute.value", domain="[('attribute_id', '=', attribute_id), " " ('id', 'in', possible_value_ids)]", string="Value", ) possible_value_ids = fields.Many2many( comodel_name="product.attribute.value", compute="_compute_possible_value_ids", readonly=True, ) price_extra = fields.Float( compute="_compute_price_extra", string="Attribute Price Extra", digits="Product Price", help="Price Extra: Extra price for the variant with this attribute " "value on sale price. eg. 200 price extra, 1000 + 200 = 1200.", ) @api.depends("attribute_id") def _compute_possible_value_ids(self): for record in self: # This should be unique due to the new constraint added attribute = record.product_tmpl_id.attribute_line_ids.filtered( lambda x: x.attribute_id == record.attribute_id) record.possible_value_ids = attribute.value_ids.sorted() @api.depends("value_id") def _compute_price_extra(self): for record in self: record.price_extra = sum( self.env["product.template.attribute.value"].search([ ("product_tmpl_id", "=", record.product_tmpl_id.id), ("product_attribute_value_id", "=", record.value_id.id), ]).mapped("price_extra"))
class Foo(models.Model): _name = 'foo' _description = 'Foo' res_model_id = fields.Many2one( comodel_name='ir.model', ) res_model = fields.Char( related='res_model_id.model', ) res_id = fields.Many2oneReference( index=True, required=True, model_field='res_model', )
class SaleLine(models.Model): _name = 'tp.sale.order.line' _description = 'TP sale' active = fields.Boolean(default=True) product_id = fields.Many2one('product.product', auto_join=1) qty = fields.Integer() price_unit = fields.Float(digits=(6, 2)) # price = fields.Float(compute='_compute_price', store=True) price = fields.Float() order_id = fields.Many2one('tp.sale.order', ondelete='cascade') dup_order_id = fields.Many2one('tp.sale.order', ondelete='cascade', compute='_compute_dup_order_id', store=True) name = fields.Char() sale_ids = fields.Many2many('tp.sale.order', 'so_sol_rel', 'sol_id', 'so_id') # line1_ids = fields.Many2many('tp.sale.order.line1') line1_ids = fields.Many2many('tp.sale.order.line1', 'line1_id') @api.depends('order_id') def _compute_dup_order_id(self): for r in self: r.dup_order_id = r.order_id # @api.depends('qty', 'price_unit') # def _compute_price(self): # for record in self: # record.price = record.qty * record.price_unit res_model = fields.Char() res_id = fields.Many2oneReference(index=True, model_field='res_model') # res_name = fields.Char( # 'Document Name', compute='_compute_res_name', compute_sudo=True, store=True, # help="Display name of the related document.", readonly=True) # @api.depends('res_model', 'res_id') # def _compute_res_name(self): # for activity in self: # activity.res_name = activity.res_model and \ # self.env[activity.res_model].browse(activity.res_id).display_name def test(self): raise UserError(_('nguyen duc tu'))
class Message(models.Model): _inherit = 'mail.message' lead_record_id = fields.Many2one('eduabroad.lead', string='Lead', compute='_compute_lead_id') res_id = fields.Many2oneReference(store=True) @api.model def _compute_lead_id(self): for message in self: if message.res_id and message.model == 'eduabroad.lead': lead_id = self.env['eduabroad.lead'].search([ ('id', 'like', message.res_id) ]).id message.lead_record_id = lead_id else: message.lead_record_id = 0
class MailingTrace(models.Model): """ MailingTrace models the statistics collected about emails. Those statistics are stored in a separated model and table to avoid bloating the mail_mail table with statistics values. This also allows to delete emails send with mass mailing without loosing the statistics about them. Note:: State management / Error codes / Failure types summary * state 'outgoing', 'sent', 'opened', 'replied', 'exception', 'bounced', 'ignored' * failure_type # mass_mailing "SMTP", "RECIPIENT", "BOUNCE", "UNKNOWN" # mass_mailing_sms 'sms_number_missing', 'sms_number_format', 'sms_credit', 'sms_server', 'sms_acc' # mass_mailing_sms mass mode specific codes 'sms_blacklist', 'sms_duplicate' * ignored: * mail: set in get_mail_values in composer, if email is blacklisted (mail) or in opt_out / seen list (mass_mailing) or email_to is void or incorrectly formatted (mass_mailing) - based on mail cancel state * sms: set in _prepare_mass_sms_trace_values in composer if sms is in cancel state; either blacklisted (sms) or in opt_out / seen list (sms); * difference: void mail -> cancel -> ignore, void sms -> error sms_number_missing -> exception * difference: invalid mail -> cancel -> ignore, invalid sms -> error sms_number_format -> sent + bounce; * exception: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if mail (sms) not sent with failure type, reset if sent; also set for sms in _prepare_mass_sms_trace_values if void number * sent: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if mail (sms) sent * clicked: triggered by add_click * opened: triggered by add_click + blank gif (mail) + gateway reply (mail) * replied: triggered by gateway reply (mail) * bounced: triggered by gateway bounce (mail) or in _prepare_mass_sms_trace_values if sms_number_format error when sending sms (sms) """ _name = 'mailing.trace' _description = 'Mailing Statistics' _rec_name = 'id' _order = 'scheduled DESC' trace_type = fields.Selection([('mail', 'Mail')], string='Type', default='mail', required=True) display_name = fields.Char(compute='_compute_display_name') # mail data mail_mail_id = fields.Many2one('mail.mail', string='Mail', index=True) mail_mail_id_int = fields.Integer( string='Mail ID (tech)', help= 'ID of the related mail_mail. This field is an integer field because ' 'the related mail_mail can be deleted separately from its statistics. ' 'However the ID is needed for several action and controllers.', index=True, ) email = fields.Char(string="Email", help="Normalized email address") message_id = fields.Char(string='Message-ID') medium_id = fields.Many2one(related='mass_mailing_id.medium_id') source_id = fields.Many2one(related='mass_mailing_id.source_id') # document model = fields.Char(string='Document model', required=True) res_id = fields.Many2oneReference(string='Document ID', model_field='model', required=True) # campaign / wave data mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True, ondelete='cascade') campaign_id = fields.Many2one(related='mass_mailing_id.campaign_id', string='Campaign', store=True, readonly=True, index=True) # Bounce and tracking ignored = fields.Datetime( help='Date when the email has been invalidated. ' 'Invalid emails are blacklisted, opted-out or invalid email format') scheduled = fields.Datetime(help='Date when the email has been created', default=fields.Datetime.now) sent = fields.Datetime(help='Date when the email has been sent') exception = fields.Datetime( help='Date of technical error leading to the email not being sent') opened = fields.Datetime( help='Date when the email has been opened the first time') replied = fields.Datetime( help='Date when this email has been replied for the first time.') bounced = fields.Datetime(help='Date when this email has bounced.') # Link tracking links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click') clicked = fields.Datetime( help='Date when customer clicked on at least one tracked link') # Status state = fields.Selection(compute="_compute_state", selection=[('outgoing', 'Outgoing'), ('exception', 'Exception'), ('sent', 'Sent'), ('opened', 'Opened'), ('replied', 'Replied'), ('bounced', 'Bounced'), ('ignored', 'Ignored')], store=True) failure_type = fields.Selection(selection=[ ("SMTP", "Connection failed (outgoing mail server problem)"), ("RECIPIENT", "Invalid email address"), ("BOUNCE", "Email address rejected by destination"), ("UNKNOWN", "Unknown error"), ], string='Failure type') state_update = fields.Datetime(compute="_compute_state", string='State Update', help='Last state update of the mail', store=True) @api.depends('trace_type', 'mass_mailing_id') def _compute_display_name(self): for trace in self: trace.display_name = '%s: %s (%s)' % ( trace.trace_type, trace.mass_mailing_id.name, trace.id) @api.depends('sent', 'opened', 'clicked', 'replied', 'bounced', 'exception', 'ignored') def _compute_state(self): self.update({'state_update': fields.Datetime.now()}) for stat in self: if stat.ignored: stat.state = 'ignored' elif stat.exception: stat.state = 'exception' elif stat.replied: stat.state = 'replied' elif stat.opened or stat.clicked: stat.state = 'opened' elif stat.bounced: stat.state = 'bounced' elif stat.sent: stat.state = 'sent' else: stat.state = 'outgoing' @api.model_create_multi def create(self, values_list): for values in values_list: if 'mail_mail_id' in values: values['mail_mail_id_int'] = values['mail_mail_id'] return super(MailingTrace, self).create(values_list) def action_view_contact(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': self.model, 'target': 'current', 'res_id': self.res_id } def _get_records(self, mail_mail_ids=None, mail_message_ids=None, domain=None): if not self.ids and mail_mail_ids: base_domain = [('mail_mail_id_int', 'in', mail_mail_ids)] elif not self.ids and mail_message_ids: base_domain = [('message_id', 'in', mail_message_ids)] else: base_domain = [('id', 'in', self.ids)] if domain: base_domain = ['&'] + domain + base_domain return self.search(base_domain) def set_opened(self, mail_mail_ids=None, mail_message_ids=None): traces = self._get_records(mail_mail_ids, mail_message_ids, [('opened', '=', False)]) traces.write({'opened': fields.Datetime.now(), 'bounced': False}) return traces def set_clicked(self, mail_mail_ids=None, mail_message_ids=None): traces = self._get_records(mail_mail_ids, mail_message_ids, [('clicked', '=', False)]) traces.write({'clicked': fields.Datetime.now()}) return traces def set_replied(self, mail_mail_ids=None, mail_message_ids=None): traces = self._get_records(mail_mail_ids, mail_message_ids, [('replied', '=', False)]) traces.write({'replied': fields.Datetime.now()}) return traces def set_bounced(self, mail_mail_ids=None, mail_message_ids=None): traces = self._get_records(mail_mail_ids, mail_message_ids, [('bounced', '=', False), ('opened', '=', False)]) traces.write({'bounced': fields.Datetime.now()}) return traces
class RecordChangeset(models.Model): _name = "record.changeset" _description = "Record Changeset" _order = "date desc" _rec_name = "date" model = fields.Char(index=True, required=True, readonly=True) res_id = fields.Many2oneReference( string="Record ID", index=True, required=True, readonly=True, model_field="model", ) change_ids = fields.One2many( comodel_name="record.changeset.change", inverse_name="changeset_id", string="Changes", readonly=True, ) date = fields.Datetime(string="Modified on", default=fields.Datetime.now(), index=True, readonly=True) modified_by_id = fields.Many2one("res.users", default=lambda self: self.env.user, readonly=True) state = fields.Selection( compute="_compute_state", selection=[("draft", "Pending"), ("done", "Done")], store=True, ) note = fields.Text() source = fields.Reference(string="Source of the change", selection="_reference_models", readonly=True) company_id = fields.Many2one("res.company") record_id = fields.Reference(selection="_reference_models", compute="_compute_resource_record", readonly=True) @api.depends("model", "res_id") def _compute_resource_record(self): for changeset in self: changeset.record_id = "{},{}".format(changeset.model, changeset.res_id or 0) @api.model def _reference_models(self): models = self.env["ir.model"].sudo().search([]) return [(model.model, model.name) for model in models] @api.depends("change_ids", "change_ids.state") def _compute_state(self): for rec in self: changes = rec.mapped("change_ids") if all(change.state in ("done", "cancel") for change in changes): rec.state = "done" else: rec.state = "draft" def name_get(self): result = [] for changeset in self: name = "{} ({})".format(changeset.date, changeset.record_id.name) result.append((changeset.id, name)) return result def apply(self): self.with_context( skip_pending_status_check=True).mapped("change_ids").apply() def cancel(self): self.with_context( skip_pending_status_check=True).mapped("change_ids").cancel() @api.model def add_changeset(self, record, values): """Add a changeset on a record By default, when a record is modified by a user or by the system, the the changeset will follow the rules configured for the global rules. A caller should pass the following keys in the context: * ``__changeset_rules_source_model``: name of the model which asks for the change * ``__changeset_rules_source_id``: id of the record which asks for the change When the source model and id are not defined, the current user is considered as the origin of the change. Should be called before the execution of ``write`` on the record so we can keep track of the existing value and also because the returned values should be used for ``write`` as some of the values may have been removed. :param values: the values being written on the record :type values: dict :returns: dict of values that should be wrote on the record (fields with a 'Validate' or 'Never' rule are excluded) """ record.ensure_one() source_model = self.env.context.get("__changeset_rules_source_model") source_id = self.env.context.get("__changeset_rules_source_id") if not source_model: # if the changes source is not defined, log the user who # made the change source_model = "res.users" if not source_id: source_id = self.env.uid if source_model and source_id: source = "{},{}".format(source_model, source_id) else: source = False change_model = self.env["record.changeset.change"] write_values = values.copy() changes = [] rules = self.env["changeset.field.rule"].get_rules( source_model_name=source_model, record_model_name=record._name) for field in values: rule = rules.get(field) if not rule: continue if field in values: if not change_model._has_field_changed(record, field, values[field]): continue change, pop_value = change_model._prepare_changeset_change( record, rule, field, values[field]) if pop_value: write_values.pop(field) changes.append(change) if changes: changeset_vals = self._prepare_changeset_vals( changes, record, source) self.env["record.changeset"].create(changeset_vals) return write_values @api.model def _prepare_changeset_vals(self, changes, record, source): has_company = "company_id" in self.env[record._name]._fields has_company = has_company and record.company_id company = record.company_id if has_company else self.env.company return { "res_id": record.id, "model": record._name, "company_id": company.id, "change_ids": [(0, 0, vals) for vals in changes], "date": fields.Datetime.now(), "source": source, }
class Rating(models.Model): _name = "rating.rating" _description = "Rating" _order = 'write_date desc' _rec_name = 'res_name' @api.model def _default_access_token(self): return uuid.uuid4().hex @api.model def _selection_target_model(self): return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])] create_date = fields.Datetime(string="Submitted on") res_name = fields.Char(string='Resource name', compute='_compute_res_name', store=True, help="The name of the rated resource.") res_model_id = fields.Many2one('ir.model', 'Related Document Model', index=True, ondelete='cascade', help='Model of the followed resource') res_model = fields.Char(string='Document Model', related='res_model_id.model', store=True, index=True, readonly=True) res_id = fields.Many2oneReference(string='Document', model_field='res_model', help="Identifier of the rated object", required=True, index=True) resource_ref = fields.Reference( string='Resource Ref', selection='_selection_target_model', compute='_compute_resource_ref', readonly=True) parent_res_name = fields.Char('Parent Document Name', compute='_compute_parent_res_name', store=True) parent_res_model_id = fields.Many2one('ir.model', 'Parent Related Document Model', index=True, ondelete='cascade') parent_res_model = fields.Char('Parent Document Model', store=True, related='parent_res_model_id.model', index=True, readonly=False) parent_res_id = fields.Integer('Parent Document', index=True) parent_ref = fields.Reference( string='Parent Ref', selection='_selection_target_model', compute='_compute_parent_ref', readonly=True) rated_partner_id = fields.Many2one('res.partner', string="Rated Operator", help="Owner of the rated resource") rated_partner_name = fields.Char(related="rated_partner_id.name") partner_id = fields.Many2one('res.partner', string='Customer', help="Author of the rating") rating = fields.Float(string="Rating Value", group_operator="avg", default=0, help="Rating value: 0=Unhappy, 5=Happy") rating_image = fields.Binary('Image', compute='_compute_rating_image') rating_image_url = fields.Char('Image URL', compute='_compute_rating_image') rating_text = fields.Selection(rating_data.RATING_TEXT, string='Rating', store=True, compute='_compute_rating_text', readonly=True) feedback = fields.Text('Comment', help="Reason of the rating") message_id = fields.Many2one( 'mail.message', string="Message", index=True, ondelete='cascade', help="Associated message when posting a review. Mainly used in website addons.") is_internal = fields.Boolean('Visible Internally Only', readonly=False, related='message_id.is_internal', store=True) access_token = fields.Char('Security Token', default=_default_access_token, help="Access token to set the rating of the value") consumed = fields.Boolean(string="Filled Rating", help="Enabled if the rating has been filled.") _sql_constraints = [ ('rating_range', 'check(rating >= 0 and rating <= 5)', 'Rating should be between 0 and 5'), ] @api.depends('res_model', 'res_id') def _compute_res_name(self): for rating in self: name = self.env[rating.res_model].sudo().browse(rating.res_id).name_get() rating.res_name = name and name[0][1] or ('%s/%s') % (rating.res_model, rating.res_id) @api.depends('res_model', 'res_id') def _compute_resource_ref(self): for rating in self: if rating.res_model and rating.res_model in self.env: rating.resource_ref = '%s,%s' % (rating.res_model, rating.res_id or 0) else: rating.resource_ref = None @api.depends('parent_res_model', 'parent_res_id') def _compute_parent_ref(self): for rating in self: if rating.parent_res_model and rating.parent_res_model in self.env: rating.parent_ref = '%s,%s' % (rating.parent_res_model, rating.parent_res_id or 0) else: rating.parent_ref = None @api.depends('parent_res_model', 'parent_res_id') def _compute_parent_res_name(self): for rating in self: name = False if rating.parent_res_model and rating.parent_res_id: name = self.env[rating.parent_res_model].sudo().browse(rating.parent_res_id).name_get() name = name and name[0][1] or ('%s/%s') % (rating.parent_res_model, rating.parent_res_id) rating.parent_res_name = name def _get_rating_image_filename(self): self.ensure_one() return 'rating_%s.png' % rating_data._rating_to_threshold(self.rating) @api.depends('rating') def _compute_rating_image(self): self.rating_image_url = False self.rating_image = False for rating in self: try: image_path = get_resource_path('rating', 'static/src/img', rating._get_rating_image_filename()) rating.rating_image_url = '/rating/static/src/img/%s' % rating._get_rating_image_filename() rating.rating_image = base64.b64encode(open(image_path, 'rb').read()) if image_path else False except (IOError, OSError): pass @api.depends('rating') def _compute_rating_text(self): for rating in self: rating.rating_text = rating_data._rating_to_text(rating.rating) @api.model_create_multi def create(self, vals_list): for values in vals_list: if values.get('res_model_id') and values.get('res_id'): values.update(self._find_parent_data(values)) return super().create(vals_list) def write(self, values): if values.get('res_model_id') and values.get('res_id'): values.update(self._find_parent_data(values)) return super(Rating, self).write(values) def unlink(self): # OPW-2181568: Delete the chatter message too self.env['mail.message'].search([('rating_ids', 'in', self.ids)]).unlink() return super(Rating, self).unlink() def _find_parent_data(self, values): """ Determine the parent res_model/res_id, based on the values to create or write """ current_model_name = self.env['ir.model'].sudo().browse(values['res_model_id']).model current_record = self.env[current_model_name].browse(values['res_id']) data = { 'parent_res_model_id': False, 'parent_res_id': False, } if hasattr(current_record, '_rating_get_parent_field_name'): current_record_parent = current_record._rating_get_parent_field_name() if current_record_parent: parent_res_model = getattr(current_record, current_record_parent) data['parent_res_model_id'] = self.env['ir.model']._get(parent_res_model._name).id data['parent_res_id'] = parent_res_model.id return data def reset(self): for record in self: record.write({ 'rating': 0, 'access_token': record._default_access_token(), 'feedback': False, 'consumed': False, }) def action_open_rated_object(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': self.res_model, 'res_id': self.res_id, 'views': [[False, 'form']] }
class IrAttachment(models.Model): """Attachments are used to link binary files or url to any openerp document. External attachment storage --------------------------- The computed field ``datas`` is implemented using ``_file_read``, ``_file_write`` and ``_file_delete``, which can be overridden to implement other storage engines. Such methods should check for other location pseudo uri (example: hdfs://hadoopserver). The default implementation is the file:dirname location that stores files on the local filesystem using name based on their sha1 hash """ _name = 'ir.attachment' _description = 'Attachment' _order = 'id desc' def _compute_res_name(self): for attachment in self: if attachment.res_model and attachment.res_id: record = self.env[attachment.res_model].browse(attachment.res_id) attachment.res_name = record.display_name else: attachment.res_name = False @api.model def _storage(self): return self.env['ir.config_parameter'].sudo().get_param('ir_attachment.location', 'file') @api.model def _filestore(self): return config.filestore(self._cr.dbname) @api.model def force_storage(self): """Force all attachments to be stored in the currently configured storage""" if not self.env.is_admin(): raise AccessError(_('Only administrators can execute this action.')) # domain to retrieve the attachments to migrate domain = { 'db': [('store_fname', '!=', False)], 'file': [('db_datas', '!=', False)], }[self._storage()] for attach in self.search(domain): attach.write({'raw': attach.raw, 'mimetype': attach.mimetype}) return True @api.model def _full_path(self, path): # sanitize path path = re.sub('[.]', '', path) path = path.strip('/\\') return os.path.join(self._filestore(), path) @api.model def _get_path(self, bin_data, sha): # retro compatibility fname = sha[:3] + '/' + sha full_path = self._full_path(fname) if os.path.isfile(full_path): return fname, full_path # keep existing path # scatter files across 256 dirs # we use '/' in the db (even on windows) fname = sha[:2] + '/' + sha full_path = self._full_path(fname) dirname = os.path.dirname(full_path) if not os.path.isdir(dirname): os.makedirs(dirname) # prevent sha-1 collision if os.path.isfile(full_path) and not self._same_content(bin_data, full_path): raise UserError("The attachment is colliding with an existing file.") return fname, full_path @api.model def _file_read(self, fname): full_path = self._full_path(fname) try: with open(full_path, 'rb') as f: return f.read() except (IOError, OSError): _logger.info("_read_file reading %s", full_path, exc_info=True) return b'' @api.model def _file_write(self, bin_value, checksum): fname, full_path = self._get_path(bin_value, checksum) if not os.path.exists(full_path): try: with open(full_path, 'wb') as fp: fp.write(bin_value) # add fname to checklist, in case the transaction aborts self._mark_for_gc(fname) except IOError: _logger.info("_file_write writing %s", full_path, exc_info=True) return fname @api.model def _file_delete(self, fname): # simply add fname to checklist, it will be garbage-collected later self._mark_for_gc(fname) def _mark_for_gc(self, fname): """ Add ``fname`` in a checklist for the filestore garbage collection. """ # we use a spooldir: add an empty file in the subdirectory 'checklist' full_path = os.path.join(self._full_path('checklist'), fname) if not os.path.exists(full_path): dirname = os.path.dirname(full_path) if not os.path.isdir(dirname): with tools.ignore(OSError): os.makedirs(dirname) open(full_path, 'ab').close() @api.autovacuum def _gc_file_store(self): """ Perform the garbage collection of the filestore. """ if self._storage() != 'file': return # Continue in a new transaction. The LOCK statement below must be the # first one in the current transaction, otherwise the database snapshot # used by it may not contain the most recent changes made to the table # ir_attachment! Indeed, if concurrent transactions create attachments, # the LOCK statement will wait until those concurrent transactions end. # But this transaction will not see the new attachements if it has done # other requests before the LOCK (like the method _storage() above). cr = self._cr cr.commit() # prevent all concurrent updates on ir_attachment while collecting, # but only attempt to grab the lock for a little bit, otherwise it'd # start blocking other transactions. (will be retried later anyway) cr.execute("SET LOCAL lock_timeout TO '10s'") cr.execute("LOCK ir_attachment IN SHARE MODE") # retrieve the file names from the checklist checklist = {} for dirpath, _, filenames in os.walk(self._full_path('checklist')): dirname = os.path.basename(dirpath) for filename in filenames: fname = "%s/%s" % (dirname, filename) checklist[fname] = os.path.join(dirpath, filename) # determine which files to keep among the checklist whitelist = set() for names in cr.split_for_in_conditions(checklist): cr.execute("SELECT store_fname FROM ir_attachment WHERE store_fname IN %s", [names]) whitelist.update(row[0] for row in cr.fetchall()) # remove garbage files, and clean up checklist removed = 0 for fname, filepath in checklist.items(): if fname not in whitelist: try: os.unlink(self._full_path(fname)) removed += 1 except (OSError, IOError): _logger.info("_file_gc could not unlink %s", self._full_path(fname), exc_info=True) with tools.ignore(OSError): os.unlink(filepath) # commit to release the lock cr.commit() _logger.info("filestore gc %d checked, %d removed", len(checklist), removed) @api.depends('store_fname', 'db_datas', 'file_size') @api.depends_context('bin_size') def _compute_datas(self): if self._context.get('bin_size'): for attach in self: attach.datas = human_size(attach.file_size) return for attach in self: attach.datas = base64.b64encode(attach.raw or b'') @api.depends('store_fname', 'db_datas') def _compute_raw(self): for attach in self: if attach.store_fname: attach.raw = attach._file_read(attach.store_fname) else: attach.raw = attach.db_datas def _inverse_raw(self): self._set_attachment_data(lambda a: a.raw or b'') def _inverse_datas(self): self._set_attachment_data(lambda attach: base64.b64decode(attach.datas or b'')) def _set_attachment_data(self, asbytes): for attach in self: # compute the fields that depend on datas bin_data = asbytes(attach) vals = self._get_datas_related_values(bin_data, attach.mimetype) # take current location in filestore to possibly garbage-collect it fname = attach.store_fname # write as superuser, as user probably does not have write access super(IrAttachment, attach.sudo()).write(vals) if fname: self._file_delete(fname) def _get_datas_related_values(self, data, mimetype): values = { 'file_size': len(data), 'checksum': self._compute_checksum(data), 'index_content': self._index(data, mimetype), 'store_fname': False, 'db_datas': data, } if data and self._storage() != 'db': values['store_fname'] = self._file_write(data, values['checksum']) values['db_datas'] = False return values def _compute_checksum(self, bin_data): """ compute the checksum for the given datas :param bin_data : datas in its binary form """ # an empty file has a checksum too (for caching) return hashlib.sha1(bin_data or b'').hexdigest() @api.model def _same_content(self, bin_data, filepath): BLOCK_SIZE = 1024 with open(filepath, 'rb') as fd: i = 0 while True: data = fd.read(BLOCK_SIZE) if data != bin_data[i * BLOCK_SIZE:(i + 1) * BLOCK_SIZE]: return False if not data: break i += 1 return True def _compute_mimetype(self, values): """ compute the mimetype of the given values :param values : dict of values to create or write an ir_attachment :return mime : string indicating the mimetype, or application/octet-stream by default """ mimetype = None if values.get('mimetype'): mimetype = values['mimetype'] if not mimetype and values.get('name'): mimetype = mimetypes.guess_type(values['name'])[0] if not mimetype and values.get('url'): mimetype = mimetypes.guess_type(values['url'])[0] if not mimetype or mimetype == 'application/octet-stream': raw = None if values.get('raw'): raw = values['raw'] elif values.get('datas'): raw = base64.b64decode(values['datas']) if raw: mimetype = guess_mimetype(raw) return mimetype or 'application/octet-stream' def _check_contents(self, values): mimetype = values['mimetype'] = self._compute_mimetype(values) xml_like = 'ht' in mimetype or ( # hta, html, xhtml, etc. 'xml' in mimetype and # other xml (svg, text/xml, etc) not 'openxmlformats' in mimetype) # exception for Office formats user = self.env.context.get('binary_field_real_user', self.env.user) force_text = (xml_like and (not user._is_system() or self.env.context.get('attachments_mime_plainxml'))) if force_text: values['mimetype'] = 'text/plain' return values @api.model def _index(self, bin_data, file_type): """ compute the index content of the given binary data. This is a python implementation of the unix command 'strings'. :param bin_data : datas in binary form :return index_content : string containing all the printable character of the binary data """ index_content = False if file_type: index_content = file_type.split('/')[0] if index_content == 'text': # compute index_content only for text type words = re.findall(b"[\x20-\x7E]{4,}", bin_data) index_content = b"\n".join(words).decode('ascii') return index_content @api.model def get_serving_groups(self): """ An ir.attachment record may be used as a fallback in the http dispatch if its type field is set to "binary" and its url field is set as the request's url. Only the groups returned by this method are allowed to create and write on such records. """ return ['base.group_system'] name = fields.Char('Name', required=True) description = fields.Text('Description') res_name = fields.Char('Resource Name', compute='_compute_res_name') res_model = fields.Char('Resource Model', readonly=True, help="The database object this attachment will be attached to.") res_field = fields.Char('Resource Field', readonly=True) res_id = fields.Many2oneReference('Resource ID', model_field='res_model', readonly=True, help="The record id this is attached to.") company_id = fields.Many2one('res.company', string='Company', change_default=True, default=lambda self: self.env.company) type = fields.Selection([('url', 'URL'), ('binary', 'File')], string='Type', required=True, default='binary', change_default=True, help="You can either upload a file from your computer or copy/paste an internet link to your file.") url = fields.Char('Url', index=True, size=1024) public = fields.Boolean('Is public document') # for external access access_token = fields.Char('Access Token', groups="base.group_user") # the field 'datas' is computed and may use the other fields below raw = fields.Binary(string="File Content (raw)", compute='_compute_raw', inverse='_inverse_raw') datas = fields.Binary(string='File Content (base64)', compute='_compute_datas', inverse='_inverse_datas') db_datas = fields.Binary('Database Data', attachment=False) store_fname = fields.Char('Stored Filename') file_size = fields.Integer('File Size', readonly=True) checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True) mimetype = fields.Char('Mime Type', readonly=True) index_content = fields.Text('Indexed Content', readonly=True, prefetch=False) def _auto_init(self): res = super(IrAttachment, self)._auto_init() tools.create_index(self._cr, 'ir_attachment_res_idx', self._table, ['res_model', 'res_id']) return res @api.constrains('type', 'url') def _check_serving_attachments(self): if self.env.is_admin(): return for attachment in self: # restrict writing on attachments that could be served by the # ir.http's dispatch exception handling # XDO note: this should be done in check(write), constraints for access rights? # XDO note: if read on sudo, read twice, one for constraints, one for _inverse_datas as user if attachment.type == 'binary' and attachment.url: has_group = self.env.user.has_group if not any(has_group(g) for g in attachment.get_serving_groups()): raise ValidationError("Sorry, you are not allowed to write on this document") @api.model def check(self, mode, values=None): """ Restricts the access to an ir.attachment, according to referred mode """ if self.env.is_superuser(): return True # Always require an internal user (aka, employee) to access to a attachment if not (self.env.is_admin() or self.env.user.has_group('base.group_user')): raise AccessError(_("Sorry, you are not allowed to access this document.")) # collect the records to check (by model) model_ids = defaultdict(set) # {model_name: set(ids)} if self: # DLE P173: `test_01_portal_attachment` self.env['ir.attachment'].flush(['res_model', 'res_id', 'create_uid', 'public', 'res_field']) self._cr.execute('SELECT res_model, res_id, create_uid, public, res_field FROM ir_attachment WHERE id IN %s', [tuple(self.ids)]) for res_model, res_id, create_uid, public, res_field in self._cr.fetchall(): if not self.env.is_system() and res_field: raise AccessError(_("Sorry, you are not allowed to access this document.")) if public and mode == 'read': continue if not (res_model and res_id): continue model_ids[res_model].add(res_id) if values and values.get('res_model') and values.get('res_id'): model_ids[values['res_model']].add(values['res_id']) # check access rights on the records for res_model, res_ids in model_ids.items(): # ignore attachments that are not attached to a resource anymore # when checking access rights (resource was deleted but attachment # was not) if res_model not in self.env: continue if res_model == 'res.users' and len(res_ids) == 1 and self.env.uid == list(res_ids)[0]: # by default a user cannot write on itself, despite the list of writeable fields # e.g. in the case of a user inserting an image into his image signature # we need to bypass this check which would needlessly throw us away continue records = self.env[res_model].browse(res_ids).exists() # For related models, check if we can write to the model, as unlinking # and creating attachments can be seen as an update to the model access_mode = 'write' if mode in ('create', 'unlink') else mode records.check_access_rights(access_mode) records.check_access_rule(access_mode) def _read_group_allowed_fields(self): return ['type', 'company_id', 'res_id', 'create_date', 'create_uid', 'name', 'mimetype', 'id', 'url', 'res_field', 'res_model'] @api.model def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): """Override read_group to add res_field=False in domain if not present.""" if not fields: raise AccessError(_("Sorry, you must provide fields to read on attachments")) if any('(' in field for field in fields + groupby): raise AccessError(_("Sorry, the syntax 'name:agg(field)' is not available for attachments")) if not any(item[0] in ('id', 'res_field') for item in domain): domain.insert(0, ('res_field', '=', False)) groupby = [groupby] if isinstance(groupby, str) else groupby allowed_fields = self._read_group_allowed_fields() fields_set = set(field.split(':')[0] for field in fields + groupby) if not self.env.is_system() and (not fields or fields_set.difference(allowed_fields)): raise AccessError(_("Sorry, you are not allowed to access these fields on attachments.")) return super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): # add res_field=False in domain if not present; the arg[0] trick below # works for domain items and '&'/'|'/'!' operators too discard_binary_fields_attachments = False if not any(arg[0] in ('id', 'res_field') for arg in args): discard_binary_fields_attachments = True args.insert(0, ('res_field', '=', False)) ids = super(IrAttachment, self)._search(args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid) if self.env.is_superuser(): # rules do not apply for the superuser return len(ids) if count else ids if not ids: return 0 if count else [] # Work with a set, as list.remove() is prohibitive for large lists of documents # (takes 20+ seconds on a db with 100k docs during search_count()!) orig_ids = ids ids = set(ids) # For attachments, the permissions of the document they are attached to # apply, so we must remove attachments for which the user cannot access # the linked document. # Use pure SQL rather than read() as it is about 50% faster for large dbs (100k+ docs), # and the permissions are checked in super() and below anyway. model_attachments = defaultdict(lambda: defaultdict(set)) # {res_model: {res_id: set(ids)}} binary_fields_attachments = set() self._cr.execute("""SELECT id, res_model, res_id, public, res_field FROM ir_attachment WHERE id IN %s""", [tuple(ids)]) for row in self._cr.dictfetchall(): if not row['res_model'] or row['public']: continue # model_attachments = {res_model: {res_id: set(ids)}} model_attachments[row['res_model']][row['res_id']].add(row['id']) # Should not retrieve binary fields attachments if not explicitly required if discard_binary_fields_attachments and row['res_field']: binary_fields_attachments.add(row['id']) if binary_fields_attachments: ids.difference_update(binary_fields_attachments) # To avoid multiple queries for each attachment found, checks are # performed in batch as much as possible. for res_model, targets in model_attachments.items(): if res_model not in self.env: continue if not self.env[res_model].check_access_rights('read', False): # remove all corresponding attachment ids ids.difference_update(itertools.chain(*targets.values())) continue # filter ids according to what access rules permit target_ids = list(targets) allowed = self.env[res_model].with_context(active_test=False).search([('id', 'in', target_ids)]) for res_id in set(target_ids).difference(allowed.ids): ids.difference_update(targets[res_id]) # sort result according to the original sort ordering result = [id for id in orig_ids if id in ids] # If the original search reached the limit, it is important the # filtered record set does so too. When a JS view receive a # record set whose length is below the limit, it thinks it # reached the last page. To avoid an infinite recursion due to the # permission checks the sub-call need to be aware of the number of # expected records to retrieve if len(orig_ids) == limit and len(result) < self._context.get('need', limit): need = self._context.get('need', limit) - len(result) result.extend(self.with_context(need=need)._search(args, offset=offset + len(orig_ids), limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)[:limit - len(result)]) return len(result) if count else list(result) def _read(self, fields): self.check('read') return super(IrAttachment, self)._read(fields) def write(self, vals): self.check('write', values=vals) # remove computed field depending of datas for field in ('file_size', 'checksum'): vals.pop(field, False) if 'mimetype' in vals or 'datas' in vals: vals = self._check_contents(vals) return super(IrAttachment, self).write(vals) def copy(self, default=None): self.check('write') return super(IrAttachment, self).copy(default) def unlink(self): if not self: return True self.check('unlink') # First delete in the database, *then* in the filesystem if the # database allowed it. Helps avoid errors when concurrent transactions # are deleting the same file, and some of the transactions are # rolled back by PostgreSQL (due to concurrent updates detection). to_delete = set(attach.store_fname for attach in self if attach.store_fname) res = super(IrAttachment, self).unlink() for file_path in to_delete: self._file_delete(file_path) return res @api.model_create_multi def create(self, vals_list): record_tuple_set = set() for values in vals_list: # remove computed field depending of datas for field in ('file_size', 'checksum'): values.pop(field, False) values = self._check_contents(values) if 'datas' in values: data = values.pop('datas') values.update(self._get_datas_related_values(base64.b64decode(data or b''), values['mimetype'])) # 'check()' only uses res_model and res_id from values, and make an exists. # We can group the values by model, res_id to make only one query when # creating multiple attachments on a single record. record_tuple = (values.get('res_model'), values.get('res_id')) record_tuple_set.add(record_tuple) for record_tuple in record_tuple_set: (res_model, res_id) = record_tuple self.check('create', values={'res_model':res_model, 'res_id':res_id}) return super(IrAttachment, self).create(vals_list) def _post_add_create(self): pass def generate_access_token(self): tokens = [] for attachment in self: if attachment.access_token: tokens.append(attachment.access_token) continue access_token = self._generate_access_token() attachment.write({'access_token': access_token}) tokens.append(access_token) return tokens def _generate_access_token(self): return str(uuid.uuid4()) @api.model def action_get(self): return self.env['ir.actions.act_window']._for_xml_id('base.action_attachment') @api.model def get_serve_attachment(self, url, extra_domain=None, extra_fields=None, order=None): domain = [('type', '=', 'binary'), ('url', '=', url)] + (extra_domain or []) fieldNames = ['__last_update', 'datas', 'mimetype'] + (extra_fields or []) return self.search_read(domain, fieldNames, order=order, limit=1)
class Followers(models.Model): """ mail_followers holds the data related to the follow mechanism inside Odoo. Partners can choose to follow documents (records) of any kind that inherits from mail.thread. Following documents allow to receive notifications for new messages. A subscription is characterized by: :param: res_model: model of the followed objects :param: res_id: ID of resource (may be 0 for every objects) """ _name = 'mail.followers' _rec_name = 'partner_id' _log_access = False _description = 'Document Followers' # Note. There is no integrity check on model names for performance reasons. # However, followers of unlinked models are deleted by models themselves # (see 'ir.model' inheritance). res_model = fields.Char('Related Document Model Name', required=True, index=True) res_id = fields.Many2oneReference('Related Document ID', index=True, help='Id of the followed resource', model_field='res_model') partner_id = fields.Many2one('res.partner', string='Related Partner', index=True, ondelete='cascade', required=True, domain=[('type', '!=', 'private')]) subtype_ids = fields.Many2many( 'mail.message.subtype', string='Subtype', help= "Message subtypes followed, meaning subtypes that will be pushed onto the user's Wall." ) name = fields.Char('Name', related='partner_id.name') email = fields.Char('Email', related='partner_id.email') is_active = fields.Boolean('Is Active', related='partner_id.active') def _invalidate_documents(self, vals_list=None): """ Invalidate the cache of the documents followed by ``self``. Modifying followers change access rights to individual documents. As the cache may contain accessible/inaccessible data, one has to refresh it. """ to_invalidate = defaultdict(list) for record in (vals_list or [{ 'res_model': rec.res_model, 'res_id': rec.res_id } for rec in self]): if record.get('res_id'): to_invalidate[record.get('res_model')].append( record.get('res_id')) @api.model_create_multi def create(self, vals_list): res = super(Followers, self).create(vals_list) res._invalidate_documents(vals_list) return res def write(self, vals): if 'res_model' in vals or 'res_id' in vals: self._invalidate_documents() res = super(Followers, self).write(vals) if any(x in vals for x in ['res_model', 'res_id', 'partner_id']): self._invalidate_documents() return res def unlink(self): self._invalidate_documents() return super(Followers, self).unlink() _sql_constraints = [ ('mail_followers_res_partner_res_model_id_uniq', 'unique(res_model,res_id,partner_id)', 'Error, a partner cannot follow twice the same object.'), ] # -------------------------------------------------- # Private tools methods to fetch followers data # -------------------------------------------------- def _get_recipient_data(self, records, message_type, subtype_id, pids=None): """ Private method allowing to fetch recipients data based on a subtype. Purpose of this method is to fetch all data necessary to notify recipients in a single query. It fetches data from * followers (partners and channels) of records that follow the given subtype if records and subtype are set; * partners if pids is given; :param records: fetch data from followers of ``records`` that follow ``subtype_id``; :param message_type: mail.message.message_type in order to allow custom behavior depending on it (SMS for example); :param subtype_id: mail.message.subtype to check against followers; :param pids: additional set of partner IDs from which to fetch recipient data independently from following status; :return dict: recipients data based on record.ids if given, else a generic '0' key to keep a dict-like return format. Each item is a dict based on recipients partner ids formatted like {'active': whether partner is active; 'id': res.partner ID; 'is_follower': True if linked to a record and if partner is a follower; 'lang': lang of the partner; 'groups': groups of the partner's user. If several users exist preference is given to internal user, then share users. In case of multiples users of same kind groups are unioned; 'notif': notification type ('inbox' or 'email'). Overrides may change this value (e.g. 'sms' in sms module); 'share': if partner is a customer (no user or share user); 'ushare': if partner has users, whether all are shared (public or portal); 'type': summary of partner 'usage' (portal, customer, internal user); } """ self.env['mail.followers'].flush_model(['partner_id', 'subtype_ids']) self.env['mail.message.subtype'].flush_model(['internal']) self.env['res.users'].flush_model( ['notification_type', 'active', 'partner_id', 'groups_id']) self.env['res.partner'].flush_model(['active', 'partner_share']) self.env['res.groups'].flush_model(['users']) # if we have records and a subtype: we have to fetch followers, unless being # in user notification mode (contact only pids) if message_type != 'user_notification' and records and subtype_id: query = """ WITH sub_followers AS ( SELECT fol.partner_id AS pid, fol.id AS fid, fol.res_id AS res_id, TRUE as is_follower, COALESCE(subrel.follow, FALSE) AS subtype_follower, COALESCE(subrel.internal, FALSE) AS internal FROM mail_followers fol LEFT JOIN LATERAL ( SELECT TRUE AS follow, subtype.internal AS internal FROM mail_followers_mail_message_subtype_rel m LEFT JOIN mail_message_subtype subtype ON subtype.id = m.mail_message_subtype_id WHERE m.mail_followers_id = fol.id AND m.mail_message_subtype_id = %s ) subrel ON TRUE WHERE fol.res_model = %s AND fol.res_id IN %s UNION ALL SELECT res_partner.id AS pid, 0 AS fid, 0 AS res_id, FALSE as is_follower, FALSE as subtype_follower, FALSE as internal FROM res_partner WHERE res_partner.id = ANY(%s) ) SELECT partner.id as pid, partner.active as active, partner.lang as lang, partner.partner_share as pshare, sub_user.uid as uid, COALESCE(sub_user.share, FALSE) as ushare, COALESCE(sub_user.notification_type, 'email') as notif, sub_user.groups as groups, sub_followers.res_id as res_id, sub_followers.is_follower as _insert_followerslower FROM res_partner partner JOIN sub_followers ON sub_followers.pid = partner.id AND (NOT sub_followers.internal OR NOT partner.partner_share) LEFT JOIN LATERAL ( SELECT users.id AS uid, users.share AS share, users.notification_type AS notification_type, ARRAY_AGG(groups_rel.gid) FILTER (WHERE groups_rel.gid IS NOT NULL) AS groups FROM res_users users LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id WHERE users.partner_id = partner.id AND users.active GROUP BY users.id, users.share, users.notification_type ORDER BY users.share ASC NULLS FIRST, users.id ASC FETCH FIRST ROW ONLY ) sub_user ON TRUE WHERE sub_followers.subtype_follower OR partner.id = ANY(%s) """ params = [ subtype_id, records._name, tuple(records.ids), list(pids or []), list(pids or []) ] self.env.cr.execute(query, tuple(params)) res = self.env.cr.fetchall() # partner_ids and records: no sub query for followers but check for follower status elif pids and records: params = [] query = """ SELECT partner.id as pid, partner.active as active, partner.lang as lang, partner.partner_share as pshare, sub_user.uid as uid, COALESCE(sub_user.share, FALSE) as ushare, COALESCE(sub_user.notification_type, 'email') as notif, sub_user.groups as groups, ARRAY_AGG(fol.res_id) FILTER (WHERE fol.res_id IS NOT NULL) AS res_ids FROM res_partner partner LEFT JOIN mail_followers fol ON fol.partner_id = partner.id AND fol.res_model = %s AND fol.res_id IN %s LEFT JOIN LATERAL ( SELECT users.id AS uid, users.share AS share, users.notification_type AS notification_type, ARRAY_AGG(groups_rel.gid) FILTER (WHERE groups_rel.gid IS NOT NULL) AS groups FROM res_users users LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id WHERE users.partner_id = partner.id AND users.active GROUP BY users.id, users.share, users.notification_type ORDER BY users.share ASC NULLS FIRST, users.id ASC FETCH FIRST ROW ONLY ) sub_user ON TRUE WHERE partner.id IN %s GROUP BY partner.id, sub_user.uid, sub_user.share, sub_user.notification_type, sub_user.groups """ params = [records._name, tuple(records.ids), tuple(pids)] self.env.cr.execute(query, tuple(params)) simplified_res = self.env.cr.fetchall() # simplified query contains res_ids -> flatten it by making it a list # with res_id and add follower status res = [] for item in simplified_res: res_ids = item[-1] if not res_ids: # keep res_ids Falsy (global), set as not follower flattened = [list(item) + [False]] else: # generate an entry for each res_id with partner being follower flattened = [ list(item[:-1]) + [res_id, True] for res_id in res_ids ] res += flattened # only partner ids: no follower status involved, fetch only direct recipients information elif pids: query = """ SELECT partner.id as pid, partner.active as active, partner.lang as lang, partner.partner_share as pshare, sub_user.uid as uid, COALESCE(sub_user.share, FALSE) as ushare, COALESCE(sub_user.notification_type, 'email') as notif, sub_user.groups as groups, 0 as res_id, FALSE as is_follower FROM res_partner partner LEFT JOIN LATERAL ( SELECT users.id AS uid, users.share AS share, users.notification_type AS notification_type, ARRAY_AGG(groups_rel.gid) FILTER (WHERE groups_rel.gid IS NOT NULL) AS groups FROM res_users users LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id WHERE users.partner_id = partner.id AND users.active GROUP BY users.id, users.share, users.notification_type ORDER BY users.share ASC NULLS FIRST, users.id ASC FETCH FIRST ROW ONLY ) sub_user ON TRUE WHERE partner.id IN %s GROUP BY partner.id, sub_user.uid, sub_user.share, sub_user.notification_type, sub_user.groups """ params = [tuple(pids)] self.env.cr.execute(query, tuple(params)) res = self.env.cr.fetchall() else: res = [] res_ids = records.ids if records else [0] doc_infos = dict((res_id, {}) for res_id in res_ids) for (partner_id, is_active, lang, pshare, uid, ushare, notif, groups, res_id, is_follower) in res: to_update = [res_id] if res_id else res_ids for res_id_to_update in to_update: # avoid updating already existing information, unnecessary dict update if not res_id and partner_id in doc_infos[res_id_to_update]: continue follower_data = { 'active': is_active, 'id': partner_id, 'is_follower': is_follower, 'lang': lang, 'groups': set(groups or []), 'notif': notif, 'share': pshare, 'uid': uid, 'ushare': ushare, } # additional information if follower_data['ushare']: # any type of share user follower_data['type'] = 'portal' elif follower_data[ 'share']: # no user, is share -> customer (partner only) follower_data['type'] = 'customer' else: # has a user not share -> internal user follower_data['type'] = 'user' doc_infos[res_id_to_update][partner_id] = follower_data return doc_infos def _get_subscription_data(self, doc_data, pids, include_pshare=False, include_active=False): """ Private method allowing to fetch follower data from several documents of a given model. Followers can be filtered given partner IDs and channel IDs. :param doc_data: list of pair (res_model, res_ids) that are the documents from which we want to have subscription data; :param pids: optional partner to filter; if None take all, otherwise limitate to pids :param include_pshare: optional join in partner to fetch their share status :param include_active: optional join in partner to fetch their active flag :return: list of followers data which is a list of tuples containing follower ID, document ID, partner ID, followed subtype IDs, share status of partner (returned only if include_pshare is True) active flag status of partner (returned only if include_active is True) """ # base query: fetch followers of given documents where_clause = ' OR '.join( ['fol.res_model = %s AND fol.res_id IN %s'] * len(doc_data)) where_params = list( itertools.chain.from_iterable( (rm, tuple(rids)) for rm, rids in doc_data)) # additional: filter on optional pids sub_where = [] if pids: sub_where += ["fol.partner_id IN %s"] where_params.append(tuple(pids)) elif pids is not None: sub_where += ["fol.partner_id IS NULL"] if sub_where: where_clause += "AND (%s)" % " OR ".join(sub_where) query = """ SELECT fol.id, fol.res_id, fol.partner_id, array_agg(subtype.id)%s%s FROM mail_followers fol %s LEFT JOIN mail_followers_mail_message_subtype_rel fol_rel ON fol_rel.mail_followers_id = fol.id LEFT JOIN mail_message_subtype subtype ON subtype.id = fol_rel.mail_message_subtype_id WHERE %s GROUP BY fol.id%s%s""" % ( ', partner.partner_share' if include_pshare else '', ', partner.active' if include_active else '', 'LEFT JOIN res_partner partner ON partner.id = fol.partner_id' if (include_pshare or include_active) else '', where_clause, ', partner.partner_share' if include_pshare else '', ', partner.active' if include_active else '') self.env.cr.execute(query, tuple(where_params)) return self.env.cr.fetchall() # -------------------------------------------------- # Private tools methods to generate new subscription # -------------------------------------------------- def _insert_followers(self, res_model, res_ids, partner_ids, subtypes=None, customer_ids=None, check_existing=True, existing_policy='skip'): """ Main internal method allowing to create or update followers for documents, given a res_model and the document res_ids. This method does not handle access rights. This is the role of the caller to ensure there is no security breach. :param subtypes: see ``_add_followers``. If not given, default ones are computed. :param customer_ids: see ``_add_default_followers`` :param check_existing: see ``_add_followers``; :param existing_policy: see ``_add_followers``; """ sudo_self = self.sudo().with_context(default_partner_id=False) if not subtypes: # no subtypes -> default computation, no force, skip existing new, upd = self._add_default_followers( res_model, res_ids, partner_ids, customer_ids=customer_ids, check_existing=check_existing, existing_policy=existing_policy) else: new, upd = self._add_followers(res_model, res_ids, partner_ids, subtypes, check_existing=check_existing, existing_policy=existing_policy) if new: sudo_self.create([ dict(values, res_id=res_id) for res_id, values_list in new.items() for values in values_list ]) for fol_id, values in upd.items(): sudo_self.browse(fol_id).write(values) def _add_default_followers(self, res_model, res_ids, partner_ids, customer_ids=None, check_existing=True, existing_policy='skip'): """ Shortcut to ``_add_followers`` that computes default subtypes. Existing followers are skipped as their subscription is considered as more important compared to new default subscription. :param customer_ids: optional list of partner ids that are customers. It is used if computing default subtype is necessary and allow to avoid the check of partners being customers (no user or share user). It is just a matter of saving queries if the info is already known; :param check_existing: see ``_add_followers``; :param existing_policy: see ``_add_followers``; :return: see ``_add_followers`` """ if not partner_ids: return dict(), dict() default, _, external = self.env[ 'mail.message.subtype'].default_subtypes(res_model) if partner_ids and customer_ids is None: customer_ids = self.env['res.partner'].sudo().search([ ('id', 'in', partner_ids), ('partner_share', '=', True) ]).ids p_stypes = dict( (pid, external.ids if pid in customer_ids else default.ids) for pid in partner_ids) return self._add_followers(res_model, res_ids, partner_ids, p_stypes, check_existing=check_existing, existing_policy=existing_policy) def _add_followers(self, res_model, res_ids, partner_ids, subtypes, check_existing=False, existing_policy='skip'): """ Internal method that generates values to insert or update followers. Callers have to handle the result, for example by making a valid ORM command, inserting or updating directly follower records, ... This method returns two main data * first one is a dict which keys are res_ids. Value is a list of dict of values valid for creating new followers for the related res_id; * second one is a dict which keys are follower ids. Value is a dict of values valid for updating the related follower record; :param subtypes: optional subtypes for new partner followers. This is a dict whose keys are partner IDs and value subtype IDs for that partner. :param channel_subtypes: optional subtypes for new channel followers. This is a dict whose keys are channel IDs and value subtype IDs for that channel. :param check_existing: if True, check for existing followers for given documents and handle them according to existing_policy parameter. Setting to False allows to save some computation if caller is sure there are no conflict for followers; :param existing policy: if check_existing, tells what to do with already existing followers: * skip: simply skip existing followers, do not touch them; * force: update existing with given subtypes only; * replace: replace existing with new subtypes (like force without old / new follower); * update: gives an update dict allowing to add missing subtypes (no subtype removal); """ _res_ids = res_ids or [0] data_fols, doc_pids = dict(), dict((i, set()) for i in _res_ids) if check_existing and res_ids: for fid, rid, pid, sids in self._get_subscription_data( [(res_model, res_ids)], partner_ids or None): if existing_policy != 'force': if pid: doc_pids[rid].add(pid) data_fols[fid] = (rid, pid, sids) if existing_policy == 'force': self.sudo().browse(data_fols.keys()).unlink() new, update = dict(), dict() for res_id in _res_ids: for partner_id in set(partner_ids or []): if partner_id not in doc_pids[res_id]: new.setdefault(res_id, list()).append({ 'res_model': res_model, 'partner_id': partner_id, 'subtype_ids': [Command.set(subtypes[partner_id])], }) elif existing_policy in ('replace', 'update'): fol_id, sids = next( ((key, val[2]) for key, val in data_fols.items() if val[0] == res_id and val[1] == partner_id), (False, [])) new_sids = set(subtypes[partner_id]) - set(sids) old_sids = set(sids) - set(subtypes[partner_id]) update_cmd = [] if fol_id and new_sids: update_cmd += [Command.link(sid) for sid in new_sids] if fol_id and old_sids and existing_policy == 'replace': update_cmd += [Command.unlink(sid) for sid in old_sids] if update_cmd: update[fol_id] = {'subtype_ids': update_cmd} return new, update
class projet_contact(models.Model): _name = 'projetprojet' _description = 'Nouvelle fiche projet' _rec_name = 'name_projet' _inherit = ['mail.thread', 'mail.activity.mixin'] _inherits = { 'res.company': 'company_id', } user_id = fields.Many2one('res.users', ondelete='set null', string='Créateur du projet', default=lambda self: self.env.user) name_projet = fields.Text(string='Nom du projet', required=True, index=True, track_visibility="onchange") company_id = fields.Many2one('res.company', 'Organisation', required=True, index=True, ondelete='restrict') type_projet = fields.Selection([ ('Etatique', 'etatique'), ('Religieux', 'religieux'), ('Associatif', 'associatif'), ('Autre', 'autre'), ], 'Type de projet', default='Associatif') date = fields.Date(string='Date de création du projet', default=datetime.today()) status = fields.Selection([ ('Annuler', 'annulé'), ('Confirmer', 'confirmé'), ('Brouillon', 'brouillon'), ('En cours', 'en cours'), ], 'Statut', default='Brouillon', track_visibility="onchange") notes = fields.Text(string='Notes') name = fields.Text() Etapes = fields.Selection([ ('Nouveau', 'nouveau'), ('Etude', 'étude'), ('Validé', 'validé'), ('Refusé', 'refusé'), ('En cours', 'en cours'), ('Cloture', 'cloture'), ], string='Etapes', readonly=True, default='Nouveau', track_visibility="onchange") country_id1 = fields.Many2one('res.country', 'Pays', required=True, index=True) is_document = fields.Boolean("Is Document") res_model = fields.Char( 'Resource Model', readonly=True, help="The database object this attachment will be attached to.") res_id = fields.Many2oneReference( 'Resource ID', model_field='res_model', readonly=True, help="The record id this is attached to.") document_new = fields.Integer() adresse_postale = fields.Text() ville = fields.Char() departement = fields.Char() description_projet = fields.Text(string='description du projet') code_analytique = fields.Char(track_visibility="onchange") budget_list_lines = fields.One2many('projetprojet.budget', 'the_budget_id', string='Budget list') contact_list_lines = fields.One2many('projetprojet.contact', 'the_contact_id', string='Contact list') event_list_lines = fields.One2many('projetprojet.event', 'the_event_id', string="Event list") versement_list_lines = fields.One2many('projetprojet.versement', 'the_versement_id', string='Vers list') evaluation_list_lines = fields.One2many('projetprojet.evaluation', 'the_evaluation_id', string='Eval list') materiel_list_lines = fields.One2many('projetprojet.materiel', 'the_materiel_id', string='Materiel list') mattechnique_list_lines = fields.One2many( 'projetprojet.mattechnique', 'the_mattechnique_id', string='Materiel technique liste') batiments_list_lines = fields.One2many('projetprojet.batiments', 'the_batiments_id', string='Batiments liste') beneficiaire_list_lines = fields.One2many('projetprojet.beneficiaire', 'the_beneficiaire_id', string='Beneficiaire liste') histoire_project = fields.Text(string="Histoire") tel_project = fields.Char() adresse_email = fields.Char() context_ville = fields.Text() context_pays = fields.Text() nombres_beneficiaire = fields.Integer() role = fields.Char(string='Nature') nombre = fields.Integer(string="Nombre") role1 = fields.Char(string='Nature') nombre1 = fields.Integer(string="Nombre") def action_etude(self): for rec in self: rec.Etapes = 'Etude' def action_valider(self): for rec in self: rec.Etapes = 'Validé' def action_refuser(self): for rec in self: rec.Etapes = 'Cloture' def action_encour(self): for rec in self: rec.Etapes = 'En cours' def action_cloture(self): for rec in self: rec.Etapes = 'Cloture' @api.onchange('state_id') def onchange_state(self): if self.state_id: self.country_id = self.state_id.country_id.id
class RestOuthToken(models.Model): _name = 'rest.oauth.token' _rec_name = 'token' @api.model def _get_unique_token(self): token = oauthlib_common.generate_token() while self.search_count([('token', '=', token)]): token = oauthlib_common.generate_token() return token @api.depends('res_model', 'res_id') def _compute_res_name(self): for token in self: token.res_name = (token.res_model and token.res_id and token.res_field) and \ '_'.join([str(token.res_id), self.env[token.res_model].browse(token.res_id).display_name, token.res_field]) type = fields.Selection(selection=[ ('bearer', "Bearer"), ], required=True, default='bearer') token = fields.Char(required=True, readonly=True, copy=False, default=_get_unique_token) expire = fields.Datetime(readonly=True, copy=False) res_model_id = fields.Many2one('ir.model', 'Related Model', required=True, readonly=True, copy=False, ondelete='cascade') res_model = fields.Char('Related Model Name', related='res_model_id.model', compute_sudo=True, store=True, readonly=True, copy=False) res_id = fields.Many2oneReference(string='Related ID', required=True, readonly=True, copy=False, model_field='res_model') res_name = fields.Char('Related Name', compute='_compute_res_name', compute_sudo=True, store=True, readonly=True, copy=False) res_field = fields.Char('Related Field', required=True, readonly=True, copy=False) _sql_constraints = [ ('token_res_uniq', 'unique (token,res_model_id,res_field)', 'The token must be unique per model and field!') ] @api.model def get_res_vals(self, res_model, res_field=None): res_model.ensure_one() res_model = res_model.sudo() vals = { 'res_id': res_model.id, 'res_model_id': res_model.env['ir.model']._get(res_model._name).id } if res_field: vals.update({'res_field': res_field}) return vals @api.model def action_generate_field_token(self, res_models, res_field, expire=0): for res_model in res_models: res_model.mapped(res_field).unlink() vals = self.get_res_vals(res_model=res_model, res_field=res_field) if expire: expire = datetime.now() + timedelta(seconds=expire) vals.update({'expire': expire}) record = self.create(vals) res_model.write({res_field: record.id})
class IrAttachment(models.Model): _inherit = 'ir.attachment' res_model = fields.Char(index=True) res_field = fields.Char(index=True) res_id = fields.Many2oneReference(index=True)
class AjacentZonesWizard(models.TransientModel): ajacent_zone_ids = fields.One2many(comodel_name="acs.zone", string="Adjacent Zones") controller_id = fields.Many2oneReference(string='Related Controller ID', index=True, required=True, model_field="acs_controller")
class EDIExchangeRecord(models.Model): """ Define an exchange record. """ _name = "edi.exchange.record" _inherit = "mail.thread" _description = "EDI exchange Record" _order = "exchanged_on desc" name = fields.Char(compute="_compute_name") identifier = fields.Char(required=True, index=True, readonly=True) external_identifier = fields.Char(index=True, readonly=True) type_id = fields.Many2one( string="EDI Exchange type", comodel_name="edi.exchange.type", required=True, ondelete="cascade", auto_join=True, ) direction = fields.Selection(related="type_id.direction") backend_id = fields.Many2one(comodel_name="edi.backend", required=True) model = fields.Char(index=True, required=False, readonly=True) res_id = fields.Many2oneReference( string="Record ID", index=True, required=False, readonly=True, model_field="model", ) exchange_file = fields.Binary(attachment=True) exchange_filename = fields.Char( compute="_compute_exchange_filename", readonly=False, store=True ) exchanged_on = fields.Datetime( string="Exchanged on", help="Sent or received on this date.", compute="_compute_exchanged_on", store=True, readonly=False, ) edi_exchange_state = fields.Selection( string="Exchange state", readonly=True, default="new", selection=[ # Common states ("new", "New"), ("validate_error", "Error on validation"), # output exchange states ("output_pending", "Waiting to be sent"), ("output_error_on_send", "error on send"), ("output_sent", "Sent"), ("output_sent_and_processed", "Sent and processed"), ("output_sent_and_error", "Sent and error"), # input exchange states ("input_pending", "Waiting to be received"), ("input_received", "Received"), ("input_receive_error", "Error on reception"), ("input_processed", "Processed"), ("input_processed_error", "Error on process"), ], ) exchange_error = fields.Text(string="Exchange error", readonly=True) # Relations w/ other records parent_id = fields.Many2one( comodel_name="edi.exchange.record", help="Original exchange which originated this record", ) related_exchange_ids = fields.One2many( string="Related records", comodel_name="edi.exchange.record", inverse_name="parent_id", ) # TODO: shall we add a constrain on the direction? # In theory if the record is outgoing the ack should be incoming and vice versa. ack_exchange_id = fields.Many2one( comodel_name="edi.exchange.record", help="Ack for this exchange", compute="_compute_ack_exchange_id", store=True, ) ack_received_on = fields.Datetime( string="ACK received on", related="ack_exchange_id.exchanged_on" ) _sql_constraints = [ ("identifier_uniq", "unique(identifier)", "The identifier must be unique."), ( "external_identifier_uniq", "unique(external_identifier)", "The external_identifier must be unique.", ), ] @api.depends("type_id.code", "model", "res_id") def _compute_name(self): for rec in self: rec.name = "{} - {}".format( rec.type_id.name, rec.record.name if rec.model else "Unrelated" ) @api.depends("model", "type_id") def _compute_exchange_filename(self): for rec in self: if not rec.type_id: continue if not rec.exchange_filename: rec.exchange_filename = rec.type_id._make_exchange_filename(rec) @api.depends("edi_exchange_state") def _compute_exchanged_on(self): for rec in self: if rec.edi_exchange_state in ("input_received", "output_sent"): rec.exchanged_on = fields.Datetime.now() @api.constrains("edi_exchange_state") def _constrain_edi_exchange_state(self): for rec in self: if rec.edi_exchange_state in ("new", "validate_error"): continue if not rec.edi_exchange_state.startswith(rec.direction): raise exceptions.ValidationError( _("Exchange state must respect direction!") ) @api.depends("related_exchange_ids.type_id") def _compute_ack_exchange_id(self): for rec in self: rec.ack_exchange_id = rec._get_ack_record() def _get_ack_record(self): if not self.type_id.ack_type_id: return None return self.related_exchange_ids.filtered( lambda x: x.type_id == self.type_id.ack_type_id ) def needs_ack(self): return self.type_id.ack_type_id and not self.ack_exchange_id @property def record(self): # In some case the res_model (and res_id) could be empty so we have to load # data from parent if not self.model and not self.parent_id: return None if not self.model and self.parent_id: return self.parent_id.record return self.env[self.model].browse(self.res_id) def _set_file_content( self, output_string, encoding="utf-8", field_name="exchange_file" ): """Handy method to no have to convert b64 back and forth.""" self.ensure_one() if not isinstance(output_string, bytes): output_string = bytes(output_string, encoding) self[field_name] = base64.b64encode(output_string) def _get_file_content(self, field_name="exchange_file"): """Handy method to no have to convert b64 back and forth.""" self.ensure_one() if not self[field_name]: return "" return base64.b64decode(self[field_name]).decode() def name_get(self): result = [] for rec in self: dt = fields.Datetime.to_string(rec.exchanged_on) if rec.exchanged_on else "" rec_name = rec.identifier if rec.res_id and rec.model: rec_name = rec.record.display_name name = "[{}] {} {}".format(rec.type_id.name, rec_name, dt) result.append((rec.id, name)) return result @api.model def create(self, vals): vals["identifier"] = self._get_identifier() return super().create(vals) def _get_identifier(self): return self.env["ir.sequence"].next_by_code("edi.exchange") @api.constrains("backend_id", "type_id") def _constrain_backend(self): for rec in self: if rec.type_id.backend_id: if rec.type_id.backend_id != rec.backend_id: raise exceptions.ValidationError( _("Backend must match with exchange type's backend!") ) else: if rec.type_id.backend_type_id != rec.backend_id.backend_type_id: raise exceptions.ValidationError( _("Backend type must match with exchange type's backend type!") ) @property def _exchange_status_messages(self): return { # status: message "send_ok": _("File %s sent") % self.exchange_filename, "send_ko": _( "An error happened while sending. Please check exchange record info." ), "process_ok": _("File %s processed successfully ") % self.exchange_filename, "process_ko": _("File %s processed with errors") % self.exchange_filename, "receive_ok": _("File %s received successfully ") % self.exchange_filename, "receive_ko": _("File %s not received") % self.exchange_filename, "ack_received": _("ACK file received."), "ack_missing": _("ACK file is required for this exchange but not found."), "ack_received_error": _("ACK file received but contains errors."), "validate_ko": _("File %s not valid") % self.exchange_filename, } def _exchange_status_message(self, key): return self._exchange_status_messages[key] def action_exchange_send(self): self.ensure_one() return self.backend_id.exchange_send(self) def action_exchange_process(self): self.ensure_one() return self.backend_id.exchange_process(self) def action_open_related_record(self): self.ensure_one() if not self.model or not self.res_id: return {} return self.record.get_formview_action() def _notify_related_record(self, message, level="info"): """Post notification on the original record.""" if not hasattr(self.record, "message_post_with_view"): return self.record.message_post_with_view( "edi_oca.message_edi_exchange_link", values={ "backend": self.backend_id, "exchange_record": self, "message": message, "level": level, }, subtype_id=self.env.ref("mail.mt_note").id, ) def _trigger_edi_event_make_name(self, name, suffix=None): return "on_edi_exchange_{name}{suffix}".format( name=name, suffix=("_" + suffix) if suffix else "", ) def _trigger_edi_event(self, name, suffix=None): """Trigger a component event linked to this backend and edi exchange.""" name = self._trigger_edi_event_make_name(name, suffix=suffix) self._event(name).notify(self) def _notify_done(self): self._notify_related_record(self._exchange_status_message("process_ok")) self._trigger_edi_event("done") def _notify_error(self, message_key): self._notify_related_record( self._exchange_status_message(message_key), level="error", ) self._trigger_edi_event("error") def _notify_ack_received(self): self._notify_related_record(self._exchange_status_message("ack_received")) self._trigger_edi_event("done", suffix="ack_received") def _notify_ack_missing(self): self._notify_related_record( self._exchange_status_message("ack_missing"), level="warning", ) self._trigger_edi_event("done", suffix="ack_missing") def _notify_ack_received_error(self): self._notify_related_record( self._exchange_status_message("ack_received_error"), ) self._trigger_edi_event("done", suffix="ack_received_error") @api.model def _search( self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None, ): ids = super()._search( args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid, ) if self.env.is_superuser(): # rules do not apply for the superuser return len(ids) if count else ids if not ids: return 0 if count else [] orig_ids = ids ids = set(ids) result = [] model_data = defaultdict( lambda: defaultdict(set) ) # {res_model: {res_id: set(ids)}} for sub_ids in self._cr.split_for_in_conditions(ids): self._cr.execute( """ SELECT id, res_id, model FROM "%s" WHERE id = ANY (%%(ids)s)""" % self._table, dict(ids=list(sub_ids)), ) for eid, res_id, model in self._cr.fetchall(): if not model: result.append(eid) continue model_data[model][res_id].add(eid) for model, targets in model_data.items(): if not self.env[model].check_access_rights("read", False): continue target_ids = list(targets) allowed = ( self.env[model] .with_context(active_test=False) ._search([("id", "in", target_ids)]) ) for target_id in allowed: result += list(targets[target_id]) if len(orig_ids) == limit and len(result) < len(orig_ids): result.extend( self._search( args, offset=offset + len(orig_ids), limit=limit, order=order, count=count, access_rights_uid=access_rights_uid, )[: limit - len(result)] ) return len(result) if count else list(result) def read(self, fields=None, load="_classic_read"): """Override to explicitely call check_access_rule, that is not called by the ORM. It instead directly fetches ir.rules and apply them.""" self.check_access_rule("read") return super().read(fields=fields, load=load) def check_access_rule(self, operation): """In order to check if we can access a record, we are checking if we can access the related document""" super(EDIExchangeRecord, self).check_access_rule(operation) if self.env.is_superuser(): return default_checker = self.env["edi.exchange.consumer.mixin"].get_edi_access by_model_rec_ids = defaultdict(set) by_model_checker = {} for exc_rec in self.sudo(): if not exc_rec.model or not exc_rec.res_id: continue by_model_rec_ids[exc_rec.model].add(exc_rec.res_id) if exc_rec.model not in by_model_checker: by_model_checker[exc_rec.model] = getattr( self.env[exc_rec.model], "get_edi_access", default_checker ) for model, rec_ids in by_model_rec_ids.items(): records = self.env[model].browse(rec_ids).with_user(self._uid) checker = by_model_checker[model] for record in records: check_operation = checker( [record.id], operation, model_name=record._name ) record.check_access_rights(check_operation) record.check_access_rule(check_operation) def write(self, vals): self.check_access_rule("write") return super().write(vals)
class MailingTrace(models.Model): """ MailingTrace models the statistics collected about emails. Those statistics are stored in a separated model and table to avoid bloating the mail_mail table with statistics values. This also allows to delete emails send with mass mailing without loosing the statistics about them. Note:: State management / Error codes / Failure types summary * trace_status 'outgoing', 'sent', 'opened', 'replied', 'error', 'bouce', 'cancel' * failure_type # generic 'unknown', # mass_mailing "mail_email_invalid", "mail_smtp", "mail_email_missing" # mass mailing mass mode specific codes "mail_bl", "mail_optout", "mail_dup" # mass_mailing_sms 'sms_number_missing', 'sms_number_format', 'sms_credit', 'sms_server', 'sms_acc' # mass_mailing_sms mass mode specific codes 'sms_blacklist', 'sms_duplicate', 'sms_optout', * cancel: * mail: set in get_mail_values in composer, if email is blacklisted (mail) or in opt_out / seen list (mass_mailing) or email_to is void or incorrectly formatted (mass_mailing) - based on mail cancel state * sms: set in _prepare_mass_sms_trace_values in composer if sms is in cancel state; either blacklisted (sms) or in opt_out / seen list (sms); * void mail / void sms number -> error (mail_missing, sms_number_missing) * invalid mail / invalid sms number -> error (RECIPIENT, sms_number_format) * exception: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if mail (sms) not sent with failure type, reset if sent; * sent: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if mail (sms) sent * clicked: triggered by add_click * opened: triggered by add_click + blank gif (mail) + gateway reply (mail) * replied: triggered by gateway reply (mail) * bounced: triggered by gateway bounce (mail) or in _prepare_mass_sms_trace_values if sms_number_format error when sending sms (sms) """ _name = 'mailing.trace' _description = 'Mailing Statistics' _rec_name = 'id' _order = 'create_date DESC' trace_type = fields.Selection([('mail', 'Email')], string='Type', default='mail', required=True) display_name = fields.Char(compute='_compute_display_name') # mail data mail_mail_id = fields.Many2one('mail.mail', string='Mail', index='btree_not_null') mail_mail_id_int = fields.Integer( string='Mail ID (tech)', help= 'ID of the related mail_mail. This field is an integer field because ' 'the related mail_mail can be deleted separately from its statistics. ' 'However the ID is needed for several action and controllers.', index='btree_not_null', ) email = fields.Char(string="Email", help="Normalized email address") message_id = fields.Char( string='Message-ID', help="Technical field for the email Message-ID (RFC 2392)") medium_id = fields.Many2one(related='mass_mailing_id.medium_id') source_id = fields.Many2one(related='mass_mailing_id.source_id') # document model = fields.Char(string='Document model', required=True) res_id = fields.Many2oneReference(string='Document ID', model_field='model') # campaign data mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True, ondelete='cascade') campaign_id = fields.Many2one(related='mass_mailing_id.campaign_id', string='Campaign', store=True, readonly=True, index='btree_not_null') # Status sent_datetime = fields.Datetime('Sent On') open_datetime = fields.Datetime('Opened On') reply_datetime = fields.Datetime('Replied On') trace_status = fields.Selection(selection=[('outgoing', 'Outgoing'), ('sent', 'Sent'), ('open', 'Opened'), ('reply', 'Replied'), ('bounce', 'Bounced'), ('error', 'Exception'), ('cancel', 'Canceled')], string='Status', default='outgoing') failure_type = fields.Selection( selection=[ # generic ("unknown", "Unknown error"), # mail ("mail_email_invalid", "Invalid email address"), ("mail_email_missing", "Missing email address"), ("mail_smtp", "Connection failed (outgoing mail server problem)"), # mass mode ("mail_bl", "Blacklisted Address"), ("mail_optout", "Opted Out"), ("mail_dup", "Duplicated Email"), ], string='Failure type') # Link tracking links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click') links_click_datetime = fields.Datetime( 'Clicked On', help='Stores last click datetime in case of multi clicks.') _sql_constraints = [ # Required on a Many2one reference field is not sufficient as actually # writing 0 is considered as a valid value, because this is an integer field. # We therefore need a specific constraint check. ('check_res_id_is_set', 'CHECK(res_id IS NOT NULL AND res_id !=0 )', 'Traces have to be linked to records with a not null res_id.') ] @api.depends('trace_type', 'mass_mailing_id') def _compute_display_name(self): for trace in self: trace.display_name = '%s: %s (%s)' % ( trace.trace_type, trace.mass_mailing_id.name, trace.id) @api.model_create_multi def create(self, values_list): for values in values_list: if 'mail_mail_id' in values: values['mail_mail_id_int'] = values['mail_mail_id'] return super(MailingTrace, self).create(values_list) def action_view_contact(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': self.model, 'target': 'current', 'res_id': self.res_id } def set_sent(self, domain=None): traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.write({ 'trace_status': 'sent', 'sent_datetime': fields.Datetime.now(), 'failure_type': False }) return traces def set_opened(self, domain=None): """ Reply / Open are a bit shared in various processes: reply implies open, click implies open. Let us avoid status override by skipping traces that are not already opened or replied. """ traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.filtered(lambda t: t.trace_status not in ('open', 'reply')).write({ 'trace_status': 'open', 'open_datetime': fields.Datetime.now() }) return traces def set_clicked(self, domain=None): traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.write({'links_click_datetime': fields.Datetime.now()}) return traces def set_replied(self, domain=None): traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.write({ 'trace_status': 'reply', 'reply_datetime': fields.Datetime.now() }) return traces def set_bounced(self, domain=None): traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.write({'trace_status': 'bounce'}) return traces def set_failed(self, domain=None, failure_type=False): traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.write({'trace_status': 'error', 'failure_type': failure_type}) return traces def set_canceled(self, domain=None): traces = self + (self.search(domain) if domain else self.env['mailing.trace']) traces.write({'trace_status': 'cancel'}) return traces
class MailActivity(models.Model): """ An actual activity to perform. Activities are linked to documents using res_id and res_model_id fields. Activities have a deadline that can be used in kanban view to display a status. Once done activities are unlinked and a message is posted. This message has a new activity_type_id field that indicates the activity linked to the message. """ _name = 'mail.activity' _description = 'Activity' _order = 'date_deadline ASC' _rec_name = 'summary' @api.model def default_get(self, fields): res = super(MailActivity, self).default_get(fields) if not fields or 'res_model_id' in fields and res.get('res_model'): res['res_model_id'] = self.env['ir.model']._get( res['res_model']).id return res # owner res_model_id = fields.Many2one('ir.model', 'Document Model', index=True, ondelete='cascade', required=True) res_model = fields.Char('Related Document Model', index=True, related='res_model_id.model', compute_sudo=True, store=True, readonly=True) res_id = fields.Many2oneReference(string='Related Document ID', index=True, required=True, model_field='res_model') res_name = fields.Char('Document Name', compute='_compute_res_name', compute_sudo=True, store=True, help="Display name of the related document.", readonly=True) # activity activity_type_id = fields.Many2one( 'mail.activity.type', string='Activity Type', domain= "['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict') activity_category = fields.Selection(related='activity_type_id.category', readonly=True) activity_decoration = fields.Selection( related='activity_type_id.decoration_type', readonly=True) icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True) summary = fields.Char('Summary') note = fields.Html('Note') date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today) automated = fields.Boolean( 'Automated activity', readonly=True, help= 'Indicates this activity has been created automatically and not by any user.' ) # description user_id = fields.Many2one('res.users', 'Assigned to', default=lambda self: self.env.user, index=True, required=True) state = fields.Selection([('overdue', 'Overdue'), ('today', 'Today'), ('planned', 'Planned')], 'State', compute='_compute_state') recommended_activity_type_id = fields.Many2one( 'mail.activity.type', string="Recommended Activity Type") previous_activity_type_id = fields.Many2one( 'mail.activity.type', string='Previous Activity Type', readonly=True) has_recommended_activities = fields.Boolean( 'Next activities available', compute='_compute_has_recommended_activities', help='Technical field for UX purpose') mail_template_ids = fields.Many2many( related='activity_type_id.mail_template_ids', readonly=True) force_next = fields.Boolean(related='activity_type_id.force_next', readonly=True) # access can_write = fields.Boolean( compute='_compute_can_write', help= 'Technical field to hide buttons if the current user has no access.') @api.onchange('previous_activity_type_id') def _compute_has_recommended_activities(self): for record in self: record.has_recommended_activities = bool( record.previous_activity_type_id.next_type_ids) @api.onchange('previous_activity_type_id') def _onchange_previous_activity_type_id(self): for record in self: if record.previous_activity_type_id.default_next_type_id: record.activity_type_id = record.previous_activity_type_id.default_next_type_id @api.depends('res_model', 'res_id') def _compute_res_name(self): for activity in self: activity.res_name = activity.res_model and \ self.env[activity.res_model].browse(activity.res_id).display_name @api.depends('date_deadline') def _compute_state(self): for record in self.filtered(lambda activity: activity.date_deadline): tz = record.user_id.sudo().tz date_deadline = record.date_deadline record.state = self._compute_state_from_date(date_deadline, tz) @api.model def _compute_state_from_date(self, date_deadline, tz=False): date_deadline = fields.Date.from_string(date_deadline) today_default = date.today() today = today_default if tz: today_utc = pytz.UTC.localize(datetime.utcnow()) today_tz = today_utc.astimezone(pytz.timezone(tz)) today = date(year=today_tz.year, month=today_tz.month, day=today_tz.day) diff = (date_deadline - today) if diff.days == 0: return 'today' elif diff.days < 0: return 'overdue' else: return 'planned' @api.depends('res_model', 'res_id', 'user_id') def _compute_can_write(self): valid_records = self._filter_access_rules('write') for record in self: record.can_write = record in valid_records @api.onchange('activity_type_id') def _onchange_activity_type_id(self): if self.activity_type_id: self.summary = self.activity_type_id.summary # Date.context_today is correct because date_deadline is a Date and is meant to be # expressed in user TZ base = fields.Date.context_today(self) if self.activity_type_id.delay_from == 'previous_activity' and 'activity_previous_deadline' in self.env.context: base = fields.Date.from_string( self.env.context.get('activity_previous_deadline')) self.date_deadline = base + relativedelta( **{ self.activity_type_id.delay_unit: self.activity_type_id.delay_count }) self.user_id = self.activity_type_id.default_user_id or self.env.user if self.activity_type_id.default_description: self.note = self.activity_type_id.default_description @api.onchange('recommended_activity_type_id') def _onchange_recommended_activity_type_id(self): if self.recommended_activity_type_id: self.activity_type_id = self.recommended_activity_type_id def _filter_access_rules(self, operation): # write / unlink: valid for creator / assigned if operation in ('write', 'unlink'): valid = super(MailActivity, self)._filter_access_rules(operation) if valid and valid == self: return self else: valid = self.env[self._name] return self._filter_access_rules_remaining(valid, operation, '_filter_access_rules') def _filter_access_rules_python(self, operation): # write / unlink: valid for creator / assigned if operation in ('write', 'unlink'): valid = super(MailActivity, self)._filter_access_rules_python(operation) if valid and valid == self: return self else: valid = self.env[self._name] return self._filter_access_rules_remaining( valid, operation, '_filter_access_rules_python') def _filter_access_rules_remaining(self, valid, operation, filter_access_rules_method): """ Return the subset of ``self`` for which ``operation`` is allowed. A custom implementation is done on activities as this document has some access rules and is based on related document for activities that are not covered by those rules. Access on activities are the following : * create: (``mail_post_access`` or write) right on related documents; * read: read rights on related documents; * write: access rule OR (``mail_post_access`` or write) rights on related documents); * unlink: access rule OR (``mail_post_access`` or write) rights on related documents); """ # compute remaining for hand-tailored rules remaining = self - valid remaining_sudo = remaining.sudo() # fall back on related document access right checks. Use the same as defined for mail.thread # if available; otherwise fall back on read for read, write for other operations. activity_to_documents = dict() for activity in remaining_sudo: # write / unlink: if not updating self or assigned, limit to automated activities to avoid # updating other people's activities. As unlinking a document bypasses access rights checks # on related activities this will not prevent people from deleting documents with activities # create / read: just check rights on related document activity_to_documents.setdefault(activity.res_model, list()).append(activity.res_id) for doc_model, doc_ids in activity_to_documents.items(): if hasattr(self.env[doc_model], '_mail_post_access'): doc_operation = self.env[doc_model]._mail_post_access elif operation == 'read': doc_operation = 'read' else: doc_operation = 'write' right = self.env[doc_model].check_access_rights( doc_operation, raise_exception=False) if right: valid_doc_ids = getattr( self.env[doc_model].browse(doc_ids), filter_access_rules_method)(doc_operation) valid += remaining.filtered( lambda activity: activity.res_model == doc_model and activity.res_id in valid_doc_ids.ids) return valid def _check_access_assignation(self): """ Check assigned user (user_id field) has access to the document. Purpose is to allow assigned user to handle their activities. For that purpose assigned user should be able to at least read the document. We therefore raise an UserError if the assigned user has no access to the document. """ for activity in self: model = self.env[activity.res_model].with_user(activity.user_id) try: model.check_access_rights('read') except exceptions.AccessError: raise exceptions.UserError( _('Assigned user %s has no access to the document and is not able to handle this activity.' ) % activity.user_id.display_name) else: try: target_user = activity.user_id target_record = self.env[activity.res_model].browse( activity.res_id) if hasattr(target_record, 'company_id') and ( target_record.company_id != target_user.company_id and (len(target_user.sudo().company_ids) > 1)): return # in that case we skip the check, assuming it would fail because of the company model.browse(activity.res_id).check_access_rule('read') except exceptions.AccessError: raise exceptions.UserError( _('Assigned user %s has no access to the document and is not able to handle this activity.' ) % activity.user_id.display_name) # ------------------------------------------------------ # ORM overrides # ------------------------------------------------------ @api.model def create(self, values): activity = super(MailActivity, self).create(values) need_sudo = False try: # in multicompany, reading the partner might break partner_id = activity.user_id.partner_id.id except exceptions.AccessError: need_sudo = True partner_id = activity.user_id.sudo().partner_id.id # send a notification to assigned user; in case of manually done activity also check # target has rights on document otherwise we prevent its creation. Automated activities # are checked since they are integrated into business flows that should not crash. if activity.user_id != self.env.user: if not activity.automated: activity._check_access_assignation() if not self.env.context.get('mail_activity_quick_update', False): if need_sudo: activity.sudo().action_notify() else: activity.action_notify() self.env[activity.res_model].browse( activity.res_id).message_subscribe(partner_ids=[partner_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 def write(self, values): if values.get('user_id'): user_changes = self.filtered( lambda activity: activity.user_id.id != values.get('user_id')) pre_responsibles = user_changes.mapped('user_id.partner_id') res = super(MailActivity, self).write(values) if values.get('user_id'): if values['user_id'] != self.env.uid: to_check = user_changes.filtered(lambda act: not act.automated) to_check._check_access_assignation() if not self.env.context.get('mail_activity_quick_update', False): user_changes.action_notify() for activity in user_changes: self.env[activity.res_model].browse( activity.res_id).message_subscribe( partner_ids=[activity.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 }) for activity in user_changes: if activity.date_deadline <= fields.Date.today(): for partner in pre_responsibles: self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner.id), { 'type': 'activity_updated', 'activity_deleted': True }) return res def unlink(self): 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() # ------------------------------------------------------ # Business Methods # ------------------------------------------------------ def action_notify(self): if not self: return original_context = self.env.context body_template = self.env.ref('mail.message_activity_assigned') for activity in self: if activity.user_id.lang: # Send the notification in the assigned user's language self = self.with_context(lang=activity.user_id.lang) body_template = body_template.with_context( lang=activity.user_id.lang) activity = activity.with_context(lang=activity.user_id.lang) model_description = self.env['ir.model']._get( activity.res_model).display_name body = body_template.render(dict( activity=activity, model_description=model_description, access_link=self.env['mail.thread']._notify_get_action_link( 'view', model=activity.res_model, res_id=activity.res_id), ), engine='ir.qweb', minimal_qcontext=True) record = self.env[activity.res_model].browse(activity.res_id) if activity.user_id: record.message_notify( partner_ids=activity.user_id.partner_id.ids, body=body, subject=_('%s: %s assigned to you') % (activity.res_name, activity.summary or activity.activity_type_id.name), record_name=activity.res_name, model_description=model_description, email_layout_xmlid='mail.mail_notification_light', ) body_template = body_template.with_context(original_context) self = self.with_context(original_context) def action_done(self): """ Wrapper without feedback because web button add context as parameter, therefore setting context to feedback """ messages, next_activities = self._action_done() return messages.ids and messages.ids[0] or False def action_feedback(self, feedback=False, attachment_ids=None): self = self.with_context(clean_context(self.env.context)) messages, next_activities = self._action_done( feedback=feedback, attachment_ids=attachment_ids) return messages.ids and messages.ids[0] or False def action_done_schedule_next(self): """ Wrapper without feedback because web button add context as parameter, therefore setting context to feedback """ return self.action_feedback_schedule_next() def action_feedback_schedule_next(self, feedback=False): ctx = dict( clean_context(self.env.context), default_previous_activity_type_id=self.activity_type_id.id, activity_previous_deadline=self.date_deadline, default_res_id=self.res_id, default_res_model=self.res_model, ) messages, next_activities = self._action_done( feedback=feedback ) # will unlink activity, dont access self after that if next_activities: return False return { 'name': _('Schedule an Activity'), 'context': ctx, 'view_mode': 'form', 'res_model': 'mail.activity', 'views': [(False, 'form')], 'type': 'ir.actions.act_window', 'target': 'new', } def _action_done(self, feedback=False, attachment_ids=None): """ Private implementation of marking activity as done: posting a message, deleting activity (since done), and eventually create the automatical next activity (depending on config). :param feedback: optional feedback from user when marking activity as done :param attachment_ids: list of ir.attachment ids to attach to the posted mail.message :returns (messages, activities) where - messages is a recordset of posted mail.message - activities is a recordset of mail.activity of forced automically created activities """ # marking as 'done' messages = self.env['mail.message'] next_activities_values = [] # Search for all attachments linked to the activities we are about to unlink. This way, we # can link them to the message posted and prevent their deletion. attachments = self.env['ir.attachment'].search_read([ ('res_model', '=', self._name), ('res_id', 'in', self.ids), ], ['id', 'res_id']) activity_attachments = defaultdict(list) for attachment in attachments: activity_id = attachment['res_id'] activity_attachments[activity_id].append(attachment['id']) for activity in self: # extract value to generate next activities if activity.force_next: Activity = self.env['mail.activity'].with_context( activity_previous_deadline=activity.date_deadline ) # context key is required in the onchange to set deadline vals = Activity.default_get(Activity.fields_get()) vals.update({ 'previous_activity_type_id': activity.activity_type_id.id, 'res_id': activity.res_id, 'res_model': activity.res_model, 'res_model_id': self.env['ir.model']._get(activity.res_model).id, }) virtual_activity = Activity.new(vals) virtual_activity._onchange_previous_activity_type_id() virtual_activity._onchange_activity_type_id() next_activities_values.append( virtual_activity._convert_to_write( virtual_activity._cache)) # post message on activity, before deleting it record = self.env[activity.res_model].browse(activity.res_id) record.message_post_with_view( 'mail.message_activity_done', values={ 'activity': activity, 'feedback': feedback, 'display_assignee': activity.user_id != self.env.user }, subtype_id=self.env['ir.model.data'].xmlid_to_res_id( 'mail.mt_activities'), mail_activity_type_id=activity.activity_type_id.id, attachment_ids=[ (4, attachment_id) for attachment_id in attachment_ids ] if attachment_ids else [], ) # Moving the attachments in the message # TODO: Fix void res_id on attachment when you create an activity with an image # directly, see route /web_editor/attachment/add activity_message = record.message_ids[0] message_attachments = self.env['ir.attachment'].browse( activity_attachments[activity.id]) if message_attachments: message_attachments.write({ 'res_id': activity_message.id, 'res_model': activity_message._name, }) activity_message.attachment_ids = message_attachments messages |= activity_message next_activities = self.env['mail.activity'].create( next_activities_values) self.unlink() # will unlink activity, dont access `self` after that return messages, next_activities def action_close_dialog(self): return {'type': 'ir.actions.act_window_close'} def activity_format(self): activities = self.read() mail_template_ids = set([ template_id for activity in activities for template_id in activity["mail_template_ids"] ]) mail_template_info = self.env["mail.template"].browse( mail_template_ids).read(['id', 'name']) mail_template_dict = dict([(mail_template['id'], mail_template) for mail_template in mail_template_info]) for activity in activities: activity['mail_template_ids'] = [ mail_template_dict[mail_template_id] for mail_template_id in activity['mail_template_ids'] ] return activities @api.model def get_activity_data(self, res_model, domain): activity_domain = [('res_model', '=', res_model)] if domain: res = self.env[res_model].search(domain) activity_domain.append(('res_id', 'in', res.ids)) grouped_activities = self.env['mail.activity'].read_group( activity_domain, [ 'res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)' ], ['res_id', 'activity_type_id'], lazy=False) # filter out unreadable records if not domain: res_ids = tuple(a['res_id'] for a in grouped_activities) res = self.env[res_model].search([('id', 'in', res_ids)]) grouped_activities = [ a for a in grouped_activities if a['res_id'] in res.ids ] res_id_to_deadline = {} activity_data = defaultdict(dict) for group in grouped_activities: res_id = group['res_id'] activity_type_id = (group.get('activity_type_id') or (False, False))[0] res_id_to_deadline[res_id] = group['date_deadline'] if ( res_id not in res_id_to_deadline or group['date_deadline'] < res_id_to_deadline[res_id]) else res_id_to_deadline[res_id] state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz) activity_data[res_id][activity_type_id] = { 'count': group['__count'], 'ids': group['ids'], 'state': state, 'o_closest_deadline': group['date_deadline'], } activity_type_infos = [] activity_type_ids = self.env['mail.activity.type'].search([ '|', ('res_model_id.model', '=', res_model), ('res_model_id', '=', False) ]) for elem in sorted(activity_type_ids, key=lambda item: item.sequence): mail_template_info = [] for mail_template_id in elem.mail_template_ids: mail_template_info.append({ "id": mail_template_id.id, "name": mail_template_id.name }) activity_type_infos.append( [elem.id, elem.name, mail_template_info]) return { 'activity_types': activity_type_infos, 'activity_res_ids': sorted(res_id_to_deadline, key=lambda item: res_id_to_deadline[item]), 'grouped_activities': activity_data, }
class MailActivity(models.Model): """ An actual activity to perform. Activities are linked to documents using res_id and res_model_id fields. Activities have a deadline that can be used in kanban view to display a status. Once done activities are unlinked and a message is posted. This message has a new activity_type_id field that indicates the activity linked to the message. """ _name = 'mail.activity' _description = 'Activity' _order = 'date_deadline ASC' _rec_name = 'summary' @api.model def default_get(self, fields): res = super(MailActivity, self).default_get(fields) if not fields or 'res_model_id' in fields and res.get('res_model'): res['res_model_id'] = self.env['ir.model']._get( res['res_model']).id return res @api.model def _default_activity_type_id(self): ActivityType = self.env["mail.activity.type"] activity_type_todo = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) default_vals = self.default_get(['res_model_id', 'res_model']) if not default_vals.get('res_model_id'): return ActivityType current_model_id = default_vals['res_model_id'] current_model = self.env["ir.model"].sudo().browse(current_model_id) if activity_type_todo and activity_type_todo.active and \ (activity_type_todo.res_model == current_model.model or not activity_type_todo.res_model): return activity_type_todo activity_type_model = ActivityType.search( [('res_model', '=', current_model.model)], limit=1) if activity_type_model: return activity_type_model activity_type_generic = ActivityType.search( [('res_model', '=', False)], limit=1) return activity_type_generic # owner res_model_id = fields.Many2one('ir.model', 'Document Model', index=True, ondelete='cascade', required=True) res_model = fields.Char('Related Document Model', index=True, related='res_model_id.model', compute_sudo=True, store=True, readonly=True) res_id = fields.Many2oneReference(string='Related Document ID', index=True, model_field='res_model') res_name = fields.Char('Document Name', compute='_compute_res_name', compute_sudo=True, store=True, help="Display name of the related document.", readonly=True) # activity activity_type_id = fields.Many2one( 'mail.activity.type', string='Activity Type', domain= "['|', ('res_model', '=', False), ('res_model', '=', res_model)]", ondelete='restrict', default=_default_activity_type_id) activity_category = fields.Selection(related='activity_type_id.category', readonly=True) activity_decoration = fields.Selection( related='activity_type_id.decoration_type', readonly=True) icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True) summary = fields.Char('Summary') note = fields.Html('Note', sanitize_style=True) date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today) automated = fields.Boolean( 'Automated activity', readonly=True, help= 'Indicates this activity has been created automatically and not by any user.' ) # description user_id = fields.Many2one('res.users', 'Assigned to', default=lambda self: self.env.user, index=True, required=True) request_partner_id = fields.Many2one('res.partner', string='Requesting Partner') state = fields.Selection([('overdue', 'Overdue'), ('today', 'Today'), ('planned', 'Planned')], 'State', compute='_compute_state') recommended_activity_type_id = fields.Many2one( 'mail.activity.type', string="Recommended Activity Type") previous_activity_type_id = fields.Many2one( 'mail.activity.type', string='Previous Activity Type', readonly=True) has_recommended_activities = fields.Boolean( 'Next activities available', compute='_compute_has_recommended_activities', help='Technical field for UX purpose') mail_template_ids = fields.Many2many( related='activity_type_id.mail_template_ids', readonly=True) chaining_type = fields.Selection(related='activity_type_id.chaining_type', readonly=True) # access can_write = fields.Boolean( compute='_compute_can_write', help= 'Technical field to hide buttons if the current user has no access.') _sql_constraints = [ # Required on a Many2one reference field is not sufficient as actually # writing 0 is considered as a valid value, because this is an integer field. # We therefore need a specific constraint check. ('check_res_id_is_set', 'CHECK(res_id IS NOT NULL AND res_id !=0 )', 'Activities have to be linked to records with a not null res_id.') ] @api.onchange('previous_activity_type_id') def _compute_has_recommended_activities(self): for record in self: record.has_recommended_activities = bool( record.previous_activity_type_id.suggested_next_type_ids) @api.onchange('previous_activity_type_id') def _onchange_previous_activity_type_id(self): for record in self: if record.previous_activity_type_id.triggered_next_type_id: record.activity_type_id = record.previous_activity_type_id.triggered_next_type_id @api.depends('res_model', 'res_id') def _compute_res_name(self): for activity in self: activity.res_name = activity.res_model and \ self.env[activity.res_model].browse(activity.res_id).display_name @api.depends('date_deadline') def _compute_state(self): for record in self.filtered(lambda activity: activity.date_deadline): tz = record.user_id.sudo().tz date_deadline = record.date_deadline record.state = self._compute_state_from_date(date_deadline, tz) @api.model def _compute_state_from_date(self, date_deadline, tz=False): date_deadline = fields.Date.from_string(date_deadline) today_default = date.today() today = today_default if tz: today_utc = pytz.utc.localize(datetime.utcnow()) today_tz = today_utc.astimezone(pytz.timezone(tz)) today = date(year=today_tz.year, month=today_tz.month, day=today_tz.day) diff = (date_deadline - today) if diff.days == 0: return 'today' elif diff.days < 0: return 'overdue' else: return 'planned' @api.depends('res_model', 'res_id', 'user_id') def _compute_can_write(self): valid_records = self._filter_access_rules('write') for record in self: record.can_write = record in valid_records @api.onchange('activity_type_id') def _onchange_activity_type_id(self): if self.activity_type_id: if self.activity_type_id.summary: self.summary = self.activity_type_id.summary self.date_deadline = self._calculate_date_deadline( self.activity_type_id) self.user_id = self.activity_type_id.default_user_id or self.env.user if self.activity_type_id.default_note: self.note = self.activity_type_id.default_note def _calculate_date_deadline(self, activity_type): # Date.context_today is correct because date_deadline is a Date and is meant to be # expressed in user TZ base = fields.Date.context_today(self) if activity_type.delay_from == 'previous_activity' and 'activity_previous_deadline' in self.env.context: base = fields.Date.from_string( self.env.context.get('activity_previous_deadline')) return base + relativedelta( **{activity_type.delay_unit: activity_type.delay_count}) @api.onchange('recommended_activity_type_id') def _onchange_recommended_activity_type_id(self): if self.recommended_activity_type_id: self.activity_type_id = self.recommended_activity_type_id def _filter_access_rules(self, operation): # write / unlink: valid for creator / assigned if operation in ('write', 'unlink'): valid = super(MailActivity, self)._filter_access_rules(operation) if valid and valid == self: return self else: valid = self.env[self._name] return self._filter_access_rules_remaining(valid, operation, '_filter_access_rules') def _filter_access_rules_python(self, operation): # write / unlink: valid for creator / assigned if operation in ('write', 'unlink'): valid = super(MailActivity, self)._filter_access_rules_python(operation) if valid and valid == self: return self else: valid = self.env[self._name] return self._filter_access_rules_remaining( valid, operation, '_filter_access_rules_python') def _filter_access_rules_remaining(self, valid, operation, filter_access_rules_method): """ Return the subset of ``self`` for which ``operation`` is allowed. A custom implementation is done on activities as this document has some access rules and is based on related document for activities that are not covered by those rules. Access on activities are the following : * create: (``mail_post_access`` or write) right on related documents; * read: read rights on related documents; * write: access rule OR (``mail_post_access`` or write) rights on related documents); * unlink: access rule OR (``mail_post_access`` or write) rights on related documents); """ # compute remaining for hand-tailored rules remaining = self - valid remaining_sudo = remaining.sudo() # fall back on related document access right checks. Use the same as defined for mail.thread # if available; otherwise fall back on read for read, write for other operations. activity_to_documents = dict() for activity in remaining_sudo: # write / unlink: if not updating self or assigned, limit to automated activities to avoid # updating other people's activities. As unlinking a document bypasses access rights checks # on related activities this will not prevent people from deleting documents with activities # create / read: just check rights on related document activity_to_documents.setdefault(activity.res_model, list()).append(activity.res_id) for doc_model, doc_ids in activity_to_documents.items(): if hasattr(self.env[doc_model], '_mail_post_access'): doc_operation = self.env[doc_model]._mail_post_access elif operation == 'read': doc_operation = 'read' else: doc_operation = 'write' right = self.env[doc_model].check_access_rights( doc_operation, raise_exception=False) if right: valid_doc_ids = getattr( self.env[doc_model].browse(doc_ids), filter_access_rules_method)(doc_operation) valid += remaining.filtered( lambda activity: activity.res_model == doc_model and activity.res_id in valid_doc_ids.ids) return valid def _check_access_assignation(self): """ Check assigned user (user_id field) has access to the document. Purpose is to allow assigned user to handle their activities. For that purpose assigned user should be able to at least read the document. We therefore raise an UserError if the assigned user has no access to the document. """ for activity in self: model = self.env[activity.res_model].with_user( activity.user_id).with_context( allowed_company_ids=activity.user_id.company_ids.ids) try: model.check_access_rights('read') except exceptions.AccessError: raise exceptions.UserError( _( 'Assigned user %s has no access to the document and is not able to handle this activity.', activity.user_id.display_name)) else: try: target_user = activity.user_id target_record = self.env[activity.res_model].browse( activity.res_id) if hasattr(target_record, 'company_id') and ( target_record.company_id != target_user.company_id and (len(target_user.sudo().company_ids) > 1)): return # in that case we skip the check, assuming it would fail because of the company model.browse(activity.res_id).check_access_rule('read') except exceptions.AccessError: raise exceptions.UserError( _( 'Assigned user %s has no access to the document and is not able to handle this activity.', activity.user_id.display_name)) # ------------------------------------------------------ # ORM overrides # ------------------------------------------------------ @api.model_create_multi def create(self, vals_list): activities = super(MailActivity, self).create(vals_list) for activity in activities: need_sudo = False try: # in multicompany, reading the partner might break partner_id = activity.user_id.partner_id.id except exceptions.AccessError: need_sudo = True partner_id = activity.user_id.sudo().partner_id.id # send a notification to assigned user; in case of manually done activity also check # target has rights on document otherwise we prevent its creation. Automated activities # are checked since they are integrated into business flows that should not crash. if activity.user_id != self.env.user: if not activity.automated: activity._check_access_assignation() if not self.env.context.get('mail_activity_quick_update', False): if need_sudo: activity.sudo().action_notify() else: activity.action_notify() self.env[activity.res_model].browse( activity.res_id).message_subscribe(partner_ids=[partner_id]) if activity.date_deadline <= fields.Date.today(): self.env['bus.bus']._sendone(activity.user_id.partner_id, 'mail.activity/updated', {'activity_created': True}) return activities def read(self, fields=None, load='_classic_read'): """ When reading specific fields, read calls _read that manually applies ir rules (_apply_ir_rules), instead of calling check_access_rule. Meaning that our custom rules enforcing from '_filter_access_rules' and '_filter_access_rules_python' are bypassed in that case. To make sure we apply our custom security rules, we force a call to 'check_access_rule'. """ self.check_access_rule('read') return super(MailActivity, self).read(fields=fields, load=load) def write(self, values): if values.get('user_id'): user_changes = self.filtered( lambda activity: activity.user_id.id != values.get('user_id')) pre_responsibles = user_changes.mapped('user_id.partner_id') res = super(MailActivity, self).write(values) if values.get('user_id'): if values['user_id'] != self.env.uid: to_check = user_changes.filtered(lambda act: not act.automated) to_check._check_access_assignation() if not self.env.context.get('mail_activity_quick_update', False): user_changes.action_notify() for activity in user_changes: self.env[activity.res_model].browse( activity.res_id).message_subscribe( partner_ids=[activity.user_id.partner_id.id]) if activity.date_deadline <= fields.Date.today(): self.env['bus.bus']._sendone(activity.user_id.partner_id, 'mail.activity/updated', {'activity_created': True}) for activity in user_changes: if activity.date_deadline <= fields.Date.today(): for partner in pre_responsibles: self.env['bus.bus']._sendone( partner, 'mail.activity/updated', {'activity_deleted': True}) return res def unlink(self): for activity in self: if activity.date_deadline <= fields.Date.today(): self.env['bus.bus']._sendone(activity.user_id.partner_id, 'mail.activity/updated', {'activity_deleted': True}) return super(MailActivity, self).unlink() @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Override that adds specific access rights of mail.activity, to remove ids uid could not see according to our custom rules. Please refer to _filter_access_rules_remaining for more details about those rules. The method is inspired by what has been done on mail.message. """ # Rules do not apply to administrator if self.env.is_superuser(): return super(MailActivity, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) # Perform a super with count as False, to have the ids, not a counter ids = super(MailActivity, self)._search(args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid) if not ids and count: return 0 elif not ids: return ids # check read access rights before checking the actual rules on the given ids super(MailActivity, self.with_user(access_rights_uid or self._uid)).check_access_rights('read') self.flush(['res_model', 'res_id']) activities_to_check = [] for sub_ids in self._cr.split_for_in_conditions(ids): self._cr.execute( """ SELECT DISTINCT activity.id, activity.res_model, activity.res_id FROM "%s" activity WHERE activity.id = ANY (%%(ids)s) AND activity.res_id != 0""" % self._table, dict(ids=list(sub_ids))) activities_to_check += self._cr.dictfetchall() activity_to_documents = {} for activity in activities_to_check: activity_to_documents.setdefault(activity['res_model'], set()).add(activity['res_id']) allowed_ids = set() for doc_model, doc_ids in activity_to_documents.items(): # fall back on related document access right checks. Use the same as defined for mail.thread # if available; otherwise fall back on read if hasattr(self.env[doc_model], '_mail_post_access'): doc_operation = self.env[doc_model]._mail_post_access else: doc_operation = 'read' DocumentModel = self.env[doc_model].with_user(access_rights_uid or self._uid) right = DocumentModel.check_access_rights(doc_operation, raise_exception=False) if right: valid_docs = DocumentModel.browse( doc_ids)._filter_access_rules(doc_operation) valid_doc_ids = set(valid_docs.ids) allowed_ids.update(activity['id'] for activity in activities_to_check if activity['res_model'] == doc_model and activity['res_id'] in valid_doc_ids) if count: return len(allowed_ids) else: # re-construct a list based on ids, because 'allowed_ids' does not keep the original order id_list = [id for id in ids if id in allowed_ids] return id_list @api.model def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): """ The base _read_group_raw method implementation computes a where based on a given domain (_where_calc) and manually applies ir rules (_apply_ir_rules). Meaning that our custom rules enforcing from '_filter_access_rules' and '_filter_access_rules_python' are bypassed in that case. This overrides re-uses the _search implementation to force the read group domain to allowed ids only, that are computed based on our custom rules (see _filter_access_rules_remaining for more details). """ # Rules do not apply to administrator if not self.env.is_superuser(): allowed_ids = self._search(domain, count=False) if allowed_ids: domain = expression.AND([domain, [('id', 'in', allowed_ids)]]) else: # force void result if no allowed ids found domain = expression.AND([domain, [(0, '=', 1)]]) return super(MailActivity, self)._read_group_raw( domain=domain, fields=fields, groupby=groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy, ) def name_get(self): res = [] for record in self: name = record.summary or record.activity_type_id.display_name res.append((record.id, name)) return res # ------------------------------------------------------ # Business Methods # ------------------------------------------------------ def action_notify(self): if not self: return body_template = self.env.ref('mail.message_activity_assigned') for activity in self: if activity.user_id.lang: # Send the notification in the assigned user's language activity = activity.with_context(lang=activity.user_id.lang) model_description = activity.env['ir.model']._get( activity.res_model).display_name body = activity.env['ir.qweb']._render( 'mail.message_activity_assigned', dict( activity=activity, model_description=model_description, access_link=activity.env['mail.thread']. _notify_get_action_link('view', model=activity.res_model, res_id=activity.res_id), ), minimal_qcontext=True) record = activity.env[activity.res_model].browse(activity.res_id) if activity.user_id: record.message_notify( partner_ids=activity.user_id.partner_id.ids, body=body, subject=_('%(activity_name)s: %(summary)s assigned to you', activity_name=activity.res_name, summary=activity.summary or activity.activity_type_id.name), record_name=activity.res_name, model_description=model_description, email_layout_xmlid='mail.mail_notification_light', ) def action_done(self): """ Wrapper without feedback because web button add context as parameter, therefore setting context to feedback """ return self.action_feedback() def action_feedback(self, feedback=False, attachment_ids=None): messages, _next_activities = self.with_context( clean_context(self.env.context))._action_done( feedback=feedback, attachment_ids=attachment_ids) return messages[0].id if messages else False def action_done_schedule_next(self): """ Wrapper without feedback because web button add context as parameter, therefore setting context to feedback """ return self.action_feedback_schedule_next() def action_feedback_schedule_next(self, feedback=False, attachment_ids=None): ctx = dict( clean_context(self.env.context), default_previous_activity_type_id=self.activity_type_id.id, activity_previous_deadline=self.date_deadline, default_res_id=self.res_id, default_res_model=self.res_model, ) _messages, next_activities = self._action_done( feedback=feedback, attachment_ids=attachment_ids ) # will unlink activity, dont access self after that if next_activities: return False return { 'name': _('Schedule an Activity'), 'context': ctx, 'view_mode': 'form', 'res_model': 'mail.activity', 'views': [(False, 'form')], 'type': 'ir.actions.act_window', 'target': 'new', } def _action_done(self, feedback=False, attachment_ids=None): """ Private implementation of marking activity as done: posting a message, deleting activity (since done), and eventually create the automatical next activity (depending on config). :param feedback: optional feedback from user when marking activity as done :param attachment_ids: list of ir.attachment ids to attach to the posted mail.message :returns (messages, activities) where - messages is a recordset of posted mail.message - activities is a recordset of mail.activity of forced automically created activities """ # marking as 'done' messages = self.env['mail.message'] next_activities_values = [] # Search for all attachments linked to the activities we are about to unlink. This way, we # can link them to the message posted and prevent their deletion. attachments = self.env['ir.attachment'].search_read([ ('res_model', '=', self._name), ('res_id', 'in', self.ids), ], ['id', 'res_id']) activity_attachments = defaultdict(list) for attachment in attachments: activity_id = attachment['res_id'] activity_attachments[activity_id].append(attachment['id']) for activity in self: # extract value to generate next activities if activity.chaining_type == 'trigger': vals = activity.with_context( activity_previous_deadline=activity.date_deadline )._prepare_next_activity_values() next_activities_values.append(vals) # post message on activity, before deleting it record = self.env[activity.res_model].browse(activity.res_id) record.message_post_with_view( 'mail.message_activity_done', values={ 'activity': activity, 'feedback': feedback, 'display_assignee': activity.user_id != self.env.user }, subtype_id=self.env['ir.model.data']._xmlid_to_res_id( 'mail.mt_activities'), mail_activity_type_id=activity.activity_type_id.id, attachment_ids=[ Command.link(attachment_id) for attachment_id in attachment_ids ] if attachment_ids else [], ) # Moving the attachments in the message # TODO: Fix void res_id on attachment when you create an activity with an image # directly, see route /web_editor/attachment/add activity_message = record.message_ids[0] message_attachments = self.env['ir.attachment'].browse( activity_attachments[activity.id]) if message_attachments: message_attachments.write({ 'res_id': activity_message.id, 'res_model': activity_message._name, }) activity_message.attachment_ids = message_attachments messages |= activity_message next_activities = self.env['mail.activity'].create( next_activities_values) self.unlink() # will unlink activity, dont access `self` after that return messages, next_activities def action_close_dialog(self): return {'type': 'ir.actions.act_window_close'} def activity_format(self): activities = self.read() mail_template_ids = set([ template_id for activity in activities for template_id in activity["mail_template_ids"] ]) mail_template_info = self.env["mail.template"].browse( mail_template_ids).read(['id', 'name']) mail_template_dict = dict([(mail_template['id'], mail_template) for mail_template in mail_template_info]) for activity in activities: activity['mail_template_ids'] = [ mail_template_dict[mail_template_id] for mail_template_id in activity['mail_template_ids'] ] return activities @api.model def get_activity_data(self, res_model, domain): activity_domain = [('res_model', '=', res_model)] if domain: res = self.env[res_model].search(domain) activity_domain.append(('res_id', 'in', res.ids)) grouped_activities = self.env['mail.activity'].read_group( activity_domain, [ 'res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)' ], ['res_id', 'activity_type_id'], lazy=False) # filter out unreadable records if not domain: res_ids = tuple(a['res_id'] for a in grouped_activities) res = self.env[res_model].search([('id', 'in', res_ids)]) grouped_activities = [ a for a in grouped_activities if a['res_id'] in res.ids ] res_id_to_deadline = {} activity_data = defaultdict(dict) for group in grouped_activities: res_id = group['res_id'] activity_type_id = (group.get('activity_type_id') or (False, False))[0] res_id_to_deadline[res_id] = group['date_deadline'] if ( res_id not in res_id_to_deadline or group['date_deadline'] < res_id_to_deadline[res_id]) else res_id_to_deadline[res_id] state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz) activity_data[res_id][activity_type_id] = { 'count': group['__count'], 'ids': group['ids'], 'state': state, 'o_closest_deadline': group['date_deadline'], } activity_type_infos = [] activity_type_ids = self.env['mail.activity.type'].search( ['|', ('res_model', '=', res_model), ('res_model', '=', False)]) for elem in sorted(activity_type_ids, key=lambda item: item.sequence): mail_template_info = [] for mail_template_id in elem.mail_template_ids: mail_template_info.append({ "id": mail_template_id.id, "name": mail_template_id.name }) activity_type_infos.append( [elem.id, elem.name, mail_template_info]) return { 'activity_types': activity_type_infos, 'activity_res_ids': sorted(res_id_to_deadline, key=lambda item: res_id_to_deadline[item]), 'grouped_activities': activity_data, } # ---------------------------------------------------------------------- # TOOLS # ---------------------------------------------------------------------- def _prepare_next_activity_values(self): """ Prepare the next activity values based on the current activity record and applies _onchange methods :returns a dict of values for the new activity """ self.ensure_one() vals = self.default_get(self.fields_get()) vals.update({ 'previous_activity_type_id': self.activity_type_id.id, 'res_id': self.res_id, 'res_model': self.res_model, 'res_model_id': self.env['ir.model']._get(self.res_model).id, }) virtual_activity = self.new(vals) virtual_activity._onchange_previous_activity_type_id() virtual_activity._onchange_activity_type_id() return virtual_activity._convert_to_write(virtual_activity._cache)
class Message(models.Model): """ Message model: notification (system, replacing res.log notifications), comment (user input), email (incoming emails) and user_notification (user-specific notification) Note:: State management / Error codes / Failure types summary * mail.notification * notification_status 'ready', 'sent', 'bounce', 'exception', 'canceled' * notification_type 'inbox', 'email', 'sms' (SMS addon), 'snail' (snailmail addon) * failure_type # mail "SMTP", "RECIPIENT", "BOUNCE", "UNKNOWN" # sms (SMS addon) 'sms_number_missing', 'sms_number_format', 'sms_credit', 'sms_server', 'sms_acc' # snailmail (snailmail addon) 'sn_credit', 'sn_trial', 'sn_price', 'sn_fields', 'sn_format', 'sn_error' * mail.mail * state 'outgoing', 'sent', 'received', 'exception', 'cancel' * failure_reason: text * sms.sms (SMS addon) * state 'outgoing', 'sent', 'error', 'canceled' * error_code 'sms_number_missing', 'sms_number_format', 'sms_credit', 'sms_server', 'sms_acc', # mass mode specific codes 'sms_blacklist', 'sms_duplicate' * snailmail.letter (snailmail addon) * state 'pending', 'sent', 'error', 'canceled' * error_code 'CREDIT_ERROR', 'TRIAL_ERROR', 'NO_PRICE_AVAILABLE', 'FORMAT_ERROR', 'UNKNOWN_ERROR', See ``mailing.trace`` model in mass_mailing application for mailing trace information. """ _name = 'mail.message' _description = 'Message' _order = 'id desc' _rec_name = 'record_name' @api.model def default_get(self, fields): res = super(Message, self).default_get(fields) missing_author = 'author_id' in fields and 'author_id' not in res missing_email_from = 'email_from' in fields and 'email_from' not in res if missing_author or missing_email_from: author_id, email_from = self.env['mail.thread']._message_compute_author(res.get('author_id'), res.get('email_from'), raise_exception=False) if missing_email_from: res['email_from'] = email_from if missing_author: res['author_id'] = author_id return res # content subject = fields.Char('Subject') date = fields.Datetime('Date', default=fields.Datetime.now) body = fields.Html('Contents', default='', sanitize_style=True) description = fields.Char( 'Short description', compute="_compute_description", help='Message description: either the subject, or the beginning of the body') attachment_ids = fields.Many2many( 'ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', string='Attachments', help='Attachments are linked to a document through model / res_id and to the message ' 'through this field.') parent_id = fields.Many2one( 'mail.message', 'Parent Message', index=True, ondelete='set null', help="Initial thread message.") child_ids = fields.One2many('mail.message', 'parent_id', 'Child Messages') # related document model = fields.Char('Related Document Model', index=True) res_id = fields.Many2oneReference('Related Document ID', index=True, model_field='model') record_name = fields.Char('Message Record Name', help="Name get of the related document.") # characteristics message_type = fields.Selection([ ('email', 'Email'), ('comment', 'Comment'), ('notification', 'System notification'), ('user_notification', 'User Specific Notification')], 'Type', required=True, default='email', help="Message type: email for email message, notification for system " "message, comment for other messages such as user replies", ) subtype_id = fields.Many2one('mail.message.subtype', 'Subtype', ondelete='set null', index=True) mail_activity_type_id = fields.Many2one( 'mail.activity.type', 'Mail Activity Type', index=True, ondelete='set null') is_internal = fields.Boolean('Employee Only', help='Hide to public / portal users, independently from subtype configuration.') # origin email_from = fields.Char('From', help="Email address of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter.") author_id = fields.Many2one( 'res.partner', 'Author', index=True, ondelete='set null', help="Author of the message. If not set, email_from may hold an email address that did not match any partner.") author_avatar = fields.Binary("Author's avatar", related='author_id.avatar_128', depends=['author_id'], readonly=False) # recipients: include inactive partners (they may have been archived after # the message was sent, but they should remain visible in the relation) partner_ids = fields.Many2many('res.partner', string='Recipients', context={'active_test': False}) # list of partner having a notification. Caution: list may change over time because of notif gc cron. # mainly usefull for testing notified_partner_ids = fields.Many2many( 'res.partner', 'mail_notification', string='Partners with Need Action', context={'active_test': False}, depends=['notification_ids']) needaction = fields.Boolean( 'Need Action', compute='_get_needaction', search='_search_needaction', help='Need Action') has_error = fields.Boolean( 'Has error', compute='_compute_has_error', search='_search_has_error', help='Has error') # notifications notification_ids = fields.One2many( 'mail.notification', 'mail_message_id', 'Notifications', auto_join=True, copy=False, depends=['notified_partner_ids']) # user interface starred_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By') starred = fields.Boolean( 'Starred', compute='_get_starred', search='_search_starred', compute_sudo=False, help='Current user has a starred notification linked to this message') # tracking tracking_value_ids = fields.One2many( 'mail.tracking.value', 'mail_message_id', string='Tracking values', groups="base.group_system", 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 reply_to_force_new = fields.Boolean( 'No threading for answers', help='If true, answers do not go in the original document discussion thread. Instead, it will check for the reply_to in tracking message-id and redirected accordingly. This has an impact on the generated message-id.') message_id = fields.Char('Message-Id', help='Message unique identifier', index=True, readonly=1, copy=False) reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.') mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') # keep notification layout informations to be able to generate mail again email_layout_xmlid = fields.Char('Layout', copy=False) # xml id of layout add_sign = fields.Boolean(default=True) # `test_adv_activity`, `test_adv_activity_full`, `test_message_assignation_inbox`,... # By setting an inverse for mail.mail_message_id, the number of SQL queries done by `modified` is reduced. # 'mail.mail' inherits from `mail.message`: `_inherits = {'mail.message': 'mail_message_id'}` # Therefore, when changing a field on `mail.message`, this triggers the modification of the same field on `mail.mail` # By setting up the inverse one2many, we avoid to have to do a search to find the mails linked to the `mail.message` # as the cache value for this inverse one2many is up-to-date. # Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing. mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system") canned_response_ids = fields.One2many('mail.shortcode', 'message_ids', string="Canned Responses", store=False) def _compute_description(self): for message in self: if message.subject: message.description = message.subject else: plaintext_ct = '' if not message.body else tools.html2plaintext(message.body) message.description = plaintext_ct[:30] + '%s' % (' [...]' if len(plaintext_ct) >= 30 else '') def _get_needaction(self): """ Need action on a mail.message = notified on my channel """ my_messages = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', False)]).mapped('mail_message_id') for message in self: message.needaction = message in my_messages @api.model def _search_needaction(self, operator, operand): is_read = False if operator == '=' and operand else True notification_ids = self.env['mail.notification']._search([('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', is_read)]) return [('notification_ids', 'in', notification_ids)] def _compute_has_error(self): error_from_notification = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('notification_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id') for message in self: message.has_error = message in error_from_notification def _search_has_error(self, operator, operand): if operator == '=' and operand: return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))] return ['!', ('notification_ids.notification_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" @api.depends('starred_partner_ids') @api.depends_context('uid') def _get_starred(self): """ Compute if the message is starred by the current user. """ # TDE FIXME: use SQL starred = self.sudo().filtered(lambda msg: self.env.user.partner_id in msg.starred_partner_ids) for message in self: message.starred = message in starred @api.model def _search_starred(self, operator, operand): if operator == '=' and operand: return [('starred_partner_ids', 'in', [self.env.user.partner_id.id])] return [('starred_partner_ids', 'not in', [self.env.user.partner_id.id])] # ------------------------------------------------------ # CRUD / ORM # ------------------------------------------------------ def init(self): self._cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""") if not self._cr.fetchone(): self._cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""") @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Override that adds specific access rights of mail.message, to remove ids uid could not see according to our custom rules. Please refer to check_access_rule for more details about those rules. Non employees users see only message with subtype (aka do not see internal logs). After having received ids of a classic search, keep only: - if author_id == pid, uid is the author, OR - uid belongs to a notified channel, OR - uid is in the specified recipients, OR - uid has a notification on the message - otherwise: remove the id """ # Rules do not apply to administrator if self.env.is_superuser(): return super(Message, self)._search( args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) # Non-employee see only messages with a subtype and not internal if not self.env['res.users'].has_group('base.group_user'): args = expression.AND([self._get_search_domain_share(), args]) # Perform a super with count as False, to have the ids, not a counter ids = super(Message, self)._search( args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid) if not ids and count: return 0 elif not ids: return ids pid = self.env.user.partner_id.id author_ids, partner_ids, allowed_ids = set([]), set([]), set([]) model_ids = {} # check read access rights before checking the actual rules on the given ids super(Message, self.with_user(access_rights_uid or self._uid)).check_access_rights('read') self.flush(['model', 'res_id', 'author_id', 'message_type', 'partner_ids']) self.env['mail.notification'].flush(['mail_message_id', 'res_partner_id']) for sub_ids in self._cr.split_for_in_conditions(ids): self._cr.execute(""" SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.message_type, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id) FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_notification" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=pid, ids=list(sub_ids))) for msg_id, rmod, rid, author_id, message_type, partner_id in self._cr.fetchall(): if author_id == pid: author_ids.add(msg_id) elif partner_id == pid: partner_ids.add(msg_id) elif rmod and rid and message_type != 'user_notification': model_ids.setdefault(rmod, {}).setdefault(rid, set()).add(msg_id) allowed_ids = self._find_allowed_doc_ids(model_ids) final_ids = author_ids | partner_ids | allowed_ids if count: return len(final_ids) else: # re-construct a list based on ids, because set did not keep the original order id_list = [id for id in ids if id in final_ids] return id_list @api.model def _find_allowed_model_wise(self, doc_model, doc_dict): doc_ids = list(doc_dict) allowed_doc_ids = self.env[doc_model].with_context(active_test=False).search([('id', 'in', doc_ids)]).ids return set([message_id for allowed_doc_id in allowed_doc_ids for message_id in doc_dict[allowed_doc_id]]) @api.model def _find_allowed_doc_ids(self, model_ids): IrModelAccess = self.env['ir.model.access'] allowed_ids = set() for doc_model, doc_dict in model_ids.items(): if not IrModelAccess.check(doc_model, 'read', False): continue allowed_ids |= self._find_allowed_model_wise(doc_model, doc_dict) return allowed_ids def check_access_rule(self, operation): """ Access rules of mail.message: - read: if - author_id == pid, uid is the author OR - uid is in the recipients (partner_ids) OR - uid has been notified (needaction) OR - uid have read access to the related document if model, res_id - otherwise: raise - create: if - no model, no res_id (private message) OR - pid in message_follower_ids if model, res_id OR - uid can read the parent OR - uid have write or create access on the related document if model, res_id, OR - otherwise: raise - write: if - author_id == pid, uid is the author, OR - uid is in the recipients (partner_ids) OR - uid has write or create access on the related document if model, res_id - otherwise: raise - unlink: if - uid has write or create access on the related document - otherwise: raise Specific case: non employee users see only messages with subtype (aka do not see internal logs). """ def _generate_model_record_ids(msg_val, msg_ids): """ :param model_record_ids: {'model': {'res_id': (msg_id, msg_id)}, ... } :param message_values: {'msg_id': {'model': .., 'res_id': .., 'author_id': ..}} """ model_record_ids = {} for id in msg_ids: vals = msg_val.get(id, {}) if vals.get('model') and vals.get('res_id'): model_record_ids.setdefault(vals['model'], set()).add(vals['res_id']) return model_record_ids if self.env.is_superuser(): return # Non employees see only messages with a subtype (aka, not internal logs) if not self.env['res.users'].has_group('base.group_user'): self._cr.execute('''SELECT DISTINCT message.id, message.subtype_id, subtype.internal FROM "%s" AS message LEFT JOIN "mail_message_subtype" as subtype ON message.subtype_id = subtype.id WHERE message.message_type = %%s AND (message.is_internal IS TRUE OR message.subtype_id IS NULL OR subtype.internal IS TRUE) AND message.id = ANY (%%s)''' % (self._table), ('comment', self.ids,)) if self._cr.fetchall(): raise 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) + ' - ({} {}, {} {})'.format(_('Records:'), self.ids[:6], _('User:'******'model', 'res_id', 'author_id', 'parent_id', 'message_type', 'partner_ids']) self.env['mail.notification'].flush(['mail_message_id', 'res_partner_id']) if operation == 'read': self._cr.execute(""" SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), m.message_type as message_type FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_notification" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, ids=self.ids)) for mid, rmod, rid, author_id, parent_id, partner_id, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'notified': any((message_values[mid].get('notified'), partner_id)), 'message_type': message_type, } elif operation == 'write': self._cr.execute(""" SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), m.message_type as message_type FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_notification" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, uid=self.env.user.id, ids=self.ids)) for mid, rmod, rid, author_id, parent_id, partner_id, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'notified': any((message_values[mid].get('notified'), partner_id)), 'message_type': message_type, } elif operation in ('create', 'unlink'): self._cr.execute("""SELECT DISTINCT id, model, res_id, author_id, parent_id, message_type FROM "%s" WHERE id = ANY (%%s)""" % self._table, (self.ids,)) for mid, rmod, rid, author_id, parent_id, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'message_type': message_type, } else: raise ValueError(_('Wrong operation name (%s)', operation)) # Author condition (READ, WRITE, CREATE (private)) author_ids = [] if operation == 'read': author_ids = [mid for mid, message in message_values.items() if message.get('author_id') and message.get('author_id') == self.env.user.partner_id.id] elif operation == 'write': author_ids = [mid for mid, message in message_values.items() if message.get('author_id') == self.env.user.partner_id.id] elif operation == 'create': author_ids = [mid for mid, message in message_values.items() if not self.is_thread_message(message)] messages_to_check = self.ids messages_to_check = set(messages_to_check).difference(set(author_ids)) if not messages_to_check: return # Recipients condition, for read and write (partner_ids) # keep on top, usefull for systray notifications notified_ids = [] model_record_ids = _generate_model_record_ids(message_values, messages_to_check) if operation in ['read', 'write']: notified_ids = [mid for mid, message in message_values.items() if message.get('notified')] messages_to_check = set(messages_to_check).difference(set(notified_ids)) if not messages_to_check: return # CRUD: Access rights related to the document document_related_ids = [] document_related_candidate_ids = [ mid for mid, message in message_values.items() if (message.get('model') and message.get('res_id') and message.get('message_type') != 'user_notification') ] model_record_ids = _generate_model_record_ids(message_values, document_related_candidate_ids) for model, doc_ids in model_record_ids.items(): DocumentModel = self.env[model] if hasattr(DocumentModel, '_get_mail_message_access'): check_operation = DocumentModel._get_mail_message_access(doc_ids, operation) ## why not giving model here? else: check_operation = self.env['mail.thread']._get_mail_message_access(doc_ids, operation, model_name=model) records = DocumentModel.browse(doc_ids) records.check_access_rights(check_operation) mids = records.browse(doc_ids)._filter_access_rules(check_operation) document_related_ids += [ mid for mid, message in message_values.items() if ( message.get('model') == model and message.get('res_id') in mids.ids and message.get('message_type') != 'user_notification' ) ] messages_to_check = messages_to_check.difference(set(document_related_ids)) if not messages_to_check: return # Parent condition, for create (check for received notifications for the created message parent) notified_ids = [] if operation == 'create': # TDE: probably clean me parent_ids = [message.get('parent_id') for message in message_values.values() if message.get('parent_id')] self._cr.execute("""SELECT DISTINCT m.id, partner_rel.res_partner_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, parent_ids,)) not_parent_ids = [mid[0] for mid in self._cr.fetchall() if mid[1]] notified_ids += [mid for mid, message in message_values.items() if message.get('parent_id') in not_parent_ids] messages_to_check = messages_to_check.difference(set(notified_ids)) if not messages_to_check: return # Recipients condition for create (message_follower_ids) if operation == 'create': for doc_model, doc_ids in model_record_ids.items(): followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', doc_model), ('res_id', 'in', list(doc_ids)), ('partner_id', '=', self.env.user.partner_id.id), ]) fol_mids = [follower.res_id for follower in followers] notified_ids += [mid for mid, message in message_values.items() if message.get('model') == doc_model and message.get('res_id') in fol_mids and message.get('message_type') != 'user_notification' ] messages_to_check = messages_to_check.difference(set(notified_ids)) if not messages_to_check: return if not self.browse(messages_to_check).exists(): return raise 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) + ' - ({} {}, {} {})'.format(_('Records:'), list(messages_to_check)[:6], _('User:'******'email_from' not in values: # needed to compute reply_to author_id, email_from = self.env['mail.thread']._message_compute_author(values.get('author_id'), email_from=None, raise_exception=False) values['email_from'] = email_from if not values.get('message_id'): values['message_id'] = self._get_message_id(values) if 'reply_to' not in values: values['reply_to'] = self._get_reply_to(values) if 'record_name' not in values and 'default_record_name' not in self.env.context: values['record_name'] = self._get_record_name(values) if 'attachment_ids' not in values: values['attachment_ids'] = [] # extract base64 images if 'body' in values: Attachments = self.env['ir.attachment'].with_context(clean_context(self._context)) data_to_url = {} def base64_to_boundary(match): key = match.group(2) if not data_to_url.get(key): name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url) try: attachment = Attachments.create({ 'name': name, 'datas': match.group(2), 'res_model': values.get('model'), 'res_id': values.get('res_id'), }) except binascii_error: _logger.warning("Impossible to create an attachment out of badly formated base64 embedded image. Image has been removed.") return match.group(3) # group(3) is the url ending single/double quote matched by the regexp else: attachment.generate_access_token() values['attachment_ids'].append((4, attachment.id)) data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name] return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1]) 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_list.append(values.pop('tracking_value_ids', False)) messages = super(Message, self).create(values_list) check_attachment_access = [] if all(isinstance(command, int) or command[0] in (4, 6) for values in values_list for command in values.get('attachment_ids')): for values in values_list: for command in values.get('attachment_ids'): if isinstance(command, int): check_attachment_access += [command] elif command[0] == 6: check_attachment_access += command[2] else: # command[0] == 4: check_attachment_access += [command[1]] else: check_attachment_access = messages.mapped('attachment_ids').ids # fallback on read if any unknow command if check_attachment_access: self.env['ir.attachment'].browse(check_attachment_access).check(mode='read') for message, values, tracking_values_cmd in zip(messages, values_list, tracking_values_list): if tracking_values_cmd: vals_lst = [dict(cmd[2], mail_message_id=message.id) for cmd in tracking_values_cmd if len(cmd) == 3 and cmd[0] == 0] other_cmd = [cmd for cmd in tracking_values_cmd if len(cmd) != 3 or cmd[0] != 0] if vals_lst: self.env['mail.tracking.value'].sudo().create(vals_lst) if other_cmd: message.sudo().write({'tracking_value_ids': tracking_values_cmd}) if message.is_thread_message(values): message._invalidate_documents(values.get('model'), values.get('res_id')) return messages def read(self, fields=None, load='_classic_read'): """ Override to explicitely call check_access_rule, that is not called by the ORM. It instead directly fetches ir.rules and apply them. """ self.check_access_rule('read') return super(Message, self).read(fields=fields, load=load) def write(self, vals): record_changed = 'model' in vals or 'res_id' in vals if record_changed or 'message_type' in vals: self._invalidate_documents() res = super(Message, self).write(vals) if vals.get('attachment_ids'): for mail in self: mail.attachment_ids.check(mode='read') if 'notification_ids' in vals or record_changed: self._invalidate_documents() return res def unlink(self): # cascade-delete attachments that are directly attached to the message (should only happen # for mail.messages that act as parent for a standalone mail.mail record). if not self: return True self.check_access_rule('unlink') self.mapped('attachment_ids').filtered( lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0) ).unlink() for elem in self: if elem.is_thread_message(): elem._invalidate_documents() return super(Message, self).unlink() @api.model def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): if not self.env.is_admin(): raise AccessError(_("Only administrators are allowed to use grouped read on message model")) return super(Message, self)._read_group_raw( domain=domain, fields=fields, groupby=groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy, ) def export_data(self, fields_to_export): if not self.env.is_admin(): raise AccessError(_("Only administrators are allowed to export mail message")) return super(Message, self).export_data(fields_to_export) # ------------------------------------------------------ # DISCUSS API # ------------------------------------------------------ @api.model def mark_all_as_read(self, domain=None): # not really efficient method: it does one db request for the # search, and one for each message in the result set is_read to True in the # current notifications from the relation. partner_id = self.env.user.partner_id.id notif_domain = [ ('res_partner_id', '=', partner_id), ('is_read', '=', False)] if domain: messages = self.search(domain) messages.set_message_done() return messages.ids notifications = self.env['mail.notification'].sudo().search(notif_domain) notifications.write({'is_read': True}) ids = [n['mail_message_id'] for n in notifications.read(['mail_message_id'])] notification = {'type': 'mark_as_read', 'message_ids': [id[0] for id in ids], 'needaction_inbox_counter': self.env.user.partner_id.get_needaction_count()} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id), notification) return ids def set_message_done(self): """ Remove the needaction from messages for the current partner. """ partner_id = self.env.user.partner_id notifications = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('res_partner_id', '=', partner_id.id), ('is_read', '=', False)]) if not notifications: return notifications.write({'is_read': True}) # notifies changes in messages through the bus. self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner_id.id), {'type': 'mark_as_read', 'message_ids': notifications.mapped('mail_message_id').ids, 'needaction_inbox_counter': self.env.user.partner_id.get_needaction_count() } ) @api.model def unstar_all(self): """ Unstar messages for the current partner. """ partner_id = self.env.user.partner_id.id starred_messages = self.search([('starred_partner_ids', 'in', partner_id)]) starred_messages.write({'starred_partner_ids': [Command.unlink(partner_id)]}) ids = [m.id for m in starred_messages] notification = {'type': 'toggle_star', 'message_ids': ids, 'starred': False} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) def toggle_message_starred(self): """ Toggle messages as (un)starred. Technically, the notifications related to uid are set to (un)starred. """ # a user should always be able to star a message he can read self.check_access_rule('read') starred = not self.starred if starred: self.sudo().write({'starred_partner_ids': [Command.link(self.env.user.partner_id.id)]}) else: self.sudo().write({'starred_partner_ids': [Command.unlink(self.env.user.partner_id.id)]}) notification = {'type': 'toggle_star', 'message_ids': [self.id], 'starred': starred} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) # ------------------------------------------------------ # MESSAGE READ / FETCH / FAILURE API # ------------------------------------------------------ def _message_format(self, fnames): """Reads values from messages and formats them for the web client.""" self.check_access_rule('read') vals_list = self._read_format(fnames) thread_ids_by_model_name = defaultdict(set) for message in self: if message.model and message.res_id: thread_ids_by_model_name[message.model].add(message.res_id) for vals in vals_list: message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids) # Author if message_sudo.author_id: author = (message_sudo.author_id.id, message_sudo.author_id.display_name) else: author = (0, message_sudo.email_from) # Attachments main_attachment = self.env['ir.attachment'] if message_sudo.attachment_ids and message_sudo.res_id and issubclass(self.pool[message_sudo.model], self.pool['mail.thread']): main_attachment = self.env[message_sudo.model].sudo().browse(message_sudo.res_id).message_main_attachment_id attachments_formatted = message_sudo.attachment_ids._attachment_format() for attachment in attachments_formatted: attachment['is_main'] = attachment['id'] == main_attachment.id # Tracking values tracking_value_ids = [] for tracking in message_sudo.tracking_value_ids: groups = tracking.field_groups if not groups or self.env.is_superuser() or self.user_has_groups(groups): tracking_value_ids.append({ '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, 'currency_id': tracking.currency_id.id, }) if message_sudo.model and message_sudo.res_id: record_name = self.env[message_sudo.model] \ .browse(message_sudo.res_id) \ .sudo() \ .with_prefetch(thread_ids_by_model_name[message_sudo.model]) \ .display_name else: record_name = False vals.update({ 'author_id': author, 'notifications': message_sudo.notification_ids._filtered_for_web_client()._notification_format(), 'attachment_ids': attachments_formatted, 'tracking_value_ids': tracking_value_ids, 'record_name': record_name, }) return vals_list def message_fetch_failed(self): """Returns all messages, sent by the current user, that have errors, in the format expected by the web client.""" messages = self.search([ ('has_error', '=', True), ('author_id', '=', self.env.user.partner_id.id), ('res_id', '!=', 0), ('model', '!=', False), ('message_type', '!=', 'user_notification') ]) return messages._message_notification_format() @api.model def message_fetch(self, domain, limit=20): """ Get a limited amount of formatted messages with provided domain. :param domain: the domain to filter messages; :param limit: the maximum amount of messages to get; :returns list(dict). """ return self.search(domain, limit=limit).message_format() def message_format(self): """ Get the message values in the format for web client. Since message values can be broadcasted, computed fields MUST NOT BE READ and broadcasted. :returns list(dict). Example : { 'body': HTML content of the message 'model': u'res.partner', 'record_name': u'Agrolait', 'attachment_ids': [ { 'file_type_icon': u'webimage', 'id': 45, 'name': u'sample.png', 'filename': u'sample.png' } ], 'needaction_partner_ids': [], # list of partner ids 'res_id': 7, 'tracking_value_ids': [ { 'old_value': "", 'changed_field': "Customer", 'id': 2965, 'new_value': "Axelor" } ], 'author_id': (3, u'Administrator'), 'email_from': '*****@*****.**' # email address or False 'subtype_id': (1, u'Discussions'), 'date': '2015-06-30 08:22:33', 'partner_ids': [[7, "Sacha Du Bourg-Palette"]], # list of partner name_get 'message_type': u'comment', 'id': 59, 'subject': False 'is_note': True # only if the message is a note (subtype == note) 'is_discussion': False # only if the message is a discussion (subtype == discussion) 'is_notification': False # only if the message is a note but is a notification aka not linked to a document like assignation } """ vals_list = self._message_format(self._get_message_format_fields()) com_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') note_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') for vals in vals_list: message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids) notifs = message_sudo.notification_ids.filtered(lambda n: n.res_partner_id) vals.update({ 'needaction_partner_ids': notifs.filtered(lambda n: not n.is_read).res_partner_id.ids, 'history_partner_ids': notifs.filtered(lambda n: n.is_read).res_partner_id.ids, 'is_note': message_sudo.subtype_id.id == note_id, 'is_discussion': message_sudo.subtype_id.id == com_id, 'subtype_description': message_sudo.subtype_id.description, 'is_notification': vals['message_type'] == 'user_notification', }) if vals['model'] and self.env[vals['model']]._original_module: vals['module_icon'] = modules.module.get_module_icon(self.env[vals['model']]._original_module) return vals_list def _get_message_format_fields(self): return [ 'id', 'body', 'date', 'author_id', 'email_from', # base message fields 'message_type', 'subtype_id', 'subject', # message specific 'model', 'res_id', 'record_name', # document related 'partner_ids', # recipients 'starred_partner_ids', # list of partner ids for whom the message is starred ] def _message_notification_format(self): """Returns the current messages and their corresponding notifications in the format expected by the web client. Notifications hold the information about each recipient of a message: if the message was successfully sent or if an exception or bounce occurred. """ return [{ 'id': message.id, 'res_id': message.res_id, 'model': message.model, 'res_model_name': message.env['ir.model']._get(message.model).display_name, 'date': message.date, 'message_type': message.message_type, 'notifications': message.notification_ids._filtered_for_web_client()._notification_format(), } for message in self] def _notify_message_notification_update(self): """Send bus notifications to update status of notifications in the web client. Purpose is to send the updated status per author.""" messages = self.env['mail.message'] for message in self: # Check if user has access to the record before displaying a notification about it. # In case the user switches from one company to another, it might happen that he doesn't # have access to the record related to the notification. In this case, we skip it. # YTI FIXME: check allowed_company_ids if necessary if message.model and message.res_id: record = self.env[message.model].browse(message.res_id) try: record.check_access_rights('read') record.check_access_rule('read') except AccessError: continue else: messages |= message messages_per_partner = defaultdict(lambda: self.env['mail.message']) for message in messages: if not self.env.user._is_public(): messages_per_partner[self.env.user.partner_id] |= message if message.author_id and not any(user._is_public() for user in message.author_id.with_context(active_test=False).user_ids): messages_per_partner[message.author_id] |= message updates = [[ (self._cr.dbname, 'res.partner', partner.id), {'type': 'message_notification_update', 'elements': messages._message_notification_format()} ] for partner, messages in messages_per_partner.items()] self.env['bus.bus'].sendmany(updates) # ------------------------------------------------------ # TOOLS # ------------------------------------------------------ @api.model def _get_record_name(self, values): """ Return the related document name, using name_get. It is done using SUPERUSER_ID, to be sure to have the record name correctly stored. """ model = values.get('model', self.env.context.get('default_model')) res_id = values.get('res_id', self.env.context.get('default_res_id')) if not model or not res_id or model not in self.env: return False return self.env[model].sudo().browse(res_id).display_name @api.model def _get_reply_to(self, values): """ Return a specific reply_to for the document """ model = values.get('model', self._context.get('default_model')) res_id = values.get('res_id', self._context.get('default_res_id')) or False email_from = values.get('email_from') message_type = values.get('message_type') records = None if self.is_thread_message({'model': model, 'res_id': res_id, 'message_type': message_type}): records = self.env[model].browse([res_id]) else: records = self.env[model] if model else self.env['mail.thread'] return records._notify_get_reply_to(default=email_from)[res_id] @api.model def _get_message_id(self, values): if values.get('reply_to_force_new', False) is True: message_id = tools.generate_tracking_message_id('reply_to') elif self.is_thread_message(values): message_id = tools.generate_tracking_message_id('%(res_id)s-%(model)s' % values) else: message_id = tools.generate_tracking_message_id('private') return message_id def is_thread_message(self, vals=None): if vals: res_id = vals.get('res_id') model = vals.get('model') message_type = vals.get('message_type') else: self.ensure_one() res_id = self.res_id model = self.model message_type = self.message_type return res_id and model and message_type != 'user_notification' def _invalidate_documents(self, model=None, res_id=None): """ Invalidate the cache of the documents followed by ``self``. """ for record in self: model = model or record.model res_id = res_id or record.res_id if issubclass(self.pool[model], self.pool['mail.thread']): self.env[model].invalidate_cache(fnames=[ 'message_ids', 'message_unread', 'message_unread_counter', 'message_needaction', 'message_needaction_counter', ], ids=[res_id]) def _get_search_domain_share(self): return ['&', '&', ('is_internal', '=', False), ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)]
class SaleOrderLine(models.Model): _inherit = "sale.order.line" encounter_id = fields.Many2one("medical.encounter", readonly=True, index=True) medical_model = fields.Char(index=True) medical_res_id = fields.Many2oneReference(index=True, model_field="medical_model") invoice_group_method_id = fields.Many2one("invoice.group.method", readonly=True, index=True) authorization_method_id = fields.Many2one( "medical.authorization.method", tracking=True, readonly=True, index=True, ) authorization_checked = fields.Boolean(default=False, readonly=True) authorization_status = fields.Selection( [ ("pending", "Pending authorization"), ("not-authorized", "Not authorized"), ("authorized", "Authorized"), ], readonly=True, ) medical_sale_discount_id = fields.Many2one("medical.sale.discount", readonly=True) authorization_number = fields.Char() subscriber_id = fields.Char() patient_name = fields.Char() coverage_template_id = fields.Many2one( "medical.coverage.template", related="order_id.coverage_id.coverage_template_id", ) def _prepare_third_party_order_line(self): res = super()._prepare_third_party_order_line() res["invoice_group_method_id"] = self.env.ref( "cb_medical_careplan_sale.third_party").id res["encounter_id"] = self.encounter_id.id or False res["authorization_number"] = self.authorization_number or False res["subscriber_id"] = self.subscriber_id or False res["patient_name"] = self.patient_name or False return res def open_medical_record(self): action = { "name": _("Medical Record"), "type": "ir.actions.act_window", "res_model": self.medical_model, "res_id": self.medical_res_id, "view_mode": "form", } return action def write(self, vals): res = super().write(vals) if not self.env.context.get("not_sale_share_values", False): shared_vals = {} for key in self.env["sale.order"].sale_shared_fields(): if key in vals: shared_vals.update({key: vals[key]}) if shared_vals: self.mapped("order_id").with_context( not_sale_share_values=True).write(shared_vals) self.mapped("order_id").mapped("order_line").filtered( lambda r: r not in self).with_context( not_sale_share_values=True).write(shared_vals) return res def _prepare_invoice_line(self): res = super()._prepare_invoice_line() if self.encounter_id: res["patient_name"] = self.patient_name res["subscriber_id"] = self.subscriber_id res["encounter_id"] = self.encounter_id.id res["authorization_number"] = self.authorization_number agreement = self.order_id.coverage_agreement_id if agreement: # TODO: Pass this to cb_facturae # if agreement.file_reference: # res["facturae_file_reference"] = agreement.file_reference if agreement.discount and agreement.discount > 0.0: res["discount"] = agreement.discount if self.coverage_template_id: nomenc = self.coverage_template_id.payor_id.invoice_nomenclature_id if nomenc: item = nomenc.item_ids.filtered( lambda r: r.product_id == self.product_id) if item: res["name"] = item.name return res
class ModelMany2oneReference(models.Model): _name = 'test_new_api.model_many2one_reference' _description = 'dummy m2oref model' res_model = fields.Char('Resource Model') res_id = fields.Many2oneReference('Resource ID', model_field='res_model')
class StudioApprovalEntry(models.Model): _name = 'studio.approval.entry' _description = 'Studio Approval Entry' # entries don't have the studio mixin since they depend on the data of the # db - they cannot be included into the Studio Customizations module @api.model def _default_user_id(self): return self.env.user name = fields.Char(compute='_compute_name', store=True) user_id = fields.Many2one('res.users', string='Approved/rejected by', ondelete='restrict', required=True, default=lambda s: s._default_user_id(), index=True) # cascade deletion from the rule should only happen when the model itself is deleted rule_id = fields.Many2one('studio.approval.rule', string='Approval Rule', ondelete='cascade', required=True, index=True) # store these for performance reasons, reading should be fast while writing can be slower model = fields.Char(string='Model Name', related="rule_id.model_name", store=True) method = fields.Char(string='Method', related="rule_id.method", store=True) action_id = fields.Many2one('ir.actions.actions', related="rule_id.action_id", store=True) res_id = fields.Many2oneReference(string='Record ID', model_field='model', required=True) reference = fields.Char(string='Reference', compute='_compute_reference') approved = fields.Boolean(string='Approved') group_id = fields.Many2one('res.groups', string='Group', related="rule_id.group_id") _sql_constraints = [ ('uniq_combination', 'unique(rule_id,model,res_id)', 'A rule can only be approved/rejected once per record.') ] def init(self): self._cr.execute( """SELECT indexname FROM pg_indexes WHERE indexname = 'studio_approval_entry_model_res_id_idx'""" ) if not self._cr.fetchone(): self._cr.execute( """CREATE INDEX studio_approval_entry_model_res_id_idx ON studio_approval_entry (model, res_id)""" ) @api.depends('user_id', 'model', 'res_id') def _compute_name(self): for entry in self: if not entry.id: entry.name = _('New Approval Entry') entry.name = '%s - %s(%s)' % (entry.user_id.name, entry.model, entry.res_id) @api.depends('model', 'res_id') def _compute_reference(self): for entry in self: entry.reference = "%s,%s" % (entry.model, entry.res_id) @api.model def create(self, vals): entry = super().create(vals) entry._notify_approval() return entry def write(self, vals): res = super().write(vals) self._notify_approval() return res def _notify_approval(self): """Post a generic note on the record if it inherits mail.thead.""" for entry in self: if not entry.rule_id.model_id.is_mail_thread: continue record = self.env[entry.model].browse(entry.res_id) template = 'web_studio.notify_approval' record.message_post_with_view( template, values={ 'user_name': entry.user_id.display_name, 'group_name': entry.group_id.display_name, 'approved': entry.approved, }, subtype_id=self.env.ref("mail.mt_note").id, author_id=self.env.user.partner_id.id)
class Followers(models.Model): """ mail_followers holds the data related to the follow mechanism inside Odoo. Partners can choose to follow documents (records) of any kind that inherits from mail.thread. Following documents allow to receive notifications for new messages. A subscription is characterized by: :param: res_model: model of the followed objects :param: res_id: ID of resource (may be 0 for every objects) """ _name = 'mail.followers' _rec_name = 'partner_id' _log_access = False _description = 'Document Followers' # Note. There is no integrity check on model names for performance reasons. # However, followers of unlinked models are deleted by models themselves # (see 'ir.model' inheritance). res_model = fields.Char('Related Document Model Name', required=True, index=True) res_id = fields.Many2oneReference('Related Document ID', index=True, help='Id of the followed resource', model_field='res_model') partner_id = fields.Many2one('res.partner', string='Related Partner', ondelete='cascade', index=True) channel_id = fields.Many2one('mail.channel', string='Listener', ondelete='cascade', index=True) subtype_ids = fields.Many2many( 'mail.message.subtype', string='Subtype', help= "Message subtypes followed, meaning subtypes that will be pushed onto the user's Wall." ) name = fields.Char( 'Name', compute='_compute_related_fields', help="Name of the related partner (if exist) or the related channel") email = fields.Char( 'Email', compute='_compute_related_fields', help="Email of the related partner (if exist) or False") is_active = fields.Boolean( 'Is Active', compute='_compute_related_fields', help= "If the related partner is active (if exist) or if related channel exist" ) def _invalidate_documents(self, vals_list=None): """ Invalidate the cache of the documents followed by ``self``. Modifying followers change access rights to individual documents. As the cache may contain accessible/inaccessible data, one has to refresh it. """ to_invalidate = defaultdict(list) for record in (vals_list or [{ 'res_model': rec.res_model, 'res_id': rec.res_id } for rec in self]): if record.get('res_id'): to_invalidate[record.get('res_model')].append( record.get('res_id')) @api.model_create_multi def create(self, vals_list): res = super(Followers, self).create(vals_list) res._invalidate_documents(vals_list) return res def write(self, vals): if 'res_model' in vals or 'res_id' in vals: self._invalidate_documents() res = super(Followers, self).write(vals) if any(x in vals for x in ['res_model', 'res_id', 'partner_id']): self._invalidate_documents() return res def unlink(self): self._invalidate_documents() return super(Followers, self).unlink() _sql_constraints = [ ('mail_followers_res_partner_res_model_id_uniq', 'unique(res_model,res_id,partner_id)', 'Error, a partner cannot follow twice the same object.'), ('mail_followers_res_channel_res_model_id_uniq', 'unique(res_model,res_id,channel_id)', 'Error, a channel cannot follow twice the same object.'), ('partner_xor_channel', 'CHECK((partner_id IS NULL) != (channel_id IS NULL))', 'Error: A follower must be either a partner or a channel (but not both).' ) ] # -------------------------------------------------- # Private tools methods to fetch followers data # -------------------------------------------------- @api.depends('partner_id', 'channel_id') def _compute_related_fields(self): for follower in self: if follower.partner_id: follower.name = follower.partner_id.name follower.email = follower.partner_id.email follower.is_active = follower.partner_id.active else: follower.name = follower.channel_id.name follower.is_active = bool(follower.channel_id) follower.email = False def _get_recipient_data(self, records, message_type, subtype_id, pids=None, cids=None): """ Private method allowing to fetch recipients data based on a subtype. Purpose of this method is to fetch all data necessary to notify recipients in a single query. It fetches data from * followers (partners and channels) of records that follow the given subtype if records and subtype are set; * partners if pids is given; * channels if cids is given; :param records: fetch data from followers of records that follow subtype_id; :param message_type: mail.message.message_type in order to allow custom behavior depending on it (SMS for example); :param subtype_id: mail.message.subtype to check against followers; :param pids: additional set of partner IDs from which to fetch recipient data; :param cids: additional set of channel IDs from which to fetch recipient data; :return: list of recipient data which is a tuple containing partner ID (void if channel ID), channel ID (void if partner ID), active value (always True for channels), share status of partner (void as irrelevant if channel ID), notification status of partner or channel (email or inbox), user groups of partner (void as irrelevant if channel ID), """ self.env['mail.followers'].flush( ['partner_id', 'channel_id', 'subtype_ids']) self.env['mail.message.subtype'].flush(['internal']) self.env['res.users'].flush( ['notification_type', 'active', 'partner_id', 'groups_id']) self.env['res.partner'].flush(['active', 'partner_share']) self.env['res.groups'].flush(['users']) self.env['mail.channel'].flush(['email_send', 'channel_type']) if records and subtype_id: query = """ WITH sub_followers AS ( SELECT fol.id, fol.partner_id, fol.channel_id, subtype.internal FROM mail_followers fol RIGHT JOIN mail_followers_mail_message_subtype_rel subrel ON subrel.mail_followers_id = fol.id RIGHT JOIN mail_message_subtype subtype ON subtype.id = subrel.mail_message_subtype_id WHERE subrel.mail_message_subtype_id = %%s AND fol.res_model = %%s AND fol.res_id IN %%s ) SELECT partner.id as pid, NULL AS cid, partner.active as active, partner.partner_share as pshare, NULL as ctype, users.notification_type AS notif, array_agg(groups.id) AS groups FROM res_partner partner LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id LEFT JOIN res_groups groups ON groups.id = groups_rel.gid WHERE EXISTS ( SELECT partner_id FROM sub_followers WHERE sub_followers.channel_id IS NULL AND sub_followers.partner_id = partner.id AND (coalesce(sub_followers.internal, false) <> TRUE OR coalesce(partner.partner_share, false) <> TRUE) ) %s GROUP BY partner.id, users.notification_type UNION SELECT NULL AS pid, channel.id AS cid, TRUE as active, NULL AS pshare, channel.channel_type AS ctype, CASE WHEN channel.email_send = TRUE THEN 'email' ELSE 'inbox' END AS notif, NULL AS groups FROM mail_channel channel WHERE EXISTS ( SELECT channel_id FROM sub_followers WHERE partner_id IS NULL AND sub_followers.channel_id = channel.id ) %s """ % ('OR partner.id IN %s' if pids else '', 'OR channel.id IN %s' if cids else '') params = [subtype_id, records._name, tuple(records.ids)] if pids: params.append(tuple(pids)) if cids: params.append(tuple(cids)) self.env.cr.execute(query, tuple(params)) res = self.env.cr.fetchall() elif pids or cids: params, query_pid, query_cid = [], '', '' if pids: query_pid = """ SELECT partner.id as pid, NULL AS cid, partner.active as active, partner.partner_share as pshare, NULL as ctype, users.notification_type AS notif, NULL AS groups FROM res_partner partner LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active WHERE partner.id IN %s""" params.append(tuple(pids)) if cids: query_cid = """ SELECT NULL AS pid, channel.id AS cid, TRUE as active, NULL AS pshare, channel.channel_type AS ctype, CASE when channel.email_send = TRUE then 'email' else 'inbox' end AS notif, NULL AS groups FROM mail_channel channel WHERE channel.id IN %s """ params.append(tuple(cids)) query = ' UNION'.join(x for x in [query_pid, query_cid] if x) self.env.cr.execute(query, tuple(params)) res = self.env.cr.fetchall() else: res = [] return res def _get_subscription_data(self, doc_data, pids, cids, include_pshare=False): """ Private method allowing to fetch follower data from several documents of a given model. Followers can be filtered given partner IDs and channel IDs. :param doc_data: list of pair (res_model, res_ids) that are the documents from which we want to have subscription data; :param pids: optional partner to filter; if None take all, otherwise limitate to pids :param cids: optional channel to filter; if None take all, otherwise limitate to cids :param include_pshare: optional join in partner to fetch their share status :return: list of followers data which is a list of tuples containing follower ID, document ID, partner ID (void if channel_id), channel ID (void if partner_id), followed subtype IDs, share status of partner (void id channel_id, returned only if include_pshare is True) """ # base query: fetch followers of given documents where_clause = ' OR '.join( ['fol.res_model = %s AND fol.res_id IN %s'] * len(doc_data)) where_params = list( itertools.chain.from_iterable( (rm, tuple(rids)) for rm, rids in doc_data)) # additional: filter on optional pids / cids sub_where = [] if pids: sub_where += ["fol.partner_id IN %s"] where_params.append(tuple(pids)) elif pids is not None: sub_where += ["fol.partner_id IS NULL"] if cids: sub_where += ["fol.channel_id IN %s"] where_params.append(tuple(cids)) elif cids is not None: sub_where += ["fol.channel_id IS NULL"] if sub_where: where_clause += "AND (%s)" % " OR ".join(sub_where) query = """ SELECT fol.id, fol.res_id, fol.partner_id, fol.channel_id, array_agg(subtype.id)%s FROM mail_followers fol %s LEFT JOIN mail_followers_mail_message_subtype_rel fol_rel ON fol_rel.mail_followers_id = fol.id LEFT JOIN mail_message_subtype subtype ON subtype.id = fol_rel.mail_message_subtype_id WHERE %s GROUP BY fol.id%s""" % ( ', partner.partner_share' if include_pshare else '', 'LEFT JOIN res_partner partner ON partner.id = fol.partner_id' if include_pshare else '', where_clause, ', partner.partner_share' if include_pshare else '') self.env.cr.execute(query, tuple(where_params)) return self.env.cr.fetchall() # -------------------------------------------------- # Private tools methods to generate new subscription # -------------------------------------------------- def _insert_followers(self, res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes, customer_ids=None, check_existing=True, existing_policy='skip'): """ Main internal method allowing to create or update followers for documents, given a res_model and the document res_ids. This method does not handle access rights. This is the role of the caller to ensure there is no security breach. :param partner_subtypes: optional subtypes for new partner followers. If not given, default ones are computed; :param channel_subtypes: optional subtypes for new channel followers. If not given, default ones are computed; :param customer_ids: see ``_add_default_followers`` :param check_existing: see ``_add_followers``; :param existing_policy: see ``_add_followers``; """ sudo_self = self.sudo().with_context(default_partner_id=False, default_channel_id=False) if not partner_subtypes and not channel_subtypes: # no subtypes -> default computation, no force, skip existing new, upd = self._add_default_followers( res_model, res_ids, partner_ids, channel_ids, customer_ids=customer_ids, check_existing=check_existing, existing_policy=existing_policy) else: new, upd = self._add_followers(res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes, check_existing=check_existing, existing_policy=existing_policy) if new: sudo_self.create([ dict(values, res_id=res_id) for res_id, values_list in new.items() for values in values_list ]) for fol_id, values in upd.items(): sudo_self.browse(fol_id).write(values) def _add_default_followers(self, res_model, res_ids, partner_ids, channel_ids=None, customer_ids=None, check_existing=True, existing_policy='skip'): """ Shortcut to ``_add_followers`` that computes default subtypes. Existing followers are skipped as their subscription is considered as more important compared to new default subscription. :param customer_ids: optional list of partner ids that are customers. It is used if computing default subtype is necessary and allow to avoid the check of partners being customers (no user or share user). It is just a matter of saving queries if the info is already known; :param check_existing: see ``_add_followers``; :param existing_policy: see ``_add_followers``; :return: see ``_add_followers`` """ if not partner_ids and not channel_ids: return dict(), dict() default, _, external = self.env[ 'mail.message.subtype'].default_subtypes(res_model) if partner_ids and customer_ids is None: customer_ids = self.env['res.partner'].sudo().search([ ('id', 'in', partner_ids), ('partner_share', '=', True) ]).ids c_stypes = dict.fromkeys(channel_ids or [], default.ids) p_stypes = dict( (pid, external.ids if pid in customer_ids else default.ids) for pid in partner_ids) return self._add_followers(res_model, res_ids, partner_ids, p_stypes, channel_ids, c_stypes, check_existing=check_existing, existing_policy=existing_policy) def _add_followers(self, res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes, check_existing=False, existing_policy='skip'): """ Internal method that generates values to insert or update followers. Callers have to handle the result, for example by making a valid ORM command, inserting or updating directly follower records, ... This method returns two main data * first one is a dict which keys are res_ids. Value is a list of dict of values valid for creating new followers for the related res_id; * second one is a dict which keys are follower ids. Value is a dict of values valid for updating the related follower record; :param check_existing: if True, check for existing followers for given documents and handle them according to existing_policy parameter. Setting to False allows to save some computation if caller is sure there are no conflict for followers; :param existing policy: if check_existing, tells what to do with already-existing followers: * skip: simply skip existing followers, do not touch them; * force: update existing with given subtypes only; * replace: replace existing with new subtypes (like force without old / new follower); * update: gives an update dict allowing to add missing subtypes (no subtype removal); """ _res_ids = res_ids or [0] data_fols, doc_pids, doc_cids = dict(), dict( (i, set()) for i in _res_ids), dict((i, set()) for i in _res_ids) if check_existing and res_ids: for fid, rid, pid, cid, sids in self._get_subscription_data( [(res_model, res_ids)], partner_ids or None, channel_ids or None): if existing_policy != 'force': if pid: doc_pids[rid].add(pid) elif cid: doc_cids[rid].add(cid) data_fols[fid] = (rid, pid, cid, sids) if existing_policy == 'force': self.sudo().browse(data_fols.keys()).unlink() new, update = dict(), dict() for res_id in _res_ids: for partner_id in set(partner_ids or []): if partner_id not in doc_pids[res_id]: new.setdefault(res_id, list()).append({ 'res_model': res_model, 'partner_id': partner_id, 'subtype_ids': [(6, 0, partner_subtypes[partner_id])], }) elif existing_policy in ('replace', 'update'): fol_id, sids = next( ((key, val[3]) for key, val in data_fols.items() if val[0] == res_id and val[1] == partner_id), (False, [])) new_sids = set(partner_subtypes[partner_id]) - set(sids) old_sids = set(sids) - set(partner_subtypes[partner_id]) if fol_id and new_sids: update[fol_id] = { 'subtype_ids': [(4, sid) for sid in new_sids] } if fol_id and old_sids and existing_policy == 'replace': update[fol_id] = { 'subtype_ids': [(3, sid) for sid in old_sids] } for channel_id in set(channel_ids or []): if channel_id not in doc_cids[res_id]: new.setdefault(res_id, list()).append({ 'res_model': res_model, 'channel_id': channel_id, 'subtype_ids': [(6, 0, channel_subtypes[channel_id])], }) elif existing_policy in ('replace', 'update'): fol_id, sids = next( ((key, val[3]) for key, val in data_fols.items() if val[0] == res_id and val[2] == channel_id), (False, [])) new_sids = set(channel_subtypes[channel_id]) - set(sids) old_sids = set(sids) - set(channel_subtypes[channel_id]) if fol_id and new_sids: update[fol_id] = { 'subtype_ids': [(4, sid) for sid in new_sids] } if fol_id and old_sids and existing_policy == 'replace': update[fol_id] = { 'subtype_ids': [(3, sid) for sid in old_sids] } return new, update
class WecomMessageMessage(models.Model): """ 企业微信消息模型:系统通知(替换res.log通知), 评论(OpenChatter讨论)和收到的电子邮件。 """ _name = "wecom.message.message" _description = "Wecom Message" _order = "id desc" _rec_name = "record_name" @api.model def default_get(self, fields): res = super(WecomMessageMessage, self).default_get(fields) missing_author = "author_id" in fields and "author_id" not in res missing_email_from = "meaasge_from" in fields and "meaasge_from" not in res if missing_author or missing_email_from: author_id, meaasge_from = self.env[ "mail.thread"]._message_compute_author(res.get("author_id"), res.get("meaasge_from"), raise_exception=False) if missing_email_from: res["meaasge_from"] = meaasge_from if missing_author: res["author_id"] = author_id return res # def _default_name(self): # return "%s" % (self.subject) # 企业微信消息内容 subject = fields.Char("Subject") date = fields.Datetime("Date", default=fields.Datetime.now) media_id = fields.Many2one( string="Media file id", comodel_name="wecom.material", help= "Media file ID, which can be obtained by calling the upload temporary material interface", ) body_html = fields.Text("Html Body", translate=True, sanitize=False) body_json = fields.Text( "Json Body", translate=True, ) body_markdown = fields.Text("Markdown Body", translate=True) description = fields.Char( "Short description", compute="_compute_description", help= "Message description: either the subject, or the beginning of the body", ) message_to_user = fields.Char(string="To Users", help="Message recipients (users)") message_to_party = fields.Char( string="To Departments", help="Message recipients (departments)", ) message_to_tag = fields.Char( string="To Tags", help="Message recipients (tags)", ) use_templates = fields.Boolean("Is template message", default=False) templates_id = fields.Many2one("wecom.message.template", string="Message template") msgtype = fields.Selection( [ ("text", "Text message"), ("image", "Picture message"), ("voice", "Voice messages"), ("video", "Video message"), ("file", "File message"), ("textcard", "Text card message"), ("news", "Graphic message"), ("mpnews", "Graphic message(mpnews)"), ("markdown", "Markdown message"), ("miniprogram", "Mini Program Notification Message"), ("taskcard", "Task card message"), ("template_card", "Template card message"), ], string="Message type", default="text", ) # 企业微信消息选项 safe = fields.Selection( [ ("0", "Shareable"), ("1", "Cannot share and content shows watermark"), ("2", "Only share within the company "), ], string="Secret message", required=True, default="1", help= "Indicates whether it is a confidential message, 0 indicates that it can be shared externally, 1 indicates that it cannot be shared and the content displays watermark, 2 indicates that it can only be shared within the enterprise, and the default is 0; Note that only messages of mpnews type support the safe value of 2, and other message types do not", ) enable_id_trans = fields.Boolean( string="Turn on id translation", help= "Indicates whether to enable ID translation, 0 indicates no, 1 indicates yes, and 0 is the default", default=False, ) enable_duplicate_check = fields.Boolean( string="Turn on duplicate message checking", help= "Indicates whether to enable duplicate message checking. 0 indicates no, 1 indicates yes. The default is 0", default=False, ) duplicate_check_interval = fields.Integer( string="Time interval for repeated message checking", help= "Indicates whether the message check is repeated. The default is 1800s and the maximum is no more than 4 hours", default="1800", ) # 消息状态 # API msgid = fields.Char( "Message-Id", help="Used to recall application messages", # index=True, readonly=1, copy=False, ) state = fields.Selection( [ ("sent", "Sent"), ("exception", "Send exception"), ("cancel", "Cancelled"), ], string="State", ) auto_delete = fields.Boolean( "Auto Delete", help= "This option permanently removes any track of message after it's been sent, in order to preserve storage space of your Odoo database.", ) failure_reason = fields.Text( "Failure Reason", readonly=1, help= "Failure reason. This is usually the exception thrown by the wecom api, stored to ease the debugging of message issues.", ) scheduled_date = fields.Char( "Scheduled Send Date", help= "If set, the queue manager will send the message after the date. If not set, the message will be send as soon as possible.", ) # 关联 model = fields.Char("Related Document Model", index=True) res_id = fields.Many2oneReference("Related Document ID", index=True, model_field="model") record_name = fields.Char("Message Record Name", help="Name get of the related document.") # 特性 message_type = fields.Selection( [ ("email", "Email Message"), ("comment", "Comment Message"), ("notification", "System Notification Message"), ("user_notification", "User Specific Notification Message"), ], "Type", required=True, default="email", help="Message type: email for email message, notification for system " "message, comment for other messages such as user replies", ) subtype_id = fields.Many2one("mail.message.subtype", "Subtype", ondelete="set null", index=True) # 子类型 # mail_activity_type_id = fields.Many2one( # "mail.activity.type", "Mail Activity Type", index=True, ondelete="set null" # ) is_internal = fields.Boolean( "Employee Only", help= "Hide to public / portal users, independently from subtype configuration.", ) # 内部消息 # 来源 # origin sender = fields.Char("Sender", ) meaasge_from = fields.Char( "From", help= "Wecom user id of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter.", ) author_id = fields.Many2one( "res.partner", "Author", index=True, ondelete="set null", help= "Author of the message. If not set, meaasge_from may hold an message wecom user id that did not match any partner.", ) author_avatar = fields.Binary( "Author's avatar", related="author_id.image_128", depends=["author_id"], readonly=False, ) # 收件人:包括非活动合作伙伴(他们可能在邮件发送后已存档,但在关系中应保持可见) partner_ids = fields.Many2many("res.partner", string="Recipients", context={"active_test": False}) # 具有通知的合作伙伴列表。警告:由于notif gc cron,列表可能会随时间而更改。 # 主要用于测试 # notified_partner_ids = fields.Many2many( # "res.partner", # "wecom_message_message_res_partner_needaction_rel", # "message_message_id", # string="Partners with Need Action", # context={"active_test": False}, # depends=["notification_ids"], # ) channel_ids = fields.Many2many("mail.channel", "wecom_message_message_mail_channel_rel", string="Channels") # notification_ids = fields.One2many( # "wecom.message.notification", # "message_message_id", # "Notifications", # auto_join=True, # copy=False, # depends=["notified_partner_ids"], # ) needaction = fields.Boolean( "Need Action", # compute="_get_needaction", search="_search_needaction", help="Need Action", ) def _compute_description(self): for message in self: if message.subject: message.description = message.subject else: plaintext_ct = ("" if not message.body_html else tools.html2plaintext(message.body_html)) message.description = plaintext_ct[:30] + "%s" % ( " [...]" if len(plaintext_ct) >= 30 else "") # ------------------------------------------------------ # CRUD / ORM # ------------------------------------------------------ @api.model_create_multi def create(self, values_list): # tracking_values_list = [] for values in values_list: if ("record_name" not in values and "default_record_name" not in self.env.context): values["record_name"] = self._get_record_name(values) messages = super(WecomMessageMessage, self).create(values_list) return messages # ------------------------------------------------------ # TOOLS # ------------------------------------------------------ def _get_record_name(self, values): """Return the related document name, using name_get. It is done using SUPERUSER_ID, to be sure to have the record name correctly stored.""" model = values.get("model", self.env.context.get("default_model")) res_id = values.get("res_id", self.env.context.get("default_res_id")) if not model or not res_id or model not in self.env: return False return self.env[model].sudo().browse(res_id).display_name # ------------------------------------------------------ # 消息格式、工具和发送机制 # mail_mail formatting, tools and send mechanism # ------------------------------------------------------ def _send_prepare_body_html(self): self.ensure_one() return self.body_html or "" def _send_prepare_body_json(self): self.ensure_one() return self.body_json or "" def _send_prepare_body_markdown(self): self.ensure_one() return self.body_markdown or "" def _send_prepare_values(self, partner=None): """ 根据合作伙伴的不同,返回特定电子邮件值的字典,或返回mail.email_to给定的整个收件人的通用字典。 :param Model partner: 特定收件人合作伙伴 """ self.ensure_one() body_html = self._send_prepare_body_html() body_json = self._send_prepare_body_json() body_markdown = self._send_prepare_body_markdown() # body_alternative = tools.html2plaintext(body) # if partner: # email_to = [ # tools.formataddr((partner.name or "False", partner.email or "False")) # ] # else: # email_to = tools.email_split_and_format(self.email_to) res = { "body_html": body_html, "body_json": body_json, "body_markdown": body_markdown, # "body_alternative": body_alternative, # "email_to": email_to, } return res def _split_messages(self): """ 拆分消息 """ groups = defaultdict(list) for record_ids in groups.items(): for message_batch in tools.split_every(record_ids): yield message_batch def send( self, auto_commit=False, raise_exception=False, company=None, ): """ 立即发送选定的企业微信消息,而忽略它们的当前状态(除非已被重新发送,否则不应该传递已经发送的企业微信消息)。 成功发送的消息被标记为“已发送”,未发送成功的消息被标记为“例外”,并且相应的错误邮件将输出到服务器日志中。 :param bool auto_commit: 在发送每条消息后是否强制提交消息状态(仅用于调度程序处理); 在正常传递中,永远不应该为True(默认值:False) :param bool raise_exception: 如果电子邮件发送过程失败,将引发异常 :param bool is_wecom_message: 标识是企业微信消息 :param company: 公司 :return: True """ if not company: company = self.env.company for batch_ids in self.ids: try: WeComMessageApi = self.env[ "wecom.message.api"].get_message_api(company) except ApiException as exc: if raise_exception: return self.env["wecom.tools"].ApiExceptionDialog(exc) else: batch = self.browse(batch_ids) batch.write({ "state": "exception", "failure_reason": exc.errMsg }) else: self.browse(batch_ids)._send( auto_commit=auto_commit, raise_exception=raise_exception, company=company, WeComMessageApi=WeComMessageApi, ) finally: pass def _send( self, auto_commit=False, raise_exception=False, company=None, WeComMessageApi=None, ): """ 发送企业微信消息 :param bool auto_commit: 发送每封邮件后是否强制提交邮件状态(仅用于调度程序处理); 在正常发送绝对不能为True(默认值:False) :param bool raise_exception: 如果电子邮件发送过程失败,是否引发异常 :return: True """ if not company: company = self.env.company ApiObj = self.env["wecom.message.api"] for message_id in self.ids: message = None try: message = self.browse(message_id) msg = ApiObj.build_message( msgtype=message.msgtype, touser=message.message_to_user, toparty=message.message_to_party, totag=message.message_to_tag, subject=message.subject, media_id=message.media_id, description=message.description, author_id=message.author_id, body_html=message.body_html, body_json=message.body_json, body_markdown=message.body_markdown, safe=message.safe, enable_id_trans=message.enable_id_trans, enable_duplicate_check=message.enable_duplicate_check, duplicate_check_interval=message.duplicate_check_interval, company=company, ) del msg["company"] # 删除message中的 company res = WeComMessageApi.httpCall( self.env["wecom.service_api_list"].get_server_api_call( "MESSAGE_SEND"), msg, ) except ApiException as exc: error = self.env["wecom.service_api_error"].get_error_by_code( exc.errCode) self.write({ "state": "exception", "failure_reason": "%s %s" % (str(error["code"]), error["name"]), }) if raise_exception: return self.env[ "wecomapi.tools.action"].ApiExceptionDialog( exc, raise_exception) else: # 如果try中的程序执行过程中没有发生错误,继续执行else中的程序; message.write({ "state": "sent", "msgid": res["msgid"], }) if auto_commit is True: self._cr.commit() return True