Ejemplo n.º 1
0
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.")
Ejemplo n.º 2
0
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"))
Ejemplo n.º 3
0
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',
    )
Ejemplo n.º 4
0
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'))
Ejemplo n.º 5
0
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
Ejemplo n.º 6
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
Ejemplo n.º 7
0
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,
        }
Ejemplo n.º 8
0
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']]
        }
Ejemplo n.º 9
0
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)
Ejemplo n.º 10
0
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
Ejemplo n.º 11
0
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
Ejemplo n.º 12
0
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})
Ejemplo n.º 13
0
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")
Ejemplo n.º 15
0
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)
Ejemplo n.º 16
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

      * 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
Ejemplo n.º 17
0
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,
        }
Ejemplo n.º 18
0
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)
Ejemplo n.º 19
0
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)]
Ejemplo n.º 20
0
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
Ejemplo n.º 21
0
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')
Ejemplo n.º 22
0
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)
Ejemplo n.º 23
0
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