Odoo18-Base/addons/marketing_card/models/card_campaign.py
2025-01-06 10:57:38 +07:00

397 lines
18 KiB
Python

import base64
from odoo import _, api, fields, models, exceptions
from .card_template import TEMPLATE_DIMENSIONS
class CardCampaign(models.Model):
_name = 'card.campaign'
_description = 'Marketing Card Campaign'
_inherit = ['mail.activity.mixin', 'mail.render.mixin', 'mail.thread']
_order = 'id DESC'
_unrestricted_rendering = True
def _default_card_template_id(self):
return self.env['card.template'].search([], limit=1)
def _get_model_selection(self):
"""Hardcoded list of models, checked against actually-present models."""
allowed_models = ['res.partner', 'event.track', 'event.booth', 'event.registration']
models = self.env['ir.model'].sudo().search_fetch([('model', 'in', allowed_models)], ['model', 'name'])
return [(model.model, model.name) for model in models]
name = fields.Char(required=True)
active = fields.Boolean(default=True)
body_html = fields.Html(related='card_template_id.body', render_engine="qweb")
card_count = fields.Integer(compute='_compute_card_stats')
card_click_count = fields.Integer(compute='_compute_card_stats')
card_share_count = fields.Integer(compute='_compute_card_stats')
mailing_ids = fields.One2many('mailing.mailing', 'card_campaign_id')
mailing_count = fields.Integer(compute='_compute_mailing_count')
card_ids = fields.One2many('card.card', inverse_name='campaign_id')
card_template_id = fields.Many2one('card.template', string="Design", default=_default_card_template_id, required=True)
image_preview = fields.Image(compute='_compute_image_preview', compute_sudo=False, readonly=True, store=True, attachment=False)
link_tracker_id = fields.Many2one('link.tracker', ondelete="restrict")
res_model = fields.Selection(
string="Model Name", compute='_compute_res_model', selection='_get_model_selection',
precompute=True, readonly=True, required=True, store=True,
)
post_suggestion = fields.Text(help="Description below the card and default text when sharing on X")
preview_record_ref = fields.Reference(string="Preview On", selection="_get_model_selection", required=True)
tag_ids = fields.Many2many('card.campaign.tag', string='Tags')
target_url = fields.Char(string='Post Link')
target_url_click_count = fields.Integer(related="link_tracker_id.count")
user_id = fields.Many2one('res.users', string='Responsible', default=lambda self: self.env.user, domain="[('share', '=', False)]")
reward_message = fields.Html(string='Thanks to You Message')
reward_target_url = fields.Char(string='Reward Link')
request_title = fields.Char('Request', default=lambda self: _('Help us share the news'))
request_description = fields.Text('Request Description')
# Static Content fields
content_background = fields.Image('Background')
content_button = fields.Char('Button')
# Dynamic Content fields
content_header = fields.Char('Header')
content_header_dyn = fields.Boolean('Is Dynamic Header')
content_header_path = fields.Char('Header Path')
content_header_color = fields.Char('Header Color')
content_sub_header = fields.Char('Sub-Header')
content_sub_header_dyn = fields.Boolean('Is Dynamic Sub-Header')
content_sub_header_path = fields.Char('Sub-Header Path')
content_sub_header_color = fields.Char('Sub Header Color')
content_section = fields.Char('Section')
content_section_dyn = fields.Boolean('Is Dynamic Section')
content_section_path = fields.Char('Section Path')
content_sub_section1 = fields.Char('Sub-Section 1')
content_sub_section1_dyn = fields.Boolean('Is Dynamic Sub-Section 1')
content_sub_section1_path = fields.Char('Sub-Section 1 Path')
content_sub_section2 = fields.Char('Sub-Section 2')
content_sub_section2_dyn = fields.Boolean('Is Dynamic Sub-Section 2')
content_sub_section2_path = fields.Char('Sub-Section 2 Path')
# images are always dynamic
content_image1_path = fields.Char('Dynamic Image 1')
content_image2_path = fields.Char('Dynamic Image 2')
@api.depends('card_ids')
def _compute_card_stats(self):
cards_by_status_count = self.env['card.card']._read_group(
domain=[('campaign_id', 'in', self.ids)],
groupby=['campaign_id', 'share_status'],
aggregates=['__count'],
order='campaign_id ASC',
)
self.update({
'card_count': 0,
'card_click_count': 0,
'card_share_count': 0,
})
for campaign, status, card_count in cards_by_status_count:
# shared cards are implicitly visited
if status == 'shared':
campaign.card_share_count += card_count
if status in ('shared', 'visited'):
campaign.card_click_count += card_count
campaign.card_count += card_count
@api.model
def _get_render_fields(self):
return [
'body_html', 'content_background', 'content_image1_path', 'content_image2_path', 'content_button', 'content_header',
'content_header_dyn', 'content_header_path', 'content_header_color', 'content_sub_header',
'content_sub_header_dyn', 'content_sub_header_path', 'content_section', 'content_section_dyn',
'content_section_path', 'content_sub_section1', 'content_sub_section1_dyn', 'content_sub_header_color',
'content_sub_section1_path', 'content_sub_section2', 'content_sub_section2_dyn', 'content_sub_section2_path'
]
def _check_access_right_dynamic_template(self):
""" `_unrestricted_rendering` being True means we trust the value on model
when rendering. This means once created, rendering is done without restriction.
But this attribute triggers a check at create / write / translation update that
current user is an admin or has full edition rights (group_mail_template_editor).
However here a Marketing Card Manager must be able to edit the fields other
than the rendering fields. The qweb rendered field `body_html` cannot be
modified by users other than the `base.group_system` users, as
- it's a related field to `card.template.body`,
- store=False
- the model `card.template` can only be altered by `base.group_system`
Hence the security is delegated to the 'card.template' model, hence the
check done by `_check_access_right_dynamic_template` can be bypassed.
"""
return
@api.depends(lambda self: self._get_render_fields() + ['preview_record_ref'])
def _compute_image_preview(self):
for campaign in self:
if campaign.preview_record_ref and campaign.preview_record_ref.exists():
image = campaign._get_image_b64(campaign.preview_record_ref)
else:
image = False
campaign.image_preview = image
@api.depends('mailing_ids')
def _compute_mailing_count(self):
self.mailing_count = 0
mailing_counts = self.env['mailing.mailing']._read_group(
[('card_campaign_id', 'in', self.ids)], ['card_campaign_id'], ['__count']
)
for campaign, mailing_count in mailing_counts:
campaign.mailing_count = mailing_count
@api.depends('preview_record_ref')
def _compute_res_model(self):
for campaign in self:
preview_model = campaign.preview_record_ref and campaign.preview_record_ref._name
campaign.res_model = preview_model or campaign.res_model or 'res.partner'
@api.model_create_multi
def create(self, create_vals):
utm_source = self.env.ref('marketing_card.utm_source_marketing_card', raise_if_not_found=False)
link_trackers = self.env['link.tracker'].sudo().create([
{
'url': vals.get('target_url') or self.env['card.campaign'].get_base_url(),
'title': vals['name'], # not having this will trigger a request in the create
'source_id': utm_source.id if utm_source else None,
'label': f"marketing_card_campaign_{vals.get('name', '')}_{fields.Datetime.now()}",
}
for vals in create_vals
])
return super().create([{
**vals,
'link_tracker_id': link_tracker_id,
} for vals, link_tracker_id in zip(create_vals, link_trackers.ids)])
def write(self, vals):
link_tracker_vals = {}
if vals.keys() & set(self._get_render_fields()):
self.env['card.card'].search([('campaign_id', 'in', self.ids)]).requires_sync = True
if 'target_url' in vals:
link_tracker_vals['url'] = vals['target_url'] or self.env['card.campaign'].get_base_url()
if link_tracker_vals:
self.link_tracker_id.sudo().write(link_tracker_vals)
# write and detect model changes on actively-used campaigns
original_models = self.mapped('res_model')
write_res = super().write(vals)
updated_model_campaigns = self.env['card.campaign'].browse([
campaign.id for campaign, new_model, old_model
in zip(self, self.mapped('res_model'), original_models)
if new_model != old_model
])
for campaign in updated_model_campaigns:
if campaign.card_count:
raise exceptions.ValidationError(_(
"Model of campaign %(campaign)s may not be changed as it already has cards",
campaign=campaign.display_name,
))
return write_res
def action_view_cards(self):
self.ensure_one()
return self.env["ir.actions.actions"]._for_xml_id("marketing_card.cards_card_action") | {
'context': {},
'domain': [('campaign_id', '=', self.id)],
}
def action_view_cards_clicked(self):
self.ensure_one()
return self.env["ir.actions.actions"]._for_xml_id("marketing_card.cards_card_action") | {
'context': {'search_default_filter_visited': True},
'domain': [('campaign_id', '=', self.id)],
}
def action_view_cards_shared(self):
self.ensure_one()
return self.env["ir.actions.actions"]._for_xml_id("marketing_card.cards_card_action") | {
'context': {'search_default_filter_shared': True},
'domain': [('campaign_id', '=', self.id)],
}
def action_view_mailings(self):
self.ensure_one()
return {
'name': _('%(card_campaign_name)s Mailings', card_campaign_name=self.name),
'type': 'ir.actions.act_window',
'res_model': 'mailing.mailing',
'domain': [('card_campaign_id', '=', self.id)],
'view_mode': 'list,form',
'target': 'current',
}
def action_preview(self):
self.ensure_one()
card = self.env['card.card'].with_context(active_test=False).search([
('campaign_id', '=', self.id),
('res_id', '=', self.preview_record_ref.id),
])
if card:
card.image = self.image_preview
else:
card = self.env['card.card'].create({
'campaign_id': self.id,
'res_id': self.preview_record_ref.id,
'image': self.image_preview,
'active': False,
})
return {'type': 'ir.actions.act_url', 'url': card._get_path('preview'), 'target': 'new'}
def action_share(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Send Cards'),
'res_model': 'mailing.mailing',
'context': {
'default_subject': self.name,
'default_card_campaign_id': self.id,
'default_mailing_model_id': self.env['ir.model']._get_id(self.res_model),
'default_body_arch': f"""
<div class="o_layout oe_unremovable oe_unmovable bg-200 o_empty_theme" data-name="Mailing">
<style id="design-element"></style>
<div class="container o_mail_wrapper o_mail_regular oe_unremovable">
<div class="row">
<div class="col o_mail_no_options o_mail_wrapper_td bg-white oe_structure o_editable theme_selection_done">
<div class="s_text_block o_mail_snippet_general pt24 pb24" style="padding-left: 15px; padding-right: 15px;" data-snippet="s_text_block" data-name="Text">
<div class="container s_allow_columns">
<p class="o_default_snippet_text">Hello everyone</p>
<p class="o_default_snippet_text">Here's the link to advertise your participation.
<br> Your help with this promotion would be greatly appreciated!`</p>
<p class="o_default_snippet_text">Many thanks</p>
</div>
</div>
<div class="s_call_to_share_card o_mail_snippet_general" style="padding-top: 10px; padding-bottom: 10px;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tbody>
<tr>
<td align="center">
<a href="/cards/{self.id}/preview" style="padding-left: 3px !important; padding-right: 3px !important">
<img src="/web/image/card.campaign/{self.id}/image_preview" alt="Card Preview" class="img-fluid" style="width: 540px;"/>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div></div></div></div>
""",
},
'views': [[False, 'form']],
'target': 'new',
}
# ==========================================================================
# Image generation
# ==========================================================================
def _get_image_b64(self, record):
if not self.card_template_id.body:
return ''
image_bytes = self.env['ir.actions.report']._run_wkhtmltoimage(
[self._render_field('body_html', record.ids, add_context={'card_campaign': self})[record.id]],
*TEMPLATE_DIMENSIONS
)[0]
return image_bytes and base64.b64encode(image_bytes)
# ==========================================================================
# Card creation
# ==========================================================================
def _update_cards(self, domain, auto_commit=False):
"""Create missing cards and update cards if necessary based for the domain."""
self.ensure_one()
TargetModel = self.env[self.res_model]
res_ids = TargetModel.search(domain).ids
cards = self.env['card.card'].with_context(active_test=False).search_fetch([
('campaign_id', '=', self.id),
('res_id', 'in', res_ids),
], ['res_id', 'requires_sync'])
# update active and res_model for preview cards
cards.active = True
self.env['card.card'].create([
{'campaign_id': self.id, 'res_id': res_id}
for res_id in set(res_ids) - set(cards.mapped('res_id'))
])
# render by batch of 100 to avoid losing progress in case of time out
updated_cards = self.env['card.card']
while cards := self.env['card.card'].search_fetch([
('requires_sync', '=', True),
('campaign_id', '=', self.id),
('res_id', 'in', res_ids),
], ['res_id'], limit=100):
# no need to autocommit if it can be done in one batch
if auto_commit and updated_cards:
self.env.cr.commit()
# avoid keeping hundreds of jpegs in memory
self.env['card.card'].invalidate_model(['image'])
TargetModelPrefetch = TargetModel.with_prefetch(cards.mapped('res_id'))
for card in cards.filtered('requires_sync'):
card.write({
'image': self._get_image_b64(TargetModelPrefetch.browse(card.res_id)),
'requires_sync': False,
'active': True,
})
cards.flush_recordset()
updated_cards += cards
return updated_cards
def _get_url_from_res_id(self, res_id, suffix='preview'):
card = self.env['card.card'].search([('campaign_id', '=', self.id), ('res_id', '=', res_id)])
return card and card._get_path(suffix) or self.target_url
# ==========================================================================
# Mail render mixin / Render utils
# ==========================================================================
@api.depends('res_model')
def _compute_render_model(self):
""" override for mail.render.mixin """
for campaign in self:
campaign.render_model = campaign.res_model
def _get_card_element_values(self, record):
"""Helper to get the right value for dynamic fields."""
self.ensure_one()
result = {
'image1': images[0] if (images := self.content_image1_path and record.mapped(self.content_image1_path)) else False,
'image2': images[0] if (images := self.content_image2_path and record.mapped(self.content_image2_path)) else False,
}
campaign_text_element_fields = (
('header', 'content_header', 'content_header_dyn', 'content_header_path'),
('sub_header', 'content_sub_header', 'content_sub_header_dyn', 'content_sub_header_path'),
('section', 'content_section', 'content_section_dyn', 'content_section_path'),
('sub_section1', 'content_sub_section1', 'content_sub_section1_dyn', 'content_sub_section1_path'),
('sub_section2', 'content_sub_section2', 'content_sub_section2_dyn', 'content_sub_section2_path'),
)
for el, text_field, dyn_field, path_field in campaign_text_element_fields:
if not self[dyn_field]:
result[el] = self[text_field]
else:
try:
m = record.mapped(self[path_field])
result[el] = m and m[0] or False
except (AttributeError, KeyError):
# for generic image, or if field incorrect, return name of field
result[el] = self[path_field]
return result