add website_membership_signup_required: force to create user before add to cart a membership product

This commit is contained in:
Luis 2026-07-07 10:27:13 +02:00
parent 80f24bcc46
commit c4d32c6add
22 changed files with 1202 additions and 0 deletions

View file

@ -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/<slug>`.
- **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 (<https://git.criptomart.net/criptomart/addons-cm>).
## 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.

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import controllers
from . import models

View file

@ -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,
}

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import main
from . import website_sale

View file

@ -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

View file

@ -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=<product_url>`
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
)

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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 ""

View file

@ -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

View file

@ -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,
)

View file

@ -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()

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,41 @@
/** @odoo-module **/
// Force-redirect handler for the membership signup flow.
//
// The `cart_update_json` controller override returns
// `{ force_redirect: <signup_url> }` 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;
},
});

View file

@ -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" }),
],
});

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="auth_signup_fields_firstname_lastname" name="Auth Signup fields (firstname / lastname)" inherit_id="auth_signup.fields">
<xpath expr="//div[hasclass('field-name')]" position="replace">
<div class="mb-3 field-firstname">
<label for="firstname">First name</label>
<input type="text" name="firstname" t-att-value="firstname" id="firstname"
class="form-control form-control-sm" placeholder="e.g. John"
required="required"
t-att-readonly="'readonly' if only_passwords else None"
t-att-autofocus="'autofocus' if login and not only_passwords else None"/>
</div>
<div class="mb-3 field-lastname">
<label for="lastname">Last name</label>
<input type="text" name="lastname" t-att-value="lastname" id="lastname"
class="form-control form-control-sm" placeholder="e.g. Doe"
required="required"
t-att-readonly="'readonly' if only_passwords else None"/>
</div>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_membership_signup" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.membership.signup</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="website_sale.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//setting[@id='website_checkout_registration']" position="after">
<setting string="Membership signup required"
help="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.">
<field name="membership_signup_required"/>
</setting>
</xpath>
</field>
</record>
</odoo>