Skip to content

Commit d43d1a7

Browse files
committed
[ADD] website_ab_testing
[FIX] website_ab_testing: Prettier [FIX] website_ab_testing: Prettier [FIX] website_ab_testing: Prettier [FIX] website_ab_testing: Prettier [FIX] website_ab_testing: Prettier [ADD] tests [ADD] tests [ADD] tests Fixup Fixup
1 parent 221cd29 commit d43d1a7

25 files changed

+916
-0
lines changed

website_ab_testing/README.rst

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
**This file is going to be generated by oca-gen-addon-readme.**
2+
3+
*Manual changes will be overwritten.*
4+
5+
Please provide content in the ``readme`` directory:
6+
7+
* **DESCRIPTION.rst** (required)
8+
* INSTALL.rst (optional)
9+
* CONFIGURE.rst (optional)
10+
* **USAGE.rst** (optional, highly recommended)
11+
* DEVELOP.rst (optional)
12+
* ROADMAP.rst (optional)
13+
* HISTORY.rst (optional, recommended)
14+
* **CONTRIBUTORS.rst** (optional, highly recommended)
15+
* CREDITS.rst (optional)
16+
17+
Content of this README will also be drawn from the addon manifest,
18+
from keys such as name, authors, maintainers, development_status,
19+
and license.
20+
21+
A good, one sentence summary in the manifest is also highly recommended.
22+
23+
24+
Automatic changelog generation
25+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26+
27+
`HISTORY.rst` can be auto generated using `towncrier <https://pypi.org/project/towncrier>`_.
28+
29+
Just put towncrier compatible changelog fragments into `readme/newsfragments`
30+
and the changelog file will be automatically generated and updated when a new fragment is added.
31+
32+
Please refer to `towncrier` documentation to know more.
33+
34+
NOTE: the changelog will be automatically generated when using `/ocabot merge $option`.
35+
If you need to run it manually, refer to `OCA/maintainer-tools README <https://github.com/OCA/maintainer-tools>`_.

website_ab_testing/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models

website_ab_testing/__manifest__.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "A/B Testing",
3+
"category": "Website",
4+
"version": "13.0.1.0.0",
5+
"author": "Onestein, Odoo Community Association (OCA)",
6+
"license": "AGPL-3",
7+
"website": "https://onestein.nl",
8+
"depends": ["website"],
9+
"data": [
10+
"security/ir_model_access.xml",
11+
"templates/assets.xml",
12+
"templates/website.xml",
13+
"views/ir_ui_view_view.xml",
14+
"views/target_view.xml",
15+
"views/target_conversion_view.xml",
16+
"menuitems.xml",
17+
],
18+
"qweb": ["static/src/xml/editor.xml"],
19+
}

website_ab_testing/menuitems.xml

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<odoo>
3+
<menuitem
4+
id="ab_testing_menu"
5+
parent="website.menu_website_configuration"
6+
name="A/B Testing"
7+
/>
8+
<menuitem
9+
id="ab_testing_target_menu"
10+
parent="ab_testing_menu"
11+
name="Targets"
12+
action="ab_testing_target_action"
13+
/>
14+
<menuitem
15+
id="ab_testing_target_conversion_menu"
16+
parent="ab_testing_menu"
17+
name="Conversions"
18+
action="ab_testing_target_conversion_action"
19+
/>
20+
</odoo>

website_ab_testing/models/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import ir_http, ir_ui_view, target, target_conversion, target_trigger

