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

[ADD] tests

[ADD] tests

[ADD] tests

Fixup

Fixup

Fixup
  • Loading branch information
tarteo committed May 4, 2021
1 parent 221cd29 commit 29ea93b
Show file tree
Hide file tree
Showing 25 changed files with 923 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
125 changes: 125 additions & 0 deletions website_ab_testing/models/ir_ui_view.py
@@ -0,0 +1,125 @@
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 (
request
and request.session
and 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 (
request
and request.session
and 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
if values is None:
values = {}
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)

0 comments on commit 29ea93b

Please sign in to comment.