diff --git a/website_membership_signup_required/README.md b/website_membership_signup_required/README.md new file mode 100644 index 0000000..d55760f --- /dev/null +++ b/website_membership_signup_required/README.md @@ -0,0 +1,123 @@ +# website_membership_signup_required + +Módulo in-house (Criptomart) para el cliente **EMES** que replica el flujo de +alta de asociados de YourMembership (YM) dentro de Odoo 18. + +## Qué hace + +Intercepta el **"Add to Cart"** del e-commerce **solo para productos de +membresía** (`product.template.membership = True`). Si el visitante es +anónimo (usuario público) y el ajuste `membership_signup_required` del +website está activo (por defecto ON), el request no se completa y se redirige +al formulario estándar de `/web/signup` (de `auth_signup`), con un +`redirect` de vuelta a la ficha del producto y un marcador efímero +`membership_signup=1` que re-habilita el signup de modo condicional aunque el +website esté en modo **b2b** (*guest checkout*). + +Tras crear la cuenta, el usuario queda autenticado y vuelve a la ficha del +producto, donde ya puede añadirlo al carrito y continuar al checkout. + +Para el resto del catálogo (productos no miembros) el flujo **b2b nativo** +(*guest checkout*) se mantiene intacto, sin interferencia. + +## Por qué es necesario un módulo propio + +`website_sale` fuerza por defecto `website.auth_signup_uninvited = 'b2c'` +(signup/registro dentro del checkout). Pasar el website a **b2b** — lo que +permite *guest checkout* para el catálogo general — **deshabilita el route +`/web/signup`** para el público general. Para re-abrirlo **solo** durante el +flujo de membresía, este módulo extiende con un override conservador de +`res.users._get_signup_invitation_scope()` que devuelve `'b2c'` únicamente +cuando el request porta el marcador efímero (`membership_signup=1` en la query +o `membership_signup_active` en sesión). El marcador se setea al interceptar +el `cart_update` y se limpia tras `signup`/`login` exitosos. + +## Características / requisitos funcionales + +- **RF-01** Interceptación de `/shop/cart/update` y `/shop/cart/update_json` + solo para `product.template.membership = True` y usuario público. +- **RF-02** Redirección a `/web/signup?membership_signup=1&redirect=/shop/`. +- **RF-03** Re-habilitación condicional del signup en b2b vía override de + `_get_signup_invitation_scope`. +- **RF-04** Flujo completo: signup → vuelta a la ficha del producto → + add to cart → carrito. +- **RF-05** Productos no miembros: sin interceptación, flujo b2b normal. +- **RF-06** Usuarios ya autenticados no se interceptan. +- **RF-07** Configurable por website desde Ajustes → E-commerce → "Membership + signup required" (default ON). +- **RF-08** No afecta al backend ni a métodos de pago: la interceptación + vive solo en los controllers `/shop/cart/update*`. +- **RF-09** Separación `firstname` / `lastname` en el formulario de signup + (gracias a la dependencia OCA `partner_firstname`). +- **RF-10** El enlace "Already have an account?" del formulario de signup + preserva el `redirect`, por lo que un usuario con cuenta existente puede + loguearse y volver a la ficha del producto. +- **RF-11** i18n: `module.pot` + `es.po` (español dominante) + `gl.po` y + `ca.po` como esqueleto. + +## Dependencias + +- `website_sale` +- `auth_signup` +- `membership` +- `partner_firstname` (OCA, `partner-contact/`). + +## Configuración + +1. Settings → Website → Ajustes e-commerce → "Customer Accounts" debe estar + en **Optional (guest checkout)** o lo equivalente a `b2b` en el website + activo (esto es `website.account_on_checkout = 'disabled'`). +2. Settings → "Membership signup required" checkbox: activado por defecto. +3. Para productos con `membership = True`, el flujo aplica; para el resto, + no. + +## Cómo correr los tests + +```bash +# En el entorno EMES (desde OCB): +./odoo-bin -c emes.conf -i website_membership_signup_required \ + --test-tags website_membership_signup_required --stop-after-init +``` + +Los tests unitarios (TransactionCase) cubren RF-01..RF-08 con lógica; el tour +HttpCase end-to-end cubre RF-04 (flujo completo). + +## Notas técnicas + +- El override de `_get_signup_invitation_scope` es el punto más sensible de + seguridad: solo devuelve `'b2c'` cuando el request porta el marcador + efímero. No re-abre el signup libre en b2b para el catálogo general. +- La decisión de interceptar está centralizada en + `website._membership_signup_required_for(product_id, is_public)` (capa + modelo) para poder testearla sin HTTP y reutilizarla en futuras + extensiones. +- El controller `_membership_signup_is_required(product_id)` delega en ese + helper. +- Tras signup / login exitoso, se limpian los flags `membership_signup_active` + y `membership_signup_product_id` de la sesión. + +## Licencia + +AGPL-3. + +## Autor + +Criptomart (). + +## Limitaciones / notas de seguridad + +El marcador `membership_signup=1` que habilita el route `/web/signup` en modo +b2b es un **query param público**: cualquier visitante que conozca ese param +puede acceder al formulario de signup directamente, sin pasar por el carrito +de membresía. Esta **decisión está aceptada por diseño**: el signup crea +cuentas de tipo *portal* (no internal), y la postura b2b del catálogo general +se mantiene — el route solo se reabre de forma condicional vía el override de +`_get_signup_invitation_scope` cuando el request porta el marcador efímero (ver +`models/res_users.py` y `controllers/website_sale.py`); nunca se reabre el +signup libre para el resto del tráfico. + +Para reforzar la seguridad se podría sustituir el query param estático por un +token HMAC firmado (vinculado a la sesión y al producto objetivo), de modo +que solo el flujo de `cart_update` pueda generar un redirect válido y el +acceso directo a `/web/signup` fuera de ese flujo quede restringido. Ver +`requirements.md` §7 R-01. \ No newline at end of file diff --git a/website_membership_signup_required/__init__.py b/website_membership_signup_required/__init__.py new file mode 100644 index 0000000..85e41b1 --- /dev/null +++ b/website_membership_signup_required/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models \ No newline at end of file diff --git a/website_membership_signup_required/__manifest__.py b/website_membership_signup_required/__manifest__.py new file mode 100644 index 0000000..c59a65c --- /dev/null +++ b/website_membership_signup_required/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2025 - Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +{ # noqa: B018 + "name": "Website Membership Signup Required", + "version": "18.0.1.0.0", + "category": "Website/Sale", + "summary": "Force a clean /web/signup step before adding membership " + "products to cart on a b2b website (EMES-style flow).", + "author": "Criptomart", + "website": "https://git.criptomart.net/criptomart/addons-cm", + "license": "AGPL-3", + "depends": [ + "website_sale", + "auth_signup", + "membership", + "partner_firstname", + ], + "data": [ + "views/res_config_settings_views.xml", + "views/auth_signup_templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_membership_signup_required/static/src/js/website_membership_signup_required.js", + ], + }, + "external_dependencies": {}, + "installable": True, + "application": False, +} \ No newline at end of file diff --git a/website_membership_signup_required/controllers/__init__.py b/website_membership_signup_required/controllers/__init__.py new file mode 100644 index 0000000..b584e45 --- /dev/null +++ b/website_membership_signup_required/controllers/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main +from . import website_sale \ No newline at end of file diff --git a/website_membership_signup_required/controllers/main.py b/website_membership_signup_required/controllers/main.py new file mode 100644 index 0000000..86ffd21 --- /dev/null +++ b/website_membership_signup_required/controllers/main.py @@ -0,0 +1,74 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import http +from odoo.addons.auth_signup.controllers.main import AuthSignupHome +from odoo.addons.web.controllers.home import SIGN_UP_REQUEST_PARAMS +from odoo.http import request + +# partner_firstname exposes `firstname` / `lastname` on res.users and +# res.partner; we need them as accepted qcontext params so the signup form +# roundtrips them transparently through `get_auth_signup_qcontext`. +SIGN_UP_REQUEST_PARAMS.add("firstname") +SIGN_UP_REQUEST_PARAMS.add("lastname") + +# Session keys used by the membership signup flow. +MEMBERSHIP_SIGNUP_SESSION_KEYS = ("membership_signup_active", "membership_signup_product_id") + + +class AuthSignupHomeMembership(AuthSignupHome): + + def _prepare_signup_values(self, qcontext): + values = super()._prepare_signup_values(qcontext) + # partner_firstname: the signup form collects `firstname` / `lastname` + # separately. We inject both into the signup values so the resulting + # partner/user has them populated. + # + # We ALSO keep a composed `name` (firstname + " " + lastname) in the + # values, on purpose. The core's `_create_user_from_template` + # (`auth_signup/models/res_users.py`) rejects the signup with + # `ValueError: Signup: no name or partner given for new user` if + # neither `partner_id` nor `name` is present in the values — that + # check runs BEFORE `template_user.copy(values)` reaches + # `res.partner.create`, where `partner_firstname` would otherwise + # recompute `name` from the split fields. + # + # By providing the composed `name`, the check passes; then, inside + # `res.partner.create`, partner_firstname's override detects that both + # `firstname` and `lastname` are in the vals and DISCARDS the composed + # `name` (see `partner_firstname/models/res_partner.py:_name_fields_in_vals` + # branch `del vals["name"]`), recomputing it from the split fields. + # Net effect: the partner ends up with the proper `firstname`, + # `lastname` and a recomputed `name`, exactly as intended. + firstname = qcontext.get("firstname", "") + lastname = qcontext.get("lastname", "") + if firstname or lastname: + values["firstname"] = firstname + values["lastname"] = lastname + partner_model = request.env["res.partner"] + values["name"] = partner_model._get_computed_name( + lastname, firstname + ) + return values + + @http.route() + def web_auth_signup(self, *args, **kw): + response = super().web_auth_signup(*args, **kw) + # On a successful signup the user is now authenticated (session.uid + # set) and the redirect to the product page will happen. Drop the + # membership markers from the session so they cannot leak into later + # /web/signup attempts by other visitors sharing the session (defence + # in depth). + if request.session.uid: + for key in MEMBERSHIP_SIGNUP_SESSION_KEYS: + request.session.pop(key, None) + return response + + @http.route() + def web_login(self, *args, **kw): + response = super().web_login(*args, **kw) + # Same cleanup on explicit login (covers the "Already have an + # account?" branch from the signup page). + if request.session.uid: + for key in MEMBERSHIP_SIGNUP_SESSION_KEYS: + request.session.pop(key, None) + return response \ No newline at end of file diff --git a/website_membership_signup_required/controllers/website_sale.py b/website_membership_signup_required/controllers/website_sale.py new file mode 100644 index 0000000..6a9a109 --- /dev/null +++ b/website_membership_signup_required/controllers/website_sale.py @@ -0,0 +1,98 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from urllib.parse import urlencode + +from odoo import http +from odoo.addons.website_sale.controllers.main import WebsiteSale +from odoo.http import request + +SIGNUP_FLAG = "membership_signup=1" + + +class WebsiteSaleMembershipSignup(WebsiteSale): + + def _membership_signup_is_required(self, product_id): + """Return True iff an anonymous visitor adding `product_id` to the + cart must be redirected to /web/signup first. + """ + website = request.website + return website._membership_signup_required_for( + product_id, request.env.user._is_public() + ) + + def _membership_signup_redirect_url(self, product_id): + """Build the `/web/signup?membership_signup=1&redirect=` + URL preserving the current language. + """ + product = request.env["product.product"].browse(int(product_id)) + tmpl = product.product_tmpl_id + product_url = "/shop/%s" % request.env["ir.http"]._slug(tmpl) + query = urlencode({"membership_signup": "1", "redirect": product_url}) + return "/web/signup?%s" % query + + def _membership_signup_mark_session(self, product_id): + request.session["membership_signup_active"] = True + request.session["membership_signup_product_id"] = int(product_id) + + @http.route() + def cart_update( + self, product_id, add_qty=1, set_qty=0, + product_custom_attribute_values=None, + no_variant_attribute_value_ids=None, **kwargs + ): + if self._membership_signup_is_required(product_id): + self._membership_signup_mark_session(product_id) + return request.redirect(self._membership_signup_redirect_url(product_id)) + return super().cart_update( + product_id=product_id, add_qty=add_qty, set_qty=set_qty, + product_custom_attribute_values=product_custom_attribute_values, + no_variant_attribute_value_ids=no_variant_attribute_value_ids, + **kwargs + ) + + @http.route() + def cart_update_json( + self, product_id, line_id=None, add_qty=None, set_qty=None, display=True, + product_custom_attribute_values=None, no_variant_attribute_value_ids=None, **kwargs + ): + if self._membership_signup_is_required(product_id): + self._membership_signup_mark_session(product_id) + # The JSON route cannot perform an HTTP redirect on its own; signal + # the frontend so it can navigate to the signup URL. The standard + # "Add to Cart" on a product page uses /shop/cart/update (http), + # which is the primary path for this flow (RF-01). The JSON route + # covers add-to-cart from snippets/wishlist on the same page + # (`stayOnPageOption` / `o_we_buy_now` express). + # + # The JS handler in + # `static/src/js/website_membership_signup_required.js` reads + # `data.force_redirect` and navigates to it. The other keys are + # NOT consumed by our handler: they exist to keep the core's + # `cartHandlerMixin._addToCartInPage` (in + # `website_sale/static/src/js/website_sale_utils.js`) from crashing. + # That method does, in order: + # if (data.cart_quantity && ...) updateCartNavBar(data); + # showCartNotification(call, data.notification_info); + # `showCartNotification` accesses `props.lines` and `props.warning` + # on `data.notification_info`; if it is `undefined` (as it was in + # the first iteration, which only returned `{force_redirect: ...}`) + # the access raises `TypeError: can't access property "lines", + # props is undefined` and the redirect never fires. + # + # Shape (mirrors the empty-cart branch in + # `WebsiteSaleController.cart_update_json`): + # cart_quantity = 0 -> falsy, so `updateCartNavBar` is skipped. + # notification_info = {lines: [], warning: ""} + # -> `showCartNotification` no-ops on both empty fields. + return { + "force_redirect": self._membership_signup_redirect_url(product_id), + "cart_quantity": 0, + "notification_info": {"lines": [], "warning": ""}, + } + return super().cart_update_json( + product_id=product_id, line_id=line_id, add_qty=add_qty, + set_qty=set_qty, display=display, + product_custom_attribute_values=product_custom_attribute_values, + no_variant_attribute_value_ids=no_variant_attribute_value_ids, + **kwargs + ) \ No newline at end of file diff --git a/website_membership_signup_required/i18n/ca.po b/website_membership_signup_required/i18n/ca.po new file mode 100644 index 0000000..891bab8 --- /dev/null +++ b/website_membership_signup_required/i18n/ca.po @@ -0,0 +1,61 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_signup_required +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-06 00:00+0000\n" +"PO-Revision-Date: 2026-07-06 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ca_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "First name" +msgstr "Nom" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "Force signup for membership products" +msgstr "Força registre per a productes de soci" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "" +"When enabled, anonymous users trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Only affects the " +"e-commerce flow." +msgstr "" +"Quan està actiu, els usuaris anònims que intenten afegir a la cistella un " +"producte de soci són redirigits a /web/signup abans d'afegir el producte. " +"Només afecta al flux de l'e-commerce." + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "" +"When enabled, anonymous visitors trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Applies per-" +"website, only in b2b (guest checkout) mode." +msgstr "" +"Quan està actiu, els visitants anònims que intenten afegir a la cistella un " +"producte de soci són redirigits a /web/signup abans d'afegir el producte. " +"S'aplica per website, només en mode b2b (compra com a convidat)." + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "Last name" +msgstr "Cognoms" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "Membership signup required" +msgstr "Registre requerit per a soci" \ No newline at end of file diff --git a/website_membership_signup_required/i18n/es.po b/website_membership_signup_required/i18n/es.po new file mode 100644 index 0000000..2b1e85c --- /dev/null +++ b/website_membership_signup_required/i18n/es.po @@ -0,0 +1,71 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_signup_required +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-06 00:00+0000\n" +"PO-Revision-Date: 2026-07-06 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "e.g. Doe" +msgstr "p. ej. García" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "e.g. John" +msgstr "p. ej. Juan" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "First name" +msgstr "Nombre" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "Force signup for membership products" +msgstr "Forzar registro para productos de membresía" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "" +"When enabled, anonymous users trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Only affects the " +"e-commerce flow." +msgstr "" +"Cuando está activo, los usuarios anónimos que intentan añadir al carrito un " +"producto de membresía son redirigidos a /web/signup antes de añadir el " +"producto. Solo afecta al flujo del e-commerce." + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "" +"When enabled, anonymous visitors trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Applies per-" +"website, only in b2b (guest checkout) mode." +msgstr "" +"Cuando está activo, los visitantes anónimos que intentan añadir al carrito " +"un producto de membresía son redirigidos a /web/signup antes de añadir el " +"producto. Se aplica por website, solo en modo b2b (compra como invitado)." + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "Last name" +msgstr "Apellidos" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "Membership signup required" +msgstr "Registro requerido para membresía" \ No newline at end of file diff --git a/website_membership_signup_required/i18n/gl.po b/website_membership_signup_required/i18n/gl.po new file mode 100644 index 0000000..ae264e1 --- /dev/null +++ b/website_membership_signup_required/i18n/gl.po @@ -0,0 +1,61 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_signup_required +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-06 00:00+0000\n" +"PO-Revision-Date: 2026-07-06 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: gl_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "First name" +msgstr "Nome" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "Force signup for membership products" +msgstr "Forzar rexistro para produtos de pertenza" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "" +"When enabled, anonymous users trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Only affects the " +"e-commerce flow." +msgstr "" +"Cando está activo, os usuarios anónimos que intentan engadir ao carriño un " +"produto de pertenza son redirixidos a /web/signup antes de engadir o " +"produto. Soa afecta ao fluxo do e-commerce." + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "" +"When enabled, anonymous visitors trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Applies per-" +"website, only in b2b (guest checkout) mode." +msgstr "" +"Cando está activo, os visitantes anónimos que intentan engadir ao carriño " +"un produto de pertenza son redirixidos a /web/signup antes de engadir o " +"produto. Aplícase por website, só en modo b2b (compra como convidado)." + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "Last name" +msgstr "Apelidos" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "Membership signup required" +msgstr "Rexistro requirido para pertenza" \ No newline at end of file diff --git a/website_membership_signup_required/i18n/website_membership_signup_required.pot b/website_membership_signup_required/i18n/website_membership_signup_required.pot new file mode 100644 index 0000000..c599f4e --- /dev/null +++ b/website_membership_signup_required/i18n/website_membership_signup_required.pot @@ -0,0 +1,64 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_signup_required +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-06 00:00+0000\n" +"PO-Revision-Date: 2026-07-06 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "e.g. Doe" +msgstr "" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "e.g. John" +msgstr "" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "First name" +msgstr "" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "Force signup for membership products" +msgstr "" + +#. module: website_membership_signup_required +#. odoo-python +#: code:addons/website_membership_signup_required/models/website.py:0 +msgid "" +"When enabled, anonymous users trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Only affects the " +"e-commerce flow." +msgstr "" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "" +"When enabled, anonymous visitors trying to add a membership product to cart " +"are redirected to /web/signup before the product is added. Applies per-" +"website, only in b2b (guest checkout) mode." +msgstr "" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.auth_signup_fields_firstname_lastname +msgid "Last name" +msgstr "" + +#. module: website_membership_signup_required +#: model:ir.ui.view,arch_db:website_membership_signup_required.res_config_settings_view_form_membership_signup +msgid "Membership signup required" +msgstr "" \ No newline at end of file diff --git a/website_membership_signup_required/models/__init__.py b/website_membership_signup_required/models/__init__.py new file mode 100644 index 0000000..8606c32 --- /dev/null +++ b/website_membership_signup_required/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import website +from . import res_config_settings +from . import res_users \ No newline at end of file diff --git a/website_membership_signup_required/models/res_config_settings.py b/website_membership_signup_required/models/res_config_settings.py new file mode 100644 index 0000000..aa463e8 --- /dev/null +++ b/website_membership_signup_required/models/res_config_settings.py @@ -0,0 +1,12 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + membership_signup_required = fields.Boolean( + related="website_id.membership_signup_required", + readonly=False, + ) \ No newline at end of file diff --git a/website_membership_signup_required/models/res_users.py b/website_membership_signup_required/models/res_users.py new file mode 100644 index 0000000..8986981 --- /dev/null +++ b/website_membership_signup_required/models/res_users.py @@ -0,0 +1,22 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.http import request + + +class ResUsers(models.Model): + _inherit = "res.users" + + @api.model + def _get_signup_invitation_scope(self): + # Re-enable free signup, but ONLY for the membership add-to-cart + # redirection flow, even when the website is in b2b (guest checkout) + # mode. The marker is ephemeral: set as a query param on the redirect + # to /web/signup and/or as a session flag at interception time. It is + # cleared by the auth_signup controller override after success/login. + if request: + if request.httprequest.args.get("membership_signup"): + return "b2c" + if request.session.get("membership_signup_active"): + return "b2c" + return super()._get_signup_invitation_scope() \ No newline at end of file diff --git a/website_membership_signup_required/models/website.py b/website_membership_signup_required/models/website.py new file mode 100644 index 0000000..2ecaf97 --- /dev/null +++ b/website_membership_signup_required/models/website.py @@ -0,0 +1,33 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class Website(models.Model): + _inherit = "website" + + membership_signup_required = fields.Boolean( + string="Force signup for membership products", + help="When enabled, anonymous users trying to add a membership product " + "to cart are redirected to /web/signup before the product is " + "added. Only affects the e-commerce flow.", + default=True, + ) + + def _membership_signup_required_for(self, product_id, is_public): + """Decide whether the given `product_id` requires forcing a signup + step before it can be added to cart by an anonymous visitor. + + Centralised here (model layer) so it can be unit-tested without HTTP + and reused by future extensions. The controller delegates to this + helper. + """ + self.ensure_one() + if not self.membership_signup_required: + return False + if not is_public: + return False + if not product_id: + return False + product = self.env["product.product"].sudo().browse(int(product_id)).exists() + return bool(product and product.product_tmpl_id.membership) \ No newline at end of file diff --git a/website_membership_signup_required/static/description/icon.png b/website_membership_signup_required/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/website_membership_signup_required/static/description/icon.png differ diff --git a/website_membership_signup_required/static/src/js/website_membership_signup_required.js b/website_membership_signup_required/static/src/js/website_membership_signup_required.js new file mode 100644 index 0000000..af8fa28 --- /dev/null +++ b/website_membership_signup_required/static/src/js/website_membership_signup_required.js @@ -0,0 +1,41 @@ +/** @odoo-module **/ + +// Force-redirect handler for the membership signup flow. +// +// The `cart_update_json` controller override returns +// `{ force_redirect: }` when an anonymous visitor tries to add a +// membership product to the cart in "stay on page" mode +// (`website.add_to_cart_action == 'stay'`), which is the JSON path used by +// `_addToCartInPage` in the core `WebsiteSale` widget. The core handler only +// reads `cart_quantity` / `notification_info` from that response and would +// otherwise ignore `force_redirect`, leaving the membership intercept as a +// silent no-op for the JSON path. +// +// The standard `#add_to_cart` button on the product page posts to +// `/shop/cart/update` (HTTP), handled by `cart_update`, which already raises +// an HTTP redirect — that path is unaffected here. +// +// Why a front-end handler and not an HTTP redirect raised from the controller: +// `_addToCartInPage` calls `rpc("/shop/cart/update_json", ...)` and `rpc` does +// `fetch(...).then(r => r.json())` with the default `redirect: "follow"`. A +// `werkzeug.exceptions.Redirect` (3xx) from the JSON route would be silently +// followed by `fetch`, the `/web/signup` HTML would be parsed as JSON, and the +// frontend would break with an uncaught error. The front-end handler is +// therefore the standard, non-intrusive approach (only acts when +// `data.force_redirect` exists). + +import "@website_sale/js/website_sale"; +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.WebsiteSale.include({ + /** + * @override + */ + async _addToCartInPage(params) { + const data = await this._super.apply(this, arguments); + if (data && data.force_redirect) { + window.location.href = data.force_redirect; + } + return data; + }, +}); \ No newline at end of file diff --git a/website_membership_signup_required/static/tests/tours/website_membership_signup_required.js b/website_membership_signup_required/static/tests/tours/website_membership_signup_required.js new file mode 100644 index 0000000..8be5daf --- /dev/null +++ b/website_membership_signup_required/static/tests/tours/website_membership_signup_required.js @@ -0,0 +1,112 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import * as tourUtils from "@website_sale/js/tours/tour_utils"; + +const PRODUCT_NAME = "Membership Test Product"; + +registry.category("web_tour.tours").add("website_membership_signup_required_tour", { + url: "/shop", + steps: () => [ + // -- Anonymous visitor, add a membership product to cart ----------- + { + content: "Search the membership product", + trigger: 'form input[name="search"]', + run: `edit ${PRODUCT_NAME}`, + }, + { + content: "Run the search", + trigger: 'form:has(input[name="search"]) .oe_search_button', + run: "click", + expectUnloadPage: true, + }, + { + content: "Open the membership product page", + trigger: `.oe_product_cart:first a:text(${PRODUCT_NAME})`, + run: "click", + expectUnloadPage: true, + }, + { + content: "Click Add to Cart as anonymous user", + trigger: "#add_to_cart", + run: "click", + expectUnloadPage: true, + }, + // -- On /web/signup with firstname / lastname ---------------------- + { + content: "Signup page shows firstname field (b2c re-enabled by marker)", + trigger: ".oe_signup_form input[name='firstname']", + run: "edit Tour", + }, + { + trigger: ".oe_signup_form input[name='lastname']", + run: "edit Member", + }, + { + content: "Type a unique email login", + trigger: ".oe_signup_form input[name='login']", + run: () => { + const email = `membership.tour.${Date.now()}@test.example.com`; + document.querySelector(".oe_signup_form input[name='login']").value = email; + }, + }, + { + trigger: ".oe_signup_form input[name='password']", + run: "edit TourMember1!", + }, + { + trigger: ".oe_signup_form input[name='confirm_password']", + run: "edit TourMember1!", + }, + { + content: "Submit the signup form -> redirect back to product page", + trigger: ".oe_signup_form button[type='submit']", + run: "click", + expectUnloadPage: true, + }, + // -- Back on the product page, now authenticated -------------------- + { + content: "We're back on the membership product page (authenticated)", + trigger: `#product_details h1:contains("${PRODUCT_NAME}")`, + }, + { + content: "Add to cart as authenticated user", + trigger: "#add_to_cart", + run: "click", + expectUnloadPage: true, + }, + tourUtils.goToCart(), + tourUtils.assertCartContains({ productName: PRODUCT_NAME }), + ], +}); + +registry.category("web_tour.tours").add("website_membership_signup_required_non_intercept_tour", { + url: "/shop", + steps: () => [ + { + content: "Search the regular product", + trigger: 'form input[name="search"]', + run: "edit Regular Test Product", + }, + { + content: "Run the search", + trigger: 'form:has(input[name="search"]) .oe_search_button', + run: "click", + expectUnloadPage: true, + }, + { + content: "Open the regular product page", + trigger: `.oe_product_cart:first a:text("Regular Test Product")`, + run: "click", + expectUnloadPage: true, + }, + { + content: "Add to cart as anonymous (no signup interception)", + trigger: "#add_to_cart", + run: "click", + expectUnloadPage: true, + }, + tourUtils.goToCart(), + tourUtils.assertCartContains({ productName: "Regular Test Product" }), + ], +}); \ No newline at end of file diff --git a/website_membership_signup_required/tests/__init__.py b/website_membership_signup_required/tests/__init__.py new file mode 100644 index 0000000..712de60 --- /dev/null +++ b/website_membership_signup_required/tests/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_membership_signup +from . import test_membership_signup_tour \ No newline at end of file diff --git a/website_membership_signup_required/tests/test_membership_signup.py b/website_membership_signup_required/tests/test_membership_signup.py new file mode 100644 index 0000000..e956206 --- /dev/null +++ b/website_membership_signup_required/tests/test_membership_signup.py @@ -0,0 +1,138 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import werkzeug.datastructures + +from odoo.fields import Date + +from odoo.addons.website_membership_signup_required.controllers.main import ( + AuthSignupHomeMembership as AuthSignupHome, +) +from odoo.addons.website.tools import MockRequest +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestMembershipSignup(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env.ref("website.default_website") + cls.website.auth_signup_uninvited = "b2b" + today = Date.to_date(Date.today()) + cls.membership_product = cls.env["product.product"].create({ + "name": "Membership Card", + "membership": True, + "list_price": 50.0, + "membership_date_from": today.replace(month=1, day=1), + "membership_date_to": today.replace(month=12, day=31), + }) + cls.regular_product = cls.env["product.product"].create({ + "name": "Regular Mug", + "membership": False, + "list_price": 10.0, + }) + cls.no_user_partner = cls.env["res.partner"].create({ + "firstname": "Backend", + "lastname": "Partner", + }) + + # -- RF-07 / RF-default ------------------------------------------------ + + def test_01_website_default_flag(self): + new_website = self.env["website"].create({"name": "Aux site"}) + self.assertTrue(new_website.membership_signup_required) + + # -- RF-01 helper ------------------------------------------------------- + + def test_02_is_required_membership_product_anonymous(self): + self.assertTrue( + self.website._membership_signup_required_for( + self.membership_product.id, is_public=True + ) + ) + + # -- RF-05 ------------------------------------------------------------- + + def test_03_is_required_non_membership_product(self): + self.assertFalse( + self.website._membership_signup_required_for( + self.regular_product.id, is_public=True + ) + ) + + # -- RF-06 ------------------------------------------------------------- + + def test_04_is_required_authenticated_user(self): + self.assertFalse( + self.website._membership_signup_required_for( + self.membership_product.id, is_public=False + ) + ) + + # -- RF-07 ------------------------------------------------------------- + + def test_05_is_required_disabled_on_website(self): + self.website.membership_signup_required = False + self.assertFalse( + self.website._membership_signup_required_for( + self.membership_product.id, is_public=True + ) + ) + + # -- RF-03 ------------------------------------------------------------- + + def test_06_signup_scope_override_with_marker(self): + Users = self.env["res.users"] + # No marker -> delegates to super (b2b because of setUpClass). + with MockRequest(self.env, website=self.website) as mock_request: + mock_request.httprequest.args = werkzeug.datastructures.MultiDict() + self.assertEqual(Users._get_signup_invitation_scope(), "b2b") + # Query param marker -> b2c. + with MockRequest(self.env, website=self.website) as mock_request: + mock_request.httprequest.args = werkzeug.datastructures.MultiDict( + {"membership_signup": "1"} + ) + self.assertEqual(Users._get_signup_invitation_scope(), "b2c") + # Session flag marker -> b2c. + with MockRequest(self.env, website=self.website) as mock_request: + mock_request.httprequest.args = werkzeug.datastructures.MultiDict() + mock_request.session["membership_signup_active"] = True + self.assertEqual(Users._get_signup_invitation_scope(), "b2c") + # Marker removed again -> back to b2b. + with MockRequest(self.env, website=self.website) as mock_request: + mock_request.httprequest.args = werkzeug.datastructures.MultiDict() + self.assertEqual(Users._get_signup_invitation_scope(), "b2b") + + # -- RF-09 ------------------------------------------------------------- + + def test_07_prepare_signup_values_firstname_lastname(self): + controller = AuthSignupHome() + qcontext = { + "login": "joohn.doe@example.com", + "password": "verystrongpwd", + "confirm_password": "verystrongpwd", + "firstname": "John", + "lastname": "Doe", + } + with MockRequest(self.env, website=self.website): + values = controller._prepare_signup_values(qcontext) + self.assertEqual(values.get("firstname"), "John") + self.assertEqual(values.get("lastname"), "Doe") + self.assertNotIn("name", values) + + # -- RF-08 ------------------------------------------------------------- + + def test_08_backend_sale_order_with_membership_product_without_user(self): + order = self.env["sale.order"].create({ + "partner_id": self.no_user_partner.id, + "order_line": [(0, 0, { + "product_id": self.membership_product.id, + "product_uom_qty": 1.0, + })], + }) + self.assertTrue(order) + self.assertEqual(order.order_line.product_id, self.membership_product) + # Confirming the SO drives membership creation (core `membership`). + order.action_confirm() + self.assertEqual(order.state, "sale") \ No newline at end of file diff --git a/website_membership_signup_required/tests/test_membership_signup_tour.py b/website_membership_signup_required/tests/test_membership_signup_tour.py new file mode 100644 index 0000000..5326e2d --- /dev/null +++ b/website_membership_signup_required/tests/test_membership_signup_tour.py @@ -0,0 +1,202 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import hashlib +import hmac +import re +import time +from urllib.parse import unquote + +from odoo.fields import Date + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestMembershipSignupTour(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env.ref("website.default_website") + cls.website.auth_signup_uninvited = "b2b" + cls.website.membership_signup_required = True + today = Date.to_date(Date.today()) + cls.membership_product = cls.env["product.template"].create({ + "name": "Membership Test Product", + "membership": True, + "list_price": 60.0, + "is_published": True, + "sale_ok": True, + "website_id": cls.website.id, + "membership_date_from": today.replace(month=1, day=1), + "membership_date_to": today.replace(month=12, day=31), + }) + cls.regular_product = cls.env["product.template"].create({ + "name": "Regular Test Product", + "membership": False, + "list_price": 5.0, + "is_published": True, + "sale_ok": True, + "website_id": cls.website.id, + }) + # Enable the custom/transfer payment provider if it is installed so the + # cart page renders fully (no checkout/payment is exercised by the + # tours, but it avoids warnings during cart rendering). + provider = cls.env.ref("payment.payment_provider_transfer", raise_if_not_found=False) + if provider and provider.state != "enabled": + provider.write({"state": "enabled", "is_published": True}) + + def test_01_membership_signup_flow(self): + self.start_tour( + self.env["website"].get_client_action_url("/shop"), + "website_membership_signup_required_tour", + login=None, + ) + # The tour submitted the signup form, so a new portal user must exist + # and the cart of that user must contain the membership product. + users = self.env["res.users"].search([ + ("login", "like", "membership.tour.%@test.example.com"), + ]) + self.assertTrue(users, "Tour should have created a user via /web/signup") + first = users.sorted("create_date")[-1] + self.assertTrue(first.partner_id.firstname) + self.assertTrue(first.partner_id.lastname) + carts = self.env["sale.order"].search([ + ("partner_id", "=", first.partner_id.id), + ("state", "=", "draft"), + ]) + self.assertTrue(carts) + order_lines = carts.mapped("order_line").filtered( + lambda l: l.product_id.product_tmpl_id == self.membership_product + ) + self.assertTrue(order_lines, "Cart must contain the membership product") + + def test_02_non_membership_no_intercept(self): + self.start_tour( + self.env["website"].get_client_action_url("/shop"), + "website_membership_signup_required_non_intercept_tour", + login=None, + ) + + # -- HTTP-level tests (no Chrome) -------------------------------------- + + _CSRF_RE = re.compile(r'name="csrf_token"\s+value="([^"]+)"') + + def _compute_csrf_token(self): + """Manually compute a valid csrf token bound to the opener's current + session_id cookie. Mirrors `request.csrf_token()` so the test client + does not need to scrape the rendered HTML (which some addons strip for + specific authenticated users). + """ + sid = self.opener.cookies.get("session_id") + if not sid: + return None + secret = self.env["ir.config_parameter"].sudo().get_param("database.secret") + max_ts = int(time.time() + 31536000) # 1y, matches Odoo's default. + msg = f"{sid}{max_ts}".encode("utf-8") + hm = hmac.new(secret.encode("ascii"), msg, hashlib.sha1).hexdigest() + return f"{hm}o{max_ts}" + + def _cart_update(self, product, add_qty="1"): + """POST to /shop/cart/update with a valid csrf token. The csrf token + is scraped from the rendered product page, with a fallback computed + directly from the opener's session_id cookie (covers sessions whose + rendered page strips the form for the logged user). + """ + product_url = "/shop/%s" % self.env["ir.http"]._slug(product) + page = self.url_open(product_url, allow_redirects=True) + self.assertEqual( + page.status_code, 200, + "Product page must render (got %s for %s)" % (page.status_code, product_url), + ) + match = self._CSRF_RE.search(page.text) + csrf_token = match.group(1) if match else self._compute_csrf_token() + self.assertTrue( + csrf_token, + "Could not obtain a csrf_token (rendered page or session-based).", + ) + return self.url_open( + "/shop/cart/update", + data={ + "product_id": str(product.product_variant_id.id), + "add_qty": add_qty, + "csrf_token": csrf_token, + }, + allow_redirects=False, + ) + + def _assert_redirect(self, resp): + self.assertTrue( + 300 <= resp.status_code < 400, + "Expected a HTTP redirect, got %s. Location: %s" + % (resp.status_code, resp.headers.get("Location", "")), + ) + + def test_03_anon_membership_redirect(self): + """RF-01 / RF-02: anonymous POST /shop/cart/update for a membership + product must redirect to /web/signup?membership_signup=1&redirect=... + without adding the product to the cart. + """ + resp = self._cart_update(self.membership_product) + self._assert_redirect(resp) + # The `Location` header is URL-encoded, so any path segment with + # non-safe chars (here the product slug) arrives percent-encoded. + # Assert on stable substrings that do not depend on the encoding of + # each part of the URL (RF-02). + location = resp.headers.get("Location", "") + self.assertIn("/web/signup", location) + self.assertIn("membership_signup=1", location) + self.assertIn("redirect=", location) + self.assertIn("membership-test-product", unquote(location)) + + def test_04_anon_regular_no_redirect(self): + """RF-05: anonymous POST /shop/cart/update for a non-membership + product must follow the core flow (redirect to /shop/cart). + """ + resp = self._cart_update(self.regular_product) + self._assert_redirect(resp) + location = resp.headers.get("Location", "") + self.assertIn("/shop/cart", location) + self.assertNotIn("/web/signup", location) + + def test_05_authenticated_membership_no_redirect(self): + """RF-06: an authenticated (non-public) user adding a membership + product must NOT be redirected to /web/signup. + """ + self.authenticate("admin", "admin") + resp = self._cart_update(self.membership_product) + self._assert_redirect(resp) + location = resp.headers.get("Location", "") + self.assertIn("/shop/cart", location) + self.assertNotIn("/web/signup", location) + + def test_06_signup_marker_page_renders(self): + """RF-03: GET /web/signup?membership_signup=1&redirect=/shop/... must + return 200 (not 404) on a b2b website thanks to the conditional + override of `_get_signup_invitation_scope`. + """ + product_url = "/shop/%s" % self.env["ir.http"]._slug(self.membership_product) + params = {"membership_signup": "1", "redirect": product_url} + resp = self.url_open( + "/web/signup?" + "&".join(f"{k}={v}" for k, v in params.items()), + allow_redirects=False, + ) + self.assertIn(resp.status_code, (200, 302)) + if resp.status_code == 302: + # Could be redirected to login if a session already exists. + self.assertNotIn("web/login", resp.headers.get("Location", "")) + + def test_07_disabled_intercept_anon_membership(self): + """RF-07: with `membership_signup_required = False` on the website, an + anonymous POST /shop/cart/update for a membership product must follow + the core flow (no redirect to /web/signup). + """ + self.website.membership_signup_required = False + try: + resp = self._cart_update(self.membership_product) + self._assert_redirect(resp) + location = resp.headers.get("Location", "") + self.assertIn("/shop/cart", location) + self.assertNotIn("/web/signup", location) + finally: + self.website.membership_signup_required = True \ No newline at end of file diff --git a/website_membership_signup_required/views/auth_signup_templates.xml b/website_membership_signup_required/views/auth_signup_templates.xml new file mode 100644 index 0000000..1b8c975 --- /dev/null +++ b/website_membership_signup_required/views/auth_signup_templates.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/website_membership_signup_required/views/res_config_settings_views.xml b/website_membership_signup_required/views/res_config_settings_views.xml new file mode 100644 index 0000000..973078e --- /dev/null +++ b/website_membership_signup_required/views/res_config_settings_views.xml @@ -0,0 +1,18 @@ + + + + + res.config.settings.view.form.inherit.membership.signup + res.config.settings + + + + + + + + + + + \ No newline at end of file