diff --git a/shopinvader_anonymous_partner/models/res_partner.py b/shopinvader_anonymous_partner/models/res_partner.py index 4400851144..4bebb32e25 100644 --- a/shopinvader_anonymous_partner/models/res_partner.py +++ b/shopinvader_anonymous_partner/models/res_partner.py @@ -3,6 +3,7 @@ import secrets import typing +from datetime import datetime from odoo import _, api, fields, models @@ -16,6 +17,7 @@ def set_cookie( key: str, value: str, max_age: int, + expires: datetime | str | int, secure: bool, httponly: bool, samesite: typing.Literal["lax", "strict", "none"], @@ -72,6 +74,18 @@ def _create_anonymous_partner__cookie(self, response: Response): ) return partner + @api.model + def _delete_anonymous_partner__cookie(self, cookies: Cookies, response: Response): + """ + Delete anonymous partner and cookie + """ + self._get_anonymous_partner__cookie(cookies).unlink() + response.set_cookie( + key=COOKIE_NAME, + max_age=0, + expires=0, + ) + @api.model def _get_anonymous_partner__token(self, token: str): return ( diff --git a/shopinvader_anonymous_partner/tests/test_anonymous_partner.py b/shopinvader_anonymous_partner/tests/test_anonymous_partner.py index b59b99f669..ddec37f24a 100644 --- a/shopinvader_anonymous_partner/tests/test_anonymous_partner.py +++ b/shopinvader_anonymous_partner/tests/test_anonymous_partner.py @@ -34,6 +34,14 @@ def anonymous_partner_get(self): return str(partner.id) return "" + @route("/test/anonymous_partner_delete", type="http", auth="none") + def anonymous_partner_delete(self): + request.env["res.partner"].with_user( + SUPERUSER_ID + )._delete_anonymous_partner__cookie( + request.httprequest.cookies, request.future_response + ) + class TestShopinvaderAnonymousPartner(TransactionCase): def test_create(self): @@ -69,9 +77,19 @@ def test_get(self): ) self.assertEqual(len(partner2), 0) + def test_delete(self): + partner = self.env["res.partner"]._create_anonymous_partner__cookie( + mock.MagicMock() + ) + self.assertTrue(partner.exists()) + self.env["res.partner"]._delete_anonymous_partner__cookie( + cookies={COOKIE_NAME: partner.anonymous_token}, response=mock.MagicMock() + ) + self.assertFalse(partner.exists()) + class TestShopinvaderAnonymousPartnerEndToEnd(HttpCase): - def test_create_and_get(self): + def test_create_and_get_and_delete(self): resp = self.url_open("/test/anonymous_partner_create") resp.raise_for_status() token = resp.cookies.get(COOKIE_NAME) @@ -89,3 +107,7 @@ def test_create_and_get(self): ) resp.raise_for_status() self.assertEqual(int(resp.text), partner_id) + # delete cookie + resp = self.url_open("/test/anonymous_partner_delete") + resp.raise_for_status() + self.assertFalse(resp.cookies.get(COOKIE_NAME)) diff --git a/shopinvader_api_signin_jwt/README.rst b/shopinvader_api_signin_jwt/README.rst index 3bf6f61b41..0127f41706 100644 --- a/shopinvader_api_signin_jwt/README.rst +++ b/shopinvader_api_signin_jwt/README.rst @@ -7,7 +7,7 @@ Shopinvader Api Signin JWT !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:75ace7db3bf1d327ad236aa12212ec10ad5363f81a8d3fd9896afe3f4d195fc4 + !! source digest: sha256:335295feb8193e9691d56e3537300ec06022d9a6abe43a72a0639e1ce6e3fc68 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -24,6 +24,7 @@ Shopinvader Api Signin JWT This addon adds a web API to signin into the application and create a partner if the email in the jwt payload is unknown. +If we had an anonymous partner, transfer his cart to the loggedin partner and delete it. **Table of contents** @@ -33,7 +34,6 @@ if the email in the jwt payload is unknown. Known issues / Roadmap ====================== -* Manage anonymous cart (see https://github.com/shopinvader/odoo-shopinvader/issues/1428) * Use ``fastapi_auth_jwt.auth_jwt_authenticated_odoo_env`` dependency for the env (see https://github.com/OCA/rest-framework/issues/406) Bug Tracker diff --git a/shopinvader_api_signin_jwt/__manifest__.py b/shopinvader_api_signin_jwt/__manifest__.py index c84506a719..a51247e489 100644 --- a/shopinvader_api_signin_jwt/__manifest__.py +++ b/shopinvader_api_signin_jwt/__manifest__.py @@ -8,7 +8,11 @@ "license": "AGPL-3", "author": "ACSONE SA/NV", "website": "https://github.com/shopinvader/odoo-shopinvader", - "depends": ["fastapi_auth_jwt"], + "depends": [ + "fastapi_auth_jwt", + "shopinvader_anonymous_partner", + "shopinvader_sale_cart", + ], "data": [ "security/res_groups.xml", "security/acl_res_partner.xml", diff --git a/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst b/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst index 9d807c0111..3c60d139e1 100644 --- a/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst +++ b/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst @@ -1,2 +1,3 @@ This addon adds a web API to signin into the application and create a partner if the email in the jwt payload is unknown. +If we had an anonymous partner, transfer his cart to the loggedin partner and delete it. diff --git a/shopinvader_api_signin_jwt/readme/ROADMAP.rst b/shopinvader_api_signin_jwt/readme/ROADMAP.rst index 945ce1163b..0bb1134142 100644 --- a/shopinvader_api_signin_jwt/readme/ROADMAP.rst +++ b/shopinvader_api_signin_jwt/readme/ROADMAP.rst @@ -1,2 +1 @@ -* Manage anonymous cart (see https://github.com/shopinvader/odoo-shopinvader/issues/1428) * Use ``fastapi_auth_jwt.auth_jwt_authenticated_odoo_env`` dependency for the env (see https://github.com/OCA/rest-framework/issues/406) diff --git a/shopinvader_api_signin_jwt/routers/signin.py b/shopinvader_api_signin_jwt/routers/signin.py index 0b9b688b40..723c8040a6 100644 --- a/shopinvader_api_signin_jwt/routers/signin.py +++ b/shopinvader_api_signin_jwt/routers/signin.py @@ -4,7 +4,7 @@ import logging from typing import Annotated, Union -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, Request, Response, status from odoo import api, models @@ -28,17 +28,28 @@ def signin( partner: Annotated[Partner, Depends(auth_jwt_optionally_authenticated_partner)], payload: Annotated[Payload, Depends(auth_jwt_authenticated_payload)], response: Response, + request: Request, ) -> None: """ Authenticate the partner based on a JWT token or a session cookie. Set the session cookie if allowed. Return HTTP code 201 if res.partner created (case of the first signin). + Transfer anonymous cart and delete anonymous partner if any. """ if not partner: - env[ + partner = env[ "shopinvader_api_signin_jwt.signin_router.helper" ]._create_partner_from_payload(payload) response.status_code = status.HTTP_201_CREATED + anonymous_partner = env["res.partner"]._get_anonymous_partner__cookie( + request.cookies + ) + if anonymous_partner: + anonymous_cart = env["sale.order"].sudo()._find_open_cart(anonymous_partner.id) + if anonymous_cart: + anonymous_cart._transfer_cart(partner.id) + anonymous_cart.unlink() + env["res.partner"]._delete_anonymous_partner__cookie(request.cookies, response) @signin_router.post("/signout") diff --git a/shopinvader_api_signin_jwt/security/acl_res_partner.xml b/shopinvader_api_signin_jwt/security/acl_res_partner.xml index 64f0f24fab..a41b91a5f8 100644 --- a/shopinvader_api_signin_jwt/security/acl_res_partner.xml +++ b/shopinvader_api_signin_jwt/security/acl_res_partner.xml @@ -9,7 +9,7 @@ - + diff --git a/shopinvader_api_signin_jwt/static/description/index.html b/shopinvader_api_signin_jwt/static/description/index.html index a5eb6e9e13..a478ab6d6d 100644 --- a/shopinvader_api_signin_jwt/static/description/index.html +++ b/shopinvader_api_signin_jwt/static/description/index.html @@ -367,11 +367,12 @@

