Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[16.0][IMP] shopinvader signin jwt anonymous cart #1523

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions shopinvader_anonymous_partner/models/res_partner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import secrets
import typing
from datetime import datetime

from odoo import _, api, fields, models

Expand All @@ -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"],
Expand Down Expand Up @@ -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 (
Expand Down
24 changes: 23 additions & 1 deletion shopinvader_anonymous_partner/tests/test_anonymous_partner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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))
4 changes: 2 additions & 2 deletions shopinvader_api_signin_jwt/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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**

Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion shopinvader_api_signin_jwt/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions shopinvader_api_signin_jwt/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion shopinvader_api_signin_jwt/readme/ROADMAP.rst
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 13 additions & 2 deletions shopinvader_api_signin_jwt/routers/signin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@qgroulard who is supposed to call _create_anonymous_partner__cookie?
Shouldn't it happen the 1st time you call a cart endpoint w/o authenticated customer?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@simahawk this is done by the auth_jwt_authenticated_or_anonymous_partner_autocreate FastAPI depend. There is an example in #1364 where the cart routes are mounted with that authentication method.

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")
Expand Down
2 changes: 1 addition & 1 deletion shopinvader_api_signin_jwt/security/acl_res_partner.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<field name="perm_read" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="0" />
<field name="perm_unlink" eval="1" />
</record>

</odoo>
6 changes: 3 additions & 3 deletions shopinvader_api_signin_jwt/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,12 @@ <h1 class="title">Shopinvader Api Signin JWT</h1>
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:75ace7db3bf1d327ad236aa12212ec10ad5363f81a8d3fd9896afe3f4d195fc4
!! source digest: sha256:335295feb8193e9691d56e3537300ec06022d9a6abe43a72a0639e1ce6e3fc68
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/shopinvader/odoo-shopinvader/tree/16.0/shopinvader_api_signin_jwt"><img alt="shopinvader/odoo-shopinvader" src="https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github" /></a></p>
<p>This addon adds a web API to signin into the application and create a partner
if the email in the jwt payload is unknown.</p>
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.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
Expand All @@ -388,7 +389,6 @@ <h1 class="title">Shopinvader Api Signin JWT</h1>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#toc-entry-1">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Manage anonymous cart (see <a class="reference external" href="https://github.com/shopinvader/odoo-shopinvader/issues/1428">https://github.com/shopinvader/odoo-shopinvader/issues/1428</a>)</li>
<li>Use <tt class="docutils literal">fastapi_auth_jwt.auth_jwt_authenticated_odoo_env</tt> dependency for the env (see <a class="reference external" href="https://github.com/OCA/rest-framework/issues/406">https://github.com/OCA/rest-framework/issues/406</a>)</li>
</ul>
</div>
Expand Down
33 changes: 33 additions & 0 deletions shopinvader_api_signin_jwt/tests/test_signin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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", "=", "[email protected]")])
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()
Expand Down
36 changes: 36 additions & 0 deletions shopinvader_sale_cart/models/sale_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions shopinvader_sale_cart/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_sale_cart
37 changes: 37 additions & 0 deletions shopinvader_sale_cart/tests/test_sale_cart.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading