class MixedModel(models.Model): _name = 'test_new_api.mixed' _description = 'Test New API Mixed' number = fields.Float(digits=(10, 2), default=3.14) number2 = fields.Float(digits='New API Precision') date = fields.Date() moment = fields.Datetime() now = fields.Datetime(compute='_compute_now') lang = fields.Selection(string='Language', selection='_get_lang') reference = fields.Reference(string='Related Document', selection='_reference_models') comment1 = fields.Html(sanitize=False) comment2 = fields.Html(sanitize_attributes=True, strip_classes=False) comment3 = fields.Html(sanitize_attributes=True, strip_classes=True) comment4 = fields.Html(sanitize_attributes=True, strip_style=True) currency_id = fields.Many2one('res.currency', default=lambda self: self.env.ref('base.EUR')) amount = fields.Monetary() def _compute_now(self): # this is a non-stored computed field without dependencies for message in self: message.now = fields.Datetime.now() @api.model def _get_lang(self): return self.env['res.lang'].get_installed() @api.model def _reference_models(self): models = self.env['ir.model'].sudo().search([('state', '!=', 'manual')]) return [(model.model, model.name) for model in models if not model.model.startswith('ir.')]
class ProductTemplate(models.Model): _inherit = "product.template" quotation_only_description = fields.Html( 'Quotation Only Description', sanitize_attributes=False, translate=html_translate, help="The quotation description (not used on eCommerce)") quotation_description = fields.Html( 'Quotation Description', compute='_compute_quotation_description', help= "This field uses the Quotation Only Description if it is defined, otherwise it will try to read the eCommerce Description." ) def _compute_quotation_description(self): for record in self: if record.quotation_only_description: record.quotation_description = record.quotation_only_description elif hasattr(record, 'website_description') and record.website_description: record.quotation_description = record.website_description else: record.quotation_description = ''
class KarmaRank(models.Model): _name = 'gamification.karma.rank' _description = 'Rank based on karma' _inherit = 'image.mixin' _order = 'karma_min' name = fields.Text(string='Rank Name', translate=True, required=True) description = fields.Html( string='Description', translate=html_translate, sanitize_attributes=False, ) description_motivational = fields.Html( string='Motivational', translate=html_translate, sanitize_attributes=False, help="Motivational phrase to reach this rank") karma_min = fields.Integer(string='Required Karma', help='Minimum karma needed to reach this rank') user_ids = fields.One2many('res.users', 'rank_id', string='Users', help="Users having this rank") @api.model_create_multi def create(self, values_list): res = super(KarmaRank, self).create(values_list) users = self.env['res.users'].sudo().search([('karma', '>', 0)]) users._recompute_rank() return res def write(self, vals): if 'karma_min' in vals: previous_ranks = self.env['gamification.karma.rank'].search( [], order="karma_min DESC").ids low = min(vals['karma_min'], self.karma_min) high = max(vals['karma_min'], self.karma_min) res = super(KarmaRank, self).write(vals) if 'karma_min' in vals: after_ranks = self.env['gamification.karma.rank'].search( [], order="karma_min DESC").ids if previous_ranks != after_ranks: users = self.env['res.users'].sudo().search([('karma', '>', 0) ]) else: users = self.env['res.users'].sudo().search([ ('karma', '>=', low), ('karma', '<=', high) ]) users._recompute_rank() return res
class Job(models.Model): _name = 'hr.job' _inherit = [ 'hr.job', 'website.seo.metadata', 'website.published.multi.mixin' ] def _get_default_website_description(self): default_description = self.env["ir.model.data"].xmlid_to_object( "website_hr_recruitment.default_website_description") return (default_description.render() if default_description else "") website_description = fields.Html('Website description', translate=html_translate, sanitize_attributes=False, default=_get_default_website_description, prefetch=False) def _compute_website_url(self): super(Job, self)._compute_website_url() for job in self: job.website_url = "/jobs/detail/%s" % job.id def set_open(self): self.write({'website_published': False}) return super(Job, self).set_open()
class ConverterTest(models.Model): _name = 'web_editor.converter.test' _description = 'Web Editor Converter Test' # disable translation export for those brilliant field labels and values _translate = False char = fields.Char() integer = fields.Integer() float = fields.Float() numeric = fields.Float(digits=(16, 2)) many2one = fields.Many2one('web_editor.converter.test.sub') binary = fields.Binary(attachment=False) date = fields.Date() datetime = fields.Datetime() selection_str = fields.Selection( [ ('A', "Qu'il n'est pas arrivé à Toronto"), ('B', "Qu'il était supposé arriver à Toronto"), ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"), ('D', "La réponse D"), ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et " u"qu'il fait une escale technique à St Claude, on dit:") html = fields.Html() text = fields.Text()
class SaleOrderTemplateLine(models.Model): _inherit = "sale.order.template.line" website_description = fields.Html( 'Website Description', related='product_id.product_tmpl_id.quotation_only_description', translate=html_translate, readonly=False) @api.onchange('product_id') def _onchange_product_id(self): ret = super(SaleOrderTemplateLine, self)._onchange_product_id() if self.product_id: self.website_description = self.product_id.quotation_description return ret @api.model def create(self, values): values = self._inject_quotation_description(values) return super(SaleOrderTemplateLine, self).create(values) def write(self, values): values = self._inject_quotation_description(values) return super(SaleOrderTemplateLine, self).write(values) def _inject_quotation_description(self, values): values = dict(values or {}) if not values.get('website_description') and values.get('product_id'): product = self.env['product.product'].browse(values['product_id']) values['website_description'] = product.quotation_description return values
class test_model(models.Model): _name = 'test_converter.test_model' _description = 'Test Converter Model' char = fields.Char() integer = fields.Integer() float = fields.Float() numeric = fields.Float(digits=(16, 2)) many2one = fields.Many2one('test_converter.test_model.sub', group_expand='_gbf_m2o') binary = fields.Binary(attachment=False) date = fields.Date() datetime = fields.Datetime() selection_str = fields.Selection( [ ('A', u"Qu'il n'est pas arrivé à Toronto"), ('B', u"Qu'il était supposé arriver à Toronto"), ('C', u"Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"), ('D', u"La réponse D"), ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et " u"qu'il fait une escale technique à St Claude, on dit:") html = fields.Html() text = fields.Text() # `base` module does not contains any model that implement the functionality # `group_expand`; test this feature here... @api.model def _gbf_m2o(self, subs, domain, order): sub_ids = subs._search([], order=order, access_rights_uid=SUPERUSER_ID) return subs.browse(sub_ids)
class SaleOrder(models.Model): _inherit = 'sale.order' website_description = fields.Html('Website Description', sanitize_attributes=False, translate=html_translate) @api.onchange('partner_id') def onchange_update_description_lang(self): if not self.sale_order_template_id: return else: template = self.sale_order_template_id.with_context(lang=self.partner_id.lang) self.website_description = template.website_description def _compute_line_data_for_template_change(self, line): vals = super(SaleOrder, self)._compute_line_data_for_template_change(line) vals.update(website_description=line.website_description) return vals def _compute_option_data_for_template_change(self, option): vals = super(SaleOrder, self)._compute_option_data_for_template_change(option) vals.update(website_description=option.website_description) return vals @api.onchange('sale_order_template_id') def onchange_sale_order_template_id(self): ret = super(SaleOrder, self).onchange_sale_order_template_id() if self.sale_order_template_id: template = self.sale_order_template_id.with_context(lang=self.partner_id.lang) self.website_description = template.website_description return ret
class MailingList(models.Model): _inherit = 'mailing.list' def _default_toast_content(self): return '<p>Thanks for subscribing!</p>' website_popup_ids = fields.One2many('website.mass_mailing.popup', 'mailing_list_id', string="Website Popups") toast_content = fields.Html(default=_default_toast_content, translate=True)
class ProductPublicCategory(models.Model): _name = "product.public.category" _inherit = ["website.seo.metadata", "website.multi.mixin", 'image.mixin'] _description = "Website Product Category" _parent_store = True _order = "sequence, name" name = fields.Char(required=True, translate=True) parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True) parent_path = fields.Char(index=True) child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories') parents_and_self = fields.Many2many('product.public.category', compute='_compute_parents_and_self') sequence = fields.Integer( help= "Gives the sequence order when displaying a list of product categories.", index=True) website_description = fields.Html('Category Description', sanitize_attributes=False, translate=html_translate) product_tmpl_ids = fields.Many2many( 'product.template', relation='product_public_category_product_template_rel') @api.constrains('parent_id') def check_parent_id(self): if not self._check_recursion(): raise ValueError( _('Error ! You cannot create recursive categories.')) def name_get(self): res = [] for category in self: res.append((category.id, " / ".join(category.parents_and_self.mapped('name')))) return res def unlink(self): self.child_id.parent_id = None return super(ProductPublicCategory, self).unlink() def _compute_parents_and_self(self): for category in self: if category.parent_path: category.parents_and_self = self.env[ 'product.public.category'].browse( [int(p) for p in category.parent_path.split('/')[:-1]]) else: category.parents_and_self = category
class SaleOrderTemplateOption(models.Model): _inherit = "sale.order.template.option" website_description = fields.Html('Website Description', translate=html_translate, sanitize_attributes=False) @api.onchange('product_id') def _onchange_product_id(self): ret = super(SaleOrderTemplateOption, self)._onchange_product_id() if self.product_id: self.website_description = self.product_id.quotation_description return ret
class MassMailingPopup(models.Model): _name = 'website.mass_mailing.popup' _description = "Mailing list popup" def _default_popup_content(self): return self.env['ir.ui.view'].render_template( 'website_mass_mailing.s_newsletter_block') mailing_list_id = fields.Many2one('mailing.list') website_id = fields.Many2one('website') popup_content = fields.Html(string="Website Popup Content", default=_default_popup_content, translate=True, sanitize=False)
class WebsiteResPartner(models.Model): _name = 'res.partner' _inherit = ['res.partner', 'website.seo.metadata'] website_description = fields.Html('Website Partner Full Description', strip_style=True, translate=html_translate) website_short_description = fields.Text( 'Website Partner Short Description', translate=True) def _compute_website_url(self): super(WebsiteResPartner, self)._compute_website_url() for partner in self: partner.website_url = "/partners/%s" % slug(partner)
class SaleOrderTemplate(models.Model): _inherit = "sale.order.template" website_description = fields.Html('Website Description', translate=html_translate, sanitize_attributes=False) def open_template(self): self.ensure_one() return { 'type': 'ir.actions.act_url', 'target': 'self', 'url': '/sale_quotation_builder/template/%d' % self.id }
class DigestTip(models.Model): _name = 'digest.tip' _description = 'Digest Tips' _order = 'sequence' sequence = fields.Integer( 'Sequence', default=1, help='Used to display digest tip in email template base on order') user_ids = fields.Many2many( 'res.users', string='Recipients', help='Users having already received this tip') tip_description = fields.Html('Tip description', translate=html_translate) group_id = fields.Many2one( 'res.groups', string='Authorized Group', default=lambda self: self.env.ref('base.group_user'))
class SaleOrderOption(models.Model): _inherit = "sale.order.option" website_description = fields.Html('Website Description', sanitize_attributes=False, translate=html_translate) @api.onchange('product_id', 'uom_id') def _onchange_product_id(self): ret = super(SaleOrderOption, self)._onchange_product_id() if self.product_id: product = self.product_id.with_context(lang=self.order_id.partner_id.lang) self.website_description = product.quotation_description return ret def _get_values_to_add_to_order(self): values = super(SaleOrderOption, self)._get_values_to_add_to_order() values.update(website_description=self.website_description) return values
class SaleOrderLine(models.Model): _inherit = "sale.order.line" website_description = fields.Html('Website Description', sanitize=False, translate=html_translate) @api.model def create(self, values): values = self._inject_quotation_description(values) return super(SaleOrderLine, self).create(values) def write(self, values): values = self._inject_quotation_description(values) return super(SaleOrderLine, self).write(values) def _inject_quotation_description(self, values): values = dict(values or {}) if not values.get('website_description') and values.get('product_id'): product = self.env['product.product'].browse(values['product_id']) values.update(website_description=product.quotation_description) return values
class ProductTabLine(models.Model): _name = "product.tab.line" _description = 'Product Label Line' _order = "sequence, id" product_id = fields.Many2one('product.template', string='Product Template') tab_name = fields.Char("Tab Name", required=True, translate=True) tab_content = fields.Html("Tab Content", sanitize_attributes=False, translate=True) website_ids = fields.Many2many( 'website', help="You can set the description in particular website.") sequence = fields.Integer('Sequence', default=1, help="Gives the sequence order when displaying.") def checkTab(self, currentWebsite, tabWebsiteArray): if currentWebsite in tabWebsiteArray or len(tabWebsiteArray) == 0: return True else: return False
class BlogPost(models.Model): _name = "blog.post" _description = "Blog Post" _inherit = ['mail.thread', 'website.seo.metadata', 'website.published.multi.mixin'] _order = 'id DESC' _mail_post_access = 'read' def _compute_website_url(self): super(BlogPost, self)._compute_website_url() for blog_post in self: blog_post.website_url = "/blog/%s/post/%s" % (slug(blog_post.blog_id), slug(blog_post)) def _default_content(self): return ''' <p class="o_default_snippet_text">''' + _("Start writing here...") + '''</p> ''' name = fields.Char('Title', required=True, translate=True, default='') subtitle = fields.Char('Sub Title', translate=True) author_id = fields.Many2one('res.partner', 'Author', default=lambda self: self.env.user.partner_id) active = fields.Boolean('Active', default=True) cover_properties = fields.Text( 'Cover Properties', default='{"background-image": "none", "background-color": "oe_black", "opacity": "0.2", "resize_class": "cover_mid"}') blog_id = fields.Many2one('blog.blog', 'Blog', required=True, ondelete='cascade') tag_ids = fields.Many2many('blog.tag', string='Tags') content = fields.Html('Content', default=_default_content, translate=html_translate, sanitize=False) teaser = fields.Text('Teaser', compute='_compute_teaser', inverse='_set_teaser') teaser_manual = fields.Text(string='Teaser Content') website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', '=', 'comment')]) # creation / update stuff create_date = fields.Datetime('Created on', index=True, readonly=True) published_date = fields.Datetime('Published Date') post_date = fields.Datetime('Publishing date', compute='_compute_post_date', inverse='_set_post_date', store=True, help="The blog post will be visible for your visitors as of this date on the website if it is set as published.") create_uid = fields.Many2one('res.users', 'Created by', index=True, readonly=True) write_date = fields.Datetime('Last Updated on', index=True, readonly=True) write_uid = fields.Many2one('res.users', 'Last Contributor', index=True, readonly=True) author_avatar = fields.Binary(related='author_id.image_128', string="Avatar", readonly=False) visits = fields.Integer('No of Views', copy=False) website_id = fields.Many2one(related='blog_id.website_id', readonly=True) @api.depends('content', 'teaser_manual') def _compute_teaser(self): for blog_post in self: if blog_post.teaser_manual: blog_post.teaser = blog_post.teaser_manual else: content = html2plaintext(blog_post.content).replace('\n', ' ') blog_post.teaser = content[:200] + '...' def _set_teaser(self): for blog_post in self: blog_post.teaser_manual = blog_post.teaser @api.depends('create_date', 'published_date') def _compute_post_date(self): for blog_post in self: if blog_post.published_date: blog_post.post_date = blog_post.published_date else: blog_post.post_date = blog_post.create_date def _set_post_date(self): for blog_post in self: blog_post.published_date = blog_post.post_date if not blog_post.published_date: blog_post._write(dict(post_date=blog_post.create_date)) # dont trigger inverse function def _check_for_publication(self, vals): if vals.get('is_published'): for post in self: post.blog_id.message_post_with_view( 'website_blog.blog_post_template_new_post', subject=post.name, values={'post': post}, subtype_id=self.env['ir.model.data'].xmlid_to_res_id('website_blog.mt_blog_blog_published')) return True return False @api.model def create(self, vals): post_id = super(BlogPost, self.with_context(mail_create_nolog=True)).create(vals) post_id._check_for_publication(vals) return post_id def write(self, vals): result = True for post in self: copy_vals = dict(vals) published_in_vals = set(vals.keys()) & {'is_published', 'website_published'} if (published_in_vals and 'published_date' not in vals and (not post.published_date or post.published_date <= fields.Datetime.now())): copy_vals['published_date'] = vals[list(published_in_vals)[0]] and fields.Datetime.now() or False result &= super(BlogPost, self).write(copy_vals) self._check_for_publication(vals) return result def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the post on website directly if user is an employee or if the post is published. """ self.ensure_one() user = access_uid and self.env['res.users'].sudo().browse(access_uid) or self.env.user if user.share and not self.sudo().website_published: return super(BlogPost, self).get_access_action(access_uid) return { 'type': 'ir.actions.act_url', 'url': self.website_url, 'target': 'self', 'target_type': 'public', 'res_id': self.id, } def _notify_get_groups(self): """ Add access button to everyone if the document is published. """ groups = super(BlogPost, self)._notify_get_groups() if self.website_published: for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups def _notify_record_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs): """ Override to avoid keeping all notified recipients of a comment. We avoid tracking needaction on post comments. Only emails should be sufficient. """ if msg_vals.get('message_type', message.message_type) == 'comment': return return super(BlogPost, self)._notify_record_by_inbox(message, recipients_data, msg_vals=msg_vals, **kwargs) def _default_website_meta(self): res = super(BlogPost, self)._default_website_meta() res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.subtitle res['default_opengraph']['og:type'] = 'article' res['default_opengraph']['article:published_time'] = self.post_date res['default_opengraph']['article:modified_time'] = self.write_date res['default_opengraph']['article:tag'] = self.tag_ids.mapped('name') # background-image might contain single quotes eg `url('/my/url')` res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = json.loads(self.cover_properties).get('background-image', 'none')[4:-1].strip("'") res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name res['default_meta_description'] = self.subtitle return res
class Post(models.Model): _name = 'forum.post' _description = 'Forum Post' _inherit = ['mail.thread', 'website.seo.metadata'] _order = "is_correct DESC, vote_count DESC, write_date DESC" name = fields.Char('Title') forum_id = fields.Many2one('forum.forum', string='Forum', required=True) content = fields.Html('Content', strip_style=True) plain_content = fields.Text('Plain Content', compute='_get_plain_content', store=True) tag_ids = fields.Many2many('forum.tag', 'forum_tag_rel', 'forum_id', 'forum_tag_id', string='Tags') state = fields.Selection([('active', 'Active'), ('pending', 'Waiting Validation'), ('close', 'Close'), ('offensive', 'Offensive'), ('flagged', 'Flagged')], string='Status', default='active') views = fields.Integer('Number of Views', default=0) active = fields.Boolean('Active', default=True) website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])]) website_id = fields.Many2one(related='forum_id.website_id', readonly=True) # history create_date = fields.Datetime('Asked on', index=True, readonly=True) create_uid = fields.Many2one('res.users', string='Created by', index=True, readonly=True) write_date = fields.Datetime('Update on', index=True, readonly=True) bump_date = fields.Datetime('Bumped on', readonly=True, help="Technical field allowing to bump a question. Writing on this field will trigger " "a write on write_date and therefore bump the post. Directly writing on write_date " "is currently not supported and this field is a workaround.") write_uid = fields.Many2one('res.users', string='Updated by', index=True, readonly=True) relevancy = fields.Float('Relevance', compute="_compute_relevancy", store=True) # vote vote_ids = fields.One2many('forum.post.vote', 'post_id', string='Votes') user_vote = fields.Integer('My Vote', compute='_get_user_vote') vote_count = fields.Integer('Total Votes', compute='_get_vote_count', store=True) # favorite favourite_ids = fields.Many2many('res.users', string='Favourite') user_favourite = fields.Boolean('Is Favourite', compute='_get_user_favourite') favourite_count = fields.Integer('Favorite Count', compute='_get_favorite_count', store=True) # hierarchy is_correct = fields.Boolean('Correct', help='Correct answer or answer accepted') parent_id = fields.Many2one('forum.post', string='Question', ondelete='cascade') self_reply = fields.Boolean('Reply to own question', compute='_is_self_reply', store=True) child_ids = fields.One2many('forum.post', 'parent_id', string='Answers') child_count = fields.Integer('Number of answers', compute='_get_child_count', store=True) uid_has_answered = fields.Boolean('Has Answered', compute='_get_uid_has_answered') has_validated_answer = fields.Boolean('Is answered', compute='_get_has_validated_answer', store=True) # offensive moderation tools flag_user_id = fields.Many2one('res.users', string='Flagged by') moderator_id = fields.Many2one('res.users', string='Reviewed by', readonly=True) # closing closed_reason_id = fields.Many2one('forum.post.reason', string='Reason') closed_uid = fields.Many2one('res.users', string='Closed by', index=True) closed_date = fields.Datetime('Closed on', readonly=True) # karma calculation and access karma_accept = fields.Integer('Convert comment to answer', compute='_get_post_karma_rights', compute_sudo=False) karma_edit = fields.Integer('Karma to edit', compute='_get_post_karma_rights', compute_sudo=False) karma_close = fields.Integer('Karma to close', compute='_get_post_karma_rights', compute_sudo=False) karma_unlink = fields.Integer('Karma to unlink', compute='_get_post_karma_rights', compute_sudo=False) karma_comment = fields.Integer('Karma to comment', compute='_get_post_karma_rights', compute_sudo=False) karma_comment_convert = fields.Integer('Karma to convert comment to answer', compute='_get_post_karma_rights', compute_sudo=False) karma_flag = fields.Integer('Flag a post as offensive', compute='_get_post_karma_rights', compute_sudo=False) can_ask = fields.Boolean('Can Ask', compute='_get_post_karma_rights', compute_sudo=False) can_answer = fields.Boolean('Can Answer', compute='_get_post_karma_rights', compute_sudo=False) can_accept = fields.Boolean('Can Accept', compute='_get_post_karma_rights', compute_sudo=False) can_edit = fields.Boolean('Can Edit', compute='_get_post_karma_rights', compute_sudo=False) can_close = fields.Boolean('Can Close', compute='_get_post_karma_rights', compute_sudo=False) can_unlink = fields.Boolean('Can Unlink', compute='_get_post_karma_rights', compute_sudo=False) can_upvote = fields.Boolean('Can Upvote', compute='_get_post_karma_rights', compute_sudo=False) can_downvote = fields.Boolean('Can Downvote', compute='_get_post_karma_rights', compute_sudo=False) can_comment = fields.Boolean('Can Comment', compute='_get_post_karma_rights', compute_sudo=False) can_comment_convert = fields.Boolean('Can Convert to Comment', compute='_get_post_karma_rights', compute_sudo=False) can_view = fields.Boolean('Can View', compute='_get_post_karma_rights', search='_search_can_view', compute_sudo=False) can_display_biography = fields.Boolean("Is the author's biography visible from his post", compute='_get_post_karma_rights', compute_sudo=False) can_post = fields.Boolean('Can Automatically be Validated', compute='_get_post_karma_rights', compute_sudo=False) can_flag = fields.Boolean('Can Flag', compute='_get_post_karma_rights', compute_sudo=False) can_moderate = fields.Boolean('Can Moderate', compute='_get_post_karma_rights', compute_sudo=False) def _search_can_view(self, operator, value): if operator not in ('=', '!=', '<>'): raise ValueError('Invalid operator: %s' % (operator,)) if not value: operator = operator == "=" and '!=' or '=' value = True user = self.env.user # Won't impact sitemap, search() in converter is forced as public user if self.env.is_admin(): return [(1, '=', 1)] req = """ SELECT p.id FROM forum_post p LEFT JOIN res_users u ON p.create_uid = u.id LEFT JOIN forum_forum f ON p.forum_id = f.id WHERE (p.create_uid = %s and f.karma_close_own <= %s) or (p.create_uid != %s and f.karma_close_all <= %s) or ( u.karma > 0 and (p.active or p.create_uid = %s) ) """ op = operator == "=" and "inselect" or "not inselect" # don't use param named because orm will add other param (test_active, ...) return [('id', op, (req, (user.id, user.karma, user.id, user.karma, user.id)))] @api.depends('content') def _get_plain_content(self): for post in self: post.plain_content = tools.html2plaintext(post.content)[0:500] if post.content else False @api.depends('vote_count', 'forum_id.relevancy_post_vote', 'forum_id.relevancy_time_decay') def _compute_relevancy(self): for post in self: if post.create_date: days = (datetime.today() - post.create_date).days post.relevancy = math.copysign(1, post.vote_count) * (abs(post.vote_count - 1) ** post.forum_id.relevancy_post_vote / (days + 2) ** post.forum_id.relevancy_time_decay) else: post.relevancy = 0 def _get_user_vote(self): votes = self.env['forum.post.vote'].search_read([('post_id', 'in', self._ids), ('user_id', '=', self._uid)], ['vote', 'post_id']) mapped_vote = dict([(v['post_id'][0], v['vote']) for v in votes]) for vote in self: vote.user_vote = mapped_vote.get(vote.id, 0) @api.depends('vote_ids.vote') def _get_vote_count(self): read_group_res = self.env['forum.post.vote'].read_group([('post_id', 'in', self._ids)], ['post_id', 'vote'], ['post_id', 'vote'], lazy=False) result = dict.fromkeys(self._ids, 0) for data in read_group_res: result[data['post_id'][0]] += data['__count'] * int(data['vote']) for post in self: post.vote_count = result[post.id] def _get_user_favourite(self): for post in self: post.user_favourite = post._uid in post.favourite_ids.ids @api.depends('favourite_ids') def _get_favorite_count(self): for post in self: post.favourite_count = len(post.favourite_ids) @api.depends('create_uid', 'parent_id') def _is_self_reply(self): for post in self: post.self_reply = post.parent_id.create_uid.id == post._uid @api.depends('child_ids.create_uid', 'website_message_ids') def _get_child_count(self): def process(node): total = len(node.website_message_ids) + len(node.child_ids) for child in node.child_ids: total += process(child) return total for post in self: post.child_count = process(post) def _get_uid_has_answered(self): for post in self: post.uid_has_answered = any(answer.create_uid.id == post._uid for answer in post.child_ids) @api.depends('child_ids.is_correct') def _get_has_validated_answer(self): for post in self: post.has_validated_answer = any(answer.is_correct for answer in post.child_ids) @api.depends_context('uid') def _get_post_karma_rights(self): user = self.env.user is_admin = self.env.is_admin() # sudoed recordset instead of individual posts so values can be # prefetched in bulk for post, post_sudo in zip(self, self.sudo()): is_creator = post.create_uid == user post.karma_accept = post.forum_id.karma_answer_accept_own if post.parent_id.create_uid == user else post.forum_id.karma_answer_accept_all post.karma_edit = post.forum_id.karma_edit_own if is_creator else post.forum_id.karma_edit_all post.karma_close = post.forum_id.karma_close_own if is_creator else post.forum_id.karma_close_all post.karma_unlink = post.forum_id.karma_unlink_own if is_creator else post.forum_id.karma_unlink_all post.karma_comment = post.forum_id.karma_comment_own if is_creator else post.forum_id.karma_comment_all post.karma_comment_convert = post.forum_id.karma_comment_convert_own if is_creator else post.forum_id.karma_comment_convert_all post.can_ask = is_admin or user.karma >= post.forum_id.karma_ask post.can_answer = is_admin or user.karma >= post.forum_id.karma_answer post.can_accept = is_admin or user.karma >= post.karma_accept post.can_edit = is_admin or user.karma >= post.karma_edit post.can_close = is_admin or user.karma >= post.karma_close post.can_unlink = is_admin or user.karma >= post.karma_unlink post.can_upvote = is_admin or user.karma >= post.forum_id.karma_upvote post.can_downvote = is_admin or user.karma >= post.forum_id.karma_downvote post.can_comment = is_admin or user.karma >= post.karma_comment post.can_comment_convert = is_admin or user.karma >= post.karma_comment_convert post.can_view = is_admin or user.karma >= post.karma_close or (post_sudo.create_uid.karma > 0 and (post_sudo.active or post_sudo.create_uid == user)) post.can_display_biography = is_admin or post_sudo.create_uid.karma >= post.forum_id.karma_user_bio post.can_post = is_admin or user.karma >= post.forum_id.karma_post post.can_flag = is_admin or user.karma >= post.forum_id.karma_flag post.can_moderate = is_admin or user.karma >= post.forum_id.karma_moderate def _update_content(self, content, forum_id): forum = self.env['forum.forum'].browse(forum_id) if content and self.env.user.karma < forum.karma_dofollow: for match in re.findall(r'<a\s.*href=".*?">', content): match = re.escape(match) # replace parenthesis or special char in regex content = re.sub(match, match[:3] + 'rel="nofollow" ' + match[3:], content) if self.env.user.karma <= forum.karma_editor: filter_regexp = r'(<img.*?>)|(<a[^>]*?href[^>]*?>)|(<[a-z|A-Z]+[^>]*style\s*=\s*[\'"][^\'"]*\s*background[^:]*:[^url;]*url)' content_match = re.search(filter_regexp, content, re.I) if content_match: raise AccessError(_('%d karma required to post an image or link.') % forum.karma_editor) return content def _default_website_meta(self): res = super(Post, self)._default_website_meta() res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.plain_content res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self.create_uid, 'image_1024') res['default_twitter']['twitter:card'] = 'summary' res['default_meta_description'] = self.plain_content return res @api.constrains('parent_id') def _check_parent_id(self): if not self._check_recursion(): raise ValidationError(_('You cannot create recursive forum posts.')) @api.model def create(self, vals): if 'content' in vals and vals.get('forum_id'): vals['content'] = self._update_content(vals['content'], vals['forum_id']) post = super(Post, self.with_context(mail_create_nolog=True)).create(vals) # deleted or closed questions if post.parent_id and (post.parent_id.state == 'close' or post.parent_id.active is False): raise UserError(_('Posting answer on a [Deleted] or [Closed] question is not possible.')) # karma-based access if not post.parent_id and not post.can_ask: raise AccessError(_('%d karma required to create a new question.') % post.forum_id.karma_ask) elif post.parent_id and not post.can_answer: raise AccessError(_('%d karma required to answer a question.') % post.forum_id.karma_answer) if not post.parent_id and not post.can_post: post.sudo().state = 'pending' # add karma for posting new questions if not post.parent_id and post.state == 'active': self.env.user.sudo().add_karma(post.forum_id.karma_gen_question_new) post.post_notification() return post @api.model def get_mail_message_access(self, res_ids, operation, model_name=None): # XDO FIXME: to be correctly fixed with new get_mail_message_access and filter access rule if operation in ('write', 'unlink') and (not model_name or model_name == 'forum.post'): # Make sure only author or moderator can edit/delete messages for post in self.browse(res_ids): if not post.can_edit: raise AccessError(_('%d karma required to edit a post.') % post.karma_edit) return super(Post, self).get_mail_message_access(res_ids, operation, model_name=model_name) def write(self, vals): trusted_keys = ['active', 'is_correct', 'tag_ids'] # fields where security is checked manually if 'content' in vals: vals['content'] = self._update_content(vals['content'], self.forum_id.id) tag_ids = False if 'tag_ids' in vals: tag_ids = set(tag.get('id') for tag in self.resolve_2many_commands('tag_ids', vals['tag_ids'])) for post in self: if 'state' in vals: if vals['state'] in ['active', 'close']: if not post.can_close: raise AccessError(_('%d karma required to close or reopen a post.') % post.karma_close) trusted_keys += ['state', 'closed_uid', 'closed_date', 'closed_reason_id'] elif vals['state'] == 'flagged': if not post.can_flag: raise AccessError(_('%d karma required to flag a post.') % post.forum_id.karma_flag) trusted_keys += ['state', 'flag_user_id'] if 'active' in vals: if not post.can_unlink: raise AccessError(_('%d karma required to delete or reactivate a post.') % post.karma_unlink) if 'is_correct' in vals: if not post.can_accept: raise AccessError(_('%d karma required to accept or refuse an answer.') % post.karma_accept) # update karma except for self-acceptance mult = 1 if vals['is_correct'] else -1 if vals['is_correct'] != post.is_correct and post.create_uid.id != self._uid: post.create_uid.sudo().add_karma(post.forum_id.karma_gen_answer_accepted * mult) self.env.user.sudo().add_karma(post.forum_id.karma_gen_answer_accept * mult) if tag_ids: if set(post.tag_ids.ids) != tag_ids and self.env.user.karma < post.forum_id.karma_edit_retag: raise AccessError(_('%d karma required to retag.') % post.forum_id.karma_edit_retag) if any(key not in trusted_keys for key in vals) and not post.can_edit: raise AccessError(_('%d karma required to edit a post.') % post.karma_edit) res = super(Post, self).write(vals) # if post content modify, notify followers if 'content' in vals or 'name' in vals: for post in self: if post.parent_id: body, subtype = _('Answer Edited'), 'website_forum.mt_answer_edit' obj_id = post.parent_id else: body, subtype = _('Question Edited'), 'website_forum.mt_question_edit' obj_id = post obj_id.message_post(body=body, subtype=subtype) if 'active' in vals: answers = self.env['forum.post'].with_context(active_test=False).search([('parent_id', 'in', self.ids)]) if answers: answers.write({'active': vals['active']}) return res def post_notification(self): for post in self: tag_partners = post.tag_ids.sudo().mapped('message_partner_ids') if post.state == 'active' and post.parent_id: post.parent_id.message_post_with_view( 'website_forum.forum_post_template_new_answer', subject=_('Re: %s') % post.parent_id.name, partner_ids=[(4, p.id) for p in tag_partners], subtype_id=self.env['ir.model.data'].xmlid_to_res_id('website_forum.mt_answer_new')) elif post.state == 'active' and not post.parent_id: post.message_post_with_view( 'website_forum.forum_post_template_new_question', subject=post.name, partner_ids=[(4, p.id) for p in tag_partners], subtype_id=self.env['ir.model.data'].xmlid_to_res_id('website_forum.mt_question_new')) elif post.state == 'pending' and not post.parent_id: # TDE FIXME: in master, you should probably use a subtype; # however here we remove subtype but set partner_ids partners = post.sudo().message_partner_ids | tag_partners partners = partners.filtered(lambda partner: partner.user_ids and any(user.karma >= post.forum_id.karma_moderate for user in partner.user_ids)) post.message_post_with_view( 'website_forum.forum_post_template_validation', subject=post.name, partner_ids=partners.ids, subtype_id=self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')) return True def reopen(self): if any(post.parent_id or post.state != 'close' for post in self): return False reason_offensive = self.env.ref('website_forum.reason_7') reason_spam = self.env.ref('website_forum.reason_8') for post in self: if post.closed_reason_id in (reason_offensive, reason_spam): _logger.info('Upvoting user <%s>, reopening spam/offensive question', post.create_uid) karma = post.forum_id.karma_gen_answer_flagged if post.closed_reason_id == reason_spam: # If first post, increase the karma to add count_post = post.search_count([('parent_id', '=', False), ('forum_id', '=', post.forum_id.id), ('create_uid', '=', post.create_uid.id)]) if count_post == 1: karma *= 10 post.create_uid.sudo().add_karma(karma * -1) self.sudo().write({'state': 'active'}) def close(self, reason_id): if any(post.parent_id for post in self): return False reason_offensive = self.env.ref('website_forum.reason_7').id reason_spam = self.env.ref('website_forum.reason_8').id if reason_id in (reason_offensive, reason_spam): for post in self: _logger.info('Downvoting user <%s> for posting spam/offensive contents', post.create_uid) karma = post.forum_id.karma_gen_answer_flagged if reason_id == reason_spam: # If first post, increase the karma to remove count_post = post.search_count([('parent_id', '=', False), ('forum_id', '=', post.forum_id.id), ('create_uid', '=', post.create_uid.id)]) if count_post == 1: karma *= 10 post.create_uid.sudo().add_karma(karma) self.write({ 'state': 'close', 'closed_uid': self._uid, 'closed_date': datetime.today().strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT), 'closed_reason_id': reason_id, }) return True def validate(self): for post in self: if not post.can_moderate: raise AccessError(_('%d karma required to validate a post.') % post.forum_id.karma_moderate) # if state == pending, no karma previously added for the new question if post.state == 'pending': post.create_uid.sudo().add_karma(post.forum_id.karma_gen_question_new) post.write({ 'state': 'active', 'active': True, 'moderator_id': self.env.user.id, }) post.post_notification() return True def refuse(self): for post in self: if not post.can_moderate: raise AccessError(_('%d karma required to refuse a post.') % post.forum_id.karma_moderate) post.moderator_id = self.env.user return True def flag(self): res = [] for post in self: if not post.can_flag: raise AccessError(_('%d karma required to flag a post.') % post.forum_id.karma_flag) if post.state == 'flagged': res.append({'error': 'post_already_flagged'}) elif post.state == 'active': # TODO: potential performance bottleneck, can be batched post.write({ 'state': 'flagged', 'flag_user_id': self.env.user.id, }) res.append( post.can_moderate and {'success': 'post_flagged_moderator'} or {'success': 'post_flagged_non_moderator'} ) else: res.append({'error': 'post_non_flaggable'}) return res def mark_as_offensive(self, reason_id): for post in self: if not post.can_moderate: raise AccessError(_('%d karma required to mark a post as offensive.') % post.forum_id.karma_moderate) # remove some karma _logger.info('Downvoting user <%s> for posting spam/offensive contents', post.create_uid) post.create_uid.sudo().add_karma(post.forum_id.karma_gen_answer_flagged) # TODO: potential bottleneck, could be done in batch post.write({ 'state': 'offensive', 'moderator_id': self.env.user.id, 'closed_date': fields.Datetime.now(), 'closed_reason_id': reason_id, 'active': False, }) return True def mark_as_offensive_batch(self, key, values): spams = self.browse() if key == 'create_uid': spams = self.filtered(lambda x: x.create_uid.id in values) elif key == 'country_id': spams = self.filtered(lambda x: x.create_uid.country_id.id in values) elif key == 'post_id': spams = self.filtered(lambda x: x.id in values) reason_id = self.env.ref('website_forum.reason_8').id _logger.info('User %s marked as spams (in batch): %s' % (self.env.uid, spams)) return spams.mark_as_offensive(reason_id) def unlink(self): for post in self: if not post.can_unlink: raise AccessError(_('%d karma required to unlink a post.') % post.karma_unlink) # if unlinking an answer with accepted answer: remove provided karma for post in self: if post.is_correct: post.create_uid.sudo().add_karma(post.forum_id.karma_gen_answer_accepted * -1) self.env.user.sudo().add_karma(post.forum_id.karma_gen_answer_accepted * -1) return super(Post, self).unlink() def bump(self): """ Bump a question: trigger a write_date by writing on a dummy bump_date field. One cannot bump a question more than once every 10 days. """ self.ensure_one() if self.forum_id.allow_bump and not self.child_ids and (datetime.today() - datetime.strptime(self.write_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)).days > 9: # write through super to bypass karma; sudo to allow public user to bump any post return self.sudo().write({'bump_date': fields.Datetime.now()}) return False def vote(self, upvote=True): Vote = self.env['forum.post.vote'] vote_ids = Vote.search([('post_id', 'in', self._ids), ('user_id', '=', self._uid)]) new_vote = '1' if upvote else '-1' voted_forum_ids = set() if vote_ids: for vote in vote_ids: if upvote: new_vote = '0' if vote.vote == '-1' else '1' else: new_vote = '0' if vote.vote == '1' else '-1' vote.vote = new_vote voted_forum_ids.add(vote.post_id.id) for post_id in set(self._ids) - voted_forum_ids: for post_id in self._ids: Vote.create({'post_id': post_id, 'vote': new_vote}) return {'vote_count': self.vote_count, 'user_vote': new_vote} def convert_answer_to_comment(self): """ Tools to convert an answer (forum.post) to a comment (mail.message). The original post is unlinked and a new comment is posted on the question using the post create_uid as the comment's author. """ self.ensure_one() if not self.parent_id: return self.env['mail.message'] # karma-based action check: use the post field that computed own/all value if not self.can_comment_convert: raise AccessError(_('%d karma required to convert an answer to a comment.') % self.karma_comment_convert) # post the message question = self.parent_id self_sudo = self.sudo() values = { 'author_id': self_sudo.create_uid.partner_id.id, # use sudo here because of access to res.users model 'email_from': self_sudo.create_uid.email_formatted, # use sudo here because of access to res.users model 'body': tools.html_sanitize(self.content, sanitize_attributes=True, strip_style=True, strip_classes=True), 'message_type': 'comment', 'subtype': 'mail.mt_comment', 'date': self.create_date, } # done with the author user to have create_uid correctly set new_message = question.with_user(self_sudo.create_uid.id).with_context(mail_create_nosubscribe=True).message_post(**values) # unlink the original answer, using SUPERUSER_ID to avoid karma issues self.sudo().unlink() return new_message @api.model def convert_comment_to_answer(self, message_id, default=None): """ Tool to convert a comment (mail.message) into an answer (forum.post). The original comment is unlinked and a new answer from the comment's author is created. Nothing is done if the comment's author already answered the question. """ comment = self.env['mail.message'].sudo().browse(message_id) post = self.browse(comment.res_id) if not comment.author_id or not comment.author_id.user_ids: # only comment posted by users can be converted return False # karma-based action check: must check the message's author to know if own / all is_author = comment.author_id.id == self.env.user.partner_id.id karma_own = post.forum_id.karma_comment_convert_own karma_all = post.forum_id.karma_comment_convert_all karma_convert = is_author and karma_own or karma_all can_convert = self.env.user.karma >= karma_convert if not can_convert: if is_author and karma_own < karma_all: raise AccessError(_('%d karma required to convert your comment to an answer.') % karma_own) else: raise AccessError(_('%d karma required to convert a comment to an answer.') % karma_all) # check the message's author has not already an answer question = post.parent_id if post.parent_id else post post_create_uid = comment.author_id.user_ids[0] if any(answer.create_uid.id == post_create_uid.id for answer in question.child_ids): return False # create the new post post_values = { 'forum_id': question.forum_id.id, 'content': comment.body, 'parent_id': question.id, 'name': _('Re: %s') % (question.name or ''), } # done with the author user to have create_uid correctly set new_post = self.with_user(post_create_uid).create(post_values) # delete comment comment.unlink() return new_post def unlink_comment(self, message_id): result = [] for post in self: user = self.env.user comment = self.env['mail.message'].sudo().browse(message_id) if not comment.model == 'forum.post' or not comment.res_id == post.id: result.append(False) continue # karma-based action check: must check the message's author to know if own or all karma_unlink = ( comment.author_id.id == user.partner_id.id and post.forum_id.karma_comment_unlink_own or post.forum_id.karma_comment_unlink_all ) can_unlink = user.karma >= karma_unlink if not can_unlink: raise AccessError(_('%d karma required to unlink a comment.') % karma_unlink) result.append(comment.unlink()) return result def set_viewed(self): self._cr.execute("""UPDATE forum_post SET views = views+1 WHERE id IN %s""", (self._ids,)) return True def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the post on the website directly """ self.ensure_one() return { 'type': 'ir.actions.act_url', 'url': '/forum/%s/question/%s' % (self.forum_id.id, self.id), 'target': 'self', 'target_type': 'public', 'res_id': self.id, } def _notify_get_groups(self): """ Add access button to everyone if the document is active. """ groups = super(Post, self)._notify_get_groups() if self.state == 'active': for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups @api.returns('mail.message', lambda value: value.id) def message_post(self, *, message_type='notification', **kwargs): if self.ids and message_type == 'comment': # user comments have a restriction on karma # add followers of comments on the parent post if self.parent_id: partner_ids = kwargs.get('partner_ids', []) comment_subtype = self.sudo().env.ref('mail.mt_comment') question_followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', self._name), ('res_id', '=', self.parent_id.id), ('partner_id', '!=', False), ]).filtered(lambda fol: comment_subtype in fol.subtype_ids).mapped('partner_id') partner_ids += question_followers.ids kwargs['partner_ids'] = partner_ids self.ensure_one() if not self.can_comment: raise AccessError(_('%d karma required to comment.') % self.karma_comment) if not kwargs.get('record_name') and self.parent_id: kwargs['record_name'] = self.parent_id.name return super(Post, self).message_post(message_type=message_type, **kwargs) def _notify_record_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs): """ Override to avoid keeping all notified recipients of a comment. We avoid tracking needaction on post comments. Only emails should be sufficient. """ if msg_vals.get('message_type', message.message_type) == 'comment': return return super(Post, self)._notify_record_by_inbox(message, recipients_data, msg_vals=msg_vals, **kwargs)
class Forum(models.Model): _name = 'forum.forum' _description = 'Forum' _inherit = ['mail.thread', 'image.mixin', 'website.seo.metadata', 'website.multi.mixin'] _order = "sequence" @api.model def _get_default_faq(self): with misc.file_open('website_forum/data/forum_default_faq.html', 'r') as f: return f.read() # description and use name = fields.Char('Forum Name', required=True, translate=True) sequence = fields.Integer('Sequence', default=1) mode = fields.Selection([ ('questions', 'Questions'), ('discussions', 'Discussions')], string='Forum Mode', required=True, default='questions', help='Questions mode: only one answer allowed\n Discussions mode: multiple answers allowed') active = fields.Boolean(default=True) faq = fields.Html('Guidelines', default=_get_default_faq, translate=html_translate, sanitize=False) description = fields.Text('Description', translate=True) welcome_message = fields.Html( 'Welcome Message', translate=True, default="""<section> <div class="container py-5"> <div class="row"> <div class="col-lg-12"> <h1 class="text-center">Welcome!</h1> <p class="text-400 text-center"> This community is for professionals and enthusiasts of our products and services. <br/>Share and discuss the best content and new marketing ideas, build your professional profile and become a better marketer together. </p> </div> <div class="col text-center mt-3"> <a href="#" class="js_close_intro btn btn-outline-light mr-2">Hide Intro</a> <a class="btn btn-light forum_register_url" href="/web/login">Register</a> </div> </div> </div> </section>""") default_order = fields.Selection([ ('create_date desc', 'Newest'), ('write_date desc', 'Last Updated'), ('vote_count desc', 'Most Voted'), ('relevancy desc', 'Relevance'), ('child_count desc', 'Answered')], string='Default', required=True, default='write_date desc') relevancy_post_vote = fields.Float('First Relevance Parameter', default=0.8, help="This formula is used in order to sort by relevance. The variable 'votes' represents number of votes for a post, and 'days' is number of days since the post creation") relevancy_time_decay = fields.Float('Second Relevance Parameter', default=1.8) allow_bump = fields.Boolean('Allow Bump', default=True, help='Check this box to display a popup for posts older than 10 days ' 'without any given answer. The popup will offer to share it on social ' 'networks. When shared, a question is bumped at the top of the forum.') allow_share = fields.Boolean('Sharing Options', default=True, help='After posting the user will be proposed to share its question ' 'or answer on social networks, enabling social network propagation ' 'of the forum content.') # posts statistics post_ids = fields.One2many('forum.post', 'forum_id', string='Posts') total_posts = fields.Integer('Post Count', compute='_compute_forum_statistics') total_views = fields.Integer('Views Count', compute='_compute_forum_statistics') total_answers = fields.Integer('Answers Count', compute='_compute_forum_statistics') total_favorites = fields.Integer('Favorites Count', compute='_compute_forum_statistics') count_posts_waiting_validation = fields.Integer(string="Number of posts waiting for validation", compute='_compute_count_posts_waiting_validation') count_flagged_posts = fields.Integer(string='Number of flagged posts', compute='_compute_count_flagged_posts') # karma generation karma_gen_question_new = fields.Integer(string='Asking a question', default=2) karma_gen_question_upvote = fields.Integer(string='Question upvoted', default=5) karma_gen_question_downvote = fields.Integer(string='Question downvoted', default=-2) karma_gen_answer_upvote = fields.Integer(string='Answer upvoted', default=10) karma_gen_answer_downvote = fields.Integer(string='Answer downvoted', default=-2) karma_gen_answer_accept = fields.Integer(string='Accepting an answer', default=2) karma_gen_answer_accepted = fields.Integer(string='Answer accepted', default=15) karma_gen_answer_flagged = fields.Integer(string='Answer flagged', default=-100) # karma-based actions karma_ask = fields.Integer(string='Ask questions', default=3) karma_answer = fields.Integer(string='Answer questions', default=3) karma_edit_own = fields.Integer(string='Edit own posts', default=1) karma_edit_all = fields.Integer(string='Edit all posts', default=300) karma_edit_retag = fields.Integer(string='Change question tags', default=75) karma_close_own = fields.Integer(string='Close own posts', default=100) karma_close_all = fields.Integer(string='Close all posts', default=500) karma_unlink_own = fields.Integer(string='Delete own posts', default=500) karma_unlink_all = fields.Integer(string='Delete all posts', default=1000) karma_tag_create = fields.Integer(string='Create new tags', default=30) karma_upvote = fields.Integer(string='Upvote', default=5) karma_downvote = fields.Integer(string='Downvote', default=50) karma_answer_accept_own = fields.Integer(string='Accept an answer on own questions', default=20) karma_answer_accept_all = fields.Integer(string='Accept an answer to all questions', default=500) karma_comment_own = fields.Integer(string='Comment own posts', default=1) karma_comment_all = fields.Integer(string='Comment all posts', default=1) karma_comment_convert_own = fields.Integer(string='Convert own answers to comments and vice versa', default=50) karma_comment_convert_all = fields.Integer(string='Convert all answers to comments and vice versa', default=500) karma_comment_unlink_own = fields.Integer(string='Unlink own comments', default=50) karma_comment_unlink_all = fields.Integer(string='Unlink all comments', default=500) karma_flag = fields.Integer(string='Flag a post as offensive', default=500) karma_dofollow = fields.Integer(string='Nofollow links', help='If the author has not enough karma, a nofollow attribute is added to links', default=500) karma_editor = fields.Integer(string='Editor Features: image and links', default=30) karma_user_bio = fields.Integer(string='Display detailed user biography', default=750) karma_post = fields.Integer(string='Ask questions without validation', default=100) karma_moderate = fields.Integer(string='Moderate posts', default=1000) @api.depends('post_ids.state', 'post_ids.views', 'post_ids.child_count', 'post_ids.favourite_count') def _compute_forum_statistics(self): result = dict((cid, dict(total_posts=0, total_views=0, total_answers=0, total_favorites=0)) for cid in self.ids) read_group_res = self.env['forum.post'].read_group( [('forum_id', 'in', self.ids), ('state', 'in', ('active', 'close'))], ['forum_id', 'views', 'child_count', 'favourite_count'], groupby=['forum_id'], lazy=False) for res_group in read_group_res: cid = res_group['forum_id'][0] result[cid]['total_posts'] += res_group.get('__count', 0) result[cid]['total_views'] += res_group.get('views', 0) result[cid]['total_answers'] += res_group.get('child_count', 0) result[cid]['total_favorites'] += res_group.get('favourite_count', 0) for record in self: record.update(result[record.id]) def _compute_count_posts_waiting_validation(self): for forum in self: domain = [('forum_id', '=', forum.id), ('state', '=', 'pending')] forum.count_posts_waiting_validation = self.env['forum.post'].search_count(domain) def _compute_count_flagged_posts(self): for forum in self: domain = [('forum_id', '=', forum.id), ('state', '=', 'flagged')] forum.count_flagged_posts = self.env['forum.post'].search_count(domain) @api.model def create(self, values): return super(Forum, self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)).create(values) def write(self, vals): res = super(Forum, self).write(vals) if 'active' in vals: # archiving/unarchiving a forum does it on its posts, too self.env['forum.post'].with_context(active_test=False).search([('forum_id', 'in', self.ids)]).write({'active': vals['active']}) return res @api.model def _tag_to_write_vals(self, tags=''): Tag = self.env['forum.tag'] post_tags = [] existing_keep = [] user = self.env.user for tag in (tag for tag in tags.split(',') if tag): if tag.startswith('_'): # it's a new tag # check that not already created meanwhile or maybe excluded by the limit on the search tag_ids = Tag.search([('name', '=', tag[1:])]) if tag_ids: existing_keep.append(int(tag_ids[0])) else: # check if user have Karma needed to create need tag if user.exists() and user.karma >= self.karma_tag_create and len(tag) and len(tag[1:].strip()): post_tags.append((0, 0, {'name': tag[1:], 'forum_id': self.id})) else: existing_keep.append(int(tag)) post_tags.insert(0, [6, 0, existing_keep]) return post_tags def get_tags_first_char(self): """ get set of first letter of forum tags """ tags = self.env['forum.tag'].search([('forum_id', '=', self.id), ('posts_count', '>', 0)]) return sorted(set([tag.name[0].upper() for tag in tags if len(tag.name)]))
class SurveyInvite(models.TransientModel): _name = 'survey.invite' _description = 'Survey Invitation Wizard' @api.model def _get_default_from(self): if self.env.user.email: return tools.formataddr((self.env.user.name, self.env.user.email)) raise UserError(_("Unable to post message, please configure the sender's email address.")) @api.model def _get_default_author(self): return self.env.user.partner_id # composer content subject = fields.Char('Subject') body = fields.Html('Contents', default='', sanitize_style=True) attachment_ids = fields.Many2many( 'ir.attachment', 'survey_mail_compose_message_ir_attachments_rel', 'wizard_id', 'attachment_id', string='Attachments') template_id = fields.Many2one( 'mail.template', 'Use template', index=True, domain="[('model', '=', 'survey.user_input')]") # origin email_from = fields.Char('From', default=_get_default_from, help="Email address of the sender.") author_id = fields.Many2one( 'res.partner', 'Author', index=True, ondelete='set null', default=_get_default_author, help="Author of the message.") # recipients partner_ids = fields.Many2many( 'res.partner', 'survey_invite_partner_ids', 'invite_id', 'partner_id', string='Recipients') existing_partner_ids = fields.Many2many( 'res.partner', compute='_compute_existing_partner_ids', readonly=True, store=False) emails = fields.Text(string='Additional emails', help="This list of emails of recipients will not be converted in contacts.\ Emails must be separated by commas, semicolons or newline.") existing_emails = fields.Text( 'Existing emails', compute='_compute_existing_emails', readonly=True, store=False) existing_mode = fields.Selection([ ('new', 'New invite'), ('resend', 'Resend invite')], string='Handle existing', default='resend', required=True) existing_text = fields.Text('Resend Comment', compute='_compute_existing_text', readonly=True, store=False) # technical info mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') # survey survey_id = fields.Many2one('survey.survey', string='Survey', required=True) survey_url = fields.Char(related="survey_id.public_url", readonly=True) survey_access_mode = fields.Selection(related="survey_id.access_mode", readonly=True) survey_users_login_required = fields.Boolean(related="survey_id.users_login_required", readonly=True) deadline = fields.Datetime(string="Answer deadline") @api.depends('partner_ids', 'survey_id') def _compute_existing_partner_ids(self): existing_answers = self.survey_id.user_input_ids self.existing_partner_ids = existing_answers.mapped('partner_id') & self.partner_ids @api.depends('emails', 'survey_id') def _compute_existing_emails(self): emails = list(set(emails_split.split(self.emails or ""))) existing_emails = self.survey_id.mapped('user_input_ids.email') self.existing_emails = '\n'.join(email for email in emails if email in existing_emails) @api.depends('existing_partner_ids', 'existing_emails') def _compute_existing_text(self): existing_text = False if self.existing_partner_ids: existing_text = '%s: %s.' % ( _('The following customers have already received an invite'), ', '.join(self.mapped('existing_partner_ids.name')) ) if self.existing_emails: existing_text = '%s\n' % existing_text if existing_text else '' existing_text += '%s: %s.' % ( _('The following emails have already received an invite'), self.existing_emails ) self.existing_text = existing_text @api.onchange('emails') def _onchange_emails(self): if self.emails and (self.survey_users_login_required and not self.survey_id.users_can_signup): raise UserError(_('This survey does not allow external people to participate. You should create user accounts or update survey access mode accordingly.')) if not self.emails: return valid, error = [], [] emails = list(set(emails_split.split(self.emails or ""))) for email in emails: email_check = tools.email_split_and_format(email) if not email_check: error.append(email) else: valid.extend(email_check) if error: raise UserError(_("Some emails you just entered are incorrect: %s") % (', '.join(error))) self.emails = '\n'.join(valid) @api.onchange('survey_users_login_required') def _onchange_survey_users_login_required(self): if self.survey_users_login_required and not self.survey_id.users_can_signup: return {'domain': { 'partner_ids': [('user_ids', '!=', False)] }} return {'domain': { 'partner_ids': [] }} @api.onchange('partner_ids') def _onchange_partner_ids(self): if self.survey_users_login_required and self.partner_ids: if not self.survey_id.users_can_signup: invalid_partners = self.env['res.partner'].search([ ('user_ids', '=', False), ('id', 'in', self.partner_ids.ids) ]) if invalid_partners: raise UserError( _('The following recipients have no user account: %s. You should create user accounts for them or allow external signup in configuration.' % (','.join(invalid_partners.mapped('name'))))) @api.onchange('template_id') def _onchange_template_id(self): """ UPDATE ME """ if self.template_id: self.subject = self.template_id.subject self.body = self.template_id.body_html @api.model def create(self, values): if values.get('template_id') and not (values.get('body') or values.get('subject')): template = self.env['mail.template'].browse(values['template_id']) if not values.get('subject'): values['subject'] = template.subject if not values.get('body'): values['body'] = template.body_html return super(SurveyInvite, self).create(values) #------------------------------------------------------ # Wizard validation and send #------------------------------------------------------ def _prepare_answers(self, partners, emails): answers = self.env['survey.user_input'] existing_answers = self.env['survey.user_input'].search([ '&', ('survey_id', '=', self.survey_id.id), '|', ('partner_id', 'in', partners.ids), ('email', 'in', emails) ]) partners_done = self.env['res.partner'] emails_done = [] if existing_answers: if self.existing_mode == 'resend': partners_done = existing_answers.mapped('partner_id') emails_done = existing_answers.mapped('email') # only add the last answer for each user of each type (partner_id & email) # to have only one mail sent per user for partner_done in partners_done: answers |= next(existing_answer for existing_answer in existing_answers.sorted(lambda answer: answer.create_date, reverse=True) if existing_answer.partner_id == partner_done) for email_done in emails_done: answers |= next(existing_answer for existing_answer in existing_answers.sorted(lambda answer: answer.create_date, reverse=True) if existing_answer.email == email_done) for new_partner in partners - partners_done: answers |= self.survey_id._create_answer(partner=new_partner, check_attempts=False, **self._get_answers_values()) for new_email in [email for email in emails if email not in emails_done]: answers |= self.survey_id._create_answer(email=new_email, check_attempts=False, **self._get_answers_values()) return answers def _get_answers_values(self): return { 'input_type': 'link', 'deadline': self.deadline, } def _send_mail(self, answer): """ Create mail specific for recipient containing notably its access token """ subject = self.env['mail.template']._render_template(self.subject, 'survey.user_input', answer.id, post_process=True) body = self.env['mail.template']._render_template(self.body, 'survey.user_input', answer.id, post_process=True) # post the message mail_values = { 'email_from': self.email_from, 'author_id': self.author_id.id, 'model': None, 'res_id': None, 'subject': subject, 'body_html': body, 'attachment_ids': [(4, att.id) for att in self.attachment_ids], 'auto_delete': True, } if answer.partner_id: mail_values['recipient_ids'] = [(4, answer.partner_id.id)] else: mail_values['email_to'] = answer.email # optional support of notif_layout in context notif_layout = self.env.context.get('notif_layout', self.env.context.get('custom_layout')) if notif_layout: try: template = self.env.ref(notif_layout, raise_if_not_found=True) except ValueError: _logger.warning('QWeb template %s not found when sending survey mails. Sending without layouting.' % (notif_layout)) else: template_ctx = { 'message': self.env['mail.message'].sudo().new(dict(body=mail_values['body_html'], record_name=self.survey_id.title)), 'model_description': self.env['ir.model']._get('survey.survey').display_name, 'company': self.env.company, } body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True) mail_values['body_html'] = self.env['mail.thread']._replace_local_links(body) return self.env['mail.mail'].sudo().create(mail_values) def action_invite(self): """ Process the wizard content and proceed with sending the related email(s), rendering any template patterns on the fly if needed """ self.ensure_one() Partner = self.env['res.partner'] # compute partners and emails, try to find partners for given emails valid_partners = self.partner_ids valid_emails = [] for email in emails_split.split(self.emails or ''): partner = False email_normalized = tools.email_normalize(email) if email_normalized: limit = None if self.survey_users_login_required else 1 partner = Partner.search([('email_normalized', '=', email_normalized)], limit=limit) if partner: valid_partners |= partner else: email_formatted = tools.email_split_and_format(email) if email_formatted: valid_emails.extend(email_formatted) if not valid_partners and not valid_emails: raise UserError(_("Please enter at least one valid recipient.")) answers = self._prepare_answers(valid_partners, valid_emails) for answer in answers: self._send_mail(answer) return {'type': 'ir.actions.act_window_close'}
class SurveyQuestion(models.Model): """ Questions that will be asked in a survey. Each question can have one of more suggested answers (eg. in case of dropdown choices, multi-answer checkboxes, radio buttons...). Technical note: survey.question is also the model used for the survey's pages (with the "is_page" field set to True). A page corresponds to a "section" in the interface, and the fact that it separates the survey in actual pages in the interface depends on the "questions_layout" parameter on the survey.survey model. Pages are also used when randomizing questions. The randomization can happen within a "page". Using the same model for questions and pages allows to put all the pages and questions together in a o2m field (see survey.survey.question_and_page_ids) on the view side and easily reorganize your survey by dragging the items around. It also removes on level of encoding by directly having 'Add a page' and 'Add a question' links on the tree view of questions, enabling a faster encoding. However, this has the downside of making the code reading a little bit more complicated. Efforts were made at the model level to create computed fields so that the use of these models still seems somewhat logical. That means: - A survey still has "page_ids" (question_and_page_ids filtered on is_page = True) - These "page_ids" still have question_ids (questions located between this page and the next) - These "question_ids" still have a "page_id" That makes the use and display of these information at view and controller levels easier to understand. """ _name = 'survey.question' _description = 'Survey Question' _rec_name = 'question' _order = 'sequence,id' @api.model def default_get(self, fields): defaults = super(SurveyQuestion, self).default_get(fields) if (not fields or 'question_type' in fields): defaults['question_type'] = False if defaults.get( 'is_page') == True else 'free_text' return defaults # Question metadata survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade') page_id = fields.Many2one('survey.question', string='Page', compute="_compute_page_id", store=True) question_ids = fields.One2many('survey.question', string='Questions', compute="_compute_question_ids") scoring_type = fields.Selection(related='survey_id.scoring_type', string='Scoring Type', readonly=True) sequence = fields.Integer('Sequence', default=10) # Question is_page = fields.Boolean('Is a page?') questions_selection = fields.Selection( related='survey_id.questions_selection', readonly=True, help= "If randomized is selected, add the number of random questions next to the section." ) random_questions_count = fields.Integer( 'Random questions count', default=1, help= "Used on randomized sections to take X random questions from all the questions of that section." ) title = fields.Char('Title', required=True, translate=True) question = fields.Char('Question', related="title") description = fields.Html( 'Description', help= "Use this field to add additional explanations about your question", translate=True) question_type = fields.Selection( [('free_text', 'Multiple Lines Text Box'), ('textbox', 'Single Line Text Box'), ('numerical_box', 'Numerical Value'), ('date', 'Date'), ('datetime', 'Datetime'), ('simple_choice', 'Multiple choice: only one answer'), ('multiple_choice', 'Multiple choice: multiple answers allowed'), ('matrix', 'Matrix')], string='Question Type') # simple choice / multiple choice / matrix labels_ids = fields.One2many( 'survey.label', 'question_id', string='Types of answers', copy=True, help= 'Labels used for proposed choices: simple choice, multiple choice and columns of matrix' ) # matrix matrix_subtype = fields.Selection( [('simple', 'One choice per row'), ('multiple', 'Multiple choices per row')], string='Matrix Type', default='simple') labels_ids_2 = fields.One2many( 'survey.label', 'question_id_2', string='Rows of the Matrix', copy=True, help='Labels used for proposed choices: rows of matrix') # Display options column_nb = fields.Selection( [('12', '1'), ('6', '2'), ('4', '3'), ('3', '4'), ('2', '6')], string='Number of columns', default='12', help= 'These options refer to col-xx-[12|6|4|3|2] classes in Bootstrap for dropdown-based simple and multiple choice questions.' ) display_mode = fields.Selection( [('columns', 'Radio Buttons'), ('dropdown', 'Selection Box')], string='Display Mode', default='columns', help='Display mode of simple choice questions.') # Comments comments_allowed = fields.Boolean('Show Comments Field') comments_message = fields.Char( 'Comment Message', translate=True, default=lambda self: _("If other, please specify:")) comment_count_as_answer = fields.Boolean( 'Comment Field is an Answer Choice') # Validation validation_required = fields.Boolean('Validate entry') validation_email = fields.Boolean('Input must be an email') validation_length_min = fields.Integer('Minimum Text Length') validation_length_max = fields.Integer('Maximum Text Length') validation_min_float_value = fields.Float('Minimum value') validation_max_float_value = fields.Float('Maximum value') validation_min_date = fields.Date('Minimum Date') validation_max_date = fields.Date('Maximum Date') validation_min_datetime = fields.Datetime('Minimum Datetime') validation_max_datetime = fields.Datetime('Maximum Datetime') validation_error_msg = fields.Char( 'Validation Error message', translate=True, default=lambda self: _("The answer you entered is not valid.")) # Constraints on number of answers (matrices) constr_mandatory = fields.Boolean('Mandatory Answer') constr_error_msg = fields.Char( 'Error message', translate=True, default=lambda self: _("This question requires an answer.")) # Answer user_input_line_ids = fields.One2many('survey.user_input_line', 'question_id', string='Answers', domain=[('skipped', '=', False)], groups='survey.group_survey_user') _sql_constraints = [ ('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'), ('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'), ('validation_length', 'CHECK (validation_length_min <= validation_length_max)', 'Max length cannot be smaller than min length!'), ('validation_float', 'CHECK (validation_min_float_value <= validation_max_float_value)', 'Max value cannot be smaller than min value!'), ('validation_date', 'CHECK (validation_min_date <= validation_max_date)', 'Max date cannot be smaller than min date!'), ('validation_datetime', 'CHECK (validation_min_datetime <= validation_max_datetime)', 'Max datetime cannot be smaller than min datetime!') ] @api.onchange('validation_email') def _onchange_validation_email(self): if self.validation_email: self.validation_required = False @api.onchange('is_page') def _onchange_is_page(self): if self.is_page: self.question_type = False # Validation methods def validate_question(self, post, answer_tag): """ Validate question, depending on question type and parameters """ self.ensure_one() try: checker = getattr(self, 'validate_' + self.question_type) except AttributeError: _logger.warning(self.question_type + ": This type of question has no validation method") return {} else: return checker(post, answer_tag) def validate_free_text(self, post, answer_tag): self.ensure_one() errors = {} answer = post[answer_tag].strip() # Empty answer to mandatory question if self.constr_mandatory and not answer: errors.update({answer_tag: self.constr_error_msg}) return errors def validate_textbox(self, post, answer_tag): self.ensure_one() errors = {} answer = post[answer_tag].strip() # Empty answer to mandatory question if self.constr_mandatory and not answer: errors.update({answer_tag: self.constr_error_msg}) # Email format validation # Note: this validation is very basic: # all the strings of the form # <something>@<anything>.<extension> # will be accepted if answer and self.validation_email: if not email_validator.match(answer): errors.update( {answer_tag: _('This answer must be an email address')}) # Answer validation (if properly defined) # Length of the answer must be in a range if answer and self.validation_required: if not (self.validation_length_min <= len(answer) <= self.validation_length_max): errors.update({answer_tag: self.validation_error_msg}) return errors def validate_numerical_box(self, post, answer_tag): self.ensure_one() errors = {} answer = post[answer_tag].strip() # Empty answer to mandatory question if self.constr_mandatory and not answer: errors.update({answer_tag: self.constr_error_msg}) # Checks if user input is a number if answer: try: floatanswer = float(answer) except ValueError: errors.update({answer_tag: _('This is not a number')}) # Answer validation (if properly defined) if answer and self.validation_required: # Answer is not in the right range with tools.ignore(Exception): floatanswer = float( answer) # check that it is a float has been done hereunder if not (self.validation_min_float_value <= floatanswer <= self.validation_max_float_value): errors.update({answer_tag: self.validation_error_msg}) return errors def date_validation(self, date_type, post, answer_tag, min_value, max_value): self.ensure_one() errors = {} if date_type not in ('date', 'datetime'): raise ValueError("Unexpected date type value") answer = post[answer_tag].strip() # Empty answer to mandatory question if self.constr_mandatory and not answer: errors.update({answer_tag: self.constr_error_msg}) # Checks if user input is a date if answer: try: if date_type == 'datetime': dateanswer = fields.Datetime.from_string(answer) else: dateanswer = fields.Date.from_string(answer) except ValueError: errors.update({answer_tag: _('This is not a date')}) return errors # Answer validation (if properly defined) if answer and self.validation_required: # Answer is not in the right range try: if date_type == 'datetime': date_from_string = fields.Datetime.from_string else: date_from_string = fields.Date.from_string dateanswer = date_from_string(answer) min_date = date_from_string(min_value) max_date = date_from_string(max_value) if min_date and max_date and not (min_date <= dateanswer <= max_date): # If Minimum and Maximum Date are entered errors.update({answer_tag: self.validation_error_msg}) elif min_date and not min_date <= dateanswer: # If only Minimum Date is entered and not Define Maximum Date errors.update({answer_tag: self.validation_error_msg}) elif max_date and not dateanswer <= max_date: # If only Maximum Date is entered and not Define Minimum Date errors.update({answer_tag: self.validation_error_msg}) except ValueError: # check that it is a date has been done hereunder pass return errors def validate_date(self, post, answer_tag): return self.date_validation('date', post, answer_tag, self.validation_min_date, self.validation_max_date) def validate_datetime(self, post, answer_tag): return self.date_validation('datetime', post, answer_tag, self.validation_min_datetime, self.validation_max_datetime) def validate_simple_choice(self, post, answer_tag): self.ensure_one() errors = {} if self.comments_allowed: comment_tag = "%s_%s" % (answer_tag, 'comment') # Empty answer to mandatory self if self.constr_mandatory and answer_tag not in post: errors.update({answer_tag: self.constr_error_msg}) if self.constr_mandatory and answer_tag in post and not post[ answer_tag].strip(): errors.update({answer_tag: self.constr_error_msg}) # Answer is a comment and is empty if self.constr_mandatory and answer_tag in post and post[ answer_tag] == "-1" and self.comment_count_as_answer and comment_tag in post and not post[ comment_tag].strip(): errors.update({answer_tag: self.constr_error_msg}) return errors def validate_multiple_choice(self, post, answer_tag): self.ensure_one() errors = {} if self.constr_mandatory: answer_candidates = dict_keys_startswith(post, answer_tag) comment_flag = answer_candidates.pop(("%s_%s" % (answer_tag, -1)), None) if self.comments_allowed: comment_answer = answer_candidates.pop( ("%s_%s" % (answer_tag, 'comment')), '').strip() # Preventing answers with blank value if all(not answer.strip() for answer in answer_candidates.values()) and answer_candidates: errors.update({answer_tag: self.constr_error_msg}) # There is no answer neither comments (if comments count as answer) if not answer_candidates and self.comment_count_as_answer and ( not comment_flag or not comment_answer): errors.update({answer_tag: self.constr_error_msg}) # There is no answer at all if not answer_candidates and not self.comment_count_as_answer: errors.update({answer_tag: self.constr_error_msg}) return errors def validate_matrix(self, post, answer_tag): self.ensure_one() errors = {} if self.constr_mandatory: lines_number = len(self.labels_ids_2) answer_candidates = dict_keys_startswith(post, answer_tag) answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() # Number of lines that have been answered if self.matrix_subtype == 'simple': answer_number = len(answer_candidates) elif self.matrix_subtype == 'multiple': answer_number = len( {sk.rsplit('_', 1)[0] for sk in answer_candidates}) else: raise RuntimeError("Invalid matrix subtype") # Validate that each line has been answered if answer_number != lines_number: errors.update({answer_tag: self.constr_error_msg}) return errors @api.depends('survey_id.question_and_page_ids.is_page', 'survey_id.question_and_page_ids.sequence') def _compute_question_ids(self): """Will take all questions of the survey for which the index is higher than the index of this page and lower than the index of the next page.""" for question in self: if question.is_page: next_page_index = False for page in question.survey_id.page_ids: if page._index() > question._index(): next_page_index = page._index() break question.question_ids = question.survey_id.question_ids.filtered( lambda q: q._index() > question._index() and (not next_page_index or q._index() < next_page_index)) else: question.question_ids = self.env['survey.question'] @api.depends('survey_id.question_and_page_ids.is_page', 'survey_id.question_and_page_ids.sequence') def _compute_page_id(self): """Will find the page to which this question belongs to by looking inside the corresponding survey""" for question in self: if question.is_page: question.page_id = None else: page = None for q in question.survey_id.question_and_page_ids.sorted(): if q == question: break if q.is_page: page = q question.page_id = page def _index(self): """We would normally just use the 'sequence' field of questions BUT, if the pages and questions are created without ever moving records around, the sequence field can be set to 0 for all the questions. However, the order of the recordset is always correct so we can rely on the index method.""" self.ensure_one() return list(self.survey_id.question_and_page_ids).index(self) def get_correct_answer_ids(self): self.ensure_one() return self.labels_ids.filtered(lambda label: label.is_correct)
class Track(models.Model): _name = "event.track" _description = 'Event Track' _order = 'priority, date' _inherit = [ 'mail.thread', 'mail.activity.mixin', 'website.seo.metadata', 'website.published.mixin' ] @api.model def _get_default_stage_id(self): return self.env['event.track.stage'].search([], limit=1).id name = fields.Char('Title', required=True, translate=True) active = fields.Boolean(default=True) user_id = fields.Many2one('res.users', 'Responsible', tracking=True, default=lambda self: self.env.user) company_id = fields.Many2one('res.company', related='event_id.company_id') partner_id = fields.Many2one('res.partner', 'Speaker') partner_name = fields.Char('Name') partner_email = fields.Char('Email') partner_phone = fields.Char('Phone') partner_biography = fields.Html('Biography') tag_ids = fields.Many2many('event.track.tag', string='Tags') stage_id = fields.Many2one('event.track.stage', string='Stage', ondelete='restrict', index=True, copy=False, default=_get_default_stage_id, group_expand='_read_group_stage_ids', required=True, tracking=True) kanban_state = fields.Selection( [('normal', 'Grey'), ('done', 'Green'), ('blocked', 'Red')], string='Kanban State', copy=False, default='normal', required=True, tracking=True, help= "A track's kanban state indicates special situations affecting it:\n" " * Grey is the default situation\n" " * Red indicates something is preventing the progress of this track\n" " * Green indicates the track is ready to be pulled to the next stage") description = fields.Html(translate=html_translate, sanitize_attributes=False) date = fields.Datetime('Track Date') date_end = fields.Datetime('Track End Date', compute='_compute_end_date', store=True) duration = fields.Float('Duration', default=1.5, help="Track duration in hours.") location_id = fields.Many2one('event.track.location', 'Room') event_id = fields.Many2one('event.event', 'Event', required=True) color = fields.Integer('Color Index') priority = fields.Selection([('0', 'Low'), ('1', 'Medium'), ('2', 'High'), ('3', 'Highest')], 'Priority', required=True, default='1') image = fields.Image("Image", related='partner_id.image_128', store=True, readonly=False) @api.depends('name') def _compute_website_url(self): super(Track, self)._compute_website_url() for track in self: if track.id: track.website_url = '/event/%s/track/%s' % (slug( track.event_id), slug(track)) @api.onchange('partner_id') def _onchange_partner_id(self): if self.partner_id: self.partner_name = self.partner_id.name self.partner_email = self.partner_id.email self.partner_phone = self.partner_id.phone self.partner_biography = self.partner_id.website_description @api.depends('date', 'duration') def _compute_end_date(self): for track in self: if track.date: delta = timedelta(minutes=60 * track.duration) track.date_end = track.date + delta else: track.date_end = False @api.model def create(self, vals): track = super(Track, self).create(vals) track.event_id.message_post_with_view( 'website_event_track.event_track_template_new', values={'track': track}, subject=track.name, subtype_id=self.env.ref('website_event_track.mt_event_track').id, ) return track def write(self, vals): if 'stage_id' in vals and 'kanban_state' not in vals: vals['kanban_state'] = 'normal' res = super(Track, self).write(vals) if vals.get('partner_id'): self.message_subscribe([vals['partner_id']]) return res @api.model def _read_group_stage_ids(self, stages, domain, order): """ Always display all stages """ return stages.search([], order=order) def _track_template(self, changes): res = super(Track, self)._track_template(changes) track = self[0] if 'stage_id' in changes and track.stage_id.mail_template_id: res['stage_id'] = (track.stage_id.mail_template_id, { 'composition_mode': 'comment', 'auto_delete_message': True, 'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'), 'email_layout_xmlid': 'mail.mail_notification_light' }) return res def _track_subtype(self, init_values): self.ensure_one() if 'kanban_state' in init_values and self.kanban_state == 'blocked': return self.env.ref('website_event_track.mt_track_blocked') elif 'kanban_state' in init_values and self.kanban_state == 'done': return self.env.ref('website_event_track.mt_track_ready') return super(Track, self)._track_subtype(init_values) def _message_get_suggested_recipients(self): recipients = super(Track, self)._message_get_suggested_recipients() for track in self: if track.partner_email and track.partner_email != track.partner_id.email: track._message_add_suggested_recipient( recipients, email=track.partner_email, reason=_('Speaker Email')) return recipients def _message_post_after_hook(self, message, msg_vals): if self.partner_email and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. new_partner = message.partner_ids.filtered( lambda partner: partner.email == self.partner_email) if new_partner: self.search([ ('partner_id', '=', False), ('partner_email', '=', new_partner.email), ('stage_id.is_cancel', '=', False), ]).write({'partner_id': new_partner.id}) return super(Track, self)._message_post_after_hook(message, msg_vals) def open_track_speakers_list(self): return { 'name': _('Speakers'), 'domain': [('id', 'in', self.mapped('partner_id').ids)], 'view_mode': 'kanban,form', 'res_model': 'res.partner', 'view_id': False, 'type': 'ir.actions.act_window', }
class CrmLeadForwardToPartner(models.TransientModel): """ Forward info history to partners. """ _name = 'crm.lead.forward.to.partner' _description = 'Lead forward to partner' @api.model def _convert_to_assignation_line(self, lead, partner): lead_location = [] partner_location = [] if lead.country_id: lead_location.append(lead.country_id.name) if lead.city: lead_location.append(lead.city) if partner: if partner.country_id: partner_location.append(partner.country_id.name) if partner.city: partner_location.append(partner.city) return { 'lead_id': lead.id, 'lead_location': ", ".join(lead_location), 'partner_assigned_id': partner and partner.id or False, 'partner_location': ", ".join(partner_location), 'lead_link': self.get_lead_portal_url(lead.id, lead.type), } @api.model def default_get(self, fields): template = self.env.ref( 'website_crm_partner_assign.email_template_lead_forward_mail', False) res = super(CrmLeadForwardToPartner, self).default_get(fields) active_ids = self.env.context.get('active_ids') default_composition_mode = self.env.context.get( 'default_composition_mode') res['assignation_lines'] = [] if template: res['body'] = template.body_html if active_ids: leads = self.env['crm.lead'].browse(active_ids) if default_composition_mode == 'mass_mail': partner_assigned_dict = leads.search_geo_partner() else: partner_assigned_dict = { lead.id: lead.partner_assigned_id.id for lead in leads } res['partner_id'] = leads[0].partner_assigned_id.id for lead in leads: partner_id = partner_assigned_dict.get(lead.id) or False partner = self.env['res.partner'].browse(partner_id) res['assignation_lines'].append( (0, 0, self._convert_to_assignation_line(lead, partner))) return res def action_forward(self): self.ensure_one() template = self.env.ref( 'website_crm_partner_assign.email_template_lead_forward_mail', False) if not template: raise UserError( _('The Forward Email Template is not in the database')) portal_group = self.env.ref('base.group_portal') local_context = self.env.context.copy() if not (self.forward_type == 'single'): no_email = set() for lead in self.assignation_lines: if lead.partner_assigned_id and not lead.partner_assigned_id.email: no_email.add(lead.partner_assigned_id.name) if no_email: raise UserError( _('Set an email address for the partner(s): %s') % ", ".join(no_email)) if self.forward_type == 'single' and not self.partner_id.email: raise UserError( _('Set an email address for the partner %s') % self.partner_id.name) partners_leads = {} for lead in self.assignation_lines: partner = self.forward_type == 'single' and self.partner_id or lead.partner_assigned_id lead_details = { 'lead_link': lead.lead_link, 'lead_id': lead.lead_id, } if partner: partner_leads = partners_leads.get(partner.id) if partner_leads: partner_leads['leads'].append(lead_details) else: partners_leads[partner.id] = { 'partner': partner, 'leads': [lead_details] } for partner_id, partner_leads in partners_leads.items(): in_portal = False if portal_group: for contact in ( partner.child_ids or partner).filtered(lambda contact: contact.user_ids): in_portal = portal_group.id in [ g.id for g in contact.user_ids[0].groups_id ] local_context['partner_id'] = partner_leads['partner'] local_context['partner_leads'] = partner_leads['leads'] local_context['partner_in_portal'] = in_portal template.with_context(local_context).send_mail(self.id) leads = self.env['crm.lead'] for lead_data in partner_leads['leads']: leads |= lead_data['lead_id'] values = { 'partner_assigned_id': partner_id, 'user_id': partner_leads['partner'].user_id.id } leads.with_context(mail_auto_subscribe_no_notify=1).write(values) self.env['crm.lead'].message_subscribe([partner_id]) return True def get_lead_portal_url(self, lead_id, type): action = type == 'opportunity' and 'action_portal_opportunities' or 'action_portal_leads' action_ref = self.env.ref('website_crm_partner_assign.%s' % (action, ), False) portal_link = "%s/?db=%s#id=%s&action=%s&view_type=form" % ( self.env['ir.config_parameter'].sudo().get_param('web.base.url'), self.env.cr.dbname, lead_id, action_ref and action_ref.id or False) return portal_link def get_portal_url(self): portal_link = "%s/?db=%s" % (self.env['ir.config_parameter'].sudo( ).get_param('web.base.url'), self.env.cr.dbname) return portal_link forward_type = fields.Selection([ ('single', 'a single partner: manual selection of partner'), ('assigned', "several partners: automatic assignation, using GPS coordinates and partner's grades" ) ], 'Forward selected leads to', default=lambda self: self.env.context.get( 'forward_type') or 'single') partner_id = fields.Many2one('res.partner', 'Forward Leads To') assignation_lines = fields.One2many('crm.lead.assignation', 'forward_id', 'Partner Assignation') body = fields.Html('Contents', help='Automatically sanitized HTML contents')
class MassMailing(models.Model): """ MassMailing models a wave of emails for a mass mailign campaign. A mass mailing is an occurence of sending emails. """ _name = 'mailing.mailing' _description = 'Mass Mailing' _inherit = ['mail.thread', 'mail.activity.mixin'] # number of periods for tracking mail_mail statistics _period_number = 6 _order = 'sent_date DESC' _inherits = {'utm.source': 'source_id'} _rec_name = "subject" @api.model def _get_default_mail_server_id(self): server_id = self.env['ir.config_parameter'].sudo().get_param( 'mass_mailing.mail_server_id') try: server_id = literal_eval(server_id) if server_id else False return self.env['ir.mail_server'].search([('id', '=', server_id) ]).id except ValueError: return False @api.model def default_get(self, fields): res = super(MassMailing, self).default_get(fields) if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get( 'mailing_model_real'): if res['mailing_model_real'] in ['res.partner', 'mailing.contact']: res['reply_to_mode'] = 'email' else: res['reply_to_mode'] = 'thread' return res active = fields.Boolean(default=True, tracking=True) subject = fields.Char('Subject', help='Subject of emails to send', required=True, translate=True) email_from = fields.Char( string='Send From', required=True, default=lambda self: self.env['mail.message']._get_default_from()) sent_date = fields.Datetime(string='Sent Date', copy=False) schedule_date = fields.Datetime(string='Scheduled for', tracking=True) # don't translate 'body_arch', the translations are only on 'body_html' body_arch = fields.Html(string='Body', translate=False) body_html = fields.Html(string='Body converted to be send by mail', sanitize_attributes=False) attachment_ids = fields.Many2many('ir.attachment', 'mass_mailing_ir_attachments_rel', 'mass_mailing_id', 'attachment_id', string='Attachments') keep_archives = fields.Boolean(string='Keep Archives') campaign_id = fields.Many2one('utm.campaign', string='UTM Campaign') source_id = fields.Many2one( 'utm.source', string='Source', required=True, ondelete='cascade', help= "This is the link source, e.g. Search Engine, another domain, or name of email list" ) medium_id = fields.Many2one('utm.medium', string='Medium', help="Delivery method: Email") clicks_ratio = fields.Integer(compute="_compute_clicks_ratio", string="Number of Clicks") state = fields.Selection([('draft', 'Draft'), ('in_queue', 'In Queue'), ('sending', 'Sending'), ('done', 'Sent')], string='Status', required=True, tracking=True, copy=False, default='draft', group_expand='_group_expand_states') color = fields.Integer(string='Color Index') user_id = fields.Many2one('res.users', string='Responsible', tracking=True, default=lambda self: self.env.user) # mailing options mailing_type = fields.Selection([('mail', 'Email')], string="Mailing Type", default="mail", required=True) reply_to_mode = fields.Selection([('thread', 'Recipient Followers'), ('email', 'Specified Email Address')], string='Reply-To Mode', required=True) reply_to = fields.Char( string='Reply To', help='Preferred Reply-To Address', default=lambda self: self.env['mail.message']._get_default_from()) # recipients mailing_model_real = fields.Char(compute='_compute_model', string='Recipients Real Model', default='mailing.contact', required=True) mailing_model_id = fields.Many2one( 'ir.model', string='Recipients Model', domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)], default=lambda self: self.env.ref('mass_mailing.model_mailing_list' ).id) mailing_model_name = fields.Char(related='mailing_model_id.model', string='Recipients Model Name', readonly=True, related_sudo=True) mailing_domain = fields.Char(string='Domain', default=[]) mail_server_id = fields.Many2one( 'ir.mail_server', string='Mail Server', default=_get_default_mail_server_id, help= "Use a specific mail server in priority. Otherwise Harpiya relies on the first outgoing mail server available (based on their sequencing) as it does for normal mails." ) contact_list_ids = fields.Many2many('mailing.list', 'mail_mass_mailing_list_rel', string='Mailing Lists') contact_ab_pc = fields.Integer( string='A/B Testing percentage', help= 'Percentage of the contacts that will be mailed. Recipients will be taken randomly.', default=100) unique_ab_testing = fields.Boolean( string='Allow A/B Testing', default=False, help= 'If checked, recipients will be mailed only once for the whole campaign. ' 'This lets you send different mailings to randomly selected recipients and test ' 'the effectiveness of the mailings, without causing duplicate messages.' ) # statistics data mailing_trace_ids = fields.One2many('mailing.trace', 'mass_mailing_id', string='Emails Statistics') total = fields.Integer(compute="_compute_total") scheduled = fields.Integer(compute="_compute_statistics") expected = fields.Integer(compute="_compute_statistics") ignored = fields.Integer(compute="_compute_statistics") sent = fields.Integer(compute="_compute_statistics") delivered = fields.Integer(compute="_compute_statistics") opened = fields.Integer(compute="_compute_statistics") clicked = fields.Integer(compute="_compute_statistics") replied = fields.Integer(compute="_compute_statistics") bounced = fields.Integer(compute="_compute_statistics") failed = fields.Integer(compute="_compute_statistics") received_ratio = fields.Integer(compute="_compute_statistics", string='Received Ratio') opened_ratio = fields.Integer(compute="_compute_statistics", string='Opened Ratio') replied_ratio = fields.Integer(compute="_compute_statistics", string='Replied Ratio') bounced_ratio = fields.Integer(compute="_compute_statistics", string='Bounced Ratio') next_departure = fields.Datetime(compute="_compute_next_departure", string='Scheduled date') def _compute_total(self): for mass_mailing in self: mass_mailing.total = len(mass_mailing.sudo()._get_recipients()) def _compute_clicks_ratio(self): self.env.cr.execute( """ SELECT COUNT(DISTINCT(stats.id)) AS nb_mails, COUNT(DISTINCT(clicks.mailing_trace_id)) AS nb_clicks, stats.mass_mailing_id AS id FROM mailing_trace AS stats LEFT OUTER JOIN link_tracker_click AS clicks ON clicks.mailing_trace_id = stats.id WHERE stats.mass_mailing_id IN %s GROUP BY stats.mass_mailing_id """, (tuple(self.ids), )) mass_mailing_data = self.env.cr.dictfetchall() mapped_data = dict([(m['id'], 100 * m['nb_clicks'] / m['nb_mails']) for m in mass_mailing_data]) for mass_mailing in self: mass_mailing.clicks_ratio = mapped_data.get(mass_mailing.id, 0) @api.depends('mailing_model_id') def _compute_model(self): for record in self: record.mailing_model_real = ( record.mailing_model_name != 'mailing.list' ) and record.mailing_model_name or 'mailing.contact' def _compute_statistics(self): """ Compute statistics of the mass mailing """ self.env.cr.execute( """ SELECT m.id as mailing_id, COUNT(s.id) AS expected, COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent, COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is null AND s.bounced is null THEN 1 ELSE null END) AS scheduled, COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is not null THEN 1 ELSE null END) AS ignored, COUNT(CASE WHEN s.sent is not null AND s.exception is null AND s.bounced is null THEN 1 ELSE null END) AS delivered, COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened, COUNT(CASE WHEN s.clicked is not null THEN 1 ELSE null END) AS clicked, COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied, COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced, COUNT(CASE WHEN s.exception is not null THEN 1 ELSE null END) AS failed FROM mailing_trace s RIGHT JOIN mailing_mailing m ON (m.id = s.mass_mailing_id) WHERE m.id IN %s GROUP BY m.id """, (tuple(self.ids), )) for row in self.env.cr.dictfetchall(): total = row['expected'] = (row['expected'] - row['ignored']) or 1 row['received_ratio'] = 100.0 * row['delivered'] / total row['opened_ratio'] = 100.0 * row['opened'] / total row['clicks_ratio'] = 100.0 * row['clicked'] / total row['replied_ratio'] = 100.0 * row['replied'] / total row['bounced_ratio'] = 100.0 * row['bounced'] / total self.browse(row.pop('mailing_id')).update(row) def _compute_next_departure(self): cron_next_call = self.env.ref( 'mass_mailing.ir_cron_mass_mailing_queue').sudo().nextcall str2dt = fields.Datetime.from_string cron_time = str2dt(cron_next_call) for mass_mailing in self: if mass_mailing.schedule_date: schedule_date = str2dt(mass_mailing.schedule_date) mass_mailing.next_departure = max(schedule_date, cron_time) else: mass_mailing.next_departure = cron_time @api.onchange('mailing_model_name', 'contact_list_ids') def _onchange_model_and_list(self): mailing_domain = literal_eval( self.mailing_domain) if self.mailing_domain else [] if self.mailing_model_name: if mailing_domain: try: self.env[self.mailing_model_name].search(mailing_domain, limit=1) except: mailing_domain = [] if not mailing_domain: if self.mailing_model_name == 'mailing.list' and self.contact_list_ids: mailing_domain = [('list_ids', 'in', self.contact_list_ids.ids)] elif 'is_blacklisted' in self.env[ self. mailing_model_name]._fields and not self.mailing_domain: mailing_domain = [('is_blacklisted', '=', False)] elif 'opt_out' in self.env[ self. mailing_model_name]._fields and not self.mailing_domain: mailing_domain = [('opt_out', '=', False)] else: mailing_domain = [] self.mailing_domain = repr(mailing_domain) @api.onchange('mailing_type') def _onchange_mailing_type(self): if self.mailing_type == 'mail' and not self.medium_id: self.medium_id = self.env.ref('utm.utm_medium_email').id # ------------------------------------------------------ # ORM # ------------------------------------------------------ @api.model def create(self, values): if values.get('subject') and not values.get('name'): values['name'] = "%s %s" % ( values['subject'], datetime.strftime(fields.datetime.now(), tools.DEFAULT_SERVER_DATETIME_FORMAT)) if values.get('body_html'): values['body_html'] = self._convert_inline_images_to_urls( values['body_html']) if 'medium_id' not in values and values.get('mailing_type', 'mail') == 'mail': values['medium_id'] = self.env.ref('utm.utm_medium_email').id return super(MassMailing, self).create(values) def write(self, values): if values.get('body_html'): values['body_html'] = self._convert_inline_images_to_urls( values['body_html']) return super(MassMailing, self).write(values) @api.returns('self', lambda value: value.id) def copy(self, default=None): self.ensure_one() default = dict(default or {}, name=_('%s (copy)') % self.name, contact_list_ids=self.contact_list_ids.ids) res = super(MassMailing, self).copy(default=default) # Re-evaluating the domain res._onchange_model_and_list() return res def _group_expand_states(self, states, domain, order): return [key for key, val in type(self).state.selection] # ------------------------------------------------------ # ACTIONS # ------------------------------------------------------ def action_duplicate(self): self.ensure_one() mass_mailing_copy = self.copy() if mass_mailing_copy: context = dict(self.env.context) context['form_view_initial_mode'] = 'edit' return { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'mailing.mailing', 'res_id': mass_mailing_copy.id, 'context': context, } return False def action_test(self): self.ensure_one() ctx = dict(self.env.context, default_mass_mailing_id=self.id) return { 'name': _('Test Mailing'), 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'mailing.mailing.test', 'target': 'new', 'context': ctx, } def action_schedule(self): self.ensure_one() action = self.env.ref( 'mass_mailing.mailing_mailing_schedule_date_action').read()[0] action['context'] = dict(self.env.context, default_mass_mailing_id=self.id) return action def action_put_in_queue(self): self.write({'state': 'in_queue'}) def action_cancel(self): self.write({'state': 'draft', 'schedule_date': False}) def action_retry_failed(self): failed_mails = self.env['mail.mail'].sudo().search([ ('mailing_id', 'in', self.ids), ('state', '=', 'exception') ]) failed_mails.mapped('mailing_trace_ids').unlink() failed_mails.unlink() self.write({'state': 'in_queue'}) def action_view_traces_scheduled(self): return self._action_view_traces_filtered('scheduled') def action_view_traces_ignored(self): return self._action_view_traces_filtered('ignored') def action_view_traces_failed(self): return self._action_view_traces_filtered('failed') def _action_view_traces_filtered(self, view_filter): action = self.env.ref('mass_mailing.mailing_trace_action').read()[0] action['name'] = _('%s Traces') % (self.name) action['context'] = { 'search_default_mass_mailing_id': self.id, } filter_key = 'search_default_filter_%s' % (view_filter) action['context'][filter_key] = True return action def action_view_sent(self): return self._action_view_documents_filtered('sent') def action_view_opened(self): return self._action_view_documents_filtered('opened') def action_view_replied(self): return self._action_view_documents_filtered('replied') def action_view_bounced(self): return self._action_view_documents_filtered('bounced') def action_view_clicked(self): return self._action_view_documents_filtered('clicked') def action_view_delivered(self): return self._action_view_documents_filtered('delivered') def _action_view_documents_filtered(self, view_filter): if view_filter in ('sent', 'opened', 'replied', 'bounced', 'clicked'): opened_stats = self.mailing_trace_ids.filtered( lambda stat: stat[view_filter]) elif view_filter == ('delivered'): opened_stats = self.mailing_trace_ids.filtered( lambda stat: stat.sent and not stat.bounced) else: opened_stats = self.env['mailing.trace'] res_ids = opened_stats.mapped('res_id') model_name = self.env['ir.model']._get( self.mailing_model_real).display_name return { 'name': model_name, 'type': 'ir.actions.act_window', 'view_mode': 'tree', 'res_model': self.mailing_model_real, 'domain': [('id', 'in', res_ids)], 'context': dict(self._context, create=False) } def update_opt_out(self, email, list_ids, value): if len(list_ids) > 0: model = self.env['mailing.contact'].with_context(active_test=False) records = model.search([('email_normalized', '=', tools.email_normalize(email))]) opt_out_records = self.env['mailing.contact.subscription'].search([ ('contact_id', 'in', records.ids), ('list_id', 'in', list_ids), ('opt_out', '!=', value) ]) opt_out_records.write({'opt_out': value}) message = _('The recipient <strong>unsubscribed from %s</strong> mailing list(s)') \ if value else _('The recipient <strong>subscribed to %s</strong> mailing list(s)') for record in records: # filter the list_id by record record_lists = opt_out_records.filtered( lambda rec: rec.contact_id.id == record.id) if len(record_lists) > 0: record.sudo().message_post(body=_(message % ', '.join( str(list.name) for list in record_lists.mapped('list_id')))) # ------------------------------------------------------ # Email Sending # ------------------------------------------------------ def _get_opt_out_list(self): """Returns a set of emails opted-out in target model""" self.ensure_one() opt_out = {} target = self.env[self.mailing_model_real] if self.mailing_model_real == "mailing.contact": # if user is opt_out on One list but not on another # or if two user with same email address, one opted in and the other one opted out, send the mail anyway # TODO DBE Fixme : Optimise the following to get real opt_out and opt_in target_list_contacts = self.env[ 'mailing.contact.subscription'].search([ ('list_id', 'in', self.contact_list_ids.ids) ]) opt_out_contacts = target_list_contacts.filtered( lambda rel: rel.opt_out).mapped('contact_id.email_normalized') opt_in_contacts = target_list_contacts.filtered( lambda rel: not rel.opt_out).mapped( 'contact_id.email_normalized') opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts) _logger.info("Mass-mailing %s targets %s, blacklist: %s emails", self, target._name, len(opt_out)) else: _logger.info( "Mass-mailing %s targets %s, no opt out list available", self, target._name) return opt_out def _get_link_tracker_values(self): self.ensure_one() vals = {'mass_mailing_id': self.id} if self.campaign_id: vals['campaign_id'] = self.campaign_id.id if self.source_id: vals['source_id'] = self.source_id.id if self.medium_id: vals['medium_id'] = self.medium_id.id return vals def _get_seen_list(self): """Returns a set of emails already targeted by current mailing/campaign (no duplicates)""" self.ensure_one() target = self.env[self.mailing_model_real] # avoid loading a large number of records in memory # + use a basic heuristic for extracting emails query = """ SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) FROM mailing_trace s JOIN %(target)s t ON (s.res_id = t.id) WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL """ # Apply same 'get email field' rule from mail_thread.message_get_default_recipients if 'partner_id' in target._fields: mail_field = 'email' query = """ SELECT lower(substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) FROM mailing_trace s JOIN %(target)s t ON (s.res_id = t.id) JOIN res_partner p ON (t.partner_id = p.id) WHERE substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL """ elif issubclass(type(target), self.pool['mail.address.mixin']): mail_field = 'email_normalized' elif 'email_from' in target._fields: mail_field = 'email_from' elif 'partner_email' in target._fields: mail_field = 'partner_email' elif 'email' in target._fields: mail_field = 'email' else: raise UserError( _("Unsupported mass mailing model %s") % self.mailing_model_id.name) if self.unique_ab_testing: query += """ AND s.campaign_id = %%(mailing_campaign_id)s; """ else: query += """ AND s.mass_mailing_id = %%(mailing_id)s AND s.model = %%(target_model)s; """ query = query % {'target': target._table, 'mail_field': mail_field} params = { 'mailing_id': self.id, 'mailing_campaign_id': self.campaign_id.id, 'target_model': self.mailing_model_real } self._cr.execute(query, params) seen_list = set(m[0] for m in self._cr.fetchall()) _logger.info("Mass-mailing %s has already reached %s %s emails", self, len(seen_list), target._name) return seen_list def _get_mass_mailing_context(self): """Returns extra context items with pre-filled blacklist and seen list for massmailing""" return { 'mass_mailing_opt_out_list': self._get_opt_out_list(), 'mass_mailing_seen_list': self._get_seen_list(), 'post_convert_links': self._get_link_tracker_values(), } def _get_recipients(self): if self.mailing_domain: domain = safe_eval(self.mailing_domain) try: res_ids = self.env[self.mailing_model_real].search(domain).ids except ValueError: res_ids = [] _logger.exception( 'Cannot get the mass mailing recipients, model: %s, domain: %s', self.mailing_model_real, domain) else: res_ids = [] domain = [('id', 'in', res_ids)] # randomly choose a fragment if self.contact_ab_pc < 100: contact_nbr = self.env[self.mailing_model_real].search_count( domain) topick = int(contact_nbr / 100.0 * self.contact_ab_pc) if self.campaign_id and self.unique_ab_testing: already_mailed = self.campaign_id._get_mailing_recipients()[ self.campaign_id.id] else: already_mailed = set([]) remaining = set(res_ids).difference(already_mailed) if topick > len(remaining): topick = len(remaining) res_ids = random.sample(remaining, topick) return res_ids def _get_remaining_recipients(self): res_ids = self._get_recipients() already_mailed = self.env['mailing.trace'].search_read( [('model', '=', self.mailing_model_real), ('res_id', 'in', res_ids), ('mass_mailing_id', '=', self.id)], ['res_id']) done_res_ids = [record['res_id'] for record in already_mailed] return [rid for rid in res_ids if rid not in done_res_ids] def action_send_mail(self, res_ids=None): author_id = self.env.user.partner_id.id for mailing in self: if not res_ids: res_ids = mailing._get_remaining_recipients() if not res_ids: raise UserError(_('There are no recipients selected.')) composer_values = { 'author_id': author_id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], 'body': mailing.body_html, 'subject': mailing.subject, 'model': mailing.mailing_model_real, 'email_from': mailing.email_from, 'record_name': False, 'composition_mode': 'mass_mail', 'mass_mailing_id': mailing.id, 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids], 'no_auto_thread': mailing.reply_to_mode != 'thread', 'template_id': None, 'mail_server_id': mailing.mail_server_id.id, } if mailing.reply_to_mode == 'email': composer_values['reply_to'] = mailing.reply_to composer = self.env['mail.compose.message'].with_context( active_ids=res_ids).create(composer_values) extra_context = self._get_mass_mailing_context() composer = composer.with_context(active_ids=res_ids, **extra_context) # auto-commit except in testing mode auto_commit = not getattr(threading.currentThread(), 'testing', False) composer.send_mail(auto_commit=auto_commit) mailing.write({ 'state': 'done', 'sent_date': fields.Datetime.now() }) return True def convert_links(self): res = {} for mass_mailing in self: html = mass_mailing.body_html if mass_mailing.body_html else '' vals = {'mass_mailing_id': mass_mailing.id} if mass_mailing.campaign_id: vals['campaign_id'] = mass_mailing.campaign_id.id if mass_mailing.source_id: vals['source_id'] = mass_mailing.source_id.id if mass_mailing.medium_id: vals['medium_id'] = mass_mailing.medium_id.id res[mass_mailing.id] = self.env['link.tracker'].convert_links( html, vals, blacklist=['/unsubscribe_from_list']) return res @api.model def _process_mass_mailing_queue(self): mass_mailings = self.search([('state', 'in', ('in_queue', 'sending')), '|', ('schedule_date', '<', fields.Datetime.now()), ('schedule_date', '=', False)]) for mass_mailing in mass_mailings: user = mass_mailing.write_uid or self.env.user mass_mailing = mass_mailing.with_context( **user.with_user(user).context_get()) if len(mass_mailing._get_remaining_recipients()) > 0: mass_mailing.state = 'sending' mass_mailing.action_send_mail() else: mass_mailing.write({ 'state': 'done', 'sent_date': fields.Datetime.now() }) # ------------------------------------------------------ # TOOLS # ------------------------------------------------------ def _unsubscribe_token(self, res_id, email): """Generate a secure hash for this mailing list and parameters. This is appended to the unsubscription URL and then checked at unsubscription time to ensure no malicious unsubscriptions are performed. :param int res_id: ID of the resource that will be unsubscribed. :param str email: Email of the resource that will be unsubscribed. """ secret = self.env["ir.config_parameter"].sudo().get_param( "database.secret") token = (self.env.cr.dbname, self.id, int(res_id), tools.ustr(email)) return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha512).hexdigest() def _convert_inline_images_to_urls(self, body_html): """ Find inline base64 encoded images, make an attachement out of them and replace the inline image with an url to the attachement. """ def _image_to_url(b64image: bytes): """Store an image in an attachement and returns an url""" attachment = self.env['ir.attachment'].create({ 'datas': b64image, 'name': "cropped_image_mailing_{}".format(self.id), 'type': 'binary', }) attachment.generate_access_token() return '/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token) modified = False root = lxml.html.fromstring(body_html) for node in root.iter('img'): match = image_re.match(node.attrib.get('src', '')) if match: mime = match.group(1) # unsed image = match.group(2).encode() # base64 image as bytes node.attrib['src'] = _image_to_url(image) modified = True if modified: return lxml.html.tostring(root) return body_html
class BaseDocumentLayout(models.TransientModel): """ Customise the company document layout and display a live preview """ _name = 'base.document.layout' _description = 'Company Document Layout' company_id = fields.Many2one('res.company', default=lambda self: self.env.company, required=True) logo = fields.Binary(related='company_id.logo', readonly=False) preview_logo = fields.Binary(related='logo', string="Preview logo") report_header = fields.Text(related='company_id.report_header', readonly=False) report_footer = fields.Text(related='company_id.report_footer', readonly=False) paperformat_id = fields.Many2one(related='company_id.paperformat_id', readonly=False) external_report_layout_id = fields.Many2one( related='company_id.external_report_layout_id', readonly=False) font = fields.Selection(related='company_id.font', readonly=False) primary_color = fields.Char(related='company_id.primary_color', readonly=False) secondary_color = fields.Char(related='company_id.secondary_color', readonly=False) custom_colors = fields.Boolean(compute="_compute_custom_colors", readonly=False) logo_primary_color = fields.Char(compute="_compute_logo_colors") logo_secondary_color = fields.Char(compute="_compute_logo_colors") report_layout_id = fields.Many2one('report.layout') preview = fields.Html(compute='_compute_preview') @api.depends('logo_primary_color', 'logo_secondary_color', 'primary_color', 'secondary_color') def _compute_custom_colors(self): for wizard in self: logo_primary = wizard.logo_primary_color or '' logo_secondary = wizard.logo_secondary_color or '' # Force lower case on color to ensure that FF01AA == ff01aa wizard.custom_colors = ( wizard.logo and wizard.primary_color and wizard.secondary_color and not (wizard.primary_color.lower() == logo_primary.lower() and wizard.secondary_color.lower() == logo_secondary.lower())) @api.depends('logo') def _compute_logo_colors(self): for wizard in self: if wizard._context.get('bin_size'): wizard_for_image = wizard.with_context(bin_size=False) else: wizard_for_image = wizard wizard.logo_primary_color, wizard.logo_secondary_color = wizard_for_image._parse_logo_colors( ) @api.depends('report_layout_id', 'logo', 'font', 'primary_color', 'secondary_color') def _compute_preview(self): """ compute a qweb based preview to display on the wizard """ for wizard in self: if wizard.report_layout_id: ir_qweb = wizard.env['ir.qweb'] wizard.preview = ir_qweb.render('base.layout_preview', {'company': wizard}) else: wizard.preview = False @api.onchange('company_id') def _onchange_company_id(self): for wizard in self: wizard.logo = wizard.company_id.logo wizard.report_header = wizard.company_id.report_header wizard.report_footer = wizard.company_id.report_footer wizard.paperformat_id = wizard.company_id.paperformat_id wizard.external_report_layout_id = wizard.company_id.external_report_layout_id wizard.font = wizard.company_id.font wizard.primary_color = wizard.company_id.primary_color wizard.secondary_color = wizard.company_id.secondary_color wizard_layout = wizard.env["report.layout"].search([ ('view_id.key', '=', wizard.company_id.external_report_layout_id.key) ]) wizard.report_layout_id = wizard_layout or wizard_layout.search( [], limit=1) if not wizard.primary_color: wizard.primary_color = wizard.logo_primary_color or DEFAULT_PRIMARY if not wizard.secondary_color: wizard.secondary_color = wizard.logo_secondary_color or DEFAULT_SECONDARY @api.onchange('custom_colors') def _onchange_custom_colors(self): for wizard in self: if wizard.logo and not wizard.custom_colors: wizard.primary_color = wizard.logo_primary_color or DEFAULT_PRIMARY wizard.secondary_color = wizard.logo_secondary_color or DEFAULT_SECONDARY @api.onchange('report_layout_id') def _onchange_report_layout_id(self): for wizard in self: wizard.external_report_layout_id = wizard.report_layout_id.view_id @api.onchange('logo') def _onchange_logo(self): for wizard in self: # It is admitted that if the user puts the original image back, it won't change colors company = wizard.company_id # at that point wizard.logo has been assigned the value present in DB if wizard.logo == company.logo and company.primary_color and company.secondary_color: continue if wizard.logo_primary_color: wizard.primary_color = wizard.logo_primary_color if wizard.logo_secondary_color: wizard.secondary_color = wizard.logo_secondary_color def _parse_logo_colors(self, logo=None, white_threshold=225): """ Identifies dominant colors First resizes the original image to improve performance, then discards transparent colors and white-ish colors, then calls the averaging method twice to evaluate both primary and secondary colors. :param logo: alternate logo to process :param white_threshold: arbitrary value defining the maximum value a color can reach :return colors: hex values of primary and secondary colors """ self.ensure_one() logo = logo or self.logo if not logo: return False, False # The "===" gives different base64 encoding a correct padding logo += b'===' if type(logo) == bytes else '===' try: # Catches exceptions caused by logo not being an image image = tools.image_fix_orientation(tools.base64_to_image(logo)) except Exception: return False, False base_w, base_h = image.size w = int(50 * base_w / base_h) h = 50 # Converts to RGBA if no alpha detected image_converted = image.convert( 'RGBA') if 'A' not in image.getbands() else image image_resized = image_converted.resize((w, h)) colors = [] for color in image_resized.getcolors(w * h): if not (color[1][0] > white_threshold and color[1][1] > white_threshold and color[1][2] > white_threshold) and color[1][3] > 0: colors.append(color) if not colors: # May happen when the whole image is white return False, False primary, remaining = tools.average_dominant_color(colors) secondary = tools.average_dominant_color( remaining)[0] if len(remaining) > 0 else primary # Lightness and saturation are calculated here. # - If both colors have a similar lightness, the most colorful becomes primary # - When the difference in lightness is too great, the brightest color becomes primary l_primary = tools.get_lightness(primary) l_secondary = tools.get_lightness(secondary) if (l_primary < 0.2 and l_secondary < 0.2) or (l_primary >= 0.2 and l_secondary >= 0.2): s_primary = tools.get_saturation(primary) s_secondary = tools.get_saturation(secondary) if s_primary < s_secondary: primary, secondary = secondary, primary elif l_secondary > l_primary: primary, secondary = secondary, primary return tools.rgb_to_hex(primary), tools.rgb_to_hex(secondary) @api.model def action_open_base_document_layout(self, action_ref=None): if not action_ref: action_ref = 'base.action_base_document_layout_configurator' return self.env.ref(action_ref).read()[0] def document_layout_save(self): # meant to be overridden return self.env.context.get('report_action') or { 'type': 'ir.actions.act_window_close' }
class Blog(models.Model): _name = 'blog.blog' _description = 'Blogs' _inherit = ['mail.thread', 'website.seo.metadata', 'website.multi.mixin'] _order = 'name' name = fields.Char('Blog Name', required=True, translate=True) subtitle = fields.Char('Blog Subtitle', translate=True) active = fields.Boolean('Active', default=True) content = fields.Html('Content', translate=html_translate, sanitize=False) cover_properties = fields.Text( 'Cover Properties', default='{"background-image": "none", "background-color": "oe_black", "opacity": "0.2", "resize_class": "cover_mid"}') def write(self, vals): res = super(Blog, self).write(vals) if 'active' in vals: # archiving/unarchiving a blog does it on its posts, too post_ids = self.env['blog.post'].with_context(active_test=False).search([ ('blog_id', 'in', self.ids) ]) for blog_post in post_ids: blog_post.active = vals['active'] return res @api.returns('mail.message', lambda value: value.id) def message_post(self, *, parent_id=False, subtype=None, **kwargs): """ Temporary workaround to avoid spam. If someone replies on a channel through the 'Presentation Published' email, it should be considered as a note as we don't want all channel followers to be notified of this answer. """ self.ensure_one() if parent_id: parent_message = self.env['mail.message'].sudo().browse(parent_id) if parent_message.subtype_id and parent_message.subtype_id == self.env.ref('website_blog.mt_blog_blog_published'): if kwargs.get('subtype_id'): kwargs['subtype_id'] = False subtype = 'mail.mt_note' return super(Blog, self).message_post(parent_id=parent_id, subtype=subtype, **kwargs) def all_tags(self, join=False, min_limit=1): BlogTag = self.env['blog.tag'] req = """ SELECT p.blog_id, count(*), r.blog_tag_id FROM blog_post_blog_tag_rel r join blog_post p on r.blog_post_id=p.id WHERE p.blog_id in %s GROUP BY p.blog_id, r.blog_tag_id ORDER BY count(*) DESC """ self._cr.execute(req, [tuple(self.ids)]) tag_by_blog = {i.id: [] for i in self} all_tags = set() for blog_id, freq, tag_id in self._cr.fetchall(): if freq >= min_limit: if join: all_tags.add(tag_id) else: tag_by_blog[blog_id].append(tag_id) if join: return BlogTag.browse(all_tags) for blog_id in tag_by_blog: tag_by_blog[blog_id] = BlogTag.browse(tag_by_blog[blog_id]) return tag_by_blog
class EventEvent(models.Model): """Event""" _name = 'event.event' _description = 'Event' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'date_begin' name = fields.Char(string='Event', translate=True, required=True, readonly=False, states={'done': [('readonly', True)]}) active = fields.Boolean(default=True) user_id = fields.Many2one('res.users', string='Responsible', default=lambda self: self.env.user, tracking=True, readonly=False, states={'done': [('readonly', True)]}) company_id = fields.Many2one('res.company', string='Company', change_default=True, default=lambda self: self.env.company, required=False, readonly=False, states={'done': [('readonly', True)]}) organizer_id = fields.Many2one( 'res.partner', string='Organizer', tracking=True, default=lambda self: self.env.company.partner_id, domain= "['|', ('company_id', '=', False), ('company_id', '=', company_id)]") event_type_id = fields.Many2one('event.type', string='Category', readonly=False, states={'done': [('readonly', True)]}) color = fields.Integer('Kanban Color Index') event_mail_ids = fields.One2many('event.mail', 'event_id', string='Mail Schedule', copy=True) # Seats and computation seats_max = fields.Integer( string='Maximum Attendees Number', readonly=True, states={ 'draft': [('readonly', False)], 'confirm': [('readonly', False)] }, help= "For each event you can define a maximum registration of seats(number of attendees), above this numbers the registrations are not accepted." ) seats_availability = fields.Selection([('limited', 'Limited'), ('unlimited', 'Unlimited')], 'Maximum Attendees', required=True, default='unlimited') seats_min = fields.Integer( string='Minimum Attendees', help= "For each event you can define a minimum reserved seats (number of attendees), if it does not reach the mentioned registrations the event can not be confirmed (keep 0 to ignore this rule)" ) seats_reserved = fields.Integer(string='Reserved Seats', store=True, readonly=True, compute='_compute_seats') seats_available = fields.Integer(string='Available Seats', store=True, readonly=True, compute='_compute_seats') seats_unconfirmed = fields.Integer(string='Unconfirmed Seat Reservations', store=True, readonly=True, compute='_compute_seats') seats_used = fields.Integer(string='Number of Participants', store=True, readonly=True, compute='_compute_seats') seats_expected = fields.Integer(string='Number of Expected Attendees', compute_sudo=True, readonly=True, compute='_compute_seats') # Registration fields registration_ids = fields.One2many('event.registration', 'event_id', string='Attendees', readonly=False, states={'done': [('readonly', True)]}) # Date fields date_tz = fields.Selection('_tz_get', string='Timezone', required=True, default=lambda self: self.env.user.tz or 'UTC') date_begin = fields.Datetime(string='Start Date', required=True, tracking=True, states={'done': [('readonly', True)]}) date_end = fields.Datetime(string='End Date', required=True, tracking=True, states={'done': [('readonly', True)]}) date_begin_located = fields.Char(string='Start Date Located', compute='_compute_date_begin_tz') date_end_located = fields.Char(string='End Date Located', compute='_compute_date_end_tz') is_one_day = fields.Boolean(compute='_compute_field_is_one_day') state = fields.Selection( [('draft', 'Unconfirmed'), ('cancel', 'Cancelled'), ('confirm', 'Confirmed'), ('done', 'Done')], string='Status', default='draft', readonly=True, required=True, copy=False, help= "If event is created, the status is 'Draft'. If event is confirmed for the particular dates the status is set to 'Confirmed'. If the event is over, the status is set to 'Done'. If event is cancelled the status is set to 'Cancelled'." ) auto_confirm = fields.Boolean(string='Autoconfirm Registrations') is_online = fields.Boolean('Online Event') address_id = fields.Many2one( 'res.partner', string='Location', default=lambda self: self.env.company.partner_id, readonly=False, states={'done': [('readonly', True)]}, domain= "['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=True) country_id = fields.Many2one('res.country', 'Country', related='address_id.country_id', store=True, readonly=False) twitter_hashtag = fields.Char('Twitter Hashtag') description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False, readonly=False, states={'done': [('readonly', True)]}) # badge fields badge_front = fields.Html(string='Badge Front') badge_back = fields.Html(string='Badge Back') badge_innerleft = fields.Html(string='Badge Inner Left') badge_innerright = fields.Html(string='Badge Inner Right') event_logo = fields.Html(string='Event Logo') @api.depends('seats_max', 'registration_ids.state') def _compute_seats(self): """ Determine reserved, available, reserved but unconfirmed and used seats. """ # initialize fields to 0 for event in self: event.seats_unconfirmed = event.seats_reserved = event.seats_used = event.seats_available = 0 # aggregate registrations by event and by state if self.ids: state_field = { 'draft': 'seats_unconfirmed', 'open': 'seats_reserved', 'done': 'seats_used', } query = """ SELECT event_id, state, count(event_id) FROM event_registration WHERE event_id IN %s AND state IN ('draft', 'open', 'done') GROUP BY event_id, state """ self.env['event.registration'].flush(['event_id', 'state']) self._cr.execute(query, (tuple(self.ids), )) for event_id, state, num in self._cr.fetchall(): event = self.browse(event_id) event[state_field[state]] += num # compute seats_available for event in self: if event.seats_max > 0: event.seats_available = event.seats_max - ( event.seats_reserved + event.seats_used) event.seats_expected = event.seats_unconfirmed + event.seats_reserved + event.seats_used @api.model def _tz_get(self): return [(x, x) for x in pytz.all_timezones] @api.depends('date_tz', 'date_begin') def _compute_date_begin_tz(self): for event in self: if event.date_begin: event.date_begin_located = format_datetime(self.env, event.date_begin, tz=event.date_tz, dt_format='medium') else: event.date_begin_located = False @api.depends('date_tz', 'date_end') def _compute_date_end_tz(self): for event in self: if event.date_end: event.date_end_located = format_datetime(self.env, event.date_end, tz=event.date_tz, dt_format='medium') else: event.date_end_located = False @api.depends('date_begin', 'date_end', 'date_tz') def _compute_field_is_one_day(self): for event in self: # Need to localize because it could begin late and finish early in # another timezone event = event.with_context(tz=event.date_tz) begin_tz = fields.Datetime.context_timestamp( event, event.date_begin) end_tz = fields.Datetime.context_timestamp(event, event.date_end) event.is_one_day = (begin_tz.date() == end_tz.date()) @api.onchange('is_online') def _onchange_is_online(self): if self.is_online: self.address_id = False @api.onchange('event_type_id') def _onchange_type(self): if self.event_type_id: self.seats_min = self.event_type_id.default_registration_min self.seats_max = self.event_type_id.default_registration_max if self.event_type_id.default_registration_max: self.seats_availability = 'limited' if self.event_type_id.auto_confirm: self.auto_confirm = self.event_type_id.auto_confirm if self.event_type_id.use_hashtag: self.twitter_hashtag = self.event_type_id.default_hashtag if self.event_type_id.use_timezone: self.date_tz = self.event_type_id.default_timezone self.is_online = self.event_type_id.is_online if self.event_type_id.event_type_mail_ids: self.event_mail_ids = [(5, 0, 0)] + [(0, 0, { attribute_name: line[attribute_name] if not isinstance(line[attribute_name], models.BaseModel) else line[attribute_name].id for attribute_name in self.env['event.type.mail']. _get_event_mail_fields_whitelist() }) for line in self.event_type_id.event_type_mail_ids] @api.constrains('seats_min', 'seats_max', 'seats_availability') def _check_seats_min_max(self): if any(event.seats_availability == 'limited' and event.seats_min > event.seats_max for event in self): raise ValidationError( _('Maximum attendees number should be greater than minimum attendees number.' )) @api.constrains('seats_max', 'seats_available') def _check_seats_limit(self): if any(event.seats_availability == 'limited' and event.seats_max and event.seats_available < 0 for event in self): raise ValidationError(_('No more available seats.')) @api.constrains('date_begin', 'date_end') def _check_closing_date(self): for event in self: if event.date_end < event.date_begin: raise ValidationError( _('The closing date cannot be earlier than the beginning date.' )) @api.depends('name', 'date_begin', 'date_end') def name_get(self): result = [] for event in self: date_begin = fields.Datetime.from_string(event.date_begin) date_end = fields.Datetime.from_string(event.date_end) dates = [ fields.Date.to_string( fields.Datetime.context_timestamp(event, dt)) for dt in [date_begin, date_end] if dt ] dates = sorted(set(dates)) result.append( (event.id, '%s (%s)' % (event.name, ' - '.join(dates)))) return result @api.model def create(self, vals): res = super(EventEvent, self).create(vals) if res.organizer_id: res.message_subscribe([res.organizer_id.id]) if res.auto_confirm: res.button_confirm() return res def write(self, vals): res = super(EventEvent, self).write(vals) if vals.get('organizer_id'): self.message_subscribe([vals['organizer_id']]) return res @api.returns('self', lambda value: value.id) def copy(self, default=None): self.ensure_one() default = dict(default or {}, name=_("%s (copy)") % (self.name)) return super(EventEvent, self).copy(default) def button_draft(self): self.write({'state': 'draft'}) def button_cancel(self): if any('done' in event.mapped('registration_ids.state') for event in self): raise UserError( _("There are already attendees who attended this event. Please reset it to draft if you want to cancel this event." )) self.registration_ids.write({'state': 'cancel'}) self.state = 'cancel' def button_done(self): self.write({'state': 'done'}) def button_confirm(self): self.write({'state': 'confirm'}) def mail_attendees(self, template_id, force_send=False, filter_func=lambda self: self.state != 'cancel'): for event in self: for attendee in event.registration_ids.filtered(filter_func): self.env['mail.template'].browse(template_id).send_mail( attendee.id, force_send=force_send) def _is_event_registrable(self): return self.date_end > fields.Datetime.now() def _get_ics_file(self): """ Returns iCalendar file for the event invitation. :returns a dict of .ics file content for each event """ result = {} if not vobject: return result for event in self: cal = vobject.iCalendar() cal_event = cal.add('vevent') cal_event.add('created').value = fields.Datetime.now().replace( tzinfo=pytz.timezone('UTC')) cal_event.add('dtstart').value = fields.Datetime.from_string( event.date_begin).replace(tzinfo=pytz.timezone('UTC')) cal_event.add('dtend').value = fields.Datetime.from_string( event.date_end).replace(tzinfo=pytz.timezone('UTC')) cal_event.add('summary').value = event.name if event.address_id: cal_event.add( 'location').value = event.sudo().address_id.contact_address result[event.id] = cal.serialize().encode('utf-8') return result
class MailComposer(models.TransientModel): """ Generic message composition wizard. You may inherit from this wizard at model and view levels to provide specific features. The behavior of the wizard depends on the composition_mode field: - 'comment': post on a record. The wizard is pre-populated via ``get_record_data`` - 'mass_mail': wizard in mass mailing mode where the mail details can contain template placeholders that will be merged with actual data before being sent to each recipient. """ _name = 'mail.compose.message' _description = 'Email composition wizard' _log_access = True _batch_size = 500 @api.model def default_get(self, fields): """ Handle composition mode. Some details about context keys: - comment: default mode, model and ID of a record the user comments - default_model or active_model - default_res_id or active_id - reply: active_id of a message the user replies to - default_parent_id or message_id or active_id: ID of the mail.message we reply to - message.res_model or default_model - message.res_id or default_res_id - mass_mail: model and IDs of records the user mass-mails - active_ids: record IDs - default_model or active_model """ result = super(MailComposer, self).default_get(fields) # author if 'author_id' not in result: result['author_id'] = self.env.user.partner_id.id if 'email_from' not in result and self.env.user.email: result['email_from'] = self.env.user.email_formatted elif 'email_from' not in result: author = self.env['res.partner'].browse(result['author_id']) if author.email: result['email_from'] = tools.formataddr( (author.name, author.email)) # v6.1 compatibility mode result['composition_mode'] = result.get( 'composition_mode', self._context.get('mail.compose.message.mode', 'comment')) result['model'] = result.get('model', self._context.get('active_model')) result['res_id'] = result.get('res_id', self._context.get('active_id')) result['parent_id'] = result.get('parent_id', self._context.get('message_id')) if 'no_auto_thread' not in result and ( result['model'] not in self.env or not hasattr(self.env[result['model']], 'message_post')): result['no_auto_thread'] = True # default values according to composition mode - NOTE: reply is deprecated, fall back on comment if result['composition_mode'] == 'reply': result['composition_mode'] = 'comment' vals = {} if 'active_domain' in self._context: # not context.get() because we want to keep global [] domains vals['active_domain'] = '%s' % self._context.get('active_domain') if result['composition_mode'] == 'comment': vals.update(self.get_record_data(result)) for field in vals: if field in fields: result[field] = vals[field] # TDE HACK: as mailboxes used default_model='res.users' and default_res_id=uid # (because of lack of an accessible pid), creating a message on its own # profile may crash (res_users does not allow writing on it) # Posting on its own profile works (res_users redirect to res_partner) # but when creating the mail.message to create the mail.compose.message # access rights issues may rise # We therefore directly change the model and res_id if result['model'] == 'res.users' and result['res_id'] == self._uid: result['model'] = 'res.partner' result['res_id'] = self.env.user.partner_id.id if fields is not None: [ result.pop(field, None) for field in list(result) if field not in fields ] return result @api.model def _get_composition_mode_selection(self): return [('comment', 'Post on a document'), ('mass_mail', 'Email Mass Mailing'), ('mass_post', 'Post on Multiple Documents')] # content subject = fields.Char('Subject') body = fields.Html('Contents', default='', sanitize_style=True) parent_id = fields.Many2one('mail.message', 'Parent Message', index=True, ondelete='set null', help="Initial thread message.") attachment_ids = fields.Many2many( 'ir.attachment', 'mail_compose_message_ir_attachments_rel', 'wizard_id', 'attachment_id', 'Attachments') # 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, help= "Author of the message. If not set, email_from may hold an email address that did not match any partner." ) # related document model = fields.Char('Related Document Model', index=True) res_id = fields.Integer('Related Document ID', index=True) record_name = fields.Char('Message Record Name', help="Name get of the related document.") # characteristics message_type = fields.Selection( [('comment', 'Comment'), ('notification', 'System notification')], 'Type', required=True, default='comment', 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, default=lambda self: self.env['ir.model.data'] .xmlid_to_res_id('mail.mt_comment')) mail_activity_type_id = fields.Many2one('mail.activity.type', 'Mail Activity Type', index=True, ondelete='set null') # destination composition_mode = fields.Selection( selection=_get_composition_mode_selection, string='Composition mode', default='comment') reply_to = fields.Char( 'Reply-To', help= 'Reply email address. Setting the reply_to bypasses the automatic thread creation.' ) no_auto_thread = fields.Boolean( 'No threading for answers', help= 'Answers do not go in the original document discussion thread. This has an impact on the generated message-id.' ) is_log = fields.Boolean( 'Log an Internal Note', help='Whether the message is an internal note (comment mode only)') partner_ids = fields.Many2many('res.partner', 'mail_compose_message_res_partner_rel', 'wizard_id', 'partner_id', 'Additional Contacts') use_active_domain = fields.Boolean('Use active domain') active_domain = fields.Text('Active domain', readonly=True) # mass mode options notify = fields.Boolean( 'Notify followers', help='Notify followers of the document (mass post only)') auto_delete = fields.Boolean('Delete Emails', help='Delete sent emails (mass mailing only)') auto_delete_message = fields.Boolean( 'Delete Message Copy', help= 'Do not keep a copy of the email in the document communication history (mass mailing only)' ) template_id = fields.Many2one('mail.template', 'Use template', index=True, domain="[('model', '=', model)]") # technical stuff mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') layout = fields.Char('Layout', copy=False) # xml id of layout add_sign = fields.Boolean(default=True) @api.model def get_record_data(self, values): """ Returns a defaults-like dict with initial values for the composition wizard when sending an email related a previous email (parent_id) or a document (model, res_id). This is based on previously computed default values. """ result, subject = {}, False if values.get('parent_id'): parent = self.env['mail.message'].browse(values.get('parent_id')) result['record_name'] = parent.record_name, subject = tools.ustr(parent.subject or parent.record_name or '') if not values.get('model'): result['model'] = parent.model if not values.get('res_id'): result['res_id'] = parent.res_id partner_ids = values.get('partner_ids', list()) + parent.partner_ids.ids result['partner_ids'] = partner_ids elif values.get('model') and values.get('res_id'): doc_name_get = self.env[values.get('model')].browse( values.get('res_id')).name_get() result['record_name'] = doc_name_get and doc_name_get[0][1] or '' subject = tools.ustr(result['record_name']) re_prefix = _('Re:') if subject and not (subject.startswith('Re:') or subject.startswith(re_prefix)): subject = "%s %s" % (re_prefix, subject) result['subject'] = subject return result #------------------------------------------------------ # Wizard validation and send #------------------------------------------------------ # action buttons call with positionnal arguments only, so we need an intermediary function # to ensure the context is passed correctly def action_send_mail(self): self.send_mail() return {'type': 'ir.actions.act_window_close', 'infos': 'mail_sent'} def send_mail(self, auto_commit=False): """ Process the wizard content and proceed with sending the related email(s), rendering any template patterns on the fly if needed. """ notif_layout = self._context.get('custom_layout') # Several custom layouts make use of the model description at rendering, e.g. in the # 'View <document>' button. Some models are used for different business concepts, such as # 'purchase.order' which is used for a RFQ and and PO. To avoid confusion, we must use a # different wording depending on the state of the object. # Therefore, we can set the description in the context from the beginning to avoid falling # back on the regular display_name retrieved in '_notify_prepare_template_context'. model_description = self._context.get('model_description') for wizard in self: # Duplicate attachments linked to the email.template. # Indeed, basic mail.compose.message wizard duplicates attachments in mass # mailing mode. But in 'single post' mode, attachments of an email template # also have to be duplicated to avoid changing their ownership. if wizard.attachment_ids and wizard.composition_mode != 'mass_mail' and wizard.template_id: new_attachment_ids = [] for attachment in wizard.attachment_ids: if attachment in wizard.template_id.attachment_ids: new_attachment_ids.append( attachment.copy({ 'res_model': 'mail.compose.message', 'res_id': wizard.id }).id) else: new_attachment_ids.append(attachment.id) new_attachment_ids.reverse() wizard.write({'attachment_ids': [(6, 0, new_attachment_ids)]}) # Mass Mailing mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post') Mail = self.env['mail.mail'] ActiveModel = self.env[wizard.model] if wizard.model and hasattr( self.env[wizard.model], 'message_post') else self.env['mail.thread'] if wizard.composition_mode == 'mass_post': # do not send emails directly but use the queue instead # add context key to avoid subscribing the author ActiveModel = ActiveModel.with_context( mail_notify_force_send=False, mail_create_nosubscribe=True) # wizard works in batch mode: [res_id] or active_ids or active_domain if mass_mode and wizard.use_active_domain and wizard.model: res_ids = self.env[wizard.model].search( safe_eval(wizard.active_domain)).ids elif mass_mode and wizard.model and self._context.get( 'active_ids'): res_ids = self._context['active_ids'] else: res_ids = [wizard.res_id] batch_size = int(self.env['ir.config_parameter'].sudo().get_param( 'mail.batch_size')) or self._batch_size sliced_res_ids = [ res_ids[i:i + batch_size] for i in range(0, len(res_ids), batch_size) ] if wizard.composition_mode == 'mass_mail' or wizard.is_log or ( wizard.composition_mode == 'mass_post' and not wizard.notify): # log a note: subtype is False subtype_id = False elif wizard.subtype_id: subtype_id = wizard.subtype_id.id else: subtype_id = self.env['ir.model.data'].xmlid_to_res_id( 'mail.mt_comment') for res_ids in sliced_res_ids: batch_mails = Mail all_mail_values = wizard.get_mail_values(res_ids) for res_id, mail_values in all_mail_values.items(): if wizard.composition_mode == 'mass_mail': batch_mails |= Mail.create(mail_values) else: post_params = dict( message_type=wizard.message_type, subtype_id=subtype_id, email_layout_xmlid=notif_layout, add_sign=not bool(wizard.template_id), mail_auto_delete=wizard.template_id.auto_delete if wizard.template_id else False, model_description=model_description) post_params.update(mail_values) if ActiveModel._name == 'mail.thread': if wizard.model: post_params['model'] = wizard.model post_params['res_id'] = res_id if not ActiveModel.message_notify(**post_params): # if message_notify returns an empty record set, no recipients where found. raise UserError(_("No recipient found.")) else: ActiveModel.browse(res_id).message_post( **post_params) if wizard.composition_mode == 'mass_mail': batch_mails.send(auto_commit=auto_commit) def get_mail_values(self, res_ids): """Generate the values that will be used by send_mail to create mail_messages or mail_mails. """ self.ensure_one() results = dict.fromkeys(res_ids, False) rendered_values = {} mass_mail_mode = self.composition_mode == 'mass_mail' # render all template-based value at once if mass_mail_mode and self.model: rendered_values = self.render_message(res_ids) # compute alias-based reply-to in batch reply_to_value = dict.fromkeys(res_ids, None) if mass_mail_mode and not self.no_auto_thread: records = self.env[self.model].browse(res_ids) reply_to_value = self.env[ 'mail.thread']._notify_get_reply_to_on_records( default=self.email_from, records=records) blacklisted_rec_ids = [] if mass_mail_mode and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']): BL_sudo = self.env['mail.blacklist'].sudo() blacklist = set(BL_sudo.search([]).mapped('email')) if blacklist: targets = self.env[self.model].browse(res_ids).read( ['email_normalized']) # First extract email from recipient before comparing with blacklist blacklisted_rec_ids.extend([ target['id'] for target in targets if target['email_normalized'] and target['email_normalized'] in blacklist ]) for res_id in res_ids: # static wizard (mail.message) values mail_values = { 'subject': self.subject, 'body': self.body or '', 'parent_id': self.parent_id and self.parent_id.id, 'partner_ids': [partner.id for partner in self.partner_ids], 'attachment_ids': [attach.id for attach in self.attachment_ids], 'author_id': self.author_id.id, 'email_from': self.email_from, 'record_name': self.record_name, 'no_auto_thread': self.no_auto_thread, 'mail_server_id': self.mail_server_id.id, 'mail_activity_type_id': self.mail_activity_type_id.id, } # mass mailing: rendering override wizard static values if mass_mail_mode and self.model: record = self.env[self.model].browse(res_id) mail_values['headers'] = record._notify_email_headers() # keep a copy unless specifically requested, reset record name (avoid browsing records) mail_values.update(notification=not self.auto_delete_message, model=self.model, res_id=res_id, record_name=False) # auto deletion of mail_mail if self.auto_delete or self.template_id.auto_delete: mail_values['auto_delete'] = True # rendered values using template email_dict = rendered_values[res_id] mail_values['partner_ids'] += email_dict.pop('partner_ids', []) mail_values.update(email_dict) if not self.no_auto_thread: mail_values.pop('reply_to') if reply_to_value.get(res_id): mail_values['reply_to'] = reply_to_value[res_id] if self.no_auto_thread and not mail_values.get('reply_to'): mail_values['reply_to'] = mail_values['email_from'] # mail_mail values: body -> body_html, partner_ids -> recipient_ids mail_values['body_html'] = mail_values.get('body', '') mail_values['recipient_ids'] = [ (4, id) for id in mail_values.pop('partner_ids', []) ] # process attachments: should not be encoded before being processed by message_post / mail_mail create mail_values['attachments'] = [ (name, base64.b64decode(enc_cont)) for name, enc_cont in email_dict.pop( 'attachments', list()) ] attachment_ids = [] for attach_id in mail_values.pop('attachment_ids'): new_attach_id = self.env['ir.attachment'].browse( attach_id).copy({ 'res_model': self._name, 'res_id': self.id }) attachment_ids.append(new_attach_id.id) attachment_ids.reverse() mail_values['attachment_ids'] = self.env[ 'mail.thread']._message_post_process_attachments( mail_values.pop('attachments', []), attachment_ids, { 'model': 'mail.message', 'res_id': 0 })['attachment_ids'] # Filter out the blacklisted records by setting the mail state to cancel -> Used for Mass Mailing stats if res_id in blacklisted_rec_ids: mail_values['state'] = 'cancel' # Do not post the mail into the recipient's chatter mail_values['notification'] = False results[res_id] = mail_values return results #------------------------------------------------------ # Template methods #------------------------------------------------------ @api.onchange('template_id') def onchange_template_id_wrapper(self): self.ensure_one() values = self.onchange_template_id(self.template_id.id, self.composition_mode, self.model, self.res_id)['value'] for fname, value in values.items(): setattr(self, fname, value) def onchange_template_id(self, template_id, composition_mode, model, res_id): """ - mass_mailing: we cannot render, so return the template values - normal mode: return rendered values /!\ for x2many field, this onchange return command instead of ids """ if template_id and composition_mode == 'mass_mail': template = self.env['mail.template'].browse(template_id) fields = [ 'subject', 'body_html', 'email_from', 'reply_to', 'mail_server_id' ] values = dict((field, getattr(template, field)) for field in fields if getattr(template, field)) if template.attachment_ids: values['attachment_ids'] = [ att.id for att in template.attachment_ids ] if template.mail_server_id: values['mail_server_id'] = template.mail_server_id.id if template.user_signature and 'body_html' in values: signature = self.env.user.signature values['body_html'] = tools.append_content_to_html( values['body_html'], signature, plaintext=False) elif template_id: values = self.generate_email_for_composer(template_id, [res_id])[res_id] # transform attachments into attachment_ids; not attached to the document because this will # be done further in the posting process, allowing to clean database if email not send attachment_ids = [] Attachment = self.env['ir.attachment'] for attach_fname, attach_datas in values.pop('attachments', []): data_attach = { 'name': attach_fname, 'datas': attach_datas, 'res_model': 'mail.compose.message', 'res_id': 0, 'type': 'binary', # override default_type from context, possibly meant for another model! } attachment_ids.append(Attachment.create(data_attach).id) if values.get('attachment_ids', []) or attachment_ids: values['attachment_ids'] = [ (6, 0, values.get('attachment_ids', []) + attachment_ids) ] else: default_values = self.with_context( default_composition_mode=composition_mode, default_model=model, default_res_id=res_id).default_get([ 'composition_mode', 'model', 'res_id', 'parent_id', 'partner_ids', 'subject', 'body', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id' ]) values = dict((key, default_values[key]) for key in [ 'subject', 'body', 'partner_ids', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id' ] if key in default_values) if values.get('body_html'): values['body'] = values.pop('body_html') # This onchange should return command instead of ids for x2many field. values = self._convert_to_write(values) return {'value': values} def save_as_template(self): """ hit save as template button: current form value will be a new template attached to the current document. """ for record in self: model = self.env['ir.model']._get(record.model or 'mail.message') model_name = model.name or '' template_name = "%s: %s" % (model_name, tools.ustr(record.subject)) values = { 'name': template_name, 'subject': record.subject or False, 'body_html': record.body or False, 'model_id': model.id or False, 'attachment_ids': [(6, 0, [att.id for att in record.attachment_ids])], } template = self.env['mail.template'].create(values) # generate the saved template record.write({'template_id': template.id}) record.onchange_template_id_wrapper() return _reopen(self, record.id, record.model, context=self._context) #------------------------------------------------------ # Template rendering #------------------------------------------------------ def render_message(self, res_ids): """Generate template-based values of wizard, for the document records given by res_ids. This method is meant to be inherited by email_template that will produce a more complete dictionary, using Jinja2 templates. Each template is generated for all res_ids, allowing to parse the template once, and render it multiple times. This is useful for mass mailing where template rendering represent a significant part of the process. Default recipients are also computed, based on mail_thread method _message_get_default_recipients. This allows to ensure a mass mailing has always some recipients specified. :param browse wizard: current mail.compose.message browse record :param list res_ids: list of record ids :return dict results: for each res_id, the generated template values for subject, body, email_from and reply_to """ self.ensure_one() multi_mode = True if isinstance(res_ids, int): multi_mode = False res_ids = [res_ids] subjects = self.env['mail.template']._render_template( self.subject, self.model, res_ids) bodies = self.env['mail.template']._render_template(self.body, self.model, res_ids, post_process=True) emails_from = self.env['mail.template']._render_template( self.email_from, self.model, res_ids) replies_to = self.env['mail.template']._render_template( self.reply_to, self.model, res_ids) default_recipients = {} if not self.partner_ids: records = self.env[self.model].browse(res_ids).sudo() default_recipients = self.env[ 'mail.thread']._message_get_default_recipients_on_records( records) results = dict.fromkeys(res_ids, False) for res_id in res_ids: results[res_id] = { 'subject': subjects[res_id], 'body': bodies[res_id], 'email_from': emails_from[res_id], 'reply_to': replies_to[res_id], } results[res_id].update(default_recipients.get(res_id, dict())) # generate template-based values if self.template_id: template_values = self.generate_email_for_composer( self.template_id.id, res_ids, fields=[ 'email_to', 'partner_to', 'email_cc', 'attachment_ids', 'mail_server_id' ]) else: template_values = {} for res_id in res_ids: if template_values.get(res_id): # recipients are managed by the template results[res_id].pop('partner_ids', None) results[res_id].pop('email_to', None) results[res_id].pop('email_cc', None) # remove attachments from template values as they should not be rendered template_values[res_id].pop('attachment_ids', None) else: template_values[res_id] = dict() # update template values by composer values template_values[res_id].update(results[res_id]) return multi_mode and template_values or template_values[res_ids[0]] @api.model def generate_email_for_composer(self, template_id, res_ids, fields=None): """ Call email_template.generate_email(), get fields relevant for mail.compose.message, transform email_cc and email_to into partner_ids """ multi_mode = True if isinstance(res_ids, int): multi_mode = False res_ids = [res_ids] if fields is None: fields = [ 'subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'attachment_ids', 'mail_server_id' ] returned_fields = fields + ['partner_ids', 'attachments'] values = dict.fromkeys(res_ids, False) template_values = self.env['mail.template'].with_context( tpl_partners_only=True).browse(template_id).generate_email( res_ids, fields=fields) for res_id in res_ids: res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field)) res_id_values['body'] = res_id_values.pop('body_html', '') values[res_id] = res_id_values return multi_mode and values or values[res_ids[0]]