diff --git a/setup/shopinvader_auth_jwt_transfer_cart/odoo/addons/shopinvader_auth_jwt_transfer_cart b/setup/shopinvader_auth_jwt_transfer_cart/odoo/addons/shopinvader_auth_jwt_transfer_cart new file mode 120000 index 0000000000..b2697d7724 --- /dev/null +++ b/setup/shopinvader_auth_jwt_transfer_cart/odoo/addons/shopinvader_auth_jwt_transfer_cart @@ -0,0 +1 @@ +../../../../shopinvader_auth_jwt_transfer_cart \ No newline at end of file diff --git a/setup/shopinvader_auth_jwt_transfer_cart/setup.py b/setup/shopinvader_auth_jwt_transfer_cart/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_auth_jwt_transfer_cart/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_auth_jwt_transfer_cart/README.rst b/shopinvader_auth_jwt_transfer_cart/README.rst new file mode 100644 index 0000000000..b063dd5adf --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/README.rst @@ -0,0 +1,77 @@ +=========================================== +Shopinvader JWT Authentication Tranfer Cart +=========================================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fshopinvader-lightgray.png?logo=github + :target: https://github.com/OCA/shopinvader/tree/14.0/shopinvader_auth_jwt_transfer_cart + :alt: OCA/shopinvader +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/shopinvader-14-0/shopinvader-14-0-shopinvader_auth_jwt_transfer_cart + :alt: Translate me on Weblate + +|badge1| |badge2| |badge3| |badge4| + +This module adds a new transfer route to cart service that takes a JWT token as POST parameter and transfer the cart from the logged in partner to the partner in the given jwt token. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Activation is automatic. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* `Akretion `_: + + * Florian Mounier + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/shopinvader `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopinvader_auth_jwt_transfer_cart/__init__.py b/shopinvader_auth_jwt_transfer_cart/__init__.py new file mode 100644 index 0000000000..71a02422d5 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/shopinvader_auth_jwt_transfer_cart/__manifest__.py b/shopinvader_auth_jwt_transfer_cart/__manifest__.py new file mode 100644 index 0000000000..0f80083229 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/__manifest__.py @@ -0,0 +1,12 @@ +{ + "name": "Shopinvader JWT Authentication Tranfer Cart", + "version": "14.0.1.0.0", + "summary": "Add a cart route to transfer cart from a partner to another", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["shopinvader_auth_jwt"], + "data": [ + "views/shopinvader_backend.xml", + ], + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", +} diff --git a/shopinvader_auth_jwt_transfer_cart/models/__init__.py b/shopinvader_auth_jwt_transfer_cart/models/__init__.py new file mode 100644 index 0000000000..37946ccf5c --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/models/__init__.py @@ -0,0 +1 @@ +from . import shopinvader_backend diff --git a/shopinvader_auth_jwt_transfer_cart/models/shopinvader_backend.py b/shopinvader_auth_jwt_transfer_cart/models/shopinvader_backend.py new file mode 100644 index 0000000000..0f4e3b2974 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/models/shopinvader_backend.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class ShopinvaderBackend(models.Model): + _inherit = "shopinvader.backend" + + merge_cart_on_transfer = fields.Boolean( + string="Merge Cart on Transfer", + help="If checked, the cart will be merged with the existing one if " + "any on transfer.", + ) diff --git a/shopinvader_auth_jwt_transfer_cart/readme/CONTRIBUTORS.rst b/shopinvader_auth_jwt_transfer_cart/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a4d0ad9229 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/shopinvader_auth_jwt_transfer_cart/readme/DESCRIPTION.rst b/shopinvader_auth_jwt_transfer_cart/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..cb4f14c0cf --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module adds a new transfer route to cart service that takes a JWT token as POST parameter and transfer the cart from the logged in partner to the partner in the given jwt token. diff --git a/shopinvader_auth_jwt_transfer_cart/readme/USAGE.rst b/shopinvader_auth_jwt_transfer_cart/readme/USAGE.rst new file mode 100644 index 0000000000..8a163b1afc --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/readme/USAGE.rst @@ -0,0 +1 @@ +Activation is automatic. diff --git a/shopinvader_auth_jwt_transfer_cart/services/__init__.py b/shopinvader_auth_jwt_transfer_cart/services/__init__.py new file mode 100644 index 0000000000..d98a08bc7f --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/services/__init__.py @@ -0,0 +1 @@ +from . import cart diff --git a/shopinvader_auth_jwt_transfer_cart/services/cart.py b/shopinvader_auth_jwt_transfer_cart/services/cart.py new file mode 100644 index 0000000000..2bc07c501e --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/services/cart.py @@ -0,0 +1,67 @@ +from odoo import _, fields +from odoo.exceptions import AccessDenied + +from odoo.addons.component.core import Component + + +class CartService(Component): + _inherit = "shopinvader.cart.service" + + def _decode_token(self, token): + validator = self.env["auth.jwt.validator"]._get_validator_by_name( + validator_name="shopinvader" + ) + while validator: + try: + return validator._decode(token) + except Exception: + validator = validator.next_validator_id + + def _validator_transfer(self): + return { + "token": {"type": "string", "required": True}, + } + + def transfer(self, token=None): + cart = self._get() + auth_token = self._decode_token(token) + if not auth_token or not auth_token.get("email"): + raise AccessDenied(_("Invalid new auth token")) + + partner = self.env["shopinvader.partner"].search( + [("partner_email", "=", auth_token["email"])] + ) + + if len(partner) != 1: + raise AccessDenied(_("Invalid partner email in token")) + + old_cart = None + if self.shopinvader_backend.merge_cart_on_transfer: + old_partner = self.work.partner + self.work.partner = partner.record_id + old_cart = self._get(False) + self.work.partner = old_partner + + # Change cart partner: + res_partner_id = partner.record_id.id + cart.date_order = fields.Datetime.now() + cart.write_with_onchange( + { + "partner_id": res_partner_id, + "partner_shipping_id": res_partner_id, + "partner_invoice_id": res_partner_id, + } + ) + + if old_cart and self.shopinvader_backend.merge_cart_on_transfer: + # Merge cart: + for line in old_cart.order_line: + self._add_item( + cart, + { + "product_id": line.product_id.id, + "item_qty": line.product_uom_qty, + }, + ) + + return self._to_json(cart) diff --git a/shopinvader_auth_jwt_transfer_cart/static/description/index.html b/shopinvader_auth_jwt_transfer_cart/static/description/index.html new file mode 100644 index 0000000000..9149c5fedc --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/static/description/index.html @@ -0,0 +1,427 @@ + + + + + + +Shopinvader JWT Authentication Tranfer Cart + + + +
+

