Skip to content

Commit

Permalink
[ADD] website_ab_testing
Browse files Browse the repository at this point in the history
[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
  • Loading branch information
tarteo committed Feb 3, 2021
1 parent 221cd29 commit 5c89f01
Show file tree
Hide file tree
Showing 22 changed files with 806 additions and 0 deletions.
35 changes: 35 additions & 0 deletions website_ab_testing/README.rst
@@ -0,0 +1,35 @@
**This file is going to be generated by oca-gen-addon-readme.**

*Manual changes will be overwritten.*

Please provide content in the ``readme`` directory:

* **DESCRIPTION.rst** (required)
* INSTALL.rst (optional)
* CONFIGURE.rst (optional)
* **USAGE.rst** (optional, highly recommended)
* DEVELOP.rst (optional)
* ROADMAP.rst (optional)
* HISTORY.rst (optional, recommended)
* **CONTRIBUTORS.rst** (optional, highly recommended)
* CREDITS.rst (optional)

Content of this README will also be drawn from the addon manifest,
from keys such as name, authors, maintainers, development_status,
and license.

A good, one sentence summary in the manifest is also highly recommended.


Automatic changelog generation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

`HISTORY.rst` can be auto generated using `towncrier <https://pypi.org/project/towncrier>`_.

Just put towncrier compatible changelog fragments into `readme/newsfragments`
and the changelog file will be automatically generated and updated when a new fragment is added.

Please refer to `towncrier` documentation to know more.

NOTE: the changelog will be automatically generated when using `/ocabot merge $option`.
If you need to run it manually, refer to `OCA/maintainer-tools README <https://github.com/OCA/maintainer-tools>`_.
1 change: 1 addition & 0 deletions website_ab_testing/__init__.py
@@ -0,0 +1 @@
from . import models
19 changes: 19 additions & 0 deletions website_ab_testing/__manifest__.py
@@ -0,0 +1,19 @@
{
"name": "A/B Testing",
"category": "Website",
"version": "13.0.1.0.0",
"author": "Onestein, Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://onestein.nl",
"depends": ["website"],
"data": [
"security/ir_model_access.xml",
"templates/assets.xml",
"templates/website.xml",
"views/ir_ui_view_view.xml",
"views/target_view.xml",
"views/target_conversion_view.xml",
"menuitems.xml",
],
"qweb": ["static/src/xml/editor.xml"],
}
20 changes: 20 additions & 0 deletions website_ab_testing/menuitems.xml
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<menuitem
id="ab_testing_menu"
parent="website.menu_website_configuration"
name="A/B Testing"
/>
<menuitem
id="ab_testing_target_menu"
parent="ab_testing_menu"
name="Targets"
action="ab_testing_target_action"
/>
<menuitem
id="ab_testing_target_conversion_menu"
parent="ab_testing_menu"
name="Conversions"
action="ab_testing_target_conversion_action"
/>
</odoo>
1 change: 1 addition & 0 deletions website_ab_testing/models/__init__.py
@@ -0,0 +1 @@
from . import ir_http, ir_ui_view, target, target_conversion, target_trigger
27 changes: 27 additions & 0 deletions website_ab_testing/models/ir_http.py
@@ -0,0 +1,27 @@
from odoo import models
from odoo.http import request


class IrHttp(models.AbstractModel):
_inherit = "ir.http"

@classmethod
def _dispatch(cls):
response = super(IrHttp, cls)._dispatch()
if request.is_frontend:
website = request.website
path = request.httprequest.path
if not request.env.user.has_group("website.group_website_designer"):
matching_triggers = (
request.env["ab.testing.target.trigger"]
.sudo()
.search(
[
("target_id.website_id", "=", website.id),
("on", "=", "url_visit"),
("url", "=", path),
]
)
)
matching_triggers.create_conversion()
return response
116 changes: 116 additions & 0 deletions website_ab_testing/models/ir_ui_view.py
@@ -0,0 +1,116 @@
import random

from odoo import _, api, fields, models
from odoo.exceptions import AccessError, UserError
from odoo.http import request


class IrUiView(models.Model):
_inherit = "ir.ui.view"

ab_testing_enabled = fields.Boolean(string="A/B Testing", copy=False)

master_id = fields.Many2one(comodel_name="ir.ui.view", copy=False)

variant_ids = fields.One2many(
comodel_name="ir.ui.view", inverse_name="master_id", string="Variants"
)

def render(self, values=None, engine="ir.qweb", minimal_qcontext=False):
website = self.env["website"].get_current_website()
if (
website
and self.ab_testing_enabled
and not self.env.user.has_group("website.group_website_publisher")
):
if "ab_testing" not in request.session:
request.session["ab_testing"] = {"active_variants": {}}
if self.id not in request.session["ab_testing"]["active_variants"]:
random_index = random.randint(0, len(self.variant_ids))
selected_view = self
if random_index:
selected_view = self.variant_ids[random_index - 1]
ab_testing = request.session["ab_testing"].copy()
ab_testing["active_variants"][self.id] = selected_view.id
request.session["ab_testing"] = ab_testing
return selected_view.render(values, engine, minimal_qcontext)
else:
selection_view_id = request.session["ab_testing"]["active_variants"][
self.id
]
if selection_view_id == self.id:
return super().render(values, engine, minimal_qcontext)
selected_view = self.search([("id", "=", selection_view_id)])
if selected_view:
return selected_view.render(values, engine, minimal_qcontext)
ab_testing = request.session["ab_testing"].copy()
del ab_testing["active_variants"][self.id]
elif website and self.env.user.has_group("website.group_website_publisher"):
variants = self.env["ir.ui.view"]
if self.master_id:
variants += self.master_id
variants += self.master_id.variant_ids
else:
variants += self
variants += self.variant_ids
values["ab_testing_variants"] = variants

if (
"ab_testing" in request.session
and not self.master_id
and self.id in request.session["ab_testing"]["active_variants"]
):
active_variant = self.variant_ids.filtered(
lambda v: v.id
== request.session["ab_testing"]["active_variants"][self.id]
)
if active_variant:
values["active_variant"] = active_variant
return active_variant.render(values, engine, minimal_qcontext)

return super().render(values, engine, minimal_qcontext)

def create_variant(self, name):
self.ensure_one()
if self.master_id:
raise UserError(_("Cannot create variant of variant."))
if self.variant_ids.filtered(lambda v: v.name == name):
raise UserError(_("Variant '%s' already exists.") % name)
variant = self.copy({"name": name, "master_id": self.id})
self._copy_inheritance(variant.id)
return variant.id

def _copy_inheritance(self, new_id):
"""Copy the inheritance recursively"""
for view in self:
for child in view.inherit_children_ids:
copy = child.copy({"inherit_id": new_id})
child._copy_inheritance(copy.id)

def toggle_ab_testing_enabled(self):
self.ensure_one()
if self.master_id:
raise UserError(_("This is not the master page."))
self.ab_testing_enabled = not self.ab_testing_enabled

def switch_variant(self, variant_id):
self.ensure_one()
if not self.env.user.has_group("website.group_website_publisher"):
raise AccessError(
_("Cannot deliberately switch variant as non-designer user.")
)
if not variant_id:
raise UserError(_("No variant specified."))

if "ab_testing" not in request.session:
request.session["ab_testing"] = {"active_variants": {}}
ab_testing = request.session["ab_testing"].copy()
ab_testing["active_variants"][self.id] = variant_id
request.session["ab_testing"] = ab_testing

@api.model
def get_active_variants(self):
if "ab_testing" not in request.session:
request.session["ab_testing"] = {"active_variants": {}}
ids = list(request.session["ab_testing"]["active_variants"].values())
return self.search([("id", "in", ids)])
55 changes: 55 additions & 0 deletions website_ab_testing/models/target.py
@@ -0,0 +1,55 @@
from odoo import api, fields, models


class Target(models.Model):
_name = "ab.testing.target"
_description = "Target"

def _default_website(self):
return self.env["website"].search(
[("company_id", "=", self.env.company.id)], limit=1
)

website_id = fields.Many2one(
comodel_name="website",
string="Website",
default=_default_website,
ondelete="cascade",
)
name = fields.Char(required=True)
active = fields.Boolean(default=True)

trigger_ids = fields.One2many(
name="Triggers",
comodel_name="ab.testing.target.trigger",
inverse_name="target_id",
)

conversion_ids = fields.One2many(
name="Conversions",
comodel_name="ab.testing.target.conversion",
inverse_name="target_id",
)

conversion_count = fields.Integer(compute="_compute_conversion_count")

@api.depends("conversion_ids")
def _compute_conversion_count(self):
for target in self:
target.conversion_count = len(target.conversion_ids)

def open_conversion_view(self):
self.ensure_one()
action = self.env.ref("website_ab_testing.ab_testing_target_conversion_action")
action = action.read()[0]
action["domain"] = [("target_id", "=", self.id)]
action["context"] = "{}"
return action

def open_conversion_graph(self):
self.ensure_one()
action = self.env.ref("website_ab_testing.ab_testing_target_conversion_action")
action = action.read()[0]
action["domain"] = [("target_id", "=", self.id)]
action["views"] = [(False, "graph")]
return action
38 changes: 38 additions & 0 deletions website_ab_testing/models/target_conversion.py
@@ -0,0 +1,38 @@
from odoo import api, fields, models


class TargetConversion(models.Model):
_name = "ab.testing.target.conversion"
_description = "Conversion"

date = fields.Datetime()
target_id = fields.Many2one(
name="Target",
comodel_name="ab.testing.target",
compute="_compute_target_id",
store=True,
)

trigger_id = fields.Many2one(
name="Trigger", comodel_name="ab.testing.target.trigger", ondelete="cascade"
)

view_ids = fields.Many2many(name="Active Variants", comodel_name="ir.ui.view")

view_names = fields.Char(
name="Active Variant Names", compute="_compute_view_names", store=True
)

@api.depends("trigger_id", "trigger_id.target_id")
def _compute_target_id(self):
for conversion in self:
conversion.target_id = (
conversion.trigger_id and conversion.trigger_id.target_id
)

@api.depends("view_ids", "view_ids.name")
def _compute_view_names(self):
for conversion in self:
conversion.view_names = ", ".join(
conversion.view_ids.sorted(key=lambda l: l.id).mapped("name")
)
41 changes: 41 additions & 0 deletions website_ab_testing/models/target_trigger.py
@@ -0,0 +1,41 @@
from odoo import _, api, fields, models


class TargetTrigger(models.Model):
_name = "ab.testing.target.trigger"
_description = "Goal Trigger"

name = fields.Char(compute="_compute_name",)

target_id = fields.Many2one(
string="Target",
comodel_name="ab.testing.target",
required=True,
ondelete="cascade",
)

on = fields.Selection(string="On", selection=[("url_visit", "Url Visit")])

url = fields.Char(default="/")

@api.depends("on", "url")
def _compute_name(self):
for trigger in self:
name = ""
if trigger.on == "url_visit":
name = _("When visitors visit '%s'") % trigger.url
trigger.name = name

def create_conversion(self, date=None, variants=None):
if variants is None:
variants = self.env["ir.ui.view"].get_active_variants()
if not date:
date = fields.Datetime.now()
for trigger in self:
self.env["ab.testing.target.conversion"].create(
{
"date": date,
"view_ids": [(4, v.id) for v in variants],
"trigger_id": trigger.id,
}
)
1 change: 1 addition & 0 deletions website_ab_testing/readme/CONTRIBUTORS.rst
@@ -0,0 +1 @@
* Dennis Sluijk <d.sluijk@onestein.nl>
6 changes: 6 additions & 0 deletions website_ab_testing/readme/DESCRIPTION.rst
@@ -0,0 +1,6 @@
This module adds A/B testing functionality to the CMS of Odoo.
A/B testing a conversion rate optimization tool.
In A/B testing you show multiple (mostly two) variants of the same page and determine
by the rate of conversion which version / variant is best.

More information on A/B testing can be found here: `<https://wikipedia.org/wiki/A/B_testing>`_
2 changes: 2 additions & 0 deletions website_ab_testing/readme/ROADMAP.rst
@@ -0,0 +1,2 @@
* E-commerce sale to conversion
* Record retention time (or make some link with website.visitor)
18 changes: 18 additions & 0 deletions website_ab_testing/readme/USAGE.rst
@@ -0,0 +1,18 @@
First you want to configure a target:

#. Go to `Website` > `A/B Testing` > `Targets`;
#. click `Create`;
#. choose a name for your target e.g. 'More Sales' or 'More Members';
#. configure the triggers (triggers generate conversions e.g. when a certain page is visited or when a visitor bought a product from the webshop);
#. click `Save`.

Now we want to create different variants of our pages so we can potentially increase our conversion rate:

* Go to the website editor;
* go to your landing page;
* on the top right click on `A/B Testing` and `New Variant` to create a different version of the page;
* 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

When your test has ran for some time we can find out which variant is best.
To find out which variant has lead to the most conversions, you can either
go to `Website` > `A/B Testing` > `Conversions` or click the `Statistics` button on the Target form.

0 comments on commit 5c89f01

Please sign in to comment.