website_ab_testing/models/ir_http.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from odoo import models
2+
from odoo.http import request
3+
4+
5+
class IrHttp(models.AbstractModel):
6+
_inherit = "ir.http"
7+
8+
@classmethod
9+
def _dispatch(cls):
10+
response = super(IrHttp, cls)._dispatch()
11+
if request.is_frontend:
12+
website = request.website
13+
path = request.httprequest.path
14+
if not request.env.user.has_group("website.group_website_designer"):
15+
matching_triggers = (
16+
request.env["ab.testing.target.trigger"]
17+
.sudo()
18+
.search(
19+
[
20+
("target_id.website_id", "=", website.id),
21+
("on", "=", "url_visit"),
22+
("url", "=", path),
23+
]
24+
)
25+
)
26+
matching_triggers.create_conversion()
27+
return response
+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import random
2+
3+
from odoo import _, api, fields, models
4+
from odoo.exceptions import AccessError, UserError
5+
from odoo.http import request
6+
7+
8+
class IrUiView(models.Model):
9+
_inherit = "ir.ui.view"
10+
11+
ab_testing_enabled = fields.Boolean(string="A/B Testing", copy=False)
12+
13+
master_id = fields.Many2one(comodel_name="ir.ui.view", copy=False)
14+
15+
variant_ids = fields.One2many(
16+
comodel_name="ir.ui.view", inverse_name="master_id", string="Variants"
17+
)
18+
19+
def render(self, values=None, engine="ir.qweb", minimal_qcontext=False):
20+
website = self.env["website"].get_current_website()
21+
if (
22+
request and request.session and website
23+
and self.ab_testing_enabled
24+
and not self.env.user.has_group("website.group_website_publisher")
25+
):
26+
if "ab_testing" not in request.session:
27+
request.session["ab_testing"] = {"active_variants": {}}
28+
if self.id not in request.session["ab_testing"]["active_variants"]:
29+
random_index = random.randint(0, len(self.variant_ids))
30+
selected_view = self
31+
if random_index:
32+
selected_view = self.variant_ids[random_index - 1]
33+
ab_testing = request.session["ab_testing"].copy()
34+
ab_testing["active_variants"][self.id] = selected_view.id
35+
request.session["ab_testing"] = ab_testing
36+
return selected_view.render(values, engine, minimal_qcontext)
37+
else:
38+
selection_view_id = request.session["ab_testing"]["active_variants"][
39+
self.id
40+
]
41+
if selection_view_id == self.id:
42+
return super().render(values, engine, minimal_qcontext)
43+
selected_view = self.search([("id", "=", selection_view_id)])
44+
if selected_view:
45+
return selected_view.render(values, engine, minimal_qcontext)
46+
ab_testing = request.session["ab_testing"].copy()
47+
del ab_testing["active_variants"][self.id]
48+
elif request and request.session and website and self.env.user.has_group("website.group_website_publisher"):
49+
variants = self.env["ir.ui.view"]
50+
if self.master_id:
51+
variants += self.master_id
52+
variants += self.master_id.variant_ids
53+
else:
54+
variants += self
55+
variants += self.variant_ids
56+
if values is None:
57+
values = {}
58+
values["ab_testing_variants"] = variants
59+
60+
if (
61+
"ab_testing" in request.session
62+
and not self.master_id
63+
and self.id in request.session["ab_testing"]["active_variants"]
64+
):
65+
active_variant = self.variant_ids.filtered(
66+
lambda v: v.id
67+
== request.session["ab_testing"]["active_variants"][self.id]
68+
)
69+
if active_variant:
70+
values["active_variant"] = active_variant
71+
return active_variant.render(values, engine, minimal_qcontext)
72+
73+
return super().render(values, engine, minimal_qcontext)
74+
75+
def create_variant(self, name):
76+
self.ensure_one()
77+
if self.master_id:
78+
raise UserError(_("Cannot create variant of variant."))
79+
if self.variant_ids.filtered(lambda v: v.name == name):
80+
raise UserError(_("Variant '%s' already exists.") % name)
81+
variant = self.copy({"name": name, "master_id": self.id})
82+
self._copy_inheritance(variant.id)
83+
return variant.id
84+
85+
def _copy_inheritance(self, new_id):
86+
"""Copy the inheritance recursively"""
87+
for view in self:
88+
for child in view.inherit_children_ids:
89+
copy = child.copy({"inherit_id": new_id})
90+
child._copy_inheritance(copy.id)
91+
92+
def toggle_ab_testing_enabled(self):
93+
self.ensure_one()
94+
if self.master_id:
95+
raise UserError(_("This is not the master page."))
96+
self.ab_testing_enabled = not self.ab_testing_enabled
97+
98+
def switch_variant(self, variant_id):
99+
self.ensure_one()
100+
if not self.env.user.has_group("website.group_website_publisher"):
101+
raise AccessError(
102+
_("Cannot deliberately switch variant as non-designer user.")
103+
)
104+
if not variant_id:
105+
raise UserError(_("No variant specified."))
106+
107+
if "ab_testing" not in request.session:
108+
request.session["ab_testing"] = {"active_variants": {}}
109+
ab_testing = request.session["ab_testing"].copy()
110+
ab_testing["active_variants"][self.id] = variant_id
111+
request.session["ab_testing"] = ab_testing
112+
113+
@api.model
114+
def get_active_variants(self):
115+
if "ab_testing" not in request.session:
116+
request.session["ab_testing"] = {"active_variants": {}}
117+
ids = list(request.session["ab_testing"]["active_variants"].values())
118+
return self.search([("id", "in", ids)])