Shopinvader JWT Authentication Tranfer Cart

+ + +

Beta License: AGPL-3 OCA/shopinvader Translate me on Weblate

+

This module adds a new transfer route to cart service that takes a JWT token as POST parameter and transfer the cart from the logged in partner to the partner in the given jwt token.

+

Table of contents

+ +
+

Usage

+

Activation is automatic.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/shopinvader project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/shopinvader_auth_jwt_transfer_cart/tests/__init__.py b/shopinvader_auth_jwt_transfer_cart/tests/__init__.py new file mode 100644 index 0000000000..787e05b9f2 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/tests/__init__.py @@ -0,0 +1 @@ +from . import test_transfer_cart diff --git a/shopinvader_auth_jwt_transfer_cart/tests/test_transfer_cart.py b/shopinvader_auth_jwt_transfer_cart/tests/test_transfer_cart.py new file mode 100644 index 0000000000..eb78432d96 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/tests/test_transfer_cart.py @@ -0,0 +1,286 @@ +import contextlib +import time +from unittest.mock import Mock + +import jwt + +import odoo.http +from odoo.tools.misc import DotDict + +from odoo.addons.shopinvader.tests.test_cart import CommonConnectedCartCase + + +class TestTransferCart(CommonConnectedCartCase): + @contextlib.contextmanager + def _mock_request(self, authorization): + environ = {} + if authorization: + environ["HTTP_AUTHORIZATION"] = authorization + request = Mock( + context={}, + db=self.env.cr.dbname, + uid=None, + httprequest=Mock(environ=environ, headers={}), + session=DotDict(), + env=self.env, + cr=self.env.cr, + ) + # These attributes are added upon successful auth, so make sure + # calling hasattr on the mock when they are not yet set returns False. + del request.jwt_payload + del request.jwt_partner_id + + with contextlib.ExitStack() as s: + odoo.http._request_stack.push(request) + s.callback(odoo.http._request_stack.pop) + yield request + + def _create_token( + self, + key="thesecret", + audience="me", + issuer="http://the.issuer", + exp_delta=100, + nbf=None, + email=None, + ): + payload = dict(aud=audience, iss=issuer, exp=time.time() + exp_delta) + if email: + payload["email"] = email + if nbf: + payload["nbf"] = nbf + return jwt.encode(payload, key=key, algorithm="HS256") + + def _create_validator( + self, + name, + audience="me", + issuer="http://the.issuer", + secret_key="thesecret", + partner_id_required=False, + partner_id_strategy="email", + ): + return self.env["auth.jwt.validator"].create( + dict( + name=name, + signature_type="secret", + secret_algorithm="HS256", + secret_key=secret_key, + audience=audience, + issuer=issuer, + user_id_strategy="static", + partner_id_strategy=partner_id_strategy, + partner_id_required=partner_id_required, + ) + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_1 = cls.env.ref("product.product_product_4b") + cls.product_2 = cls.env.ref("product.product_product_13") + cls.product_3 = cls.env.ref("product.product_product_11") + + def setUp(self): + super().setUp() + + with self.work_on_services(partner=self.partner) as work: + self.service = work.component(usage="cart") + + self.guest = self.env.ref("shopinvader.partner_2") + with self.work_on_services(partner=self.guest) as work: + self.guest_service = work.component(usage="cart") + + self.guest_cart = self.env.ref("shopinvader.sale_order_3") + self.token = self._create_token(email=self.partner.email) + self.guest_token = self._create_token(email=self.guest.email) + self._create_validator("shopinvader") + + def test_cart_transfer(self): + self.cart.unlink() + self.service.dispatch("search") + self.service.dispatch( + "add_item", params={"product_id": self.product_1.id, "item_qty": 2} + ) + self.service.dispatch( + "add_item", params={"product_id": self.product_2.id, "item_qty": 1} + ) + + self.guest_cart.unlink() + self.guest_service.dispatch("search") + self.guest_service.dispatch( + "add_item", params={"product_id": self.product_3.id, "item_qty": 2} + ) + self.guest_service.dispatch( + "add_item", params={"product_id": self.product_2.id, "item_qty": 5} + ) + + # Logged in cart + cart = self.service.dispatch("search")["data"] + self.assertEquals(cart["lines"]["count"], 3) + + self.assertEquals( + cart["lines"]["items"][0]["name"], + "[FURN_0097] Customizable Desk (CONFIG) (Steel, Black)\n160x80cm, with large legs.", + ) + self.assertEquals(cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + cart["lines"]["items"][1]["name"], "[FURN_1118] Corner Desk Left Sit" + ) + self.assertEquals(cart["lines"]["items"][1]["qty"], 1) + + # Guest cart + guest_cart = self.guest_service.dispatch("search")["data"] + self.assertEquals(guest_cart["lines"]["count"], 7) + self.assertEquals( + guest_cart["lines"]["items"][0]["name"], + "[E-COM12] Conference Chair (CONFIG) (Steel)", + ) + self.assertEquals(guest_cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + guest_cart["lines"]["items"][1]["name"], "[FURN_1118] Corner Desk Left Sit" + ) + self.assertEquals(guest_cart["lines"]["items"][1]["qty"], 5) + + # Transfer guest to logged in cart + + with self._mock_request("Bearer " + self.guest_token): + transferred_cart = self.guest_service.dispatch( + "transfer", params={"token": self.token} + )["data"] + + self.assertEquals(transferred_cart["lines"]["count"], 7) + self.assertEquals( + transferred_cart["lines"]["items"][0]["name"], + "[E-COM12] Conference Chair (CONFIG) (Steel)", + ) + self.assertEquals(transferred_cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + transferred_cart["lines"]["items"][1]["name"], + "[FURN_1118] Corner Desk Left Sit", + ) + self.assertEquals(transferred_cart["lines"]["items"][1]["qty"], 5) + + self.assertEquals(self.service.dispatch("search")["data"], transferred_cart) + + def test_cart_transfer_merge(self): + self.backend.merge_cart_on_transfer = True + + self.cart.unlink() + self.service.dispatch("search") + self.service.dispatch( + "add_item", params={"product_id": self.product_1.id, "item_qty": 2} + ) + self.service.dispatch( + "add_item", params={"product_id": self.product_2.id, "item_qty": 1} + ) + + self.guest_cart.unlink() + self.guest_service.dispatch("search") + self.guest_service.dispatch( + "add_item", params={"product_id": self.product_3.id, "item_qty": 2} + ) + self.guest_service.dispatch( + "add_item", params={"product_id": self.product_2.id, "item_qty": 5} + ) + + # Logged in cart + cart = self.service.dispatch("search")["data"] + self.assertEquals(cart["lines"]["count"], 3) + + self.assertEquals( + cart["lines"]["items"][0]["name"], + "[FURN_0097] Customizable Desk (CONFIG) (Steel, Black)\n160x80cm, with large legs.", + ) + self.assertEquals(cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + cart["lines"]["items"][1]["name"], "[FURN_1118] Corner Desk Left Sit" + ) + self.assertEquals(cart["lines"]["items"][1]["qty"], 1) + + # Guest cart + guest_cart = self.guest_service.dispatch("search")["data"] + self.assertEquals(guest_cart["lines"]["count"], 7) + self.assertEquals( + guest_cart["lines"]["items"][0]["name"], + "[E-COM12] Conference Chair (CONFIG) (Steel)", + ) + self.assertEquals(guest_cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + guest_cart["lines"]["items"][1]["name"], "[FURN_1118] Corner Desk Left Sit" + ) + self.assertEquals(guest_cart["lines"]["items"][1]["qty"], 5) + + # Transfer guest to logged in cart + + with self._mock_request("Bearer " + self.guest_token): + transferred_cart = self.guest_service.dispatch( + "transfer", params={"token": self.token} + )["data"] + + self.assertEquals(transferred_cart["lines"]["count"], 10) + self.assertEquals( + transferred_cart["lines"]["items"][0]["name"], + "[E-COM12] Conference Chair (CONFIG) (Steel)", + ) + self.assertEquals(transferred_cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + transferred_cart["lines"]["items"][1]["name"], + "[FURN_1118] Corner Desk Left Sit", + ) + self.assertEquals(transferred_cart["lines"]["items"][1]["qty"], 6) + self.assertEquals( + transferred_cart["lines"]["items"][2]["name"], + "[FURN_0097] Customizable Desk (CONFIG) (Steel, Black)\n160x80cm, with large legs.", + ) + self.assertEquals(transferred_cart["lines"]["items"][2]["qty"], 2) + + self.assertEquals(self.service.dispatch("search")["data"], transferred_cart) + + def test_cart_transfer_merge_no_cart(self): + self.backend.merge_cart_on_transfer = True + + self.cart.unlink() + + self.guest_cart.unlink() + self.guest_service.dispatch("search") + self.guest_service.dispatch( + "add_item", params={"product_id": self.product_3.id, "item_qty": 2} + ) + self.guest_service.dispatch( + "add_item", params={"product_id": self.product_2.id, "item_qty": 5} + ) + + # Guest cart + guest_cart = self.guest_service.dispatch("search")["data"] + self.assertEquals(guest_cart["lines"]["count"], 7) + self.assertEquals( + guest_cart["lines"]["items"][0]["name"], + "[E-COM12] Conference Chair (CONFIG) (Steel)", + ) + self.assertEquals(guest_cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + guest_cart["lines"]["items"][1]["name"], "[FURN_1118] Corner Desk Left Sit" + ) + self.assertEquals(guest_cart["lines"]["items"][1]["qty"], 5) + + # Transfer guest to logged in cart + + with self._mock_request("Bearer " + self.guest_token): + transferred_cart = self.guest_service.dispatch( + "transfer", params={"token": self.token} + )["data"] + + self.assertEquals(transferred_cart["lines"]["count"], 7) + self.assertEquals( + transferred_cart["lines"]["items"][0]["name"], + "[E-COM12] Conference Chair (CONFIG) (Steel)", + ) + self.assertEquals(transferred_cart["lines"]["items"][0]["qty"], 2) + self.assertEquals( + transferred_cart["lines"]["items"][1]["name"], + "[FURN_1118] Corner Desk Left Sit", + ) + self.assertEquals(transferred_cart["lines"]["items"][1]["qty"], 5) + + self.assertEquals(self.service.dispatch("search")["data"], transferred_cart) diff --git a/shopinvader_auth_jwt_transfer_cart/views/shopinvader_backend.xml b/shopinvader_auth_jwt_transfer_cart/views/shopinvader_backend.xml new file mode 100644 index 0000000000..4857b3cc79 --- /dev/null +++ b/shopinvader_auth_jwt_transfer_cart/views/shopinvader_backend.xml @@ -0,0 +1,17 @@ + + + + shopinvader.backend + + + + + + + + + + + + + diff --git a/shopinvader_customer_price/models/shopinvader_backend.py b/shopinvader_customer_price/models/shopinvader_backend.py index 143122e5f3..5ead944470 100644 --- a/shopinvader_customer_price/models/shopinvader_backend.py +++ b/shopinvader_customer_price/models/shopinvader_backend.py @@ -40,7 +40,7 @@ def _get_cart_pricelist(self, partner=None): def _get_partner_pricelist(self, partner): pricelist = super()._get_partner_pricelist(partner) - if pricelist is None: + if pricelist is None and self.cart_pricelist_partner_field_id: pricelist = partner.property_product_pricelist return pricelist diff --git a/shopinvader_customer_price/tests/test_cart.py b/shopinvader_customer_price/tests/test_cart.py index 17b177c3a4..92d06b96de 100644 --- a/shopinvader_customer_price/tests/test_cart.py +++ b/shopinvader_customer_price/tests/test_cart.py @@ -11,6 +11,7 @@ class ConnectedItemCase(ItemCaseMixin, CommonCase): def setUpClass(cls): super().setUpClass() cls._setup_products() + cls.cart = cls.env.ref("shopinvader.sale_order_2") cls.partner = cls.env.ref("shopinvader.partner_1") cls.custom_pricelist = cls.env["product.pricelist"].create( {"name": "Test Pricelist"} @@ -26,11 +27,15 @@ def setUp(self): self.service = work.component(usage="cart") def test_default_pricelist(self): + self.cart.unlink() self.assertFalse(self.backend.cart_pricelist_partner_field_id) cart = self.service._get() - self.assertEqual(cart.pricelist_id, self.partner.property_product_pricelist) + + self.assertEqual(cart.pricelist_id, self.backend.pricelist_id) def test_custom_pricelist(self): + self.cart.unlink() self.backend.cart_pricelist_partner_field_id = self.pricelist_field cart = self.service._get() + self.assertEqual(cart.pricelist_id, self.custom_pricelist)