add website_membership_signup_required: force to create user before add to cart a membership product
This commit is contained in:
parent
80f24bcc46
commit
c4d32c6add
22 changed files with 1202 additions and 0 deletions
123
website_membership_signup_required/README.md
Normal file
123
website_membership_signup_required/README.md
Normal 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.
|
||||||
4
website_membership_signup_required/__init__.py
Normal file
4
website_membership_signup_required/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from . import controllers
|
||||||
|
from . import models
|
||||||
31
website_membership_signup_required/__manifest__.py
Normal file
31
website_membership_signup_required/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from . import main
|
||||||
|
from . import website_sale
|
||||||
74
website_membership_signup_required/controllers/main.py
Normal file
74
website_membership_signup_required/controllers/main.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
61
website_membership_signup_required/i18n/ca.po
Normal file
61
website_membership_signup_required/i18n/ca.po
Normal 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"
|
||||||
71
website_membership_signup_required/i18n/es.po
Normal file
71
website_membership_signup_required/i18n/es.po
Normal 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"
|
||||||
61
website_membership_signup_required/i18n/gl.po
Normal file
61
website_membership_signup_required/i18n/gl.po
Normal 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"
|
||||||
|
|
@ -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 ""
|
||||||
5
website_membership_signup_required/models/__init__.py
Normal file
5
website_membership_signup_required/models/__init__.py
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
22
website_membership_signup_required/models/res_users.py
Normal file
22
website_membership_signup_required/models/res_users.py
Normal 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()
|
||||||
33
website_membership_signup_required/models/website.py
Normal file
33
website_membership_signup_required/models/website.py
Normal 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)
|
||||||
BIN
website_membership_signup_required/static/description/icon.png
Normal file
BIN
website_membership_signup_required/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
|
|
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
4
website_membership_signup_required/tests/__init__.py
Normal file
4
website_membership_signup_required/tests/__init__.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue