[REF] all: pass pre-commit on the whole repo
Some checks failed
CI / lint (pull_request) Successful in 33s
CI / test (pull_request) Failing after 9m29s
CI / review (pull_request) Has been skipped

Needed for the lint job of the CI to go green. Mostly automatic hook
fixes (black, isort, prettier, pyupgrade, autoflake, whitespace). By hand:

- flake8: two unused imports, an ambiguous variable name `l`.
- pylint-odoo mandatory: manifest keys equal to their default value
  (installable, application, data, external_dependencies).
- end-of-file-fixer skips *.patch too, like trailing-whitespace: the
  upstream patches stay as git format-patch wrote them.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-10-04 14:17:09 +02:00
parent d9d9e8be2d
commit fbd43bb459
Signed by: snt
GPG key ID: A9FD34930EADBE71
45 changed files with 330 additions and 295 deletions

View file

@ -120,4 +120,4 @@ 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.
`requirements.md` §7 R-01.

View file

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

View file

@ -6,7 +6,7 @@
"version": "18.0.1.0.1",
"category": "Website/Sale",
"summary": "Force a clean /web/signup step before adding membership "
"products to cart on a b2b website (EMES-style flow).",
"products to cart on a b2b website (EMES-style flow).",
"author": "Criptomart",
"website": "https://git.criptomart.net/criptomart/addons-cm",
"license": "AGPL-3",
@ -25,7 +25,4 @@
"website_membership_signup_required/static/src/js/website_membership_signup_required.js",
],
},
"external_dependencies": {},
"installable": True,
"application": False,
}
}

View file

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

View file

@ -1,11 +1,13 @@
# 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 import _
from odoo import http
from odoo.exceptions import UserError
from odoo.http import request
from odoo.addons.auth_signup.controllers.main import AuthSignupHome
from odoo.addons.web.controllers.home import SIGN_UP_REQUEST_PARAMS
# 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`.
@ -18,7 +20,10 @@ SIGN_UP_REQUEST_PARAMS.add("lastname")
SIGN_UP_REQUEST_PARAMS.add("accepted_terms")
# Session keys used by the membership signup flow.
MEMBERSHIP_SIGNUP_SESSION_KEYS = ("membership_signup_active", "membership_signup_product_id")
MEMBERSHIP_SIGNUP_SESSION_KEYS = (
"membership_signup_active",
"membership_signup_product_id",
)
class AuthSignupHomeMembership(AuthSignupHome):
@ -71,9 +76,7 @@ class AuthSignupHomeMembership(AuthSignupHome):
values["firstname"] = firstname
values["lastname"] = lastname
partner_model = request.env["res.partner"]
values["name"] = partner_model._get_computed_name(
lastname, firstname
)
values["name"] = partner_model._get_computed_name(lastname, firstname)
return values
@http.route()
@ -134,4 +137,4 @@ class AuthSignupHomeMembership(AuthSignupHome):
# Drop the markers: the membership flow is now complete.
for key in MEMBERSHIP_SIGNUP_SESSION_KEYS:
request.session.pop(key, None)
return request.redirect("/shop/cart")
return request.redirect("/shop/cart")

View file

@ -3,9 +3,10 @@
from urllib.parse import urlencode
from odoo import http
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
SIGNUP_FLAG = "membership_signup=1"
@ -36,24 +37,37 @@ class WebsiteSaleMembershipSignup(WebsiteSale):
@http.route()
def cart_update(
self, product_id, add_qty=1, set_qty=0,
self,
product_id,
add_qty=1,
set_qty=0,
product_custom_attribute_values=None,
no_variant_attribute_value_ids=None, **kwargs
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_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
**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
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)
@ -90,11 +104,14 @@ class WebsiteSaleMembershipSignup(WebsiteSale):
"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_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
**kwargs,
)
def _get_mandatory_address_fields(self, country_sudo):
@ -122,4 +139,4 @@ class WebsiteSaleMembershipSignup(WebsiteSale):
# So even after we drop 'phone' from the address set above, the
# portal set re-adds it for billing. We need to drop it here too.
fields = list(super()._get_mandatory_fields())
return [f for f in fields if f != "phone"]
return [f for f in fields if f != "phone"]

View file

@ -3,4 +3,4 @@
from . import website
from . import res_config_settings
from . import res_partner
from . import res_users
from . import res_users

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel):
@ -9,4 +10,4 @@ class ResConfigSettings(models.TransientModel):
membership_signup_required = fields.Boolean(
related="website_id.membership_signup_required",
readonly=False,
)
)

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models
from odoo import api
from odoo import models
class ResPartner(models.Model):
@ -29,4 +30,4 @@ class ResPartner(models.Model):
if partner:
res["firstname"] = partner.firstname or ""
res["lastname"] = partner.lastname or ""
return res
return res

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models
from odoo import api
from odoo import models
from odoo.http import request
@ -19,4 +20,4 @@ class ResUsers(models.Model):
return "b2c"
if request.session.get("membership_signup_active"):
return "b2c"
return super()._get_signup_invitation_scope()
return super()._get_signup_invitation_scope()

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
from odoo import fields
from odoo import models
class Website(models.Model):
@ -9,8 +10,8 @@ class Website(models.Model):
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.",
"to cart are redirected to /web/signup before the product is "
"added. Only affects the e-commerce flow.",
default=True,
)
@ -30,4 +31,4 @@ class Website(models.Model):
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)
return bool(product and product.product_tmpl_id.membership)