Shopinvader Api Signin JWT

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:75ace7db3bf1d327ad236aa12212ec10ad5363f81a8d3fd9896afe3f4d195fc4 +!! source digest: sha256:335295feb8193e9691d56e3537300ec06022d9a6abe43a72a0639e1ce6e3fc68 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 shopinvader/odoo-shopinvader

This addon adds a web API to signin into the application and create a partner -if the email in the jwt payload is unknown.

+if the email in the jwt payload is unknown. +If we had an anonymous partner, transfer his cart to the loggedin partner and delete it.

Table of contents

    @@ -388,7 +389,6 @@

    Shopinvader Api Signin JWT

    Known issues / Roadmap

    diff --git a/shopinvader_api_signin_jwt/tests/test_signin.py b/shopinvader_api_signin_jwt/tests/test_signin.py index 3243e0283c..39ffa4ebbe 100644 --- a/shopinvader_api_signin_jwt/tests/test_signin.py +++ b/shopinvader_api_signin_jwt/tests/test_signin.py @@ -7,6 +7,7 @@ from odoo.addons.fastapi.tests.common import FastAPITransactionCase from odoo.addons.fastapi_auth_jwt.dependencies import auth_jwt_default_validator_name +from odoo.addons.shopinvader_anonymous_partner.models.res_partner import COOKIE_NAME from ..routers import signin_router @@ -79,6 +80,38 @@ def test_signin(self): res = client.post("/signin", headers={"Authorization": token}) self.assertEqual(res.status_code, 200) + def test_signin_anonymous_cart(self): + anonymous_partner = self.env["res.partner"].create( + {"name": "Test anonymous", "anonymous_token": "1234", "active": False} + ) + product = self.env["product.product"].create( + {"name": "product", "uom_id": self.env.ref("uom.product_uom_unit").id} + ) + anonymous_cart = self.env["sale.order"].create( + { + "partner_id": anonymous_partner.id, + "order_line": [ + (0, 0, {"product_id": product.id, "product_uom_qty": 1}), + ], + "typology": "cart", + } + ) + + token = self._get_token() + with self._create_test_client() as client: + res = client.post( + "/signin", + headers={"Authorization": token}, + cookies={COOKIE_NAME: "1234"}, + ) + self.assertFalse(res.cookies.get(COOKIE_NAME)) + self.assertFalse(anonymous_partner.exists()) + self.assertFalse(anonymous_cart.exists()) + partner = self.env["res.partner"].search([("email", "=", "test@mail.com")]) + cart = self.env["sale.order"].search([("partner_id", "=", partner.id)]) + self.assertEqual(len(cart.order_line), 1) + self.assertEqual(cart.order_line[0].product_id, product) + def test_signout(self): self.validator.write({"cookie_enabled": True, "cookie_name": "test_cookie"}) token = self._get_token() diff --git a/shopinvader_sale_cart/models/sale_order.py b/shopinvader_sale_cart/models/sale_order.py index 327d96596d..69208b4067 100644 --- a/shopinvader_sale_cart/models/sale_order.py +++ b/shopinvader_sale_cart/models/sale_order.py @@ -83,3 +83,39 @@ def _get_cart_line(self, product_id): return self.order_line.filtered( lambda l, product_id=product_id: l.product_id.id == product_id )[:1] + + def _update_cart_lines_from_cart(self, cart): + self.ensure_one() + update_cmds = [] + for cart_line in cart.order_line: + line = self._get_cart_line(cart_line.product_id.id) + if line: + new_qty = line.product_uom_qty + cart_line.product_uom_qty + vals = {"product_uom_qty": new_qty} + vals.update(line._play_onchanges_cart_line(vals)) + cmd = (1, line.id, vals) + else: + vals = { + "order_id": self.id, + "product_id": cart_line.product_id.id, + "product_uom_qty": cart_line.product_uom_qty, + } + vals.update(self.env["sale.order.line"]._play_onchanges_cart_line(vals)) + cmd = (0, None, vals) + update_cmds.append(cmd) + self.write({"order_line": update_cmds}) + + def _transfer_cart(self, partner_id): + """ + Copy the lines of this cart to the current cart of a given partner. + Merge lines of identical products. + If the partner does not have a current cart yet, create one. + This cart is not deleted. + It is the responsibility of the caller to remove it if desired. + """ + self.ensure_one() + cart = self._find_open_cart(partner_id) + if not cart: + cart = self._create_empty_cart(partner_id) + cart._update_cart_lines_from_cart(self) + return cart diff --git a/shopinvader_sale_cart/tests/__init__.py b/shopinvader_sale_cart/tests/__init__.py new file mode 100644 index 0000000000..7388d2baaa --- /dev/null +++ b/shopinvader_sale_cart/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_cart diff --git a/shopinvader_sale_cart/tests/test_sale_cart.py b/shopinvader_sale_cart/tests/test_sale_cart.py new file mode 100644 index 0000000000..a7b2aec1f8 --- /dev/null +++ b/shopinvader_sale_cart/tests/test_sale_cart.py @@ -0,0 +1,37 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.sale_cart.tests.test_sale_cart import TestSaleCart as TestSaleCartBase + + +class TestSaleCart(TestSaleCartBase): + def test_transfer_cart(self): + product_2 = self.env["product.product"].create( + {"name": "product", "uom_id": self.env.ref("uom.product_uom_unit").id} + ) + partner_2 = self.env["res.partner"].create({"name": "partner 2"}) + so_cart_2 = self.env["sale.order"].create( + { + "partner_id": partner_2.id, + "order_line": [ + (0, 0, {"product_id": self.product.id, "product_uom_qty": 1}), + (0, 0, {"product_id": product_2.id, "product_uom_qty": 1}), + ], + "typology": "cart", + } + ) + merged_cart = so_cart_2._transfer_cart(self.partner.id) + self.assertEqual(merged_cart.id, self.so_cart.id) + self.assertEqual(len(merged_cart.order_line), 2) + self.assertEqual( + merged_cart.order_line.filtered( + lambda l, product=self.product: l.product_id.id == product.id + ).product_uom_qty, + 2, + ) + self.assertEqual( + merged_cart.order_line.filtered( + lambda l, product=product_2: l.product_id.id == product.id + ).product_uom_qty, + 1, + )