- add modules(marketing automation + approvals + webstudio) \n

- create new module hr_promote (depend: hr,approvals) \n
- customize approval_type (WIP)
This commit is contained in:
hoangvv 2025-01-17 07:32:51 +07:00
parent 055b0f33e2
commit b8024171a2
405 changed files with 176428 additions and 10 deletions

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem
id="menu_hr_employee_org_chart"
name="Org Chart"
@ -9,5 +8,4 @@
parent="hr.menu_hr_employee_payroll"
sequence="5"
/>
</odoo>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_employee_view_search" model="ir.ui.view">
<field name="name">hr.employee.skill.search</field>
<field name="model">hr.employee</field>

View File

@ -14,16 +14,30 @@ CATEGORY_SELECTION = [
class ApprovalCategory(models.Model):
# ----------------------------------- Private Attributes ---------------------------------
_name = 'approval.category'
_description = 'Approval Category'
_order = 'sequence, id'
_check_company_auto = True
DEFAULT_PYTHON_CODE = """# Available variables:
# - env: environment on which the action is triggered
# - model: model of the record on which the action is triggered; is a void recordset
# - record: record on which the action is triggered; may be void
# - records: recordset of all records on which the action is triggered in multi-mode; may be void
# - time, datetime, dateutil, timezone: useful Python libraries
# - float_compare: utility function to compare floats based on specific precision
# - b64encode, b64decode: functions to encode/decode binary data
# - log: log(message, level='info'): logging function to record debug information in ir.logging table
# - _logger: _logger.info(message): logger to emit messages in server logs
# - UserError: exception class for raising user-facing warning messages
# - Command: x2many commands namespace
# To return an action, assign: action = {...}\n\n\n\n"""
def _get_default_image(self):
default_image_path = 'approvals/static/src/img/Folder.png'
return base64.b64encode(tools.misc.file_open(default_image_path, 'rb').read())
# ----------------------------------- Fields Declaration ----------------------------------
name = fields.Char(string="Name", translate=True, required=True)
company_id = fields.Many2one(
'res.company', 'Company', copy=False,
@ -59,16 +73,30 @@ class ApprovalCategory(models.Model):
Is Approver: the employee's manager will be in the approver list
Is Required Approver: the employee's manager will be required to approve the request.
""")
# Python code
code = fields.Text(string='Python Code', groups='base.group_system',
default=DEFAULT_PYTHON_CODE,
help="Write Python code that the action will execute. Some variables are "
"available for use; help about python expression is given in the help tab.")
# ---------------------------------------- Relational -----------------------------------------
user_ids = fields.Many2many('res.users', compute='_compute_user_ids', string="Approver Users")
approver_ids = fields.One2many('approval.category.approver', 'category_id', string="Approvers")
approver_sequence = fields.Boolean('Approvers Sequence?', help="If checked, the approvers have to approve in sequence (one after the other). If Employee's Manager is selected as approver, they will be the first in line.")
request_to_validate_count = fields.Integer("Number of requests to validate", compute="_compute_request_to_validate_count")
automated_sequence = fields.Boolean('Automated Sequence?',
help="If checked, the Approval Requests will have an automated generated name based on the given code.")
sequence_code = fields.Char(string="Code")
sequence_id = fields.Many2one('ir.sequence', 'Reference Sequence',
copy=False, check_company=True)
model_id = fields.Many2one('ir.model', 'Model',copy=False)
# ---------------------------------------- Methods -----------------------------------------
def _compute_request_to_validate_count(self):
domain = [('request_status', '=', 'pending'), ('approver_ids.user_id', '=', self.env.user.id)]
requests_data = self.env['approval.request']._read_group(domain, ['category_id'], ['__count'])
@ -76,6 +104,9 @@ class ApprovalCategory(models.Model):
for category in self:
category.request_to_validate_count = requests_mapped_data.get(category.id, 0)
@api.depends_context('lang')
@api.depends('approval_minimum', 'approver_ids', 'manager_approval')
def _compute_invalid_minimum(self):

View File

@ -69,15 +69,16 @@
</div>
<group>
<field name="description"/>
<field name="model_id"/>
<field name="approval_type" invisible="1"/>
<field name="automated_sequence"/>
<field name="sequence_code" invisible="not automated_sequence" required="automated_sequence"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
</group>
<notebook>
<page string="Options" name="options">
<page string="Coditions" name="conditions">
<group>
<group string="Fields" name="option_settings">
<!-- <group string="Fields" name="option_settings">
<field name="active" invisible="1"/>
<field name="requirer_document" string="Document" widget="radio" options="{'horizontal': true}"/>
<field name="has_partner" string="Contact" widget="radio" options="{'horizontal': true}"/>
@ -90,8 +91,8 @@
<field name="has_reference" string="Reference" widget="radio" options="{'horizontal': true}"/>
<field name="has_payment_method" string="Payment" widget="radio" options="{'horizontal': true}" invisible="1"/>
<field name="has_location" string="Location" widget="radio" options="{'horizontal': true}"/>
</group>
<group string="Approvers" name="approvers">
</group> -->
<!-- <group string="Approvers" name="approvers">
<field name="manager_approval"/>
<separator colspan="2"/>
<field name="approver_ids"/>
@ -101,9 +102,13 @@
<div class="text-warning" colspan="2" invisible="not invalid_minimum">
<span class="fa fa-warning" title="Invalid minimum approvals"/><field name="invalid_minimum_warning" nolabel="1"/>
</div>
</group>
</group> -->
</group>
</page>
<page string="Code">
<field name="code" widget="code" options="{'mode': 'python'}" placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."/>
</page>
</notebook>
</sheet>
</form>

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View File

@ -0,0 +1,17 @@
{
"name": 'Employee Promote',
"category": 'Hidden',
"version": '0.1',
"description":
"""
Promote Module for HR
""",
"depends": ['hr','approvals'],
'auto_install': ['hr','approvals'],
"data": [
'views/hr_promote_views.xml',
'views/hr_promote_menus.xml',
'security/ir.model.access.csv',
],
"license": "LGPL-3",
}

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_promote

View File

@ -0,0 +1,129 @@
from odoo.exceptions import ValidationError,UserError
from odoo import api, fields, models
from datetime import date
class HRPromote(models.Model):
# ----------------------------------- Private Attributes ---------------------------------
_name = 'hr.promote'
_description = 'Employees/Promotes'
_order = 'promotion_date desc'
# ----------------------------------- Fields Declaration ----------------------------------
promotion_date = fields.Date(
string="Promotion Date",
help="The date the employee was promoted.",
required=True
)
description = fields.Text(
string="Description",
help="Details about the promotion.",
required=True
)
state = fields.Selection(
selection=[
("draft", "Draft"),
("approval_needed", "Wait for Approval"),
("confirmed", "Confirmed"),
],
string="State",
required=True,
default="draft",
help="The current state of the promotion process."
)
# ---------------------------------------- Relational -----------------------------------------
employee_id = fields.Many2one(
'hr.employee',
string="Employee",
required=True,
ondelete='cascade',
help="The employee being promoted."
)
job_id = fields.Many2one(
"hr.job",
string="Current Job",
required=True,
ondelete='cascade',
help="The employee's current job position."
)
designation_id = fields.Many2one(
"hr.job",
string="New Designation",
required=True,
ondelete='cascade',
help="The new job position the employee is promoted to."
)
# ---------------------------------------- Compute Value -----------------------------------------
name = fields.Char(string="Promotion Reference", compute="_compute_name", store=True)
# ---------------------------------------- Methods -----------------------------------------
@api.depends('employee_id', 'promotion_date')
def _compute_name(self):
for record in self:
if record.employee_id and record.promotion_date:
record.name = f"{record.employee_id.name} - {record.promotion_date}"
else:
record.name = "Promotion"
@api.model
def create(self, vals):
vals['state'] = 'approval_needed'
if 'employee_id' in vals and not vals.get('job_id'):
employee = self.env['hr.employee'].browse(vals['employee_id'])
vals['job_id'] = employee.job_id.id if employee.job_id else False
if not vals.get('job_id'):
raise ValueError("The employee does not have a current job position.")
promotion_record = super().create(vals)
# Create the approval request related to the promotion
self.env['approval.request'].create({
'employee_id': vals['employee_id'],
'promotion_record_id': promotion_record.id,
'state': 'draft', # You can adjust this based on your workflow
'approval_type': 'promotion', # Custom field for the type of approval
})
return promotion_record
def action_save_and_close(self):
return {
'type': 'ir.actions.act_window',
'name': 'Promote',
'res_model': 'hr.promote',
'view_mode': 'list',
'target': 'current',
}
@api.onchange('employee_id')
def _onchange_employee_id(self):
"""
When an employee is selected, automatically set their current job position.
"""
if self.employee_id:
if self.employee_id.job_id:
self.job_id = self.employee_id.job_id
else:
self.job_id = False
return {
'warning': {
'title': "No Job Position",
'message': f"The selected employee '{self.employee_id.name}' does not have a job position.",
}
}
else:
self.job_id = False
@api.onchange('promotion_date')
def _onchange_promotion_date(self):
"""
Validates the promotion date to ensure it's in the future.
"""
if self.promotion_date and self.promotion_date <= date.today():
self.promotion_date = False
raise UserError("The promotion date must be a future date.")
@api.constrains('promotion_date')
def _check_promotion_date(self):
for record in self:
if record.promotion_date and record.promotion_date <= date.today():
raise ValidationError("The promotion date must be a future date.")

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_promote,hr.promote,model_hr_promote,hr.group_hr_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_promote hr.promote model_hr_promote hr.group_hr_user 1 1 1 1

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem
id="menu_hr_employee_promote"
name="Promote"
action="action_hr_promote"
parent="hr.menu_hr_employee_payroll"
sequence="4"
groups="hr.group_hr_user"
/>
</odoo>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="hr_promote_view_list" model="ir.ui.view">
<field name="name">hr.promote.list</field>
<field name="model">hr.promote</field>
<field name="arch" type="xml">
<list string="Promote">
<field name="employee_id" width="20%"/>
<field name="description" width="30%"/>
<field name="job_id" readonly="1" width="15%"/>
<field name="designation_id" width="15%"/>
<field name="promotion_date" width="20%"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="hr_promote_view_form" model="ir.ui.view">
<field name="name">hr.promote.form</field>
<field name="model">hr.promote</field>
<field name="arch" type="xml">
<form string="Promotion Record">
<header>
<field name="state" widget="statusbar" statusbar_visible="draft,approval_needed,confirmed"/>
<button name="action_save_and_close" type="object" string="Save and Close" class="oe_highlight"/>
</header>
<sheet>
<group>
<group>
<field name="employee_id" width="50%"/>
<field name="job_id" readonly="1" width="50%" />
</group>
<group>
<field name="designation_id" string="New Job Position" width="50%"/>
<field name="promotion_date" width="50%"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_hr_promote" model="ir.actions.act_window">
<field name="name">Promote</field>
<field name="res_model">hr.promote</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import report
from . import wizard
def uninstall_hook(env):
# remove reference to mailing.mailing use_in_marketing_automation field
act_window = env.ref('mass_mailing.mailing_mailing_action_mail', False)
if act_window and act_window.domain and 'use_in_marketing_automation' in act_window.domain:
act_window.domain = [('mailing_type', '=', 'mail')]

View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Marketing Automation",
'version': "1.0",
'summary': "Build automated mailing campaigns",
'website': 'https://www.odoo.com/app/marketing-automation',
'category': "Marketing/Marketing Automation",
'sequence': 195,
'depends': ['mass_mailing'],
'data': [
'security/marketing_automation_security.xml',
'security/ir.model.access.csv',
'views/ir_actions_views.xml',
'views/ir_model_views.xml',
'views/marketing_automation_menus.xml',
'wizard/marketing_campaign_test_views.xml',
'views/link_tracker_views.xml',
'views/mailing_mailing_views.xml',
'views/mailing_trace_views.xml',
'views/marketing_activity_views.xml',
'views/marketing_participant_views.xml',
'views/marketing_trace_views.xml',
'views/marketing_campaign_views.xml',
'data/ir_cron_data.xml',
'data/marketing_activity_data_templates.xml',
],
'application': True,
'license': 'OEEL-1',
'uninstall_hook': 'uninstall_hook',
'assets': {
'web._assets_primary_variables': [
'marketing_automation/static/src/scss/variables.scss',
],
'web.assets_backend': [
'marketing_automation/static/src/js/**/*.js',
'marketing_automation/static/src/js/*.js',
'marketing_automation/static/src/scss/*.scss',
'marketing_automation/static/src/xml/*.xml',
'marketing_automation/static/src/components/**/*',
# Don't include dark mode files in light mode
('remove', 'marketing_automation/static/src/scss/*.dark.scss'),
],
"web.assets_web_dark": [
'marketing_automation/static/src/scss/*.dark.scss',
],
'web.qunit_suite_tests': [
'marketing_automation/static/tests/hierarchy_kanban_tests.js',
],
'web.assets_unit_tests': [
'marketing_automation/static/tests/campaign_picker.test.js'
],
}
}

View File

@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo noupdate="1">
<record id="ir_cron_campaign_sync_participants" model="ir.cron">
<field name="name">Marketing Automation: sync participants</field>
<field name="model_id" ref="model_marketing_campaign"/>
<field name="state">code</field>
<field name="code">model.search([('state', '=', 'running')]).sync_participants()</field>
<field name="interval_number">12</field>
<field name="interval_type">hours</field>
</record>
<record id="ir_cron_campaign_execute_activities" model="ir.cron">
<field name="name">Marketing Automation: execute activities</field>
<field name="model_id" ref="model_marketing_campaign"/>
<field name="state">code</field>
<field name="code">model.search([('state', '=', 'running')]).execute_activities()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
</record>
</odoo>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<template id="marketing_activity_summary_template" name="Activity Summary Template">
<strong>Summary</strong><br/>
Starting from <strong><t t-out="activity.interval_number"/> <t t-out="interval_type_label"/></strong>
<t t-set="action_or_template_id" t-value="activity.server_action_id or activity.mass_mailing_id"/>
<t t-if="action_or_template_id and activity.trigger_type == 'begin'">
after the <strong>beginning of the workflow</strong>,<br/>the <strong><t t-out="activity_type_label"/> "<t t-out="action_or_template_id.display_name"/>"</strong> will be <t t-if="activity.activity_type == 'action'">run</t><t t-else="">sent</t>.
</t>
<t t-elif="action_or_template_id and activity.parent_id">
<t t-if="activity.trigger_type == 'activity'">
after the execution of the Activity "<strong t-out="parent_activity_name"/>",
</t>
<t t-elif="activity.trigger_type == 'mail_open'">
after the Participant <strong>opened</strong> the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>",
</t>
<t t-elif="activity.trigger_type == 'mail_not_open'">
if the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>" was <strong>not opened</strong>,
</t>
<t t-elif="activity.trigger_type == 'mail_reply'">
after the Participant <strong>replied</strong> to the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>",
</t>
<t t-elif="activity.trigger_type == 'mail_not_reply'">
if the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>" did <strong>not receive a reply</strong>,
</t>
<t t-elif="activity.trigger_type == 'mail_click'">
after the Participant <strong>clicked</strong>,<br/>on any link included in the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>",
</t>
<t t-elif="activity.trigger_type == 'mail_not_click'">
if no link included in the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>" gets clicked,
</t>
<t t-elif="activity.trigger_type == 'mail_bounce'">
after the <strong>Mailing</strong> sent by the Activity "<strong t-out="parent_activity_name"/>" <strong>bounced</strong>,
</t>
<t name="activity_action_or_template">
the <strong><t t-out="activity_type_label"/> "<t t-out="action_or_template_id.display_name"/>"</strong> will be <t t-if="activity.activity_type == 'action'">run</t><t t-else="">sent</t>.
</t>
</t>
<span t-else="" class="ms-1">...</span>
<t t-if="activity.validity_duration">
<br/>This activity will be <strong>cancelled, if <t t-out="activity.validity_duration_number"/> <t t-out="validity_duration_type_label"/></strong> have passed since the scheduled date.
</t>
</template>
</data>
</odoo>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="mail_template_body_confirmation_template" name="Email Confirmation Template">
<div class="o_layout o_mail_wrapper o_mail_no_resize o_mail_wrapper_td oe_structure o_editable" style="margin: 0; padding: 0; width: 100%; background-color: #f3f3f3;" data-name="Mailing">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #fff;">
<div style="text-align: center;">
<h2>Welcome!</h2>
<p>Dear Customer,</p>
<p>Thank you for registering. Please click the button below to confirm your registration:</p>
<div style="text-align: center; margin-top: 30px;">
<a t-att-href="db_host" style="background-color: #0079CC; color: #fff; padding: 15px 30px; text-decoration: none; border-radius: 5px;">
Confirm Registration
</a>
</div>
<p style="margin-top: 30px;"> If you didn't make this request, you can ignore this email.</p>
</div>
</div>
</div>
</template>
</odoo>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="mail_template_body_free_trial_template">
<div class="o_layout o_default_theme">
<div class="container o_mail_wrapper" style="text-align: center;">
<div class="row">
<div class="col o_mail_no_resize o_mail_wrapper_td oe_structure o_editable">
<div class="o_mail_block_header_logo mb16" data-snippet="s_mail_block_header_logo">
<div class="o_mail_snippet_general">
<div class="container o_mail_table_styles o_mail_h_padding">
<div class="row">
<div class="col-2"></div>
<div valign="center" class="col-8 o_mail_logo_container text-center o_mail_v_padding">
<a href="http://www.example.com">
<img border="0" src="/mass_mailing/static/src/img/theme_default/s_default_image_header_logo.png" style="max-width:400px;" alt="Your Logo" class="img-fluid"/>
</a>
</div>
<div class="col-2" style="text-align:right"></div>
</div>
</div>
</div>
</div>
<div class="o_mail_block_title_text" data-snippet="s_mail_block_title_text">
<div class="o_mail_snippet_general">
<div class="container o_mail_table_styles">
<div class="row">
<div class="col-12 o_mail_h_padding o_mail_v_padding">
<h2 class="mt0 o_default_snippet_text">Thank you for joining us!</h2>
<p class="">We want to take this opportunity to welcome you to our ever-growing community!<br/></p>
<p class="">Here is your link to download our product catalog for free.</p>
<p class=""><a t-att-href="company_website" role="button" class="btn btn-primary" data-bs-original-title="" title="">Download</a></p>
<p class=""><br/></p>
<p class="o_default_snippet_text">Enjoy,</p>
<img src="/mass_mailing/static/src/img/theme_default/signature.png" style="width:125px; margin-top:8px;margin-bottom:-25px;" alt="Demo Signature" class="img-fluid"/>
<p>
<small>
<strong class="o_default_snippet_text">Michael Fletcher</strong>
<br/><small class="">Community Manager</small>
</small>
</p>
</div>
</div>
<div class="row">
<div class="col-12 o_mail_h_padding o_mail_v_padding"><br/></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</odoo>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="mail_template_body_join_partnership_template">
<div class="o_layout o_default_theme">
<div class="container o_mail_wrapper" style="text-align: center;">
<div class="row">
<div class="col o_mail_no_resize o_mail_wrapper_td oe_structure o_editable">
<div class="o_mail_block_header_logo mb16" data-snippet="s_mail_block_header_logo">
<div class="o_mail_snippet_general">
<div class="container o_mail_table_styles o_mail_h_padding">
<div class="row">
<div class="col-2"></div>
<div valign="center" class="col-8 o_mail_logo_container text-center o_mail_v_padding">
<a href="http://www.example.com">
<img border="0" src="/mass_mailing/static/src/img/theme_default/s_default_image_header_logo.png" style="max-width:400px;" alt="Your Logo" class="img-fluid"/>
</a>
</div>
<div class="col-2" style="text-align:right"></div>
</div>
</div>
</div>
</div>
<div class="o_mail_block_title_text" data-snippet="s_mail_block_title_text">
<div class="o_mail_snippet_general">
<div class="container o_mail_table_styles">
<div class="row">
<div class="col-12 o_mail_h_padding o_mail_v_padding">
<h2 class="mt0">Join partnership!</h2>
<p class="">We want to take this opportunity to welcome you to our ever-growing community!<br/></p>
<p class="">Here is the link to download our product catalog.</p>
<p class=""><a t-att-href="company_website" role="button" class="btn btn-primary" data-bs-original-title="" title="">Download</a></p>
<p class=""><br/></p>
<p class="o_default_snippet_text">Enjoy,</p>
<img src="/mass_mailing/static/src/img/theme_default/signature.png" style="width:125px; margin-top:8px;margin-bottom:-25px;" alt="Demo Signature" class="img-fluid"/>
<p><small><strong class="o_default_snippet_text">Michael Fletcher</strong><br/><small class="">Community Manager</small></small></p>
</div>
</div>
<div class="row">
<div class="col-12 o_mail_h_padding o_mail_v_padding"><br/></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</odoo>

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="mail_template_body_welcome_template">
<div class="o_layout oe_unremovable oe_unmovable bg-200 o_default_theme" data-name="Mailing">
<style id="design-element">.o_mail_wrapper h2 {
font-weight: bolder;
}
.o_mail_wrapper p {
color: rgb(108, 117, 125);
}
.o_mail_wrapper p > * {
color: rgb(108, 117, 125);
}
.o_mail_wrapper li {
color: rgb(108, 117, 125);
}
.o_mail_wrapper li > * {
color: rgb(108, 117, 125);
}
.o_mail_wrapper hr {
border-top-color: rgb(206, 212, 218) !important;
}</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_header_logo o_mail_block_header_logo o_mail_snippet_general pt16 pb16">
<div class="container">
<div class="row">
<div class="col-lg-4"></div>
<div class="col-lg-4" style="text-align: center;">
<a style="text-decoration:none;" target="_blank" t-att-href="db_host">
<img border="0" src="/mass_mailing/static/src/img/theme_default/s_default_image_header_logo.png" width="180" class="img-fluid"/>
</a>
</div>
<div class="col-lg-4" style="text-align: right;"></div>
</div>
</div>
</div>
<div class="s_text_block o_mail_snippet_general pt40 pb16" style="padding-left: 15px; padding-right: 15px;">
<div class="container s_allow_columns">
<h2>Thank you for joining us!</h2>
<p>
<br/>We want to take this opportunity to welcome you to our ever-growing community!
<br/>Your platform is ready for work, it will help you reduce the costs of digital signatures, attract new customers and increase sales.
</p>
<p>
<img src="/mass_mailing/static/src/img/theme_default/signature.png" style="width:125px; margin-top:8px;margin-bottom:-25px;" alt="Signature" class="img-fluid"/>
</p>
<p>Michael Fletcher
<br/>
<span style="font-size: 12px; font-weight: bolder;">Customer Service</span>
</p>
<p style="text-align: center;">
<a role="button" t-att-href="db_host" class="btn btn-primary">LOGIN</a>
</p>
</div>
</div>
<div class="s_hr pt16 pb16" data-snippet="s_hr" data-name="Separator">
<hr class="s_hr_1px s_hr_solid"/>
</div>
<div class="s_footer_social o_mail_block_footer_social o_mail_footer_social_left o_mail_snippet_general">
<div class="container">
<div class="row">
<div class="col-lg o_mail_footer_description">
<p>
<strong>YourCompany</strong>
</p>
<div class="o_mail_footer_links">
<a role="button" href="/unsubscribe_from_list" class="btn btn-link">Unsubscribe</a>
</div>
</div>
<div class="col-lg" align="right">
<div class="o_mail_footer_social pb16">
<a aria-label="Facebook" title="Facebook" href="https://www.facebook.com/Odoo">
<span class="fa fa-facebook"></span>
</a>&#160;&#160;
<a style="margin-left:10px" aria-label="LinkedIn" title="LinkedIn" href="https://www.linkedin.com/company/odoo">
<span class="fa fa-linkedin"></span>
</a>&#160;&#160;
<a style="margin-left:10px" aria-label="X" title="X" href="https://twitter.com/Odoo">
<span class="fa fa-twitter"></span>
</a>&#160;&#160;
<a style="margin-left:10px" aria-label="Instagram" title="Instagram" href="https://www.instagram.com/explore/tags/odoo/">
<span class="fa fa-instagram"></span>
</a>&#160;&#160;
<a style="margin-left:10px" aria-label="TikTok" title="TikTok" href="https://www.tiktok.com/@odoo">
<span class="fa fa-tiktok"></span>
</a>
</div>
<p class="o_mail_footer_copy">© 2023 All Rights Reserved</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</odoo>

View File

@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="mail_template_body_yellow_discount_template">
<div class="o_layout oe_unremovable oe_unmovable bg-200 o_solar_theme" data-name="Mailing">
<style id="design-element">
.o_mail_wrapper h1 {
font-weight: bolder;
}
.o_mail_wrapper h2 {
font-weight: bolder;
}
.o_mail_wrapper h3 {
font-size: 18px;
font-weight: bolder;
}
.o_mail_wrapper a:not(.btn) {
color: rgb(0, 0, 0);
}
.o_mail_wrapper a.btn.btn-link {
color: rgb(0, 0, 0);
}
.o_mail_wrapper a.btn.btn-primary {
background-color: rgb(0, 0, 0);
border-color: rgb(0, 0, 0);
}
.o_mail_wrapper a.btn.btn-outline-primary {
background-color: rgb(0, 0, 0);
border-color: rgb(0, 0, 0);
}
.o_mail_wrapper a.btn.btn-fill-primary {
background-color: rgb(0, 0, 0);
border-color: rgb(0, 0, 0);
}
.o_mail_wrapper hr {
width: 75%;
}
</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_header_text_social o_mail_block_header_text_social o_mail_snippet_general pt16 pb16" data-snippet="s_mail_block_header_text_social" data-name="Left Text">
<div class="container">
<div class="row">
<div class="col-lg-4 pt16 pb16">
<h1>
<a target="_blank" t-att-href="db_host">
<span style="color: rgb(255, 187, 0) !important; font-size: 32px" class="fa fa-1x fa-sun-o"></span>
</a>
<span style="font-size: 32px;">
<font style="font-weight: bolder; color: rgb(255, 255, 255); background-color: rgb(255, 187, 0);">SOLAR</font>
</span>
</h1>
</div>
<div class="col-lg-8 o_mail_header_social" style="text-align:right;">
<a href="/view">
<span style="font-weight: bolder;">View online</span>&#160;
<span class="fa fa-external-link"></span>
</a>
</div>
</div>
</div>
</div>
<div class="s_title o_mail_snippet_general pt72 pb80" data-snippet="s_title" data-name="Title" style="background-color: rgb(255, 187, 0) !important;">
<div class="container s_allow_columns">
<h1 style="text-align:center">
<font style="font-size: 62px; background-color: rgb(255, 255, 255);">&#160;10% OFF&#160;</font>
<br/>
<font style="font-size: 62px; background-color: rgb(255, 255, 255);">&#160;SALES&#160;</font>
</h1>
</div>
</div>
<div class="s_discount2 o_mail_block_discount2 o_mail_snippet_general pt32 pb32" data-name="Promo Code" style="padding-left: 15px; padding-right: 15px;">
<h2 style="text-align: center;">GET 10%&#160;OFF</h2>
<p style="text-align: center;">
Valid on all sales prices in our webshop.
</p>
<table border="0" cellpadding="0" cellspacing="0" align="center" class="border" style="border-collapse: collapse; border-color: rgb(255, 187, 0) !important; border-width: 3px !important; mso-table-lspace:0pt; mso-table-rspace:0pt;">
<tbody>
<tr>
<td width="50" height="50" align="center" class="o_mail_no_resize o_cc" style="width:50px!important; min-width: 50px; max-width:5.6rem; text-align: center">
<i class="fa fa-2x fa-ticket" style="color: rgb(255, 187, 0) !important;"></i>
</td>
<td width="200" height="50" align="center" class="o_cc" style="min-width: 150px; width: 200px; text-align: center;">
<p class="mb0">ENDOFSUMMER20</p>
</td>
</tr>
</tbody>
</table>
<br/>
<p style="text-align:center;">
<a role="button" class="btn btn-primary btn-lg" t-att-href="company_website">Visit the website</a>
</p>
</div>
<div class="s_hr pt16 pb16" data-snippet="s_hr" data-name="Separator">
<hr class="s_hr_1px s_hr_solid"/>
</div>
<div class="s_features o_mail_snippet_general" data-snippet="s_features" data-name="Features">
<div class="container">
<div class="row">
<div class="col-lg-4 pt16 pb16" style="text-align:center;">
<img alt="Customer Service" src="/mass_mailing_themes/static/src/img/theme_promotion/s_default_image_product_1.jpg" class="img-fluid o_we_custom_image mx-auto d-block" style="width: 100px;"/>
<h3>24/7
<br/>Customer Service
</h3>
</div>
<div class="col-lg-4 pt16 pb16" style="text-align:center;">
<img alt="Free Delivery" src="/mass_mailing_themes/static/src/img/theme_promotion/s_default_image_product_2.jpg" class="img-fluid o_we_custom_image mx-auto d-block" style="width: 100px;"/>
<h3 style="text-align:center;">Free delivery*</h3>
<p style="text-align:center;">*For orders over $39.</p>
</div>
<div class="col-lg-4 pt16 pb16" style="text-align:center;">
<img alt="Easy Returns" src="/mass_mailing_themes/static/src/img/theme_promotion/s_default_image_product_3.jpg" class="img-fluid o_we_custom_image mx-auto d-block" style="width: 100px;"/>
<h3 style="text-align:center;">Easy Returns</h3>
</div>
</div>
</div>
</div>
<div class="s_footer_social o_mail_block_footer_social o_mail_footer_social_left o_mail_snippet_general pt16 pb16" data-snippet="s_mail_block_footer_social_left" data-name="Footer Left" style="background-color: rgb(255, 187, 0) !important;">
<div class="container">
<div class="row">
<div class="col-lg-6">
<p>
<span style="font-weight: bolder;">
<font style="background-color: rgb(255, 255, 255); color: rgb(255, 187, 0);">SOLAR</font>
</span>
</p>
<div class="o_mail_footer_links">
<a role="button" href="/unsubscribe_from_list" class="btn btn-link ">
Unsubscribe
</a>
</div>
</div>
<div class="col-lg-6">
<div class="pb16" style="text-align: right;">
<a aria-label="Facebook" title="Facebook" href="https://www.facebook.com/Odoo">
<span class="fa fa-facebook rounded bg-black" style="color: rgb(255, 187, 0) !important;"></span>
</a>&#160;&#160;
<a style="margin-left:10px" aria-label="LinkedIn" title="LinkedIn" href="https://www.linkedin.com/company/odoo">
<span class="fa fa-linkedin rounded bg-black" style="color: rgb(255, 187, 0) !important;"></span>
</a>&#160;&#160;
<a style="margin-left:10px" aria-label="X" title="X" href="https://twitter.com/Odoo">
<span class="fa fa-twitter rounded bg-black" style="color: rgb(255, 187, 0) !important;"></span>
</a>&#160;&#160;
<a aria-label="Instagram" title="Instagram" href="https://www.instagram.com/explore/tags/odoo/">
<span class="fa fa-instagram rounded bg-black" style="color: rgb(255, 187, 0) !important;"></span>
</a>&#160;&#160;
<a aria-label="TikTok" title="TikTok" href="https://www.tiktok.com/@odoo">
<span class="fa fa-tiktok rounded bg-black" style="color: rgb(255, 187, 0) !important;"></span>
</a>
</div>
<p style="text-align: right">
<font style="color: rgb(137, 101, 1);">© 2023 All Rights Reserved</font>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</odoo>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import marketing_activity
from . import marketing_campaign
from . import marketing_participant
from . import marketing_trace
from . import mailing_trace
from . import mailing_mailing
from . import utm_campaign
from . import utm_source

View File

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class MassMailing(models.Model):
_inherit = 'mailing.mailing'
@api.model
def default_get(self, fields):
vals = super(MassMailing, self).default_get(fields)
if 'subject' in fields and self.env.context.get('default_use_in_marketing_automation', False):
vals['subject'] = self.env.context.get('default_name')
return vals
use_in_marketing_automation = fields.Boolean(
string='Specific mailing used in marketing campaign', default=False,
help='Marketing campaigns use mass mailings with some specific behavior; this field is used to indicate its statistics may be suspicious.')
marketing_activity_ids = fields.One2many('marketing.activity', 'mass_mailing_id', string='Marketing Activities', copy=False)
# TODO: remove in master
def convert_links(self):
"""Override convert_links so we can add marketing automation campaign instead of mass mail campaign"""
res = {}
done = self.env['mailing.mailing']
for mass_mailing in self:
if self.env.context.get('default_marketing_activity_id'):
activity = self.env['marketing.activity'].browse(self.env.context['default_marketing_activity_id'])
vals = {
'mass_mailing_id': mass_mailing.id,
'campaign_id': activity.campaign_id.utm_campaign_id.id,
'source_id': activity.source_id.id,
'medium_id': mass_mailing.medium_id.id,
}
res[mass_mailing.id] = mass_mailing._shorten_links(
mass_mailing.body_html or '',
vals,
blacklist=['/unsubscribe_from_list', '/view']
)
done |= mass_mailing
res.update(super(MassMailing, self - done).convert_links())
return res
def _get_link_tracker_values(self):
# We don't want to create link trackers for tests
if self.env.context.get('active_model') == 'marketing.campaign.test':
return {}
res = super(MassMailing, self)._get_link_tracker_values()
if self.env.context.get('default_marketing_activity_id'):
activity = self.env['marketing.activity'].browse(self.env.context['default_marketing_activity_id'])
res['campaign_id'] = activity.campaign_id.utm_campaign_id.id
res['source_id'] = activity.source_id.id
return res
def _get_seen_list_extra(self):
return ('LEFT JOIN marketing_trace m ON (s.marketing_trace_id = m.id)', 'AND (m.is_test IS NULL OR m.is_test = false)')
@api.ondelete(at_uninstall=False)
def _unlink_if_no_linked_activities(self):
protected = self.filtered(lambda m: m.marketing_activity_ids)
if protected:
raise UserError(
_("Mailings %(mailing_names)s are used in marketing campaigns. You should take care of this before unlinking the mailings.",
mailing_names=", ".join(protected.mapped("name"))
)
)

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailingTrace(models.Model):
_inherit = 'mailing.trace'
marketing_trace_id = fields.Many2one(
'marketing.trace', string='Marketing Trace',
index=True, ondelete='cascade')
def set_clicked(self, domain=None):
traces = super(MailingTrace, self).set_clicked(domain=domain)
marketing_mail_traces = traces.filtered(lambda trace: trace.marketing_trace_id and trace.marketing_trace_id.activity_type == 'email')
for marketing_trace in marketing_mail_traces.marketing_trace_id:
marketing_trace.process_event('mail_click')
return traces
def set_opened(self, domain=None):
traces = super(MailingTrace, self).set_opened(domain=domain)
marketing_mail_traces = traces.filtered(lambda trace: trace.marketing_trace_id and trace.marketing_trace_id.activity_type == 'email')
for marketing_trace in marketing_mail_traces.marketing_trace_id:
marketing_trace.process_event('mail_open')
return traces
def set_replied(self, domain=None):
traces = super(MailingTrace, self).set_replied(domain=domain)
marketing_mail_traces = traces.filtered(lambda trace: trace.marketing_trace_id and trace.marketing_trace_id.activity_type == 'email')
for marketing_trace in marketing_mail_traces.marketing_trace_id:
marketing_trace.process_event('mail_reply')
return traces
def set_bounced(self, domain=None, bounce_message=False):
traces = super(MailingTrace, self).set_bounced(domain=domain, bounce_message=bounce_message)
marketing_mail_traces = traces.filtered(lambda trace: trace.marketing_trace_id and trace.marketing_trace_id.activity_type == 'email')
for marketing_trace in marketing_mail_traces.marketing_trace_id:
marketing_trace.process_event('mail_bounce')
return traces

View File

@ -0,0 +1,565 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import threading
from ast import literal_eval
from datetime import timedelta, date, datetime
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.fields import Datetime
from odoo.exceptions import ValidationError, AccessError
from odoo.osv import expression
from odoo.tools.misc import clean_context
_logger = logging.getLogger(__name__)
class MarketingActivity(models.Model):
_name = 'marketing.activity'
_description = 'Marketing Activity'
_inherit = ['utm.source.mixin']
_order = 'interval_standardized, id ASC'
# definition and UTM
activity_type = fields.Selection([
('email', 'Email'),
('action', 'Server Action')
], string='Activity Type', required=True, default='email')
mass_mailing_id = fields.Many2one(
'mailing.mailing', string='Marketing Template', compute='_compute_mass_mailing_id',
readonly=False, store=True)
# Technical field doing the mapping of activity type and mailing type
mass_mailing_id_mailing_type = fields.Selection([
('mail', 'Email')], string='Mailing Type', compute='_compute_mass_mailing_id_mailing_type',
readonly=True, store=True)
server_action_id = fields.Many2one(
'ir.actions.server', string='Server Action', compute='_compute_server_action_id',
readonly=False, store=True)
campaign_id = fields.Many2one(
'marketing.campaign', string='Campaign',
index=True, ondelete='cascade', required=True)
utm_campaign_id = fields.Many2one(
'utm.campaign', string='UTM Campaign',
readonly=True, related='campaign_id.utm_campaign_id') # propagate to mailings
# interval
interval_number = fields.Integer(string='Send after', default=0)
interval_type = fields.Selection([
('hours', 'Hours'),
('days', 'Days'),
('weeks', 'Weeks'),
('months', 'Months')], string='Delay Type',
default='hours', required=True)
interval_standardized = fields.Integer('Send after (in hours)', compute='_compute_interval_standardized', store=True, readonly=True)
# validity
validity_duration = fields.Boolean('Validity Duration',
help='Check this to make sure your actions are not executed after a specific amount of time after the scheduled date. (e.g. Time-limited offer, Upcoming event, …)')
validity_duration_number = fields.Integer(string='Valid during', default=0)
validity_duration_type = fields.Selection([
('hours', 'Hours'),
('days', 'Days'),
('weeks', 'Weeks'),
('months', 'Months')],
default='hours', required=True)
# target
domain = fields.Char(
string='Applied Filter',
help='Activity will only be performed if record satisfies this domain, obtained from the combination of the activity filter and its inherited filter',
compute='_compute_inherited_domain', recursive=True, store=True, readonly=True)
activity_domain = fields.Char(
string='Activity Filter', default='[]',
help='Domain that applies to this activity and its child activities')
model_id = fields.Many2one('ir.model', related='campaign_id.model_id', string='Model', readonly=True)
model_name = fields.Char(related='model_id.model', string='Model Name', readonly=True)
# Related to parent activity
parent_id = fields.Many2one(
'marketing.activity', string='Activity', compute='_compute_parent_id',
index=True, readonly=False, store=True, ondelete='cascade')
allowed_parent_ids = fields.Many2many('marketing.activity', string='Allowed parents', help='All activities which can be the parent of this one', compute='_compute_allowed_parent_ids')
child_ids = fields.One2many('marketing.activity', 'parent_id', string='Child Activities')
trigger_type = fields.Selection([
('begin', 'beginning of workflow'),
('activity', 'another activity'),
('mail_open', 'Mail: opened'),
('mail_not_open', 'Mail: not opened'),
('mail_reply', 'Mail: replied'),
('mail_not_reply', 'Mail: not replied'),
('mail_click', 'Mail: clicked'),
('mail_not_click', 'Mail: not clicked'),
('mail_bounce', 'Mail: bounced')], default='begin', required=True)
trigger_category = fields.Selection([('email', 'Mail')], compute='_compute_trigger_category')
# cron / updates
require_sync = fields.Boolean('Require trace sync', copy=False)
# For trace
trace_ids = fields.One2many('marketing.trace', 'activity_id', string='Traces', copy=False)
processed = fields.Integer(compute='_compute_statistics')
rejected = fields.Integer(compute='_compute_statistics')
total_sent = fields.Integer(compute='_compute_statistics')
total_click = fields.Integer(compute='_compute_statistics')
total_open = fields.Integer(compute='_compute_statistics')
total_reply = fields.Integer(compute='_compute_statistics')
total_bounce = fields.Integer(compute='_compute_statistics')
statistics_graph_data = fields.Char(compute='_compute_statistics_graph_data')
# activity summary
activity_summary = fields.Html(string='Activity Summary', compute='_compute_activity_summary')
@api.constrains('trigger_type', 'parent_id')
def _check_consistency_in_activities(self):
"""Check the consistency in the activity chaining."""
for activity in self:
if (activity.parent_id or activity.allowed_parent_ids) and activity.parent_id not in activity.allowed_parent_ids:
trigger_string = dict(activity._fields['trigger_type']._description_selection(self.env))[activity.trigger_type]
raise ValidationError(
_('You are trying to set the activity "%(parent_activity)s" as "%(parent_type)s" while its child "%(activity)s" has the trigger type "%(trigger_type)s"\nPlease modify one of those activities before saving.',
parent_activity=activity.parent_id.name, parent_type=activity.parent_id.activity_type, activity=activity.name, trigger_type=trigger_string))
@api.depends('activity_type')
def _compute_mass_mailing_id_mailing_type(self):
for activity in self:
if activity.activity_type == 'email':
activity.mass_mailing_id_mailing_type = 'mail'
elif activity.activity_type == 'action':
activity.mass_mailing_id_mailing_type = False
@api.depends('mass_mailing_id_mailing_type')
def _compute_mass_mailing_id(self):
for activity in self:
if activity.mass_mailing_id_mailing_type != activity.mass_mailing_id.mailing_type:
activity.mass_mailing_id = False
@api.depends('activity_type')
def _compute_server_action_id(self):
for activity in self:
if activity.activity_type != 'action':
activity.server_action_id = False
@api.depends('activity_domain', 'campaign_id.domain', 'parent_id.domain')
def _compute_inherited_domain(self):
for activity in self:
domain = expression.AND([literal_eval(activity.activity_domain or '[]'),
literal_eval(activity.campaign_id.domain or '[]')])
ancestor = activity.parent_id
while ancestor:
domain = expression.AND([domain, literal_eval(ancestor.activity_domain or '[]')])
ancestor = ancestor.parent_id
activity.domain = domain
@api.depends('interval_type', 'interval_number')
def _compute_interval_standardized(self):
factors = {'hours': 1,
'days': 24,
'weeks': 168,
'months': 720}
for activity in self:
activity.interval_standardized = activity.interval_number * factors[activity.interval_type]
@api.depends('trigger_type')
def _compute_parent_id(self):
for activity in self:
if not activity.parent_id or (activity.parent_id and activity.trigger_type == 'begin'):
activity.parent_id = False
@api.depends('trigger_type', 'campaign_id.marketing_activity_ids')
def _compute_allowed_parent_ids(self):
for activity in self:
if activity.trigger_type == 'activity':
activity.allowed_parent_ids = activity.campaign_id.marketing_activity_ids.filtered(
lambda parent_id: parent_id.ids != activity.ids)
elif activity.trigger_category:
activity.allowed_parent_ids = activity.campaign_id.marketing_activity_ids.filtered(
lambda parent_id: parent_id.ids != activity.ids and parent_id.activity_type == activity.trigger_category)
else:
activity.allowed_parent_ids = False
@api.depends('trigger_type')
def _compute_trigger_category(self):
for activity in self:
if activity.trigger_type in ['mail_open', 'mail_not_open', 'mail_reply', 'mail_not_reply',
'mail_click', 'mail_not_click', 'mail_bounce']:
activity.trigger_category = 'email'
else:
activity.trigger_category = False
@api.depends('activity_type', 'trace_ids')
def _compute_statistics(self):
# Fix after ORM-pocalyspe : Update in any case, otherwise, None to some values (crash)
self.update({
'total_bounce': 0, 'total_reply': 0, 'total_sent': 0,
'rejected': 0, 'total_click': 0, 'processed': 0, 'total_open': 0,
})
if self.ids:
activity_data = {activity._origin.id: {} for activity in self}
for stat in self._get_full_statistics():
activity_data[stat.pop('activity_id')].update(stat)
for activity in self:
activity.update(activity_data[activity._origin.id])
@api.depends('activity_type', 'trace_ids')
def _compute_statistics_graph_data(self):
if not self.ids:
date_range = [date.today() - timedelta(days=d) for d in range(0, 15)]
date_range.reverse()
default_values = [{'x': date_item.strftime('%d %b'), 'y': 0} for date_item in date_range]
self.statistics_graph_data = json.dumps([
{'points': default_values, 'label': _('Success'), 'color': '#28A745'},
{'points': default_values, 'label': _('Rejected'), 'color': '#D23f3A'}])
else:
activity_data = {activity._origin.id: {} for activity in self}
for act_id, graph_data in self._get_graph_statistics().items():
activity_data[act_id]['statistics_graph_data'] = json.dumps(graph_data)
for activity in self:
activity.update(activity_data[activity._origin.id])
def _get_activity_summary_dependencies(self):
return ['activity_type', 'mass_mailing_id', 'server_action_id', 'interval_number', 'interval_type', 'trigger_type', 'parent_id', 'validity_duration', 'validity_duration_number', 'validity_duration_type']
@api.depends(lambda self: self._get_activity_summary_dependencies())
def _compute_activity_summary(self):
""" Compute activity summary based on selection made by user, which includes information about the
activity's starting point, the linked Server Action or Mail/SMS Template, trigger type, and the expiry duration.
"""
for activity in self:
activity.activity_summary = self.env['ir.qweb']._render('marketing_automation.marketing_activity_summary_template', {
'activity': activity,
'parent_activity_name': activity.parent_id.name,
'activity_type_label': dict(activity._fields['activity_type']._description_selection(self.env))[activity.activity_type],
'interval_type_label': dict(activity._fields['interval_type']._description_selection(self.env))[activity.interval_type],
'validity_duration_type_label': dict(activity._fields['validity_duration_type']._description_selection(self.env))[activity.validity_duration_type]
})
@api.constrains('parent_id')
def _check_parent_id(self):
if self._has_cycle():
raise ValidationError(_("Error! You can't create recursive hierarchy of Activity."))
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
campaign_id = values.get('campaign_id')
if not campaign_id:
campaign_id = self.default_get(['campaign_id'])['campaign_id']
values['require_sync'] = self.env['marketing.campaign'].browse(campaign_id).state == 'running'
return super().create(vals_list)
def copy_data(self, default=None):
""" When copying the activities, we should also copy their mailings. """
default = dict(default or {})
if self.mass_mailing_id:
default['mass_mailing_id'] = self.mass_mailing_id.copy().id
return super(MarketingActivity, self).copy_data(default=default)
def write(self, values):
if any(field in values.keys() for field in ('interval_number', 'interval_type')):
values['require_sync'] = True
return super(MarketingActivity, self).write(values)
def _get_full_statistics(self):
self.env.cr.execute("""
SELECT
trace.activity_id,
COUNT(stat.sent_datetime) AS total_sent,
COUNT(stat.links_click_datetime) AS total_click,
COUNT(stat.trace_status) FILTER (WHERE stat.trace_status = 'reply') AS total_reply,
COUNT(stat.trace_status) FILTER (WHERE stat.trace_status in ('open', 'reply')) AS total_open,
COUNT(stat.trace_status) FILTER (WHERE stat.trace_status = 'bounce') AS total_bounce,
COUNT(trace.state) FILTER (WHERE trace.state = 'processed') AS processed,
COUNT(trace.state) FILTER (WHERE trace.state = 'rejected') AS rejected
FROM
marketing_trace AS trace
LEFT JOIN
mailing_trace AS stat
ON (stat.marketing_trace_id = trace.id)
JOIN
marketing_participant AS part
ON (trace.participant_id = part.id)
WHERE
(part.is_test = false or part.is_test IS NULL) AND
trace.activity_id IN %s
GROUP BY
trace.activity_id;
""", (tuple(self.ids), ))
return self.env.cr.dictfetchall()
def _get_graph_statistics(self):
""" Compute activities statistics based on their traces state for the last fortnight """
past_date = (Datetime.from_string(Datetime.now()) + timedelta(days=-14)).strftime('%Y-%m-%d 00:00:00')
stat_map = {}
base = date.today() + timedelta(days=-14)
date_range = [base + timedelta(days=d) for d in range(0, 15)]
self.env.cr.execute("""
SELECT
activity.id AS activity_id,
trace.schedule_date::date AS dt,
count(*) AS total,
trace.state
FROM
marketing_trace AS trace
JOIN
marketing_activity AS activity
ON (activity.id = trace.activity_id)
WHERE
activity.id IN %s AND
trace.schedule_date >= %s AND
(trace.is_test = false or trace.is_test IS NULL)
GROUP BY activity.id , dt, trace.state
ORDER BY dt;
""", (tuple(self.ids), past_date))
for stat in self.env.cr.dictfetchall():
stat_map[(stat['activity_id'], stat['dt'], stat['state'])] = stat['total']
graph_data = {}
for activity in self:
success = []
rejected = []
for i in date_range:
x = i.strftime('%d %b')
success.append({
'x': x,
'y': stat_map.get((activity._origin.id, i, 'processed'), 0)
})
rejected.append({
'x': x,
'y': stat_map.get((activity._origin.id, i, 'rejected'), 0)
})
graph_data[activity._origin.id] = [
{'points': success, 'label': _('Success'), 'color': '#28A745'},
{'points': rejected, 'label': _('Rejected'), 'color': '#D23f3A'}
]
return graph_data
def execute(self, domain=None):
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
# organize traces by activity
trace_domain = [
('schedule_date', '<=', Datetime.now()),
('state', '=', 'scheduled'),
('activity_id', 'in', self.ids),
('participant_id.state', '=', 'running'),
]
if domain:
trace_domain += domain
trace_to_activities = {
activity: traces
for activity, traces in self.env['marketing.trace']._read_group(
trace_domain, groupby=['activity_id'], aggregates=['id:recordset']
)
}
# execute activity on their traces
BATCH_SIZE = 500 # same batch size as the MailComposer
for activity, traces in trace_to_activities.items():
for traces_batch in (traces[i:i + BATCH_SIZE] for i in range(0, len(traces), BATCH_SIZE)):
activity.execute_on_traces(traces_batch)
if auto_commit:
self.env.cr.commit()
def execute_on_traces(self, traces):
""" Execute current activity on given traces.
:param traces: record set of traces on which the activity should run
"""
self.ensure_one()
new_traces = self.env['marketing.trace']
if self.validity_duration:
duration = relativedelta(**{self.validity_duration_type: self.validity_duration_number})
invalid_traces = traces.filtered(
lambda trace: not trace.schedule_date or trace.schedule_date + duration < datetime.now()
)
invalid_traces.action_cancel()
traces = traces - invalid_traces
# Filter traces not fitting the activity filter and whose record has been deleted
if self.domain:
rec_domain = literal_eval(self.domain)
else:
rec_domain = literal_eval(self.campaign_id.domain or '[]')
if rec_domain:
user_id = self.campaign_id.user_id or self.env.user
rec_valid = self.env[self.model_name].with_context(lang=user_id.lang).search(rec_domain)
rec_ids_domain = rec_valid.ids
traces_allowed = traces.filtered(lambda trace: trace.res_id in rec_ids_domain)
traces_rejected = traces.filtered(lambda trace: trace.res_id not in rec_ids_domain) # either rejected, either deleted record
else:
traces_allowed = traces
traces_rejected = self.env['marketing.trace']
if traces_allowed:
activity_method = getattr(self, '_execute_%s' % (self.activity_type))
new_traces += self._generate_children_traces(traces_allowed)
activity_method(traces_allowed)
traces.mapped('participant_id').check_completed()
if traces_rejected:
traces_rejected.write({
'state': 'rejected',
'state_msg': _('Rejected by activity filter or record deleted / archived')
})
traces_rejected.mapped('participant_id').check_completed()
return new_traces
def _execute_action(self, traces):
if not self.server_action_id:
return False
# Do a loop here because we have to try / catch each execution separately to ensure other traces are executed
# and proper state message stored
traces_ok = self.env['marketing.trace']
for trace in traces:
action = self.server_action_id.with_context(
active_model=self.model_name,
active_ids=[trace.res_id],
active_id=trace.res_id,
)
try:
action.run()
except Exception as e:
_logger.warning('Marketing Automation: activity <%s> encountered server action issue %s', self.id, str(e), exc_info=True)
trace.write({
'state': 'error',
'schedule_date': Datetime.now(),
'state_msg': _('Exception in server action: %s', e),
})
else:
traces_ok += trace
# Update status
traces_ok.write({
'state': 'processed',
'schedule_date': Datetime.now(),
})
return True
def _execute_email(self, traces):
# we only allow to continue if the user has sufficient rights, as a sudo() follows
if not self.env.is_superuser() and not self.env.user.has_group('marketing_automation.group_marketing_automation_user'):
raise AccessError(_('To use this feature you should be an administrator or belong to the marketing automation group.'))
def _uniquify_list(seq):
seen = set()
return [x for x in seq if x not in seen and not seen.add(x)]
res_ids = _uniquify_list(traces.mapped('res_id'))
ctx = dict(clean_context(self._context), default_marketing_activity_id=self.ids[0], active_ids=res_ids)
mailing = self.mass_mailing_id.sudo().with_context(ctx)
try:
mailing.action_send_mail(res_ids)
except Exception as e:
_logger.warning('Marketing Automation: activity <%s> encountered mass mailing issue %s', self.id, str(e), exc_info=True)
traces.write({
'state': 'error',
'schedule_date': Datetime.now(),
'state_msg': _('Exception in mass mailing: %s', e),
})
else:
# TDE Note: bounce is not really set at launch, let us consider it as an error
failed_stats = self.env['mailing.trace'].sudo().search([
('marketing_trace_id', 'in', traces.ids),
('trace_status', 'in', ['error', 'bounce', 'cancel'])
])
error_doc_ids = [stat.res_id for stat in failed_stats if stat.trace_status in ('error', 'bounce')]
cancel_doc_ids = [stat.res_id for stat in failed_stats if stat.trace_status == 'cancel']
processed_traces = traces
canceled_traces = traces.filtered(lambda trace: trace.res_id in cancel_doc_ids)
error_traces = traces.filtered(lambda trace: trace.res_id in error_doc_ids)
if canceled_traces:
canceled_traces.write({
'state': 'canceled',
'schedule_date': Datetime.now(),
'state_msg': _('Email cancelled')
})
processed_traces = processed_traces - canceled_traces
if error_traces:
error_traces.write({
'state': 'error',
'schedule_date': Datetime.now(),
'state_msg': _('Email failed')
})
processed_traces = processed_traces - error_traces
if processed_traces:
processed_traces.write({
'state': 'processed',
'schedule_date': Datetime.now(),
})
return True
def _generate_children_traces(self, traces):
"""Generate child traces for child activities and compute their schedule date except for mail_open,
mail_click, mail_reply, mail_bounce which are computed when processing the mail event """
child_traces = self.env['marketing.trace']
cron_trigger_dates = set()
for activity in self.child_ids:
activity_offset = relativedelta(**{activity.interval_type: activity.interval_number})
for trace in traces:
vals = {
'parent_id': trace.id,
'participant_id': trace.participant_id.id,
'activity_id': activity.id
}
if activity.trigger_type in self._get_reschedule_trigger_types():
schedule_date = Datetime.from_string(trace.schedule_date) + activity_offset
vals['schedule_date'] = schedule_date
cron_trigger_dates.add(schedule_date)
child_traces += child_traces.create(vals)
if cron_trigger_dates:
# based on created activities, we schedule CRON triggers that match the scheduled_dates
# we use a set to only trigger the CRON once per timeslot event if there are multiple
# marketing.participants
cron = self.env.ref('marketing_automation.ir_cron_campaign_execute_activities')
cron._trigger(cron_trigger_dates)
return child_traces
def _get_reschedule_trigger_types(self):
""" Retrieve a set of trigger types used for rescheduling actions.
The marketing activity will be rescheduled after these triggers are activated.
:returns set[str]: set of elements, each containing trigger_type
"""
return {'activity', 'mail_not_open', 'mail_not_click', 'mail_not_reply'}
def action_view_sent(self):
return self._action_view_documents_filtered('sent')
def action_view_replied(self):
return self._action_view_documents_filtered('reply')
def action_view_clicked(self):
return self._action_view_documents_filtered('click')
def action_view_opened(self):
return self._action_view_documents_filtered('open')
def _action_view_documents_filtered(self, view_filter):
if not self.mass_mailing_id: # Only available for mass mailing
return False
action = self.env["ir.actions.actions"]._for_xml_id("marketing_automation.marketing_participants_action_mail")
if view_filter == 'reply':
found_traces = self.trace_ids.filtered(lambda trace: trace.mailing_trace_status == view_filter)
elif view_filter == 'open':
found_traces = self.trace_ids.filtered(lambda trace: trace.mailing_trace_status in ('open', 'reply'))
elif view_filter == 'sent':
found_traces = self.trace_ids.filtered('mailing_trace_ids.sent_datetime')
elif view_filter == 'click':
found_traces = self.trace_ids.filtered('mailing_trace_ids.links_click_datetime')
else:
found_traces = self.env['marketing.trace']
participants = found_traces.participant_id
action.update({
'display_name': _('Participants of %(activity)s (%(filter)s)', activity=self.name, filter=view_filter),
'domain': [('id', 'in', participants.ids)],
'context': dict(self._context, create=False)
})
return action

View File

@ -0,0 +1,879 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import threading
from ast import literal_eval
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, tools, _
from odoo.fields import Datetime
from odoo.exceptions import ValidationError, AccessError
from odoo.tools import convert
class MarketingCampaign(models.Model):
_name = 'marketing.campaign'
_description = 'Marketing Campaign'
_inherits = {'utm.campaign': 'utm_campaign_id'}
_order = 'create_date DESC'
utm_campaign_id = fields.Many2one('utm.campaign', 'UTM Campaign', ondelete='restrict', required=True)
active = fields.Boolean(default=True)
state = fields.Selection([
('draft', 'New'),
('running', 'Running'),
('stopped', 'Stopped')
], copy=False, default='draft',
group_expand=True)
model_id = fields.Many2one(
'ir.model', string='Model', index=True, required=True, ondelete='cascade',
default=lambda self: self.env.ref('base.model_res_partner', raise_if_not_found=False),
domain="['&', ('is_mail_thread', '=', True), ('model', '!=', 'mail.blacklist')]")
model_name = fields.Char(string='Model Name', related='model_id.model', readonly=True, store=True)
unique_field_id = fields.Many2one(
'ir.model.fields', string='Unique Field',
compute='_compute_unique_field_id', readonly=False, store=True,
domain="[('model_id', '=', model_id), ('ttype', 'in', ['char', 'int', 'many2one', 'text', 'selection'])]",
help="""Used to avoid duplicates based on model field.\ne.g.
For model 'Customers', select email field here if you don't
want to process records which have the same email address""")
domain = fields.Char(string="Filter", compute='_compute_domain', readonly=False, store=True)
# Mailing Filter
mailing_filter_id = fields.Many2one(
'mailing.filter', string='Favorite Filter',
domain="[('mailing_model_name', '=', model_name)]",
compute='_compute_mailing_filter_id', readonly=False, store=True)
mailing_filter_domain = fields.Char('Favorite filter domain', related='mailing_filter_id.mailing_domain')
mailing_filter_count = fields.Integer('# Favorite Filters', compute='_compute_mailing_filter_count')
# activities
marketing_activity_ids = fields.One2many('marketing.activity', 'campaign_id', string='Activities', copy=False)
mass_mailing_count = fields.Integer('# Mailings', compute='_compute_mass_mailing_count')
link_tracker_click_count = fields.Integer('# Clicks', compute='_compute_link_tracker_click_count')
last_sync_date = fields.Datetime(string='Last activities synchronization', copy=False)
require_sync = fields.Boolean(string="Sync of participants is required", compute='_compute_require_sync')
# participants
participant_ids = fields.One2many('marketing.participant', 'campaign_id', string='Participants', copy=False)
running_participant_count = fields.Integer(string="# of active participants", compute='_compute_participants')
completed_participant_count = fields.Integer(string="# of completed participants", compute='_compute_participants')
total_participant_count = fields.Integer(string="# of active and completed participants", compute='_compute_participants')
test_participant_count = fields.Integer(string="# of test participants", compute='_compute_participants')
@api.constrains('model_id', 'mailing_filter_id')
def _check_mailing_filter_model(self):
"""Check that if the favorite filter is set, it must contain the same target model as campaign"""
for campaign in self:
if campaign.mailing_filter_id and campaign.model_id != campaign.mailing_filter_id.mailing_model_id:
raise ValidationError(
_("The saved filter targets different model and is incompatible with this campaign.")
)
@api.depends('model_id')
def _compute_unique_field_id(self):
for campaign in self:
campaign.unique_field_id = False
@api.depends('model_id', 'mailing_filter_id')
def _compute_domain(self):
for campaign in self:
if campaign.mailing_filter_id:
campaign.domain = campaign.mailing_filter_id.mailing_domain
else:
campaign.domain = repr([])
@api.depends('marketing_activity_ids.require_sync', 'last_sync_date')
def _compute_require_sync(self):
for campaign in self:
if campaign.last_sync_date and campaign.state == 'running':
activities_changed = campaign.marketing_activity_ids.filtered(lambda activity: activity.require_sync)
campaign.require_sync = bool(activities_changed)
else:
campaign.require_sync = False
@api.depends('model_id', 'domain')
def _compute_mailing_filter_count(self):
filter_data = self.env['mailing.filter']._read_group([
('mailing_model_id', 'in', self.model_id.ids)
], ['mailing_model_id'], ['__count'])
mapped_data = {mailing_model.id: count for mailing_model, count in filter_data}
for campaign in self:
campaign.mailing_filter_count = mapped_data.get(campaign.model_id.id, 0)
@api.depends('model_name')
def _compute_mailing_filter_id(self):
for mailing in self:
mailing.mailing_filter_id = False
@api.depends('marketing_activity_ids.mass_mailing_id')
def _compute_mass_mailing_count(self):
# TDE NOTE: this could be optimized but is currently displayed only in a form view, no need to optimize now
for campaign in self:
campaign.mass_mailing_count = len(campaign.mapped('marketing_activity_ids.mass_mailing_id').filtered(lambda mailing: mailing.mailing_type == 'mail'))
@api.depends('utm_campaign_id')
def _compute_link_tracker_click_count(self):
click_data = self.env['link.tracker.click'].sudo()._read_group(
[('campaign_id', 'in', self.utm_campaign_id.ids)],
['campaign_id'],
['__count']
)
mapped_data = {utm_campaign.id: count for utm_campaign, count in click_data}
for campaign in self:
campaign.link_tracker_click_count = mapped_data.get(campaign.utm_campaign_id.id, 0)
@api.depends('participant_ids.state')
def _compute_participants(self):
participants_data = self.env['marketing.participant']._read_group(
[('campaign_id', 'in', self.ids)],
['campaign_id', 'state', 'is_test'],
['__count'])
mapped_data = defaultdict(dict)
for campaign, state, is_test, count in participants_data:
if is_test:
mapped_data[campaign.id]['is_test'] = mapped_data[campaign.id].get('is_test', 0) + count
else:
mapped_data[campaign.id][state] = count
for campaign in self:
campaign_data = mapped_data[campaign.id]
campaign.running_participant_count = campaign_data.get('running', 0)
campaign.completed_participant_count = campaign_data.get('completed', 0)
campaign.total_participant_count = campaign.completed_participant_count + campaign.running_participant_count
campaign.test_participant_count = campaign_data.get('is_test', 0)
@api.returns('self')
def copy(self, default=None):
""" Copy the activities of the campaign, each parent_id of each child
activities should be set to the new copied parent activity. """
new_compaigns = super().copy(dict(default or {}))
for old_campaign, new_compaign in zip(self, new_compaigns):
old_to_new = {}
for marketing_activity_id in old_campaign.marketing_activity_ids:
new_marketing_activity_id = marketing_activity_id.copy()
old_to_new[marketing_activity_id] = new_marketing_activity_id
new_marketing_activity_id.write({
'campaign_id': new_compaign.id,
'require_sync': False,
'trace_ids': False,
})
for marketing_activity_id in new_compaign.marketing_activity_ids:
marketing_activity_id.parent_id = old_to_new.get(
marketing_activity_id.parent_id)
return new_compaigns
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
vals.update({'is_auto_campaign': True})
return super(MarketingCampaign, self).create(vals_list)
@api.onchange('model_id')
def _onchange_model_id(self):
if any(campaign.marketing_activity_ids for campaign in self):
return {'warning': {
'title': _("Warning"),
'message': _("Switching Target Model invalidates the existing activities. "
"Either update your activity actions to match the new Target Model or delete them.")
}}
def write(self, vals):
if not vals.get('active', True):
vals['state'] = 'stopped'
return super().write(vals)
def action_set_synchronized(self):
self.write({'last_sync_date': self.env.cr.now()})
self.mapped('marketing_activity_ids').write({'require_sync': False})
def action_update_participants(self):
""" Synchronizes all participants based campaign activities demanding synchronization
It is done in 2 part:
* update traces related to updated activities. This means basically recomputing the
schedule date
* creating new traces for activities recently added in the workflow :
* 'begin' activities simple create new traces for all running participants;
* other activities: create child for traces linked to the parent of the newly created activity
* we consider scheduling to be done after parent processing, independently of other time considerations
* for 'not' triggers take into account brother traces that could be already processed
"""
now = self.env.cr.now()
for campaign in self:
# Action 1: On activity modification
modified_activities = campaign.marketing_activity_ids.filtered(lambda activity: activity.require_sync)
traces_to_reschedule = self.env['marketing.trace'].search([
('state', '=', 'scheduled'),
('activity_id', 'in', modified_activities.ids)])
for trace in traces_to_reschedule:
trace_offset = relativedelta(**{trace.activity_id.interval_type: trace.activity_id.interval_number})
trigger_type = trace.activity_id.trigger_type
if trigger_type == 'begin':
trace.schedule_date = Datetime.from_string(trace.participant_id.create_date) + trace_offset
elif trigger_type in ['activity', 'mail_not_open', 'mail_not_click', 'mail_not_reply'] and trace.parent_id:
trace.schedule_date = Datetime.from_string(trace.parent_id.schedule_date) + trace_offset
elif trace.parent_id:
if trace.parent_id.mailing_trace_ids.mapped('write_date'):
process_dt = Datetime.from_string(trace.parent_id.mailing_trace_ids.mapped('write_date')[0])
else:
process_dt = now
trace.schedule_date = process_dt + trace_offset
# Action 2: On activity creation
created_activities = campaign.marketing_activity_ids.filtered(
lambda activity: (
campaign.last_sync_date and activity.create_date >= campaign.last_sync_date
)
)
# pre-fetch existing traces to avoid duplicates
existing_traces = self.env['marketing.trace']
if created_activities:
existing_traces = self.env['marketing.trace'].search([
('activity_id', 'in', created_activities.ids),
])
for activity in created_activities:
activity_offset = relativedelta(**{activity.interval_type: activity.interval_number})
participants_with_traces = existing_traces.filtered(lambda trace: trace.activity_id == activity).participant_id
# Case 1: Trigger = begin
# Create new root traces for all running participants -> consider campaign begin date is now to avoid spamming participants
if activity.trigger_type == 'begin':
participants = self.env['marketing.participant'].search([
('state', '=', 'running'),
('campaign_id', '=', campaign.id),
('id', 'not in', participants_with_traces.ids),
])
for participant in participants:
schedule_date = now + activity_offset
self.env['marketing.trace'].create({
'activity_id': activity.id,
'participant_id': participant.id,
'schedule_date': schedule_date,
})
else:
valid_parent_traces = self.env['marketing.trace'].search([
('state', '=', 'processed'),
('activity_id', '=', activity.parent_id.id),
('participant_id', 'not in', participants_with_traces.ids),
])
# avoid creating new traces that would have processed brother traces already processed
# example: do not create a mail_not_click trace if mail_click is already processed
if activity.trigger_type in ['mail_not_open', 'mail_not_click', 'mail_not_reply']:
opposite_trigger = activity.trigger_type.replace('_not_', '_')
brother_traces = self.env['marketing.trace'].search([
('parent_id', 'in', valid_parent_traces.ids),
('trigger_type', '=', opposite_trigger),
('state', '=', 'processed'),
])
valid_parent_traces = valid_parent_traces - brother_traces.mapped('parent_id')
valid_parent_traces.mapped('participant_id').filtered(lambda participant: participant.state == 'completed').action_set_running()
for parent_trace in valid_parent_traces:
self.env['marketing.trace'].create({
'activity_id': activity.id,
'participant_id': parent_trace.participant_id.id,
'parent_id': parent_trace.id,
'schedule_date': Datetime.from_string(parent_trace.schedule_date) + activity_offset,
})
self.action_set_synchronized()
def action_start_campaign(self):
if any(not campaign.marketing_activity_ids for campaign in self):
raise ValidationError(_('You must set up at least one activity to start this campaign.'))
# trigger CRON job ASAP so that participants are synced
cron = self.env.ref('marketing_automation.ir_cron_campaign_sync_participants')
cron._trigger(at=Datetime.now())
self.write({'state': 'running'})
def action_stop_campaign(self):
self.write({'state': 'stopped'})
def action_view_mailings(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("marketing_automation.mail_mass_mailing_action_marketing_automation")
action['domain'] = [
'&',
('use_in_marketing_automation', '=', True),
('id', 'in', self.mapped('marketing_activity_ids.mass_mailing_id').ids),
('mailing_type', '=', 'mail')
]
action['context'] = dict(self.env.context)
action['context'].update({
# defaults
'default_mailing_model_id': self.model_id.id,
'default_campaign_id': self.utm_campaign_id.id,
'default_use_in_marketing_automation': True,
'default_mailing_type': 'mail',
'default_state': 'done',
# action
'create': False,
})
return action
def action_view_tracker_statistics(self):
action = self.env["ir.actions.actions"]._for_xml_id("marketing_automation.link_tracker_action_marketing_campaign")
action['domain'] = [('campaign_id', 'in', self.utm_campaign_id.ids)]
return action
def sync_participants(self):
""" Creates new participants, taking into account already-existing ones
as well as campaign filter and unique field. """
def _uniquify_list(seq):
seen = set()
return [x for x in seq if x not in seen and not seen.add(x)]
participants = self.env['marketing.participant']
now = self.env.cr.now()
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
for campaign in self.filtered(lambda c: c.marketing_activity_ids):
if not campaign.last_sync_date:
campaign.last_sync_date = now
user_id = campaign.user_id or self.env.user
RecordModel = self.env[campaign.model_name].with_context(lang=user_id.lang)
# Fetch existing participants
participants_data = participants.search_read([('campaign_id', '=', campaign.id)], ['res_id'])
existing_rec_ids = _uniquify_list([live_participant['res_id'] for live_participant in participants_data])
record_domain = literal_eval(campaign.domain or "[]")
db_rec_ids = _uniquify_list(RecordModel.search(record_domain).ids)
to_create = [rid for rid in db_rec_ids if rid not in existing_rec_ids] # keep ordered IDs
to_remove = set(existing_rec_ids) - set(db_rec_ids)
unique_field = campaign.unique_field_id.sudo()
if unique_field.name != 'id':
without_duplicates = []
existing_records = RecordModel.with_context(prefetch_fields=False).browse(existing_rec_ids).exists()
# Split the read in batch of 1000 to avoid the prefetch
# crawling the cache for the next 1000 records to fetch
unique_field_vals = {rec[unique_field.name]
for index in range(0, len(existing_records), 1000)
for rec in existing_records[index:index+1000]}
for rec in RecordModel.with_context(prefetch_fields=False).browse(to_create):
field_val = rec[unique_field.name]
# we exclude the empty recordset with the first condition
if (not unique_field.relation or field_val) and field_val not in unique_field_vals:
without_duplicates.append(rec.id)
unique_field_vals.add(field_val)
to_create = without_duplicates
BATCH_SIZE = 100
for to_create_batch in tools.split_every(BATCH_SIZE, to_create, piece_maker=list):
participants += participants.create([{
'campaign_id': campaign.id,
'res_id': rec_id,
} for rec_id in to_create_batch])
if auto_commit:
self.env.cr.commit()
if to_remove:
participants_to_unlink = participants.search([
('res_id', 'in', list(to_remove)),
('campaign_id', '=', campaign.id),
('state', '!=', 'unlinked'),
])
for index in range(0, len(participants_to_unlink), 1000):
participants_to_unlink[index:index+1000].action_set_unlink()
# Commit only every 100 operation to avoid committing to often
# this mean every 10k record. It should be ok, it takes 1sec second to process 10k
if not index % (BATCH_SIZE * 100):
self.env.cr.commit()
return participants
def execute_activities(self):
for campaign in self:
campaign.marketing_activity_ids.execute()
# --------------------------------------
# Prepare actions data
# --------------------------------------
def _prepare_res_partner_category_tag_hot_data(self):
return {
'xml_id': 'marketing_automation.res_partner_category_tag_hot',
'values': {
'name': _('Hot')
}
}
def _prepare_mailing_list_contact_list_data(self):
return {
'xml_id': 'marketing_automation.mailing_list_contact_list',
'values': {
'name': _('Confirmed contacts'),
'active': True,
'is_public': True
}
}
def _prepare_ir_actions_server_partner_tag_data(self):
# Add the "Hot" category on partners who will click on a mail sent to them.
self._create_records_with_xml_ids({'res.partner.category': [self._prepare_res_partner_category_tag_hot_data()]})
hot_id = self.env.ref('marketing_automation.res_partner_category_tag_hot', raise_if_not_found=False).id
return {
'xml_id': 'marketing_automation.ir_actions_server_partner_tag',
'values': {
'name': _('Add Hot Category'),
'model_id': self.env['ir.model']._get_id('res.partner'),
'update_field_id': self.env["ir.model.fields"]._get_ids('res.partner')['category_id'],
'update_path': 'category_id',
'evaluation_type': 'value',
'resource_ref': f'res.partner.category,{hot_id}',
'value': str(hot_id)
}
}
def _prepare_ir_actions_server_partner_todo_data(self):
# Assign activity to admin called Bounced: check email address.
return {
'xml_id': 'marketing_automation.ir_actions_server_partner_todo',
'values': {
'name': _('Next activity: Check Email Address'),
'model_id': self.env['ir.model']._get_id('res.partner'),
'state': 'next_activity',
'activity_date_deadline_range': 2,
'activity_date_deadline_range_type': 'days',
'activity_summary': _('Check Email Address'),
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'activity_user_type': 'generic',
'activity_user_field_name': 'user_id',
}
}
def _prepare_ir_actions_server_contact_blacklist_data(self):
# If mail bounces on some contact, blacklist that contact.
return {
'xml_id': 'marketing_automation.ir_actions_server_contact_blacklist',
'values': {
'name': _('Blacklist record'),
'model_id': self.env['ir.model']._get_id('mailing.contact'),
'state': 'code',
'code':
"""
for record in records:
record.env['mail.blacklist']._add(
record.email,
message='Added in blacklist from automated action',
)
"""
}
}
def _prepare_ir_actions_server_contact_add_list_data(self):
# If partner clicks on sent mail, add that contact to separate list called 'Confirmed contacts'.
return {
'xml_id': 'marketing_automation.ir_actions_server_contact_add_list',
'values': {
'name': _('Add To Confirmed List'),
'model_id': self.env['ir.model']._get_id('mailing.contact'),
'state': 'code',
'code':
"""
mailing_list = env.ref('marketing_automation.mailing_list_contact_list', raise_if_not_found=False)
if mailing_list:
records.write({'list_ids': [(4, mailing_list.id)]})
"""
}
}
def _prepare_ir_actions_server_partner_message_data(self):
return {
'xml_id': 'marketing_automation.ir_actions_server_partner_message',
'values': {
'name': _('Message for sales person'),
'model_id': self.env['ir.model']._get_id('res.partner'),
'state': 'code',
'code':
"""
for record in records:
record.message_post(body='%s is interested in becoming partner.' % record.name)
"""
}
}
def _create_records_with_xml_ids(self, create_xmls):
for model_name, values in create_xmls.items():
for record in values:
module, name = record['xml_id'].split('.')
if not self.env.ref(f'{module}.{name}', raise_if_not_found=False):
created_record = self.env[model_name].sudo().create(record['values'])
self.env['ir.model.data'].sudo().create({
'name': name,
'module': module,
'model': model_name,
'res_id': created_record.id,
})
# --------------------------------------
# Sample Templates Creation
# --------------------------------------
@api.model
def get_action_marketing_campaign_from_template(self, template_str):
if not self.env.su and not self.env.user.has_group('marketing_automation.group_marketing_automation_user'):
raise AccessError(_('To use this feature you should be an administrator or belong to the marketing automation group.'))
campaign_templates_info = self.get_campaign_templates_info()
template = next(
(template_value
for group in campaign_templates_info.values()
for template_key, template_value in group['templates'].items()
if template_key == template_str),
False)
if not template:
return False
load_method = template.get('function')
if not load_method:
return {
'type': 'ir.actions.act_window',
'res_model': 'marketing.campaign',
'views': [[False, 'form']]
}
if not load_method.startswith('_get_marketing_template') or not hasattr(self, load_method):
return
loaded_method = getattr(self, load_method)
campaign = loaded_method()
return {
'name': 'marketing_automation_templates_action',
'type': 'ir.actions.act_window',
'view_mode': 'list,form',
'res_id': campaign.id,
'res_model': 'marketing.campaign',
'views': [[False, 'form']]
}
@api.model
def get_campaign_templates_info(self):
return {
'misc': {
'label': _("Misc"),
'templates': {
'start_from_scratch': {
'title': _('Start from scratch'),
'description': _('Design your own marketing campaign from the ground up.'),
'icon': '/marketing_automation/static/img/paintbrush.svg',
},
'hot_contacts': {
'title': _('Tag Hot Contacts'),
'description': _('Send a welcome email to contacts and tag them if they click in it.'),
'icon': '/marketing_automation/static/img/tag.svg',
'function': '_get_marketing_template_hot_contacts_values',
},
'commercial_prospection': {
'title': _('Commercial prospection'),
'description': _('Send a free catalog and follow-up according to reactions.'),
'icon': '/marketing_automation/static/img/search.svg',
'function': '_get_marketing_template_commercial_prospection_values',
},
},
},
'marketing': {
'label': _("Marketing"),
'templates': {
'welcome': {
'title': _('Welcome Flow'),
'description': _('Send a welcome email to new subscribers, remove the addresses that bounced.'),
'icon': '/marketing_automation/static/img/hand_peace.svg',
'function': '_get_marketing_template_welcome_values',
},
'double_opt_in': {
'title': _('Double Opt-in'),
'description': _('Send an email to new recipients to confirm their consent.'),
'icon': '/marketing_automation/static/img/square-check.svg',
'function': '_get_marketing_template_double_opt_in_values',
},
}
}
}
def _get_marketing_template_hot_contacts_values(self):
convert.convert_file(
self.sudo().env,
'marketing_automation',
'data/templates/mail_template_body_welcome_template.xml',
idref={}, mode='init', kind='data'
)
rendered_template = self.env['ir.qweb']._render(self.env.ref('marketing_automation.mail_template_body_welcome_template').id,
{'db_host': self.get_base_url(), 'company_website': self.env.company.website})
prerequisites = {
'mailing.mailing': [{
'subject': _('Welcome!'),
'body_arch': rendered_template,
'body_html': rendered_template,
'mailing_model_id': self.env['ir.model']._get_id('res.partner'),
'reply_to_mode': 'update',
'use_in_marketing_automation': True,
'mailing_type': 'mail',
}],
}
for model_name, values in prerequisites.items():
records = self.env[model_name].create(values)
for idx, record in enumerate(records):
prerequisites[model_name][idx] = record
self._create_records_with_xml_ids({
'ir.actions.server': [self._prepare_ir_actions_server_partner_tag_data(),
self._prepare_ir_actions_server_partner_todo_data()]
})
campaign = self.env['marketing.campaign'].create({
'name': _('Tag Hot Contacts'),
'domain': ["&", "&", ("email", "!=", False), ("is_blacklisted", "=", False), ("user_ids", "=", False)],
'model_id': self.env['ir.model']._get_id('res.partner'),
'unique_field_id': self.env['ir.model.fields']._get('res.partner', 'email').id
})
self.env['marketing.activity'].create([
{
'trigger_type': 'begin',
'activity_type': 'email',
'interval_type': 'hours',
'mass_mailing_id': prerequisites['mailing.mailing'][0].id,
'interval_number': 2,
'name': _('Send Welcome Email'),
'campaign_id': campaign.id,
'child_ids': [
(0, 0, {
'trigger_type': 'mail_click',
'activity_type': 'action',
'interval_type': 'hours',
'mass_mailing_id': None,
'interval_number': 2,
'name': _('Add Tag'),
'campaign_id': campaign.id, # use the campaign_id here too,
'server_action_id': self.env.ref('marketing_automation.ir_actions_server_partner_tag').id,
}),
(0, 0, {
'trigger_type': 'mail_bounce',
'activity_type': 'action',
'interval_type': 'hours',
'mass_mailing_id': None,
'interval_number': 2,
'name': _('Check Bounce Contact'),
'campaign_id': campaign.id, # use the campaign_id here too,
'server_action_id': self.env.ref('marketing_automation.ir_actions_server_partner_todo').id
})
]
}
])
return campaign
def _get_marketing_template_welcome_values(self):
convert.convert_file(
self.sudo().env,
'marketing_automation',
'data/templates/mail_template_body_yellow_discount_template.xml',
idref={}, mode='init', kind='data'
)
rendered_template = self.env['ir.qweb']._render(self.env.ref('marketing_automation.mail_template_body_yellow_discount_template').id,
{'db_host': self.get_base_url(), 'company_website': self.env.company.website})
prerequisites = {
'mailing.mailing': [{
'subject': _('Get 10% OFF'),
'body_arch': rendered_template, # set Yellow 10% template
'body_html': rendered_template, # set Yellow 10% template
'mailing_model_id': self.env['ir.model']._get_id('mailing.contact'),
'reply_to_mode': 'update',
'mailing_type': 'mail',
'use_in_marketing_automation': True
}],
}
for model_name, values in prerequisites.items():
records = self.env[model_name].create(values)
for idx, record in enumerate(records):
prerequisites[model_name][idx] = record
create_xmls = {
'ir.actions.server': [
self._prepare_ir_actions_server_contact_blacklist_data()
],
}
self._create_records_with_xml_ids(create_xmls)
campaign = self.env['marketing.campaign'].create({
'name': _('Welcome Flow'),
'domain': ["&", ("email", "!=", False), ("is_blacklisted", "=", False)],
'model_id': self.env['ir.model']._get_id('mailing.contact'),
'unique_field_id': self.env['ir.model.fields']._get('mailing.contact', 'email').id
})
self.env['marketing.activity'].create({
'trigger_type': 'begin',
'activity_type': 'email',
'interval_type': 'hours',
'mass_mailing_id': prerequisites['mailing.mailing'][0].id,
'interval_number': 2,
'name': _('Send 10% Welcome Discount'),
'campaign_id': campaign.id,
'child_ids': [(0, 0, {
'trigger_type': 'mail_bounce',
'activity_type': 'action',
'interval_type': 'hours',
'mass_mailing_id': None,
'interval_number': 2,
'name': _('Blacklist Bounces'),
'parent_id': None,
'campaign_id': campaign.id, # use the campaign_id here too,
'server_action_id': self.env.ref('marketing_automation.ir_actions_server_contact_blacklist').id
})]
})
return campaign
def _get_marketing_template_double_opt_in_values(self):
convert.convert_file(
self.sudo().env,
'marketing_automation',
'data/templates/mail_template_body_confirmation_template.xml',
idref={}, mode='init', kind='data'
)
rendered_template = self.env['ir.qweb']._render(self.env.ref('marketing_automation.mail_template_body_confirmation_template').id,
{'db_host': self.get_base_url()})
prerequisites = {
'mailing.mailing': [{
'subject': _('Confirmation'),
'body_arch': rendered_template,
'body_html': rendered_template,
'mailing_model_id': self.env['ir.model']._get_id('mailing.contact'),
'reply_to_mode': 'update',
'mailing_type': 'mail',
'use_in_marketing_automation': True
}],
}
for model_name, values in prerequisites.items():
records = self.env[model_name].create(values)
for idx, record in enumerate(records):
prerequisites[model_name][idx] = record
create_xmls = {
'mailing.list': [self._prepare_mailing_list_contact_list_data()],
'ir.actions.server': [
self._prepare_ir_actions_server_contact_add_list_data()
],
}
self._create_records_with_xml_ids(create_xmls)
campaign = self.env['marketing.campaign'].create({
'name': _('Double Opt-in'),
'domain': ["&", "&", ("email", "!=", False), ("is_blacklisted", "=", False), ("list_ids", "ilike", "Newsletter")],
'model_id': self.env['ir.model']._get_id('mailing.contact'),
'unique_field_id': self.env['ir.model.fields']._get('mailing.contact', 'email').id
})
self.env['marketing.activity'].create({
'trigger_type': 'begin',
'activity_type': 'email',
'interval_type': 'hours',
'mass_mailing_id': prerequisites['mailing.mailing'][0].id,
'interval_number': 0,
'name': _('Confirmation'),
'campaign_id': campaign.id,
'child_ids': [(0, 0, {
'trigger_type': 'mail_click',
'activity_type': 'action',
'interval_type': 'hours',
'mass_mailing_id': None,
'interval_number': 0,
'name': _('Add to list'),
'parent_id': None,
'campaign_id': campaign.id, # use the campaign_id here too,
'server_action_id': self.env.ref('marketing_automation.ir_actions_server_contact_add_list').id
})]
})
return campaign
def _get_marketing_template_commercial_prospection_values(self):
convert.convert_file(
self.sudo().env,
'marketing_automation',
'data/templates/mail_template_body_join_partnership_template.xml',
idref={}, mode='init', kind='data'
)
convert.convert_file(
self.sudo().env,
'marketing_automation',
'data/templates/mail_template_body_free_trial_template.xml',
idref={}, mode='init', kind='data'
)
free_trial_rendered = self.env['ir.qweb']._render(self.env.ref('marketing_automation.mail_template_body_free_trial_template').id,
{'company_website': self.env.company.website})
join_partnership_rendered = self.env['ir.qweb']._render(self.env.ref('marketing_automation.mail_template_body_join_partnership_template').id,
{'company_website': self.env.company.website})
prerequisites = {
'mailing.mailing': [{
'subject': _('Welcome!'),
'body_arch': free_trial_rendered,
'body_html': free_trial_rendered,
'mailing_model_id': self.env['ir.model']._get_id('res.partner'),
'reply_to_mode': 'update',
'mailing_type': 'mail',
'use_in_marketing_automation': True
}, {
'subject': _('Join partnership!'),
'body_arch': join_partnership_rendered,
'body_html': join_partnership_rendered,
'mailing_model_id': self.env['ir.model']._get_id('res.partner'),
'reply_to_mode': 'update',
'mailing_type': 'mail',
'use_in_marketing_automation': True
}],
}
for model_name, values in prerequisites.items():
records = self.env[model_name].create(values)
for idx, record in enumerate(records):
prerequisites[model_name][idx] = record
create_xmls = {
'ir.actions.server': [
self._prepare_ir_actions_server_partner_message_data(),
],
}
self._create_records_with_xml_ids(create_xmls)
campaign = self.env['marketing.campaign'].create({
'name': _('Commercial prospection'),
'model_id': self.env['ir.model']._get_id('res.partner'),
'unique_field_id': self.env['ir.model.fields']._get('res.partner', 'email').id
})
self.env['marketing.activity'].create([{
'trigger_type': 'begin',
'activity_type': 'email',
'interval_type': 'hours',
'mass_mailing_id': prerequisites['mailing.mailing'][0].id,
'interval_number': 1,
'name': _('Offer free catalog'),
'campaign_id': campaign.id,
}, {
'trigger_type': 'begin',
'activity_type': 'email',
'interval_type': 'days',
'mass_mailing_id': prerequisites['mailing.mailing'][1].id,
'interval_number': 7,
'name': _('After 7 days'),
'campaign_id': campaign.id,
'child_ids': [(0, 0, {
'trigger_type': 'mail_reply',
'activity_type': 'action',
'interval_type': 'hours',
'mass_mailing_id': None,
'interval_number': 1,
'name': _('Message for sales person'),
'parent_id': None,
'campaign_id': campaign.id, # use the campaign_id here too,
'server_action_id': self.env.ref('marketing_automation.ir_actions_server_partner_message').id
})]
}])
return campaign

View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.fields import Datetime
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS
class MarketingParticipant(models.Model):
_name = 'marketing.participant'
_description = 'Marketing Participant'
_order = 'id ASC'
_rec_name = 'resource_ref'
@api.model
def default_get(self, default_fields):
defaults = super(MarketingParticipant, self).default_get(default_fields)
if 'res_id' in default_fields and not defaults.get('res_id'):
model_name = defaults.get('model_name')
if not model_name and defaults.get('campaign_id'):
model_name = self.env['marketing.campaign'].browse(defaults['campaign_id']).model_name
if model_name and model_name in self.env:
resource = self.env[model_name].search([], limit=1)
defaults['res_id'] = resource.id
return defaults
@api.model
def _selection_target_model(self):
models = self.env['ir.model'].sudo().search([('is_mail_thread', '=', True)])
return [(model.model, model.name) for model in models]
def _search_resource_ref(self, operator, value):
ir_models = set([model['model_name'] for model in self.env['marketing.campaign'].search([]).read(['model_name'])])
ir_model_ids = []
for model in ir_models:
if model in self.env:
ir_model_ids += self.env['marketing.participant'].search(['&', ('model_name', '=', model), ('res_id', 'in', [name[0] for name in self.env[model].name_search(name=value)])]).ids
operator = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
return [('id', operator, ir_model_ids)]
campaign_id = fields.Many2one(
'marketing.campaign', string='Campaign',
index=True, ondelete='cascade', required=True)
model_id = fields.Many2one(
'ir.model', string='Model', related='campaign_id.model_id',
index=True, readonly=True, store=True)
model_name = fields.Char(
string='Record model', related='campaign_id.model_id.model',
readonly=True, store=True)
res_id = fields.Integer(string='Record ID', index=True)
resource_ref = fields.Reference(
string='Record', selection='_selection_target_model',
compute='_compute_resource_ref', inverse='_set_resource_ref', search='_search_resource_ref')
trace_ids = fields.One2many('marketing.trace', 'participant_id', string='Actions')
state = fields.Selection([
('running', 'Running'),
('completed', 'Completed'),
('unlinked', 'Removed'),
], default='running', index=True, required=True,
help='Removed means the related record does not exist anymore.')
is_test = fields.Boolean('Test Record', default=False)
@api.depends('model_name', 'res_id')
def _compute_resource_ref(self):
for participant in self:
if participant.model_name and participant.model_name in self.env:
participant.resource_ref = '%s,%s' % (participant.model_name, participant.res_id or 0)
else:
participant.resource_ref = None
def _set_resource_ref(self):
for participant in self:
if participant.resource_ref:
participant.res_id = participant.resource_ref.id
def check_completed(self):
existing_traces = self.env['marketing.trace'].search([
('participant_id', 'in', self.ids),
('state', '=', 'scheduled'),
])
(self - existing_traces.mapped('participant_id')).write({'state': 'completed'})
@api.model_create_multi
def create(self, vals_list):
participants = super().create(vals_list)
now = Datetime.now()
cron_trigger_dates = set()
for res in participants:
# prepare first traces related to begin activities
primary_activities = res.campaign_id.marketing_activity_ids.filtered(lambda act: act.trigger_type == 'begin')
trace_ids = [
(0, 0, {
'activity_id': activity.id,
'schedule_date': now + relativedelta(**{activity.interval_type: activity.interval_number}),
}) for activity in primary_activities]
res.write({'trace_ids': trace_ids})
cron_trigger_dates |= set([
now + relativedelta(**{activity.interval_type: activity.interval_number})
for activity in primary_activities
])
if cron_trigger_dates:
# based on activities with 'begin' trigger_type, we schedule CRON triggers
# that match the scheduled_dates of created marketing.traces
# we use a set to only trigger the CRON once per timeslot event if there are multiple
# marketing.participants
cron = self.env.ref('marketing_automation.ir_cron_campaign_execute_activities')
cron._trigger(cron_trigger_dates)
return participants
def action_set_completed(self):
''' Manually mark as a completed and cancel every scheduled trace '''
# TDE TODO: delegate set Canceled to trace record
self.write({'state': 'completed'})
self.env['marketing.trace'].search([
('participant_id', 'in', self.ids),
('state', '=', 'scheduled')
]).write({
'state': 'canceled',
'schedule_date': Datetime.now(),
'state_msg': _('Marked as completed')
})
def action_set_running(self):
self.write({'state': 'running'})
def action_set_unlink(self):
self.write({'state': 'unlinked'})
self.env['marketing.trace'].search([
('participant_id', 'in', self.ids),
('state', '=', 'scheduled')
]).write({
'state': 'canceled',
'state_msg': _('Record deleted'),
})
return True

View File

@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.fields import Datetime
class MarketingTrace(models.Model):
_name = 'marketing.trace'
_description = 'Marketing Trace'
_order = 'schedule_date DESC, id ASC'
_rec_name = 'participant_id'
participant_id = fields.Many2one(
'marketing.participant', string='Participant',
index=True, ondelete='cascade', required=True)
res_id = fields.Integer(string='Document ID', related='participant_id.res_id', index=True, store=True, readonly=False)
is_test = fields.Boolean(string='Test Trace', related='participant_id.is_test', index=True, store=True, readonly=True)
activity_id = fields.Many2one(
'marketing.activity', string='Activity',
index=True, ondelete='cascade', required=True)
activity_type = fields.Selection(related='activity_id.activity_type', readonly=True)
trigger_type = fields.Selection(related='activity_id.trigger_type', readonly=True)
state = fields.Selection([
('scheduled', 'Scheduled'),
('processed', 'Processed'),
('rejected', 'Rejected'),
('canceled', 'Cancelled'),
('error', 'Error')
], default='scheduled', index=True, required=True)
schedule_date = fields.Datetime()
state_msg = fields.Char(string='Error message')
# hierarchy
parent_id = fields.Many2one('marketing.trace', string='Parent', index=True, ondelete='cascade')
child_ids = fields.One2many('marketing.trace', 'parent_id', string='Direct child traces')
# mailing traces
mailing_trace_ids = fields.One2many('mailing.trace', 'marketing_trace_id', string='Mass mailing statistics')
mailing_trace_status = fields.Selection(related='mailing_trace_ids.trace_status', readonly=True)
links_click_datetime = fields.Datetime(compute='_compute_links_click_datetime')
@api.depends('mailing_trace_ids')
def _compute_links_click_datetime(self):
# necessary, because sometimes mailing_trace_ids aren't available
# due to failed messages, which prevents `links_click_datetime` to get assigned
self.links_click_datetime = False
mailing_trace = self.filtered(lambda x: x.mailing_trace_ids)
for trace in mailing_trace:
trace.links_click_datetime = trace.mailing_trace_ids[0].links_click_datetime
def participant_action_cancel(self):
self.action_cancel(message=_('Manually'))
def action_cancel(self, message=None):
values = {'state': 'canceled', 'schedule_date': Datetime.now()}
if message:
values['state_msg'] = message
self.write(values)
self.mapped('participant_id').check_completed()
def action_execute(self):
self.activity_id.execute_on_traces(self)
# DANE: try to make this function to work on batches later
def process_event(self, action):
"""Process event coming from customers currently centered on email actions.
It updates child traces :
* opposite actions are canceled, for example mail_not_open when mail_open is triggered;
* bounced mail cancel all child actions not being mail_bounced;
:param string action: see trigger_type field of activity
"""
self.ensure_one()
if self.participant_id.campaign_id.state not in ['draft', 'running']:
return
now = Datetime.from_string(Datetime.now())
msg = {
'mail_not_reply': _('Parent activity mail replied'),
'mail_not_click': _('Parent activity mail clicked'),
'mail_not_open': _('Parent activity mail opened'),
'mail_bounce': _('Parent activity mail bounced'),
}
opened_child = self.child_ids.filtered(lambda trace: trace.state == 'scheduled')
cron_trigger_dates = set()
for next_trace in opened_child.filtered(lambda trace: trace.activity_id.trigger_type == action):
if next_trace.activity_id.interval_number == 0:
next_trace.write({
'schedule_date': now,
})
next_trace.activity_id.execute_on_traces(next_trace)
else:
schedule_date = now + relativedelta(**{
next_trace.activity_id.interval_type: next_trace.activity_id.interval_number
})
next_trace.write({
'schedule_date': schedule_date,
})
cron_trigger_dates.add(schedule_date)
if cron_trigger_dates:
# based on updated activities, we schedule CRON triggers that match the scheduled_dates
# we use a set to only trigger the CRON once per timeslot event if there are multiple
# marketing.participants
cron = self.env.ref('marketing_automation.ir_cron_campaign_execute_activities')
cron._trigger(cron_trigger_dates)
if action in ['mail_reply', 'mail_click', 'mail_open']:
opposite_trigger = action.replace('_', '_not_')
opened_child.filtered(
lambda trace: trace.activity_id.trigger_type == opposite_trigger
).action_cancel(message=msg[opposite_trigger])
elif action == 'mail_bounce':
opened_child.filtered(
lambda trace: trace.activity_id.trigger_type != 'mail_bounce'
).action_cancel(message=msg[action])
return True

View File

@ -0,0 +1,23 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models
from odoo.exceptions import UserError
class UtmCampaign(models.Model):
_inherit = 'utm.campaign'
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_marketing_campaigns(self):
""" Already handled by ondelete='restrict', but let's show a nice error message """
linked_marketing_campaigns = self.env['marketing.campaign'].sudo().search([
('utm_campaign_id', 'in', self.ids)
])
if linked_marketing_campaigns:
raise UserError(_(
"You cannot delete these UTM Campaigns as they are linked to the following marketing campaigns in "
"Marketing Automation:\n%(campaign_names)s",
campaign_names=', '.join(['"%s"' % name for name in linked_marketing_campaigns.mapped('name')])))

View File

@ -0,0 +1,23 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models
from odoo.exceptions import UserError
class UtmSource(models.Model):
_inherit = 'utm.source'
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_activities(self):
""" Already handled by ondelete='restrict', but let's show a nice error message """
linked_activities = self.env['marketing.activity'].sudo().search([
('source_id', 'in', self.ids)
])
if linked_activities:
raise UserError(_(
"You cannot delete these UTM Sources as they are linked to the following marketing activities in "
"Marketing Automation:\n%(activities_names)s",
activities_names=', '.join(['"%s"' % name for name in linked_activities.mapped('name')])))

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mailing_trace_report

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools
class MailingTraceReport(models.Model):
_inherit = 'mailing.trace.report'
def _report_get_request_where_items(self):
res = super(MailingTraceReport, self)._report_get_request_where_items()
res.append("mailing.use_in_marketing_automation IS NOT TRUE")
return res

View File

@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_marketing_campaign,marketing.campaign,model_marketing_campaign,group_marketing_automation_user,1,1,1,1
access_marketing_activity,marketing.activity,model_marketing_activity,group_marketing_automation_user,1,1,1,1
access_marketing_participant,marketing.participant,model_marketing_participant,group_marketing_automation_user,1,1,1,1
access_marketing_trace,marketing.trace,model_marketing_trace,group_marketing_automation_user,1,1,1,1
access_marketing_campaign_test,access.marketing.campaign.test,model_marketing_campaign_test,marketing_automation.group_marketing_automation_user,1,1,1,0
access_ir_model_fields_marketing_automation_user,access.ir.model.fields.marketing.automation.user,base.model_ir_model_fields,group_marketing_automation_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_marketing_campaign marketing.campaign model_marketing_campaign group_marketing_automation_user 1 1 1 1
3 access_marketing_activity marketing.activity model_marketing_activity group_marketing_automation_user 1 1 1 1
4 access_marketing_participant marketing.participant model_marketing_participant group_marketing_automation_user 1 1 1 1
5 access_marketing_trace marketing.trace model_marketing_trace group_marketing_automation_user 1 1 1 1
6 access_marketing_campaign_test access.marketing.campaign.test model_marketing_campaign_test marketing_automation.group_marketing_automation_user 1 1 1 0
7 access_ir_model_fields_marketing_automation_user access.ir.model.fields.marketing.automation.user base.model_ir_model_fields group_marketing_automation_user 1 0 0 0

View File

@ -0,0 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="module_marketing_automation_category" model="ir.module.category">
<field name="name">Marketing Automation</field>
<field name="sequence">20</field>
</record>
<record id="group_marketing_automation_user" model="res.groups">
<field name="name">User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user')), (4, ref('mass_mailing.group_mass_mailing_user'))]"/>
<field name="category_id" ref="base.module_category_marketing_marketing_automation"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4, ref('marketing_automation.group_marketing_automation_user'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M21.897 17H5v-4a4 4 0 0 1 4-4h15.837l7.347 8H46a4 4 0 0 1 4 4v4H29.244l-7.347-8Z" fill="#FC868B"/><path fill-rule="evenodd" clip-rule="evenodd" d="M16.772 33H0v-4a4 4 0 0 1 4-4h15.962l7.347 9H41a4 4 0 0 1 4 4v4H24.12l-7.348-9Z" fill="#1AD3BB"/><path fill-rule="evenodd" clip-rule="evenodd" d="M31.225 25H50v-4a4 4 0 0 0-4-4H27.959l-8.164 8H4a4 4 0 0 0-4 4v4h23.062l8.163-8Z" fill="#985184"/><path d="M24.968 31.132 19.962 25H4a4.005 4.005 0 0 0-4 4v4h23.062l1.906-1.868Z" fill="#005E7A"/></svg>

After

Width:  |  Height:  |  Size: 627 B

Some files were not shown because too many files have changed in this diff Show More