diff --git a/addons/link_tracker/__init__.py b/addons/link_tracker/__init__.py index c57bb82153a..faf00003256 100644 --- a/addons/link_tracker/__init__.py +++ b/addons/link_tracker/__init__.py @@ -1,3 +1,5 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import models -from . import controller \ No newline at end of file +from . import controller diff --git a/addons/link_tracker/__manifest__.py b/addons/link_tracker/__manifest__.py index 206fbd438d7..c458d23f539 100644 --- a/addons/link_tracker/__manifest__.py +++ b/addons/link_tracker/__manifest__.py @@ -1,15 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + { 'name': 'Link Tracker', 'category': 'Marketing', 'description': """ -Create short and trackable URLs. -===================================================== - - """, +Shorten URLs and use them to track clicks and UTMs +""", 'version': '1.0', 'depends': ['utm'], 'data': [ - 'views/link_tracker.xml', + 'views/link_tracker_views.xml', 'security/ir.model.access.csv', ], } diff --git a/addons/link_tracker/controller/__init__.py b/addons/link_tracker/controller/__init__.py index 757b12a1f17..5d4b25db9c0 100644 --- a/addons/link_tracker/controller/__init__.py +++ b/addons/link_tracker/controller/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import main diff --git a/addons/link_tracker/models/__init__.py b/addons/link_tracker/models/__init__.py index 6436fc3a4ba..d0881ab6f62 100644 --- a/addons/link_tracker/models/__init__.py +++ b/addons/link_tracker/models/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import link_tracker diff --git a/addons/link_tracker/models/link_tracker.py b/addons/link_tracker/models/link_tracker.py index ae52a6966f2..c0147fe0e56 100644 --- a/addons/link_tracker/models/link_tracker.py +++ b/addons/link_tracker/models/link_tracker.py @@ -1,21 +1,22 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + import base64 import datetime import random import re +import requests import string -import requests from lxml import html from werkzeug import urls, utils - from odoo import models, fields, api, _ -from odoo.tools import ustr + URL_REGEX = r'(\bhref=[\'"](?!mailto:|tel:|sms:)([^\'"]+)[\'"])' + def VALIDATE_URL(url): if urls.url_parse(url).scheme not in ('http', 'https', 'ftp', 'ftps'): return 'http://' + url @@ -23,50 +24,32 @@ def VALIDATE_URL(url): return url -class link_tracker(models.Model): - """link_tracker allow users to wrap any URL into a short and trackable URL. - link_tracker counts clicks on each tracked link. - This module is also used by mass_mailing, where each link in mail_mail html_body are converted into - a trackable link to get the click-through rate of each mass_mailing.""" +class LinkTracker(models.Model): + """ Link trackers allow users to wrap any URL into a short URL that can be + tracked by Odoo. Clicks are counter on each link. A tracker is linked to + UTMs allowing to analyze marketing actions. + This model is also used in mass_mailing where each link in html body is + automatically converted into a short link that is tracked and integrates + UTMs. """ _name = "link.tracker" _rec_name = "short_url" - _description = 'Link Tracker' - - _inherit = ['utm.mixin'] + _description = "Link Tracker" + _inherit = ["utm.mixin"] + # URL info url = fields.Char(string='Target URL', required=True) - count = fields.Integer(string='Number of Clicks', compute='_compute_count', store=True) short_url = fields.Char(string='Tracked URL', compute='_compute_short_url') - link_click_ids = fields.One2many('link.tracker.click', 'link_id', string='Clicks') - title = fields.Char(string='Page Title', store=True) - favicon = fields.Char(string='Favicon', compute='_compute_favicon', store=True) - link_code_ids = fields.One2many('link.tracker.code', 'link_id', string='Codes') - code = fields.Char(string='Short URL code', compute='_compute_code') redirected_url = fields.Char(string='Redirected URL', compute='_compute_redirected_url') short_url_host = fields.Char(string='Host of the short URL', compute='_compute_short_url_host') + title = fields.Char(string='Page Title', store=True) + favicon = fields.Char(string='Favicon', compute='_compute_favicon', store=True) icon_src = fields.Char(string='Favicon Source', compute='_compute_icon_src') - - @api.model - def convert_links(self, html, vals, blacklist=None): - for match in re.findall(URL_REGEX, html): - - short_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/' - - href = match[0] - long_url = match[1] - - vals['url'] = utils.unescape(long_url) - - if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema): - link = self.create(vals) - shorten_url = self.browse(link.id)[0].short_url - - if shorten_url: - new_href = href.replace(long_url, shorten_url) - html = html.replace(href, new_href) - - return html + # Tracking + link_code_ids = fields.One2many('link.tracker.code', 'link_id', string='Codes') + code = fields.Char(string='Short URL code', compute='_compute_code') + link_click_ids = fields.One2many('link.tracker.click', 'link_id', string='Clicks') + count = fields.Integer(string='Number of Clicks', compute='_compute_count', store=True) @api.one @api.depends('link_click_ids.link_id') @@ -130,32 +113,6 @@ class link_tracker(models.Model): self.favicon = icon_base64 - @api.multi - def action_view_statistics(self): - action = self.env['ir.actions.act_window'].for_xml_id('link_tracker', 'action_view_click_statistics') - action['domain'] = [('link_id', '=', self.id)] - return action - - @api.multi - def action_visit_page(self): - return { - 'name': _("Visit Webpage"), - 'type': 'ir.actions.act_url', - 'url': self.url, - 'target': 'new', - } - - @api.model - def recent_links(self, filter, limit): - if filter == 'newest': - return self.search_read([], order='create_date DESC', limit=limit) - elif filter == 'most-clicked': - return self.search_read([('count', '!=', 0)], order='count DESC', limit=limit) - elif filter == 'recently-used': - return self.search_read([('count', '!=', 0)], order='write_date DESC', limit=limit) - else: - return {'Error': "This filter doesn't exist."} - @api.model def create(self, vals): create_vals = vals.copy() @@ -182,13 +139,60 @@ class link_tracker(models.Model): if fname not in create_vals: create_vals[fname] = False - link = super(link_tracker, self).create(create_vals) + link = super(LinkTracker, self).create(create_vals) code = self.env['link.tracker.code'].get_random_code_string() self.env['link.tracker.code'].sudo().create({'code': code, 'link_id': link.id}) return link + @api.model + def convert_links(self, html, vals, blacklist=None): + for match in re.findall(URL_REGEX, html): + + short_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/' + + href = match[0] + long_url = match[1] + + vals['url'] = utils.unescape(long_url) + + if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema): + link = self.create(vals) + shorten_url = self.browse(link.id)[0].short_url + + if shorten_url: + new_href = href.replace(long_url, shorten_url) + html = html.replace(href, new_href) + + return html + + @api.multi + def action_view_statistics(self): + action = self.env['ir.actions.act_window'].for_xml_id('link_tracker', 'link_tracker_click_action_statistics') + action['domain'] = [('link_id', '=', self.id)] + return action + + @api.multi + def action_visit_page(self): + return { + 'name': _("Visit Webpage"), + 'type': 'ir.actions.act_url', + 'url': self.url, + 'target': 'new', + } + + @api.model + def recent_links(self, filter, limit): + if filter == 'newest': + return self.search_read([], order='create_date DESC', limit=limit) + elif filter == 'most-clicked': + return self.search_read([('count', '!=', 0)], order='count DESC', limit=limit) + elif filter == 'recently-used': + return self.search_read([('count', '!=', 0)], order='write_date DESC', limit=limit) + else: + return {'Error': "This filter doesn't exist."} + @api.model def get_url_from_code(self, code, context=None): code_rec = self.env['link.tracker.code'].sudo().search([('code', '=', code)]) @@ -203,13 +207,17 @@ class link_tracker(models.Model): ] -class link_tracker_code(models.Model): +class LinkTrackerCode(models.Model): _name = "link.tracker.code" - _description = 'Link Tracker Code' + _description = "Link Tracker Code" code = fields.Char(string='Short URL Code', store=True) link_id = fields.Many2one('link.tracker', 'Link', required=True, ondelete='cascade') + _sql_constraints = [ + ('code', 'unique( code )', 'Code must be unique.') + ] + @api.model def get_random_code_string(self): size = 3 @@ -221,18 +229,14 @@ class link_tracker_code(models.Model): else: return code_proposition - _sql_constraints = [ - ('code', 'unique( code )', 'Code must be unique.') - ] - -class link_tracker_click(models.Model): +class LinkTrackerClick(models.Model): _name = "link.tracker.click" _rec_name = "link_id" - _description = 'Link Tracker Click' + _description = "Link Tracker Click" - click_date = fields.Date(string='Create Date') link_id = fields.Many2one('link.tracker', 'Link', required=True, ondelete='cascade') + click_date = fields.Date(string='Create Date') ip = fields.Char(string='Internet Protocol') country_id = fields.Many2one('res.country', 'Country') diff --git a/addons/link_tracker/views/link_tracker.xml b/addons/link_tracker/views/link_tracker_views.xml similarity index 50% rename from addons/link_tracker/views/link_tracker.xml rename to addons/link_tracker/views/link_tracker_views.xml index 951bc2d8154..0ed9fe524c4 100644 --- a/addons/link_tracker/views/link_tracker.xml +++ b/addons/link_tracker/views/link_tracker_views.xml @@ -1,28 +1,20 @@ - - Statistics of Clicks - link.tracker.click - graph - [('link_id', 'IN', ids)] - - - - - Links + + + link.tracker.view.search link.tracker - - + + - - - link.tracker.form + + link.tracker.view.form link.tracker
@@ -34,56 +26,29 @@ - - - - - - - - - - + + + + + + + + + +
- - - link.tracker.my.form + + link.tracker.view.tree link.tracker -
- - - - - - - - - -
-
-
- - - link.tracker.form.graph.action - link.tracker - form - form - - - - - link.tracker.tree - link.tracker - - + @@ -93,24 +58,23 @@ - - link.tracker.graph + + link.tracker.view.graph link.tracker - + - - + Link Tracker link.tracker form tree,form,graph - +

Create a new link tracker @@ -118,14 +82,12 @@ - - - link.tracker.click.form + + + link.tracker.click.view.form link.tracker.click -

-
-
+ @@ -137,11 +99,11 @@ - - link.tracker.click.tree + + link.tracker.click.view.tree link.tracker.click - + @@ -149,11 +111,11 @@ - - link.tracker.click.graph + + link.tracker.click.view.graph link.tracker.click - + @@ -161,8 +123,18 @@ - + + Click Statistics + link.tracker.click + graph + [] + - + + diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py index cbf5279f176..b0c6daec3bd 100644 --- a/addons/mass_mailing/__manifest__.py +++ b/addons/mass_mailing/__manifest__.py @@ -25,6 +25,7 @@ 'wizard/mass_mailing_list_merge_views.xml', 'wizard/test_mailing_views.xml', 'wizard/mass_mailing_schedule_date_views.xml', + 'views/mass_mailing_views_menus.xml', 'views/mass_mailing_report_views.xml', 'views/mass_mailing_stats_views.xml', 'views/link_tracker_views.xml', diff --git a/addons/mass_mailing/views/link_tracker_views.xml b/addons/mass_mailing/views/link_tracker_views.xml index f7ea013cefd..695768e0f8d 100644 --- a/addons/mass_mailing/views/link_tracker_views.xml +++ b/addons/mass_mailing/views/link_tracker_views.xml @@ -1,42 +1,63 @@ - - - link.tracker.tree + + + link.tracker.view.search.inherit.mass.mail link.tracker - 10 + - - - - - - - - - - link.tracker.search - link.tracker - - - + - + - + + link.tracker.view.form.inherit.mass.mail + link.tracker + + + + + + + + + + Statistics of Clicks link.tracker - tree + form + tree,form,graph + + +

+ Create a new link tracker +

+
{'search_default_mass_mailing_id': active_id}
- - + + Statistics of Clicks link.tracker - tree - {'search_default_mass_mailing_campaign_id': active_id} + form + tree,form,graph + + +

+ Create a new link tracker +

+
+ {'id="link_tracker_action_mass_mailing"': active_id}
+ + + +
diff --git a/addons/mass_mailing/views/mass_mailing_blacklist_views.xml b/addons/mass_mailing/views/mass_mailing_blacklist_views.xml index 9940d09ba8c..85118d3f66d 100644 --- a/addons/mass_mailing/views/mass_mailing_blacklist_views.xml +++ b/addons/mass_mailing/views/mass_mailing_blacklist_views.xml @@ -1,4 +1,4 @@ - + diff --git a/addons/mass_mailing/views/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml index 84bf335ca59..a2a6ff17780 100644 --- a/addons/mass_mailing/views/mass_mailing_views.xml +++ b/addons/mass_mailing/views/mass_mailing_views.xml @@ -1,14 +1,5 @@ - - - - - - - mail.mass_mailing.list_contact_rel.view.form @@ -712,7 +703,7 @@
- Clicks
@@ -866,11 +857,8 @@ - - @@ -897,7 +885,7 @@ - + @@ -968,7 +956,7 @@ type="action" class="oe_stat_button"> - diff --git a/addons/mass_mailing/views/mass_mailing_views_menus.xml b/addons/mass_mailing/views/mass_mailing_views_menus.xml new file mode 100644 index 00000000000..57832e6850a --- /dev/null +++ b/addons/mass_mailing/views/mass_mailing_views_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/addons/mass_mailing/views/res_config_settings_views.xml b/addons/mass_mailing/views/res_config_settings_views.xml index 38600acbec9..a8a56b27461 100644 --- a/addons/mass_mailing/views/res_config_settings_views.xml +++ b/addons/mass_mailing/views/res_config_settings_views.xml @@ -74,8 +74,6 @@ {'module' : 'mass_mailing'} - + parent="mass_mailing_configuration" sequence="0" action="action_mass_mailing_configuration" groups="base.group_system"/> diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 91eb4b3f817..aa4e8be7704 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -28,6 +28,8 @@ + + diff --git a/addons/utm/__init__.py b/addons/utm/__init__.py index a0fdc10fe11..dc5e6b693d1 100644 --- a/addons/utm/__init__.py +++ b/addons/utm/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import models diff --git a/addons/utm/__manifest__.py b/addons/utm/__manifest__.py index 66fb0775f94..ffa06da03bf 100644 --- a/addons/utm/__manifest__.py +++ b/addons/utm/__manifest__.py @@ -1,16 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + { 'name': 'UTM Trackers', 'category': 'Hidden', 'description': """ -Enable UTM trackers in shared links. -===================================================== - """, +Enable management of UTM trackers: campaign, medium, source. +""", 'version': '1.0', 'depends': ['base'], 'data': [ + 'data/utm_data.xml', + 'views/utm_views.xml', 'security/ir.model.access.csv', - 'views/utm.xml', - 'data/utm_data.xml' ], 'demo': [], 'auto_install': False, diff --git a/addons/utm/models/__init__.py b/addons/utm/models/__init__.py index faef4eabef7..6ebdc46b951 100644 --- a/addons/utm/models/__init__.py +++ b/addons/utm/models/__init__.py @@ -1,3 +1,6 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import utm +from . import utm_mixin from . import ir_http diff --git a/addons/utm/models/utm.py b/addons/utm/models/utm.py index 0627177ffd2..41efca78cf5 100644 --- a/addons/utm/models/utm.py +++ b/addons/utm/models/utm.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, SUPERUSER_ID -from odoo.http import request -from odoo.tools import pycompat +from odoo import fields, models class UtmMedium(models.Model): @@ -29,57 +27,3 @@ class UtmSource(models.Model): _description = 'UTM Source' name = fields.Char(string='Source Name', required=True, translate=True) - - -class UtmMixin(models.AbstractModel): - - """Mixin class for objects which can be tracked by marketing. """ - _name = 'utm.mixin' - _description = 'UTM Mixin' - - campaign_id = fields.Many2one('utm.campaign', 'Campaign', - help="This is a name that helps you keep track of your different campaign efforts, e.g. Fall_Drive, Christmas_Special") - source_id = fields.Many2one('utm.source', 'Source', - help="This is the source of the link, e.g. Search Engine, another domain, or name of email list") - medium_id = fields.Many2one('utm.medium', 'Medium', - help="This is the method of delivery, e.g. Postcard, Email, or Banner Ad", oldname='channel_id') - - def tracking_fields(self): - # This function cannot be overridden in a model which inherit utm.mixin - # Limitation by the heritage on AbstractModel - # record_crm_lead.tracking_fields() will call tracking_fields() from module utm.mixin (if not overridden on crm.lead) - # instead of the overridden method from utm.mixin. - # To force the call of overridden method, we use self.env['utm.mixin'].tracking_fields() which respects overridden - # methods of utm.mixin, but will ignore overridden method on crm.lead - return [ - # ("URL_PARAMETER", "FIELD_NAME_MIXIN", "NAME_IN_COOKIES") - ('utm_campaign', 'campaign_id', 'odoo_utm_campaign'), - ('utm_source', 'source_id', 'odoo_utm_source'), - ('utm_medium', 'medium_id', 'odoo_utm_medium'), - ] - - @api.model - def default_get(self, fields): - values = super(UtmMixin, self).default_get(fields) - - # We ignore UTM for salemen, except some requests that could be done as superuser_id to bypass access rights. - if self.env.uid != SUPERUSER_ID and self.env.user.has_group('sales_team.group_sale_salesman'): - return values - - for url_param, field_name, cookie_name in self.env['utm.mixin'].tracking_fields(): - if field_name in fields: - field = self._fields[field_name] - value = False - if request: - # ir_http dispatch saves the url params in a cookie - value = request.httprequest.cookies.get(cookie_name) - # if we receive a string for a many2one, we search/create the id - if field.type == 'many2one' and isinstance(value, pycompat.string_types) and value: - Model = self.env[field.comodel_name] - records = Model.search([('name', '=', value)], limit=1) - if not records: - records = Model.create({'name': value}) - value = records.id - if value: - values[field_name] = value - return values diff --git a/addons/utm/models/utm_mixin.py b/addons/utm/models/utm_mixin.py new file mode 100644 index 00000000000..c239501c972 --- /dev/null +++ b/addons/utm/models/utm_mixin.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, SUPERUSER_ID +from odoo.http import request +from odoo.tools import pycompat + + +class UtmMixin(models.AbstractModel): + """ Mixin class for objects which can be tracked by marketing. """ + _name = 'utm.mixin' + _description = 'UTM Mixin' + + campaign_id = fields.Many2one('utm.campaign', 'Campaign', + help="This is a name that helps you keep track of your different campaign efforts, e.g. Fall_Drive, Christmas_Special") + source_id = fields.Many2one('utm.source', 'Source', + help="This is the source of the link, e.g. Search Engine, another domain, or name of email list") + medium_id = fields.Many2one('utm.medium', 'Medium', + help="This is the method of delivery, e.g. Postcard, Email, or Banner Ad", oldname='channel_id') + + @api.model + def default_get(self, fields): + values = super(UtmMixin, self).default_get(fields) + + # We ignore UTM for salemen, except some requests that could be done as superuser_id to bypass access rights. + if self.env.uid != SUPERUSER_ID and self.env.user.has_group('sales_team.group_sale_salesman'): + return values + + for url_param, field_name, cookie_name in self.env['utm.mixin'].tracking_fields(): + if field_name in fields: + field = self._fields[field_name] + value = False + if request: + # ir_http dispatch saves the url params in a cookie + value = request.httprequest.cookies.get(cookie_name) + # if we receive a string for a many2one, we search/create the id + if field.type == 'many2one' and isinstance(value, pycompat.string_types) and value: + Model = self.env[field.comodel_name] + records = Model.search([('name', '=', value)], limit=1) + if not records: + records = Model.create({'name': value}) + value = records.id + if value: + values[field_name] = value + return values + + def tracking_fields(self): + # This function cannot be overridden in a model which inherit utm.mixin + # Limitation by the heritage on AbstractModel + # record_crm_lead.tracking_fields() will call tracking_fields() from module utm.mixin (if not overridden on crm.lead) + # instead of the overridden method from utm.mixin. + # To force the call of overridden method, we use self.env['utm.mixin'].tracking_fields() which respects overridden + # methods of utm.mixin, but will ignore overridden method on crm.lead + return [ + # ("URL_PARAMETER", "FIELD_NAME_MIXIN", "NAME_IN_COOKIES") + ('utm_campaign', 'campaign_id', 'odoo_utm_campaign'), + ('utm_source', 'source_id', 'odoo_utm_source'), + ('utm_medium', 'medium_id', 'odoo_utm_medium'), + ] diff --git a/addons/utm/views/utm.xml b/addons/utm/views/utm.xml deleted file mode 100644 index 6e66bf89103..00000000000 --- a/addons/utm/views/utm.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - utm.campaign.tree - utm.campaign - - - - - - - - - utm.campaign.form - utm.campaign - - - - - - - - - - - Campaigns - utm.campaign - form - - - - - - - - utm.medium.tree - utm.medium - - - - - - - - - utm.medium.form - utm.medium - -
- - - - -
-
-
- - Medium - utm.medium - form - tree,form - -

- Define a new medium -

-
-
- - - - - - utm.source.tree - utm.source - - - - - - - - utm.source.form - utm.source - -
- - - -
-
-
- - Sources - utm.source - form - tree,form - -

- Define a new source -

-
-
- - -
diff --git a/addons/utm/views/utm_views.xml b/addons/utm/views/utm_views.xml new file mode 100644 index 00000000000..18852e1c270 --- /dev/null +++ b/addons/utm/views/utm_views.xml @@ -0,0 +1,136 @@ + + + + + + + + utm.campaign.view.tree + utm.campaign + + + + + + + + utm.campaign.view.form + utm.campaign + +
+ + + +
+
+
+ + + Campaigns + utm.campaign + form + + +

+ Define a new UTM campaign +

+
+
+ + + + + + utm.medium.view.tree + utm.medium + + + + + + + + + utm.medium.view.form + utm.medium + +
+ + + + +
+
+
+ + + Mediums + utm.medium + form + tree,form + +

+ Define a new UTM medium +

+
+
+ + + + + + utm.source.view.tree + utm.source + + + + + + + + utm.source.view.form + utm.source + +
+ + + +
+
+
+ + + Sources + utm.source + form + tree,form + +

+ Define a new UTM source +

+
+
+ + + +