website_ab_testing/models/target.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from odoo import api, fields, models
2+
3+
4+
class Target(models.Model):
5+
_name = "ab.testing.target"
6+
_description = "Target"
7+
8+
def _default_website(self):
9+
return self.env["website"].search(
10+
[("company_id", "=", self.env.company.id)], limit=1
11+
)
12+
13+
website_id = fields.Many2one(
14+
comodel_name="website",
15+
string="Website",
16+
default=_default_website,
17+
ondelete="cascade",
18+
)
19+
name = fields.Char(required=True)
20+
active = fields.Boolean(default=True)
21+
22+
trigger_ids = fields.One2many(
23+
name="Triggers",
24+
comodel_name="ab.testing.target.trigger",
25+
inverse_name="target_id",
26+
)
27+
28+
conversion_ids = fields.One2many(
29+
name="Conversions",
30+
comodel_name="ab.testing.target.conversion",
31+
inverse_name="target_id",
32+
)
33+
34+
conversion_count = fields.Integer(compute="_compute_conversion_count")
35+
36+
@api.depends("conversion_ids")
37+
def _compute_conversion_count(self):
38+
for target in self:
39+
target.conversion_count = len(target.conversion_ids)
40+
41+
def open_conversion_view(self):
42+
self.ensure_one()
43+
action = self.env.ref("website_ab_testing.ab_testing_target_conversion_action")
44+
action = action.read()[0]
45+
action["domain"] = [("target_id", "=", self.id)]
46+
action["context"] = "{}"
47+
return action
48+
49+
def open_conversion_graph(self):
50+
self.ensure_one()
51+
action = self.env.ref("website_ab_testing.ab_testing_target_conversion_action")
52+
action = action.read()[0]
53+
action["domain"] = [("target_id", "=", self.id)]
54+
action["views"] = [(False, "graph")]
55+
return action
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from odoo import api, fields, models
2+
3+
4+
class TargetConversion(models.Model):
5+
_name = "ab.testing.target.conversion"
6+
_description = "Conversion"
7+
8+
date = fields.Datetime()
9+
target_id = fields.Many2one(
10+
name="Target",
11+
comodel_name="ab.testing.target",
12+
compute="_compute_target_id",
13+
store=True,
14+
)
15+
16+
trigger_id = fields.Many2one(
17+
name="Trigger", comodel_name="ab.testing.target.trigger", ondelete="cascade"
18+
)
19+
20+
view_ids = fields.Many2many(name="Active Variants", comodel_name="ir.ui.view")
21+
22+
view_names = fields.Char(
23+
name="Active Variant Names", compute="_compute_view_names", store=True
24+
)
25+
26+
@api.depends("trigger_id", "trigger_id.target_id")
27+
def _compute_target_id(self):
28+
for conversion in self:
29+
conversion.target_id = (
30+
conversion.trigger_id and conversion.trigger_id.target_id
31+
)
32+
33+
@api.depends("view_ids", "view_ids.name")
34+
def _compute_view_names(self):
35+
for conversion in self:
36+
conversion.view_names = ", ".join(
37+
conversion.view_ids.sorted(key=lambda l: l.id).mapped("name")
38+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from odoo import _, api, fields, models
2+
3+
4+
class TargetTrigger(models.Model):
5+
_name = "ab.testing.target.trigger"
6+
_description = "Goal Trigger"
7+
8+
name = fields.Char(compute="_compute_name",)
9+
10+
target_id = fields.Many2one(
11+
string="Target",
12+
comodel_name="ab.testing.target",
13+
required=True,
14+
ondelete="cascade",
15+
)
16+
17+
on = fields.Selection(string="On", selection=[("url_visit", "Url Visit")])
18+
19+
url = fields.Char(default="/")
20+
21+
@api.depends("on", "url")
22+
def _compute_name(self):
23+
for trigger in self:
24+
name = ""
25+
if trigger.on == "url_visit":
26+
name = _("When visitors visit '%s'") % trigger.url
27+
trigger.name = name
28+
29+
def create_conversion(self, date=None, variants=None):
30+
if variants is None:
31+
variants = self.env["ir.ui.view"].get_active_variants()
32+
if not date:
33+
date = fields.Datetime.now()
34+
for trigger in self:
35+
self.env["ab.testing.target.conversion"].create(
36+
{
37+
"date": date,
38+
"view_ids": [(4, v.id) for v in variants],
39+
"trigger_id": trigger.id,
40+
}
41+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Dennis Sluijk <[email protected]>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
This module adds A/B testing functionality to the CMS of Odoo.
2+
A/B testing a conversion rate optimization tool.
3+
In A/B testing you show multiple (mostly two) variants of the same page and determine
4+
by the rate of conversion which version / variant is best.
5+
6+
More information on A/B testing can be found here: `<https://wikipedia.org/wiki/A/B_testing>`_

website_ab_testing/readme/ROADMAP.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
* E-commerce sale to conversion
2+
* Record retention time (or make some link with website.visitor)

website_ab_testing/readme/USAGE.rst

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
First you want to configure a target:
2+
3+
#. Go to `Website` > `A/B Testing` > `Targets`;
4+
#. click `Create`;
5+
#. choose a name for your target e.g. 'More Sales' or 'More Members';
6+
#. configure the triggers (triggers generate conversions e.g. when a certain page is visited or when a visitor bought a product from the webshop);
7+
#. click `Save`.
8+
9+
Now we want to create different variants of our pages so we can potentially increase our conversion rate:
10+
11+
* Go to the website editor;
12+
* go to your landing page;
13+
* on the top right click on `A/B Testing` and `New Variant` to create a different version of the page;
14+
* when you're done making variants make sure to enable A/B testing for the page by clicking the toggle in the `A/B Testing` menu
15+
16+
When your test has ran for some time we can find out which variant is best.
17+
To find out which variant has lead to the most conversions, you can either
18+
go to `Website` > `A/B Testing` > `Conversions` or click the `Statistics` button on the Target form.

0 commit comments

Comments
 (0)