View file

@ -38,4 +38,4 @@ publicWidget.registry.WebsiteSale.include({
}
return data;
},
});
});

View file

@ -64,8 +64,9 @@ registry.category("web_tour.tours").add("website_membership_signup_required_tour
run: "click",
},
{
content: "Submit the signup form -> redirect straight to cart "
+ "with the product already added (post-login auto-add)",
content:
"Submit the signup form -> redirect straight to cart " +
"with the product already added (post-login auto-add)",
trigger: ".oe_signup_form button[type='submit']",
run: "click",
expectUnloadPage: true,
@ -108,4 +109,4 @@ registry.category("web_tour.tours").add("website_membership_signup_required_non_
tourUtils.goToCart(),
tourUtils.assertCartContains({ productName: "Regular Test Product" }),
],
});
});

View file

@ -1,4 +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
from . import test_membership_signup_tour

View file

@ -4,15 +4,16 @@ import werkzeug.datastructures
from odoo.exceptions import UserError
from odoo.fields import Date
from odoo.tests import TransactionCase
from odoo.tests import tagged
from odoo.addons.website.tools import MockRequest
from odoo.addons.website_membership_signup_required.controllers.main import (
AuthSignupHomeMembership as AuthSignupHome,
)
from odoo.addons.website_membership_signup_required.controllers.website_sale import (
WebsiteSaleMembershipSignup,
)
from odoo.addons.website.tools import MockRequest
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
@ -24,22 +25,28 @@ class TestMembershipSignup(TransactionCase):
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",
})
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 ------------------------------------------------
@ -198,13 +205,21 @@ class TestMembershipSignup(TransactionCase):
# -- 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,
})],
})
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`).
@ -276,12 +291,14 @@ class TestMembershipSignup(TransactionCase):
The invited partner must NOT have a user yet, otherwise the token
payload check (`user_ids`) won't match.
"""
partner = self.env["res.partner"].create({
"firstname": "Invited",
"lastname": "Researcher",
"email": "invited.researcher@example.com",
"signup_type": "signup",
})
partner = self.env["res.partner"].create(
{
"firstname": "Invited",
"lastname": "Researcher",
"email": "invited.researcher@example.com",
"signup_type": "signup",
}
)
self.assertFalse(partner.user_ids, "test precondition: no user yet")
token = partner._generate_signup_token()
info = self.env["res.partner"]._signup_retrieve_info(token)
@ -297,11 +314,13 @@ class TestMembershipSignup(TransactionCase):
still works — the form will show empty readonly inputs for the split
fields, which is the best we can do without re-splitting.
"""
partner = self.env["res.partner"].create({
"name": "Legacy Researcher",
"email": "legacy@example.com",
"signup_type": "signup",
})
partner = self.env["res.partner"].create(
{
"name": "Legacy Researcher",
"email": "legacy@example.com",
"signup_type": "signup",
}
)
# partner_firstname's post_create hook may or may not have split
# `name` automatically; we just check the override does not crash
# and returns the contract keys.
@ -309,4 +328,4 @@ class TestMembershipSignup(TransactionCase):
info = self.env["res.partner"]._signup_retrieve_info(token)
self.assertIn("firstname", info)
self.assertIn("lastname", info)
self.assertEqual(info.get("name"), partner.name)
self.assertEqual(info.get("name"), partner.name)

View file

@ -7,8 +7,8 @@ import time
from urllib.parse import unquote
from odoo.fields import Date
from odoo.tests import HttpCase, tagged
from odoo.tests import HttpCase
from odoo.tests import tagged
@tagged("post_install", "-at_install")
@ -21,28 +21,34 @@ class TestMembershipSignupTour(HttpCase):
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,
})
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)
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})
@ -54,20 +60,24 @@ class TestMembershipSignupTour(HttpCase):
)
# 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"),
])
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"),
])
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
lambda line: line.product_id.product_tmpl_id == self.membership_product
)
self.assertTrue(order_lines, "Cart must contain the membership product")
@ -93,7 +103,7 @@ class TestMembershipSignupTour(HttpCase):
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")
msg = f"{sid}{max_ts}".encode()
hm = hmac.new(secret.encode("ascii"), msg, hashlib.sha1).hexdigest()
return f"{hm}o{max_ts}"
@ -106,8 +116,10 @@ class TestMembershipSignupTour(HttpCase):
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),
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()
@ -199,4 +211,4 @@ class TestMembershipSignupTour(HttpCase):
self.assertIn("/shop/cart", location)
self.assertNotIn("/web/signup", location)
finally:
self.website.membership_signup_required = True
self.website.membership_signup_required = True

View file

@ -37,4 +37,4 @@
</xpath>
</template>
</odoo>
</odoo>

View file

@ -15,4 +15,4 @@
</field>
</record>
</odoo>
</odoo>