From 29ea93b537f72088b19f7b4891279e483d7066db Mon Sep 17 00:00:00 2001 From: tarteo Date: Thu, 28 Jan 2021 16:11:06 +0100 Subject: [PATCH] [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 Fixup --- website_ab_testing/README.rst | 35 +++++ website_ab_testing/__init__.py | 1 + website_ab_testing/__manifest__.py | 19 +++ website_ab_testing/menuitems.xml | 20 +++ website_ab_testing/models/__init__.py | 1 + website_ab_testing/models/ir_http.py | 27 ++++ website_ab_testing/models/ir_ui_view.py | 125 ++++++++++++++++ website_ab_testing/models/target.py | 55 ++++++++ .../models/target_conversion.py | 38 +++++ website_ab_testing/models/target_trigger.py | 41 ++++++ website_ab_testing/readme/CONTRIBUTORS.rst | 1 + website_ab_testing/readme/DESCRIPTION.rst | 6 + website_ab_testing/readme/ROADMAP.rst | 2 + website_ab_testing/readme/USAGE.rst | 18 +++ .../security/ir_model_access.xml | 36 +++++ website_ab_testing/static/src/js/editor.js | 133 ++++++++++++++++++ website_ab_testing/static/src/xml/editor.xml | 11 ++ website_ab_testing/templates/assets.xml | 8 ++ website_ab_testing/templates/website.xml | 59 ++++++++ website_ab_testing/tests/__init__.py | 1 + website_ab_testing/tests/test_ab_testing.py | 89 ++++++++++++ website_ab_testing/tests/test_http.py | 18 +++ website_ab_testing/views/ir_ui_view_view.xml | 17 +++ .../views/target_conversion_view.xml | 71 ++++++++++ website_ab_testing/views/target_view.xml | 91 ++++++++++++ 25 files changed, 923 insertions(+) create mode 100644 website_ab_testing/README.rst create mode 100644 website_ab_testing/__init__.py create mode 100644 website_ab_testing/__manifest__.py create mode 100644 website_ab_testing/menuitems.xml create mode 100644 website_ab_testing/models/__init__.py create mode 100644 website_ab_testing/models/ir_http.py create mode 100644 website_ab_testing/models/ir_ui_view.py create mode 100644 website_ab_testing/models/target.py create mode 100644 website_ab_testing/models/target_conversion.py create mode 100644 website_ab_testing/models/target_trigger.py create mode 100644 website_ab_testing/readme/CONTRIBUTORS.rst create mode 100644 website_ab_testing/readme/DESCRIPTION.rst create mode 100644 website_ab_testing/readme/ROADMAP.rst create mode 100644 website_ab_testing/readme/USAGE.rst create mode 100644 website_ab_testing/security/ir_model_access.xml create mode 100644 website_ab_testing/static/src/js/editor.js create mode 100644 website_ab_testing/static/src/xml/editor.xml create mode 100644 website_ab_testing/templates/assets.xml create mode 100644 website_ab_testing/templates/website.xml create mode 100644 website_ab_testing/tests/__init__.py create mode 100644 website_ab_testing/tests/test_ab_testing.py create mode 100644 website_ab_testing/tests/test_http.py create mode 100644 website_ab_testing/views/ir_ui_view_view.xml create mode 100644 website_ab_testing/views/target_conversion_view.xml create mode 100644 website_ab_testing/views/target_view.xml diff --git a/website_ab_testing/README.rst b/website_ab_testing/README.rst new file mode 100644 index 0000000000..38929e8775 --- /dev/null +++ b/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 `_. + +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 `_. diff --git a/website_ab_testing/__init__.py b/website_ab_testing/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/website_ab_testing/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/website_ab_testing/__manifest__.py b/website_ab_testing/__manifest__.py new file mode 100644 index 0000000000..0814bb776d --- /dev/null +++ b/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"], +} diff --git a/website_ab_testing/menuitems.xml b/website_ab_testing/menuitems.xml new file mode 100644 index 0000000000..e9ac01cc4d --- /dev/null +++ b/website_ab_testing/menuitems.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/website_ab_testing/models/__init__.py b/website_ab_testing/models/__init__.py new file mode 100644 index 0000000000..20f8ad358e --- /dev/null +++ b/website_ab_testing/models/__init__.py @@ -0,0 +1 @@ +from . import ir_http, ir_ui_view, target, target_conversion, target_trigger diff --git a/website_ab_testing/models/ir_http.py b/website_ab_testing/models/ir_http.py new file mode 100644 index 0000000000..fe3b81c42c --- /dev/null +++ b/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 diff --git a/website_ab_testing/models/ir_ui_view.py b/website_ab_testing/models/ir_ui_view.py new file mode 100644 index 0000000000..72759154bf --- /dev/null +++ b/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)]) diff --git a/website_ab_testing/models/target.py b/website_ab_testing/models/target.py new file mode 100644 index 0000000000..9da84a30a1 --- /dev/null +++ b/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 diff --git a/website_ab_testing/models/target_conversion.py b/website_ab_testing/models/target_conversion.py new file mode 100644 index 0000000000..007057d8a3 --- /dev/null +++ b/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") + ) diff --git a/website_ab_testing/models/target_trigger.py b/website_ab_testing/models/target_trigger.py new file mode 100644 index 0000000000..871618c625 --- /dev/null +++ b/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, + } + ) diff --git a/website_ab_testing/readme/CONTRIBUTORS.rst b/website_ab_testing/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..47b6403d06 --- /dev/null +++ b/website_ab_testing/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Dennis Sluijk diff --git a/website_ab_testing/readme/DESCRIPTION.rst b/website_ab_testing/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..e81c73ae3c --- /dev/null +++ b/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: ``_ diff --git a/website_ab_testing/readme/ROADMAP.rst b/website_ab_testing/readme/ROADMAP.rst new file mode 100644 index 0000000000..9c1c184608 --- /dev/null +++ b/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) diff --git a/website_ab_testing/readme/USAGE.rst b/website_ab_testing/readme/USAGE.rst new file mode 100644 index 0000000000..2d50859e19 --- /dev/null +++ b/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. diff --git a/website_ab_testing/security/ir_model_access.xml b/website_ab_testing/security/ir_model_access.xml new file mode 100644 index 0000000000..d5ab1e9cc5 --- /dev/null +++ b/website_ab_testing/security/ir_model_access.xml @@ -0,0 +1,36 @@ + + + + ab_testing_target_access + + + + + + + + + ab_testing_target_trigger_access + + + + + + + + + ab_testing_target_conversion_access + + + + + + + + diff --git a/website_ab_testing/static/src/js/editor.js b/website_ab_testing/static/src/js/editor.js new file mode 100644 index 0000000000..fe5c886c3b --- /dev/null +++ b/website_ab_testing/static/src/js/editor.js @@ -0,0 +1,133 @@ +odoo.define("website_ab_testing.editor", function(require) { + "use strict"; + var Dialog = require("web.Dialog"); + var core = require("web.core"); + var qweb = core.qweb; + var _t = core._t; + var websiteNavbarData = require("website.navbar"); + + var AddVariantDialog = Dialog.extend({ + init: function(parent) { + var options = { + title: _t("Enter Name"), + $content: $(qweb.render("website_ab_testing.AddVariantDialog")), + buttons: [ + { + text: _t("Confirm"), + classes: "btn-primary", + click: this.confirmClicked.bind(this), + }, + { + text: _t("Cancel"), + classes: "btn-default", + close: true, + }, + ], + }; + return this._super(parent, options); + }, + confirmClicked: function() { + this.trigger("confirm", this.$('input[name="name"]').val()); + }, + }); + + var ABTestingMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + events: _.extend( + {}, + websiteNavbarData.WebsiteNavbarActionWidget.prototype.events || {}, + { + "click .ab_testing_add": "_onAddVariantClick", + "click .ab_testing_select": "_onSelectVariantClick", + "click .ab_testing_delete": "_onDeleteVariantClick", + "click .ab_testing_toggle": "_onToggleABTestingClick", + } + ), + + xmlDependencies: ["/website_ab_testing/static/src/xml/editor.xml"], + + start: function() { + var res = this._super.apply(this, arguments); + this.masterId = Number( + this.$el.find('[data-master-variant="1"]').data("id") + ); + return res; + }, + + _onAddVariantClick: function() { + var addDialog = new AddVariantDialog(this); + addDialog.on( + "confirm", + this, + function(name) { + this._createVariant(name) + .then(this._switchVariant.bind(this)) + .then(function() { + window.location.reload(); + }); + addDialog.close(); + }.bind(this) + ); + addDialog.open(); + }, + + _onDeleteVariantClick: function(ev) { + var $target = $(ev.target); + var variantId = Number($target.data("id")); + this._deleteVariant(variantId).then(function() { + window.location.reload(); + }); + }, + + _onToggleABTestingClick: function(ev) { + ev.stopImmediatePropagation(); + this._toggleABTesting(); + }, + + _onSelectVariantClick: function(ev) { + var $target = $(ev.target); + var variantId = Number($target.data("id")); + this._switchVariant(variantId).then(function() { + window.location.reload(); + }); + }, + + _toggleABTesting: _.debounce(function() { + return this._rpc({ + model: "ir.ui.view", + method: "toggle_ab_testing_enabled", + args: [[this.masterId]], + }); + }, 100), + + _switchVariant: function(variantId) { + return this._rpc({ + model: "ir.ui.view", + method: "switch_variant", + args: [[this.masterId], variantId], + }); + }, + + _createVariant: function(name) { + return this._rpc({ + model: "ir.ui.view", + method: "create_variant", + args: [[this.masterId], name], + }); + }, + + _deleteVariant: function(variantId) { + return this._rpc({ + model: "ir.ui.view", + method: "unlink", + args: [[variantId]], + }); + }, + }); + + websiteNavbarData.websiteNavbarRegistry.add(ABTestingMenu, "#ab_testing_menu"); + + return { + AddVariantDialog: AddVariantDialog, + ABTestingMenu: ABTestingMenu, + }; +}); diff --git a/website_ab_testing/static/src/xml/editor.xml b/website_ab_testing/static/src/xml/editor.xml new file mode 100644 index 0000000000..3e6c5c52a4 --- /dev/null +++ b/website_ab_testing/static/src/xml/editor.xml @@ -0,0 +1,11 @@ + + diff --git a/website_ab_testing/templates/assets.xml b/website_ab_testing/templates/assets.xml new file mode 100644 index 0000000000..a12853cfcb --- /dev/null +++ b/website_ab_testing/templates/assets.xml @@ -0,0 +1,8 @@ + + +