[IMP] utm: ensure the uniqueness of the names of the UTM models

Purpose
=======
Ensure that the names of the UTMs models (campaign, medium and source)
are unique.

If not, generate automatically an unique name.

The name field is no more translatable; it makes no sense to translate
a technical which will be added in the URL, and can even cause issues
(the UTM record is not recognized because of the translation).

For the campaign, we keep a "translatable" name which is called
"title".

For the UTM source, use a mixin to generate automatically the name of
the source based on the content (_rec_name) of the record. So we remove
the override of create / write / copy in the different models and
ensure consistency between those models.

Task-2245823

closes odoo/odoo#60501

Related: odoo/enterprise#21882
Related: odoo/upgrade#1863
Signed-off-by: Warnon Aurélien (awa) <awa@odoo.com>
This commit is contained in:
std-odoo
2022-02-22 12:53:00 +00:00
parent dcf33627f5
commit 4dbcefb5e5
14 changed files with 338 additions and 38 deletions
@@ -21,9 +21,8 @@ AVAILABLE_PRIORITIES = [
class RecruitmentSource(models.Model):
_name = "hr.recruitment.source"
_description = "Source of Applicants"
_inherits = {"utm.source": "source_id"}
_inherit = ['utm.source.mixin']
source_id = fields.Many2one('utm.source', "Source", ondelete='restrict', required=True)
email = fields.Char(related='alias_id.display_name', string="Email", readonly=True)
job_id = fields.Many2one('hr.job', "Job", ondelete='cascade')
alias_id = fields.Many2one('mail.alias', "Alias ID")
+2 -10
View File
@@ -30,9 +30,8 @@ class MassMailing(models.Model):
A mass mailing is an occurence of sending emails. """
_name = 'mailing.mailing'
_description = 'Mass Mailing'
_inherit = ['mail.thread', 'mail.activity.mixin', 'mail.render.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'mail.render.mixin', 'utm.source.mixin']
_order = 'calendar_date DESC'
_inherits = {'utm.source': 'source_id'}
_rec_name = "subject"
@api.model
@@ -93,8 +92,6 @@ class MassMailing(models.Model):
'mass_mailing_id', 'attachment_id', string='Attachments')
keep_archives = fields.Boolean(string='Keep Archives')
campaign_id = fields.Many2one('utm.campaign', string='UTM Campaign', index=True, ondelete='set null')
source_id = fields.Many2one('utm.source', string='Source', required=True, ondelete='restrict',
help="This is the link source, e.g. Search Engine, another domain, or name of email list")
medium_id = fields.Many2one(
'utm.medium', string='Medium',
compute='_compute_medium_id', readonly=False, store=True, ondelete='restrict',
@@ -400,11 +397,8 @@ class MassMailing(models.Model):
@api.model_create_multi
def create(self, vals_list):
now = fields.Datetime.now()
ab_testing_cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo()
for values in vals_list:
if values.get('subject') and not values.get('name'):
values['name'] = "%s %s" % (values['subject'], now)
if values.get('body_html'):
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
if values.get('ab_testing_schedule_datetime'):
@@ -451,9 +445,7 @@ class MassMailing(models.Model):
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
self.ensure_one()
default = dict(default or {},
name=_('%s (copy)', self.name),
contact_list_ids=self.contact_list_ids.ids)
default = dict(default or {}, contact_list_ids=self.contact_list_ids.ids)
return super(MassMailing, self).copy(default=default)
def _group_expand_states(self, states, domain, order):
@@ -5,10 +5,12 @@ from ast import literal_eval
from datetime import datetime
from freezegun import freeze_time
from psycopg2 import IntegrityError
from unittest.mock import patch
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.exceptions import ValidationError
from odoo.sql_db import Cursor
from odoo.tests.common import users, Form
from odoo.tools import mute_logger
@@ -227,6 +229,50 @@ class TestMassMailValues(MassMailCommon):
activity.write({'res_id': 0})
activity.flush()
@freeze_time('2022-01-02')
@patch.object(Cursor, 'now', lambda *args, **kwargs: datetime(2022, 1, 2))
@users('user_marketing')
def test_mailing_unique_name(self):
"""Test that the names are generated and unique for each mailing.
If the name is missing, it's generated from the subject. Then we should ensure
that this generated name is unique.
"""
mailing_0 = self.env['mailing.mailing'].create({'subject': 'First subject'})
mailing_1, mailing_2, mailing_3, mailing_4, mailing_5, mailing_6 = self.env['mailing.mailing'].create([{
'subject': 'First subject',
}, {
'subject': 'First subject',
}, {
'subject': 'First subject',
'source_id': self.env['utm.source'].create({'name': 'Custom Source'}).id,
}, {
'subject': 'First subject',
'name': 'Mailing',
}, {
'subject': 'Second subject',
'name': 'Mailing',
}, {
'subject': 'Second subject',
}])
self.assertEqual(mailing_0.name, 'First subject (Mass Mailing created on 2022-01-02)')
self.assertEqual(mailing_1.name, 'First subject (Mass Mailing created on 2022-01-02) [2]')
self.assertEqual(mailing_2.name, 'First subject (Mass Mailing created on 2022-01-02) [3]')
self.assertEqual(mailing_3.name, 'Custom Source')
self.assertEqual(mailing_4.name, 'Mailing')
self.assertEqual(mailing_5.name, 'Mailing [2]')
self.assertEqual(mailing_6.name, 'Second subject (Mass Mailing created on 2022-01-02)')
mailing_0.subject = 'First subject'
self.assertEqual(mailing_0.name, 'First subject (Mass Mailing created on 2022-01-02) [4]',
msg='The name must have been re-generated')
mailing_0.name = 'Second subject (Mass Mailing created on 2022-01-02)'
self.assertEqual(mailing_0.name, 'Second subject (Mass Mailing created on 2022-01-02) [2]',
msg='The name must be unique')
class TestMassMailFeatures(MassMailCommon, CronMixinCase):
@@ -121,14 +121,17 @@
<value name="mailing_trace_id" eval="ref('mailing_sms_0_trace_2')"/>
</function>
<record id="mailing_sms_0_utm_source" model="utm.source">
<field name="name">Mailing Extra Promo</field>
</record>
<record id="mailing_sms_1" model="mailing.mailing">
<field name="name">Extra Promo</field>
<field name="subject">Extra Promo</field>
<field name="mailing_type">sms</field>
<field name="state">done</field>
<field name="user_id" ref="base.user_admin"/>
<field name="sent_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="campaign_id" ref="utm_campaign_0"/>
<field name="source_id" ref="mailing_sms_0_utm_source"/>
<field name="mailing_model_id" ref="mass_mailing.model_mailing_list"/>
<field name="contact_list_ids" eval="[(5, 0), (4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
<field name="body_plaintext">Extra promotion for you !</field>
+24 -1
View File
@@ -7,8 +7,11 @@ from odoo import fields, models, api, SUPERUSER_ID
class UtmCampaign(models.Model):
_name = 'utm.campaign'
_description = 'UTM Campaign'
_rec_name = 'title'
name = fields.Char(string='Campaign Name', required=True, translate=True)
name = fields.Char(string='Campaign Identifier', required=True, compute='_compute_name',
store=True, readonly=False, precompute=True, translate=False)
title = fields.Char(string='Campaign Name', required=True, translate=True)
user_id = fields.Many2one(
'res.users', string='Responsible',
@@ -24,6 +27,26 @@ class UtmCampaign(models.Model):
is_auto_campaign = fields.Boolean(default=False, string="Automatically Generated Campaign", help="Allows us to filter relevant Campaigns")
color = fields.Integer(string='Color Index')
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'The name must be unique'),
]
@api.depends('title')
def _compute_name(self):
new_names = self.env['utm.mixin']._get_unique_names(self._name, [c.title for c in self])
for campaign, new_name in zip(self, new_names):
campaign.name = new_name
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('title') and vals.get('name'):
vals['title'] = vals['name']
new_names = self.env['utm.mixin']._get_unique_names(self._name, [vals.get('name') for vals in vals_list])
for vals, new_name in zip(vals_list, new_names):
vals['name'] = new_name
return super().create(vals_list)
@api.model
def _group_expand_stage_ids(self, stages, domain, order):
"""Read group customization in order to display all the stages in the
+12 -1
View File
@@ -11,9 +11,20 @@ class UtmMedium(models.Model):
_description = 'UTM Medium'
_order = 'name'
name = fields.Char(string='Medium Name', required=True, translate=True)
name = fields.Char(string='Medium Name', required=True, translate=False)
active = fields.Boolean(default=True)
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'The name must be unique'),
]
@api.model_create_multi
def create(self, vals_list):
new_names = self.env['utm.mixin']._get_unique_names(self._name, [vals.get('name') for vals in vals_list])
for vals, new_name in zip(vals_list, new_names):
vals['name'] = new_name
return super().create(vals_list)
@api.ondelete(at_uninstall=False)
def _unlink_except_utm_medium_email(self):
utm_medium_email = self.env.ref('utm.utm_medium_email', raise_if_not_found=False)
+84 -8
View File
@@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from collections import defaultdict
from odoo import api, fields, models
from odoo.http import request
from odoo.osv import expression
class UtmMixin(models.AbstractModel):
@@ -34,14 +38,8 @@ class UtmMixin(models.AbstractModel):
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, str) and value:
Model = self.env[field.comodel_name]
records = Model.search([('name', '=', value)], limit=1)
if not records:
if 'is_auto_campaign' in records._fields:
records = Model.create({'name': value, 'is_auto_campaign': True})
else:
records = Model.create({'name': value})
value = records.id
record = self._find_or_create_record(field.comodel_name, value)
value = record.id
if value:
values[field_name] = value
return values
@@ -59,3 +57,81 @@ class UtmMixin(models.AbstractModel):
('utm_source', 'source_id', 'odoo_utm_source'),
('utm_medium', 'medium_id', 'odoo_utm_medium'),
]
def _find_or_create_record(self, model_name, name):
"""Based on the model name and on the name of the record, retrieve the corresponding record or create it."""
Model = self.env[model_name]
record = Model.search([('name', '=', name)], limit=1)
if not record:
# No record found, create a new one
record_values = {'name': name}
if 'is_auto_campaign' in record._fields:
record_values['is_auto_campaign'] = True
record = Model.create(record_values)
return record
@api.model
def _get_unique_names(self, model_name, names):
"""Generate unique names for the given model.
Take a list of names and return for each names, the new names to set
in the same order (with a counter added if needed).
E.G.
The name "test" already exists in database
Input: ['test', 'test [3]', 'bob', 'test', 'test']
Output: ['test [2]', 'test [3]', 'bob', 'test [4]', 'test [5]']
:param model_name: name of the model for which we will generate unique names
:param names: list of names, we will ensure that each name will be unique
:return: a list of new values for each name, in the same order
"""
def _split_name_and_count(name):
"""
Return the name part and the counter based on the given name.
e.g.
"Medium" -> "Medium", 1
"Medium [1234]" -> "Medium", 1234
"""
name = name or ''
name_counter_re = r'(.*)\s+\[([0-9]+)\]'
match = re.match(name_counter_re, name)
if match:
return match.group(1), int(match.group(2) or '1')
return name, 1
# Remove potential counter part in each names
names_without_counter = {_split_name_and_count(name)[0] for name in names}
# Retrieve existing similar names
seach_domain = expression.OR([[('name', 'ilike', name)] for name in names_without_counter])
existing_names = {vals['name'] for vals in self.env[model_name].search_read(seach_domain, ['name'])}
# Count for each names, based on the names list given in argument
# and the record names in database
count_per_names = defaultdict(lambda: 0)
count_per_names.update({
name: max((
_split_name_and_count(existing_name)[1] + 1
for existing_name in existing_names
if existing_name == name or existing_name.startswith(f'{name} [')
), default=1)
for name in names_without_counter
})
result = []
for name in names:
if not name:
result.append(False)
continue
name_without_counter = _split_name_and_count(name)[0]
counter = count_per_names[name_without_counter]
result.append(f'{name_without_counter} [{counter}]' if counter > 1 else name)
count_per_names[name_without_counter] += 1
return result
+76 -2
View File
@@ -2,11 +2,85 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import _, api, fields, models, tools
class UtmSource(models.Model):
_name = 'utm.source'
_description = 'UTM Source'
name = fields.Char(string='Source Name', required=True, translate=True)
name = fields.Char(string='Source Name', required=True)
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'The name must be unique'),
]
@api.model_create_multi
def create(self, vals_list):
new_names = self.env['utm.mixin']._get_unique_names(self._name, [vals.get('name') for vals in vals_list])
for vals, new_name in zip(vals_list, new_names):
vals['name'] = new_name
return super().create(vals_list)
def _generate_name(self, record, content):
"""Generate the UTM source name based on the content of the source."""
if not content:
return False
content = content.replace('\n', ' ')
if len(content) >= 24:
content = f'{content[:20]}...'
create_date = record.create_date or fields.date.today()
create_date = fields.date.strftime(create_date, tools.DEFAULT_SERVER_DATE_FORMAT)
model_description = self.env['ir.model']._get(record._name).name
return _(
'%(content)s (%(model_description)s created on %(create_date)s)',
content=content, model_description=model_description, create_date=create_date,
)
class UtmSourceMixin(models.AbstractModel):
"""Mixin responsible of generating the name of the source based on the content
(field defined by _rec_name) of the record (mailing, social post,...).
"""
_name = 'utm.source.mixin'
_description = 'UTM Source Mixin'
name = fields.Char('Name', related='source_id.name', readonly=False)
source_id = fields.Many2one('utm.source', string='Source', required=True, ondelete='restrict', copy=False)
@api.model_create_multi
def create(self, vals_list):
"""Create the UTM sources if necessary, generate the name based on the content in batch."""
# Create all required <utm.source>
utm_sources = self.env['utm.source'].create([
{'name': values.get('name') or self.env['utm.source']._generate_name(self, values.get(self._rec_name))}
for values in vals_list
if not values.get('source_id')
])
# Update "vals_list" to add the ID of the newly created source
vals_list_missing_source = [values for values in vals_list if not values.get('source_id')]
for values, source in zip(vals_list_missing_source, utm_sources):
values['source_id'] = source.id
for values in vals_list:
if 'name' in values:
del values['name']
return super().create(vals_list)
def write(self, values):
if values.get(self._rec_name) and not values.get('name'):
values['name'] = self.env['utm.source']._generate_name(self, values[self._rec_name])
if values.get('name'):
values['name'] = self.env['utm.mixin']._get_unique_names(self._name, [values['name']])[0]
super().write(values)
def copy(self, default=None):
"""Increment the counter when duplicating the source."""
default = default or {}
default['name'] = self.env['utm.mixin']._get_unique_names(self._name, [self.name])[0]
return super().copy(default)
+1 -1
View File
@@ -13,4 +13,4 @@ class UtmStage(models.Model):
_order = 'sequence'
name = fields.Char(required=True, translate=True)
sequence = fields.Integer()
sequence = fields.Integer(default=1)
+1
View File
@@ -3,5 +3,6 @@
from . import common
from . import test_routes
from . import test_utm
from . import test_utm_consistency
from . import test_utm_security
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.utm.tests.common import TestUTMCommon
class TestUtm(TestUTMCommon):
def test_find_or_create_record(self):
source_1, source_2 = self.env['utm.source'].create([{
'name': 'Source 1',
}, {
'name': 'Source 2',
}])
# Find the record based on the given name
source = self.env['utm.mixin']._find_or_create_record('utm.source', 'Source 1')
self.assertEqual(source, source_1)
# Create a new record
source_4 = self.env['utm.mixin']._find_or_create_record('utm.source', 'Source 3')
self.assertNotIn(source_4, source_1 | source_2)
self.assertEqual(source_4.name, 'Source 3')
def test_name_generation(self):
"""Test that the name is always unique.
A counter must be added at the end of the name if it's not the case.
"""
for utm_model in ('utm.source', 'utm.medium', 'utm.campaign'):
utm_0 = self.env[utm_model].create({'name': 'UTM dup'})
utm_1, utm_2, utm_3, utm_4, utm_5 = self.env[utm_model].create([{
'name': 'UTM 1',
}, {
'name': 'UTM 2',
}, {
'name': 'UTM dup',
}, {
# UTM record 4 has the same name of the previous UTM record
'name': 'UTM dup',
}, {
# UTM record 5 has the same name of the previous UTM record
# but with a wrong counter part, it will be removed and updated
'name': 'UTM dup [0]',
}])
self.assertEqual(utm_0.name, 'UTM dup', msg='The first "UTM dup" should be left unchanged since it is unique')
self.assertEqual(utm_1.name, 'UTM 1', msg='This name is already unique')
self.assertEqual(utm_2.name, 'UTM 2', msg='This name is already unique')
self.assertEqual(utm_3.name, 'UTM dup [2]', msg='Must add a counter as suffix to ensure uniqueness')
self.assertEqual(utm_4.name, 'UTM dup [3]', msg='Must add a counter as suffix to ensure uniqueness')
self.assertEqual(utm_5.name, 'UTM dup [4]', msg='Must add a counter as suffix to ensure uniqueness')
(utm_0 | utm_3 | utm_4).unlink()
utm_6 = self.env[utm_model].create({'name': 'UTM dup'})
self.assertEqual(utm_6.name, 'UTM dup [5]')
utm_7 = self.env[utm_model].create({'name': 'UTM d'})
self.assertEqual(utm_7.name, 'UTM d', msg='Even if this name has the same prefix as the other, it is still unique')
utm_8 = utm_7.copy()
self.assertEqual(utm_8.name, 'UTM d [2]', msg='Must add a counter as suffix to ensure uniqueness')
+6 -6
View File
@@ -17,9 +17,9 @@ class TestUTMSecurity(TestUTMCommon):
UtmSource = self.env['utm.source']
# CREATE
test_utm_campaign = UtmCampaign.create({'name': 'Test Campaign'})
test_utm_medium = UtmMedium.create({'name': 'Test Medium'})
test_utm_source = UtmSource.create({'name': 'Test Source'})
test_utm_campaign = UtmCampaign.create({'name': 'Campaign ACLs'})
test_utm_medium = UtmMedium.create({'name': 'Medium ACLs'})
test_utm_source = UtmSource.create({'name': 'Source ACLs'})
# READ
self.assertEqual(
@@ -50,9 +50,9 @@ class TestUTMSecurity(TestUTMCommon):
UtmSource = self.env['utm.source']
# CREATE
test_utm_campaign = UtmCampaign.create({'name': 'Test Campaign'})
test_utm_medium = UtmMedium.create({'name': 'Test Medium'})
test_utm_source = UtmSource.create({'name': 'Test Source'})
test_utm_campaign = UtmCampaign.create({'name': 'Campaign ACLs'})
test_utm_medium = UtmMedium.create({'name': 'Medium ACLs'})
test_utm_source = UtmSource.create({'name': 'Source ACLs'})
# READ
self.assertEqual(
+8 -5
View File
@@ -5,7 +5,7 @@
<field name="model">utm.campaign</field>
<field name="arch" type="xml">
<search string="UTM Campaigns">
<field name="name" string="Campaigns"/>
<field name="title" string="Campaigns"/>
<field name="tag_ids"/>
<field name="user_id"/>
<field name="is_auto_campaign"/>
@@ -31,7 +31,8 @@
<div class="oe_button_box d-flex justify-content-end" name="button_box">
</div>
<group id="top-group">
<field class="o_text_overflow" name="name" string="Campaign Name" placeholder="e.g. Black Friday"/>
<field class="o_text_overflow" name="title" string="Campaign Name" placeholder="e.g. Black Friday"/>
<field name="name" invisible="1"/>
<field name="user_id" domain="[('share', '=', False)]"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
@@ -47,7 +48,8 @@
<field name="model">utm.campaign</field>
<field name="arch" type="xml">
<tree string="UTM Campaigns" multi_edit="1" sample="1">
<field name="name" readonly="1"/>
<field name="title" readonly="1"/>
<field name="name" invisible="1"/>
<field name="user_id"/>
<field name="stage_id"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
@@ -62,7 +64,8 @@
<field name="arch" type="xml">
<form>
<group>
<field class="o_text_overflow" name="name" string="Campaign Name" placeholder="e.g. Black Friday"/>
<field name="name" invisible="1"/>
<field class="o_text_overflow" name="title" string="Campaign Name" placeholder="e.g. Black Friday"/>
<field name="user_id" domain="[('share', '=', False)]"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
@@ -99,7 +102,7 @@
<div class="oe_kanban_content">
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<h3 class="oe_margin_bottom_8 o_kanban_record_title"><field name="name"/></h3>
<h3 class="oe_margin_bottom_8 o_kanban_record_title"><field name="title"/></h3>
</div>
</div>
<div class="o_kanban_record_body">
+10 -1
View File
@@ -8,9 +8,12 @@ import odoo.tests
class TestWebsiteCrm(odoo.tests.HttpCase):
def test_tour(self):
all_utm_campaign = self.env['utm.campaign'].search([])
utm_medium = self.env['utm.medium'].create({'name': 'Medium'})
utm_source = self.env['utm.source'].create({'name': 'Source'})
# change action to create opportunity
self.start_tour("/", 'website_crm_pre_tour', login='admin')
self.start_tour("/", 'website_crm_tour')
self.start_tour("/?utm_source=Source&utm_medium=Medium&utm_campaign=New campaign", 'website_crm_tour')
# check result
record = self.env['crm.lead'].search([('description', '=', '### TOUR DATA ###')])
@@ -19,6 +22,12 @@ class TestWebsiteCrm(odoo.tests.HttpCase):
self.assertEqual(record.email_from, 'john@smith.com')
self.assertEqual(record.partner_name, 'Odoo S.A.')
# check UTM records
self.assertEqual(record.source_id, utm_source)
self.assertEqual(record.medium_id, utm_medium)
self.assertNotIn(record.campaign_id, all_utm_campaign, 'Should have created a new campaign')
self.assertEqual(record.campaign_id.name, 'New campaign', 'Name of the "on the fly" created campaign is wrong')
def test_catch_logged_partner_info_tour(self):
user_login = 'admin'
user_partner = self.env['res.users'].search([('login', '=', user_login)]).partner_id