diff --git a/setup/shopinvader_api_sale_loyalty/odoo/addons/shopinvader_api_sale_loyalty b/setup/shopinvader_api_sale_loyalty/odoo/addons/shopinvader_api_sale_loyalty new file mode 120000 index 0000000000..b0e73dfabc --- /dev/null +++ b/setup/shopinvader_api_sale_loyalty/odoo/addons/shopinvader_api_sale_loyalty @@ -0,0 +1 @@ +../../../../shopinvader_api_sale_loyalty \ No newline at end of file diff --git a/setup/shopinvader_api_sale_loyalty/setup.py b/setup/shopinvader_api_sale_loyalty/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_api_sale_loyalty/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_api_sale_loyalty/README.rst b/shopinvader_api_sale_loyalty/README.rst new file mode 100644 index 0000000000..f126451be5 --- /dev/null +++ b/shopinvader_api_sale_loyalty/README.rst @@ -0,0 +1,88 @@ +============================ +Shopinvader API Sale Loyalty +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:181f22ca86c6b563f98c38b69eb73b031e2d5464d9cba7c3ebd919f889c80c73 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/shopinvader_api_sale_loyalty + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module extends the functionality of Shopinvader to support Coupons +and Promotions from `sale_loyalty` core module. +It is the new version providing FastAPI services. + +Not to be confused with `shopinvader_promotion_rule`, that implements +promotion programs from OCA module `sale_promotion_rule`, an alternative +to the core `sale_loyalty` module. + +Available services: + +* ``/loyalty/{code}`` under the ``loyalty`` router, to get all rewards claimable with a given coupon code +* ``/coupon`` under the ``cart`` router, to apply a given coupon to the cart. Allows to specify which reward and/or which free product to offer. +* ``/reward`` under the ``cart`` router, to apply a given reward (automatic promotion). Note that automatic promotions are applied automatically at cart update, when possible (if no choice must be done). This service allows to apply an automatic promotion for which the reward/free product choice is mandatory. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Some routes are added to the `cart_router`. +Please note that for this router, the `/cart` prefix is not automatically +added, to allow to mount it as a nested app. +But you must ensure that this prefix is added when using this router. + +See the README of `shopinvader_api_cart` for more details on how to do it. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* `Camptocamp `_ + + * Iván Todorovich + +* `Acsone `_ + + * Marie Lejeune + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_api_sale_loyalty/__init__.py b/shopinvader_api_sale_loyalty/__init__.py new file mode 100644 index 0000000000..62a5d54f85 --- /dev/null +++ b/shopinvader_api_sale_loyalty/__init__.py @@ -0,0 +1 @@ +from . import routers diff --git a/shopinvader_api_sale_loyalty/__manifest__.py b/shopinvader_api_sale_loyalty/__manifest__.py new file mode 100644 index 0000000000..ab8828e7c7 --- /dev/null +++ b/shopinvader_api_sale_loyalty/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader API Sale Loyalty", + "summary": """ + FastAPI services to add coupons and loyalties to carts.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "sale_loyalty", + "sale_loyalty_order_info", + "shopinvader_api_cart", + "shopinvader_api_security_sale", + "shopinvader_schema_sale", + "pydantic", + "extendable", + "fastapi", + "extendable_fastapi", + ], + "data": [ + "security/groups.xml", + "security/acl_loyalty_card.xml", + "security/acl_loyalty_program.xml", + "security/acl_loyalty_reward.xml", + "security/acl_loyalty_rule.xml", + "security/acl_product_product.xml", + "security/acl_product_tag.xml", + "security/acl_sale_order_coupon_points.xml", + "security/acl_sale_loyalty_reward_wizard.xml", + ], + "external_dependencies": { + "python": [ + "fastapi", + "pydantic>=2.0.0", + "extendable-pydantic>=1.2.0", + ] + }, +} diff --git a/shopinvader_api_sale_loyalty/readme/CONTRIBUTORS.rst b/shopinvader_api_sale_loyalty/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..08b03bf177 --- /dev/null +++ b/shopinvader_api_sale_loyalty/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* `Camptocamp `_ + + * Iván Todorovich + +* `Acsone `_ + + * Marie Lejeune diff --git a/shopinvader_api_sale_loyalty/readme/DESCRIPTION.rst b/shopinvader_api_sale_loyalty/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..36f38c2325 --- /dev/null +++ b/shopinvader_api_sale_loyalty/readme/DESCRIPTION.rst @@ -0,0 +1,13 @@ +This module extends the functionality of Shopinvader to support Coupons +and Promotions from `sale_loyalty` core module. +It is the new version providing FastAPI services. + +Not to be confused with `shopinvader_promotion_rule`, that implements +promotion programs from OCA module `sale_promotion_rule`, an alternative +to the core `sale_loyalty` module. + +Available services: + +* ``/loyalty/{code}`` under the ``loyalty`` router, to get all rewards claimable with a given coupon code +* ``/coupon`` under the ``cart`` router, to apply a given coupon to the cart. Allows to specify which reward and/or which free product to offer. +* ``/reward`` under the ``cart`` router, to apply a given reward (automatic promotion). Note that automatic promotions are applied automatically at cart update, when possible (if no choice must be done). This service allows to apply an automatic promotion for which the reward/free product choice is mandatory. diff --git a/shopinvader_api_sale_loyalty/readme/USAGE.rst b/shopinvader_api_sale_loyalty/readme/USAGE.rst new file mode 100644 index 0000000000..b6ac721d10 --- /dev/null +++ b/shopinvader_api_sale_loyalty/readme/USAGE.rst @@ -0,0 +1,6 @@ +Some routes are added to the `cart_router`. +Please note that for this router, the `/cart` prefix is not automatically +added, to allow to mount it as a nested app. +But you must ensure that this prefix is added when using this router. + +See the README of `shopinvader_api_cart` for more details on how to do it. diff --git a/shopinvader_api_sale_loyalty/routers/__init__.py b/shopinvader_api_sale_loyalty/routers/__init__.py new file mode 100644 index 0000000000..ccaac479e5 --- /dev/null +++ b/shopinvader_api_sale_loyalty/routers/__init__.py @@ -0,0 +1,3 @@ +from .loyalty import loyalty_router +from . import loyalty +from . import cart diff --git a/shopinvader_api_sale_loyalty/routers/cart.py b/shopinvader_api_sale_loyalty/routers/cart.py new file mode 100644 index 0000000000..599960af33 --- /dev/null +++ b/shopinvader_api_sale_loyalty/routers/cart.py @@ -0,0 +1,205 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from odoo import _, api, models +from odoo.exceptions import UserError + +from odoo.addons.base.models.res_partner import Partner as ResPartner +from odoo.addons.fastapi.dependencies import ( + authenticated_partner, + authenticated_partner_env, +) +from odoo.addons.sale.models.sale_order import SaleOrder +from odoo.addons.shopinvader_api_cart.routers import cart_router +from odoo.addons.shopinvader_api_cart.schemas import CartTransaction + +from ..schemas import LoyaltyCardInput, LoyaltyRewardInput, Sale + + +@cart_router.post("/apply_coupon/{uuid}", deprecated=True) +@cart_router.post("/apply_coupon", deprecated=True) +@cart_router.post("/{uuid}/coupon") +@cart_router.post("/current/coupon") +@cart_router.post("/coupon") +def apply_coupon( + data: LoyaltyCardInput, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_partner)], + uuid: UUID | None = None, +) -> Sale | None: + """ + Apply a coupon on a specific cart. + + One can specify in LoyaltyCartInput which reward to choose, and + which free product to choose. + If some info is missing to uniquely determine which reward to apply, + raise an error. + """ + cart = env["sale.order"]._find_open_cart(partner.id, str(uuid) if uuid else None) + if cart: + env["shopinvader_api_cart.cart_router.helper"]._apply_coupon(cart, data) + return Sale.from_sale_order(cart) if cart else None + + +@cart_router.post("/apply_reward/{uuid}", deprecated=True) +@cart_router.post("/apply_reward", deprecated=True) +@cart_router.post("/{uuid}/reward") +@cart_router.post("/current/reward") +@cart_router.post("/reward") +def apply_reward( + data: LoyaltyRewardInput, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_partner)], + uuid: UUID | None = None, +) -> Sale | None: + """ + Apply claimable rewards on a specific cart. + + One can specify in LoyaltyReardInput which free product to choose. + If this piece of info is needed and missing, raise an error. + """ + cart = env["sale.order"]._find_open_cart(partner.id, str(uuid) if uuid else None) + if cart: + env["shopinvader_api_cart.cart_router.helper"]._apply_reward(cart, data) + return Sale.from_sale_order(cart) if cart else None + + +class ShopinvaderApiCartRouterHelper(models.AbstractModel): + _inherit = "shopinvader_api_cart.cart_router.helper" + + # Apply coupon + @api.model + def _check_code_and_reward(self, cart, code, reward_id=None): + """ + * Check the code is valid. + * Check the provided reward is allowed. + If no reward is given, return the unique allowed reward, + or raise an error. + + :return: the browsed reward + """ + status = cart._try_apply_code(code) + if "error" in status: + raise UserError(status["error"]) + all_rewards = self.env["loyalty.reward"] + for rewards in status.values(): + all_rewards |= rewards + + if not all_rewards: + raise UserError(_("No reward available for this code.")) + if reward_id and reward_id not in all_rewards.ids: + raise UserError(_("Reward not allowed for this code.")) + if not reward_id and len(all_rewards) > 1: + raise UserError(_("Several rewards available. Please specify one.")) + + if reward_id: + return self.env["loyalty.reward"].browse(reward_id) + return all_rewards[0] # In this case only 1 reward available + + @api.model + def _check_free_reward_product(self, reward, product_id=None): + """ + Check the free product is allowed (if given) or check + that it is unique (if not given). + + :return: the free product id + """ + if not reward.reward_type == "product": + return + + reward_products = reward.reward_product_ids + + if not reward_products: + raise UserError(_("No free products available.")) + if product_id and product_id not in reward_products.ids: + raise UserError(_("Free product not allowed for this reward.")) + if not product_id and len(reward_products) > 1: + raise UserError(_("Several free products available. Please specify one.")) + + if product_id: + return product_id + return reward_products[0].id + + @api.model + def _apply_coupon(self, cart: "SaleOrder", data: LoyaltyCardInput): + """Apply a coupon or promotion code. + + It can raise UserError if coupon is not applicable, or + if the coupon let the choice between several rewards, and the + selected reward is not specified. + """ + reward = self._check_code_and_reward(cart, data.code, data.reward_id) + product_id = None + if reward.reward_type == "product": + product_id = self._check_free_reward_product(reward, data.free_product_id) + + self.env["sale.loyalty.reward.wizard"].sudo().create( + { + "order_id": cart.id, + "selected_reward_id": reward.id if reward else None, + "selected_product_id": product_id if product_id else None, + } + ).action_apply() + return cart + + # Apply claimable reward + def _apply_reward(self, cart: "SaleOrder", data: LoyaltyRewardInput): + """ + Try to apply the given reward. + + Raise an error if there is a choice between free products + and the chosen product is not specified. + """ + cart._update_programs_and_rewards() + reward = self.env["loyalty.reward"].browse(data.reward_id) + product_id = self._check_free_reward_product(reward, data.free_product_id) + return ( + self.env["sale.loyalty.reward.wizard"] + .sudo() + .create( + { + "order_id": cart.id, + "selected_reward_id": data.reward_id, + "selected_product_id": product_id if product_id else None, + } + ) + .action_apply() + ) + + # Apply automatic rewards at cart updates + def _apply_automatic_rewards(self, cart: SaleOrder): + claimable_rewards = cart._get_claimable_rewards() + + for coupon, rewards in claimable_rewards.items(): + if ( + len(rewards) > 1 + or len(coupon.program_id.reward_ids) != 1 + or (rewards.reward_type == "product" and rewards.multi_product) + or rewards.id in cart.order_line.mapped("reward_id").ids + ): + continue + try: + cart._apply_program_reward(rewards, coupon) + except UserError: # pylint: disable=except-pass + pass + + @api.model + def _sync_cart( + self, + partner: ResPartner, + cart: SaleOrder, + uuid: str, + transactions: list[CartTransaction], + ): + cart = super()._sync_cart(partner, cart, uuid, transactions) + # Try to auto apply rewards. + # Only rewards that are the only reward of the program and not + # with a multi product reward + if cart: + cart._update_programs_and_rewards() + self._apply_automatic_rewards(cart) + return cart diff --git a/shopinvader_api_sale_loyalty/routers/loyalty.py b/shopinvader_api_sale_loyalty/routers/loyalty.py new file mode 100644 index 0000000000..4fda1da3be --- /dev/null +++ b/shopinvader_api_sale_loyalty/routers/loyalty.py @@ -0,0 +1,46 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from typing import Annotated + +from fastapi import APIRouter, Depends + +from odoo import api, models + +from odoo.addons.fastapi.dependencies import authenticated_partner_env + +from ..schemas import LoyaltyRewardResponse + +loyalty_router = APIRouter(tags=["loyalties"]) + + +@loyalty_router.get("/rewards/{code}", deprecated=True) +@loyalty_router.get("/loyalty/{code}") +def get_rewards( + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + code: str, +) -> list[LoyaltyRewardResponse]: + """ + Return all claimable loyalty rewards for a given coupon code. + """ + rewards = env["shopinvader_api_loyalty.loyalty_router.helper"]._get_rewards(code) + return [LoyaltyRewardResponse.from_loyalty_reward(reward) for reward in rewards] + + +class ShopinvaderApiLoyaltyRouterHelper(models.AbstractModel): + _name = "shopinvader_api_loyalty.loyalty_router.helper" + _description = "ShopInvader API Loyalty Router Helper" + + # Get rewards + @api.model + def _get_rewards(self, code: str): + card = self.env["loyalty.card"].search([("code", "=", code)], limit=1) + program_id = self.env["loyalty.program"] + if card: + program_id = card.program_id + else: + rule_id = self.env["loyalty.rule"].search([("code", "=", code)], limit=1) + if rule_id: + program_id = rule_id.program_id + if program_id: + return program_id.reward_ids + return self.env["loyalty.reward"] diff --git a/shopinvader_api_sale_loyalty/schemas/__init__.py b/shopinvader_api_sale_loyalty/schemas/__init__.py new file mode 100644 index 0000000000..818169af49 --- /dev/null +++ b/shopinvader_api_sale_loyalty/schemas/__init__.py @@ -0,0 +1,5 @@ +from .loyalty_program import LoyaltyProgram +from .loyalty_card import LoyaltyCard, LoyaltyCardInput +from .loyalty_reward import LoyaltyRewardResponse, LoyaltyRewardInput +from .sale_line import SaleLine +from .sale import Sale diff --git a/shopinvader_api_sale_loyalty/schemas/loyalty_card.py b/shopinvader_api_sale_loyalty/schemas/loyalty_card.py new file mode 100644 index 0000000000..cad3a0cdad --- /dev/null +++ b/shopinvader_api_sale_loyalty/schemas/loyalty_card.py @@ -0,0 +1,25 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel + +from . import LoyaltyProgram + + +class LoyaltyCard(StrictExtendableBaseModel): + id: int + program: LoyaltyProgram + + @classmethod + def from_loyalty_card(cls, odoo_rec): + return cls.model_construct( + id=odoo_rec.id, + program=LoyaltyProgram.from_loyalty_program(odoo_rec.program_id), + ) + + +class LoyaltyCardInput(StrictExtendableBaseModel): + code: str + reward_id: int | None = None + free_product_id: int | None = None diff --git a/shopinvader_api_sale_loyalty/schemas/loyalty_program.py b/shopinvader_api_sale_loyalty/schemas/loyalty_program.py new file mode 100644 index 0000000000..81d9359974 --- /dev/null +++ b/shopinvader_api_sale_loyalty/schemas/loyalty_program.py @@ -0,0 +1,17 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel + + +class LoyaltyProgram(StrictExtendableBaseModel): + id: int + name: str + + @classmethod + def from_loyalty_program(cls, odoo_rec): + return cls.model_construct( + id=odoo_rec.id, + name=odoo_rec.name, + ) diff --git a/shopinvader_api_sale_loyalty/schemas/loyalty_reward.py b/shopinvader_api_sale_loyalty/schemas/loyalty_reward.py new file mode 100644 index 0000000000..85cfbea2bc --- /dev/null +++ b/shopinvader_api_sale_loyalty/schemas/loyalty_reward.py @@ -0,0 +1,35 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + +from . import LoyaltyProgram + + +class LoyaltyRewardResponse(StrictExtendableBaseModel): + id: int + program: LoyaltyProgram + reward_type: str + discount: float | None = None + discount_mode: str + discount_applicability: str | None = None + all_discount_product_ids: list[int] = [] + reward_product_ids: list[int] = [] + + @classmethod + def from_loyalty_reward(cls, odoo_rec): + return cls.model_construct( + id=odoo_rec.id, + program=LoyaltyProgram.from_loyalty_program(odoo_rec.program_id), + reward_type=odoo_rec.reward_type, + discount=odoo_rec.discount or None, + discount_mode=odoo_rec.discount_mode, + discount_applicability=odoo_rec.discount_applicability or None, + all_discount_product_ids=odoo_rec.all_discount_product_ids.ids, + reward_product_ids=odoo_rec.reward_product_ids.ids, + ) + + +class LoyaltyRewardInput(StrictExtendableBaseModel): + reward_id: int + free_product_id: int | None = None diff --git a/shopinvader_api_sale_loyalty/schemas/sale.py b/shopinvader_api_sale_loyalty/schemas/sale.py new file mode 100644 index 0000000000..747b96b41c --- /dev/null +++ b/shopinvader_api_sale_loyalty/schemas/sale.py @@ -0,0 +1,45 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.shopinvader_schema_sale.schemas import Sale as BaseSale + +from ..schemas import ( + LoyaltyCard, + LoyaltyProgram, + LoyaltyRewardResponse as LoyaltyReward, +) + + +class Sale(BaseSale, extends=True): + promo_codes: list[str] = [] + reward_amount: float = 0 + reward_amount_tax_incl: float = 0 + programs: list[LoyaltyProgram] = [] + generated_coupons: list[LoyaltyCard] = [] + claimable_rewards: list[LoyaltyReward] = [] + + @classmethod + def from_sale_order(cls, odoo_rec): + obj = super().from_sale_order(odoo_rec) + obj.promo_codes = safe_eval(odoo_rec.promo_codes) + obj.reward_amount = odoo_rec.reward_amount + obj.reward_amount_tax_incl = odoo_rec.reward_amount_tax_incl + obj.programs = [ + LoyaltyProgram.from_loyalty_program(program) + for program in odoo_rec.program_ids + ] + obj.generated_coupons = [ + LoyaltyCard.from_loyalty_card(card) + for card in odoo_rec.generated_coupon_ids + ] + # Get claimable rewards + odoo_rec._update_programs_and_rewards() + claimable_rewards = odoo_rec._get_claimable_rewards() + obj.claimable_rewards = [] + for _, rewards in claimable_rewards.items(): + obj.claimable_rewards += [ + LoyaltyReward.from_loyalty_reward(reward) for reward in rewards + ] + return obj diff --git a/shopinvader_api_sale_loyalty/schemas/sale_line.py b/shopinvader_api_sale_loyalty/schemas/sale_line.py new file mode 100644 index 0000000000..d88e9c7879 --- /dev/null +++ b/shopinvader_api_sale_loyalty/schemas/sale_line.py @@ -0,0 +1,14 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.shopinvader_schema_sale.schemas import SaleLine as BaseSaleLine + + +class SaleLine(BaseSaleLine): + is_reward_line: bool + + @classmethod + def from_sale_order_line(cls, odoo_rec): + obj = super().from_sale_order_line(odoo_rec) + obj.is_reward_line = odoo_rec.is_reward_line + return obj diff --git a/shopinvader_api_sale_loyalty/security/acl_loyalty_card.xml b/shopinvader_api_sale_loyalty/security/acl_loyalty_card.xml new file mode 100644 index 0000000000..8aa91e5432 --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_loyalty_card.xml @@ -0,0 +1,19 @@ + + + + + + loyalty.card shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_loyalty_program.xml b/shopinvader_api_sale_loyalty/security/acl_loyalty_program.xml new file mode 100644 index 0000000000..fd1e63000b --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_loyalty_program.xml @@ -0,0 +1,19 @@ + + + + + + loyalty.program shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_loyalty_reward.xml b/shopinvader_api_sale_loyalty/security/acl_loyalty_reward.xml new file mode 100644 index 0000000000..aebf30b704 --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_loyalty_reward.xml @@ -0,0 +1,19 @@ + + + + + + loyalty.reward shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_loyalty_rule.xml b/shopinvader_api_sale_loyalty/security/acl_loyalty_rule.xml new file mode 100644 index 0000000000..5770fddc13 --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_loyalty_rule.xml @@ -0,0 +1,19 @@ + + + + + + loyalty.rule shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_product_product.xml b/shopinvader_api_sale_loyalty/security/acl_product_product.xml new file mode 100644 index 0000000000..1847ae6954 --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_product_product.xml @@ -0,0 +1,19 @@ + + + + + + product.product shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_product_tag.xml b/shopinvader_api_sale_loyalty/security/acl_product_tag.xml new file mode 100644 index 0000000000..9a5943e6bc --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_product_tag.xml @@ -0,0 +1,19 @@ + + + + + + product.tag shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_sale_loyalty_reward_wizard.xml b/shopinvader_api_sale_loyalty/security/acl_sale_loyalty_reward_wizard.xml new file mode 100644 index 0000000000..7529dd3967 --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_sale_loyalty_reward_wizard.xml @@ -0,0 +1,24 @@ + + + + + + sale.loyalty.reward.wizard shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/acl_sale_order_coupon_points.xml b/shopinvader_api_sale_loyalty/security/acl_sale_order_coupon_points.xml new file mode 100644 index 0000000000..6024faaa98 --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/acl_sale_order_coupon_points.xml @@ -0,0 +1,22 @@ + + + + + + sale.order.coupon.points shopinvader user read access + + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/security/groups.xml b/shopinvader_api_sale_loyalty/security/groups.xml new file mode 100644 index 0000000000..c8a36551ae --- /dev/null +++ b/shopinvader_api_sale_loyalty/security/groups.xml @@ -0,0 +1,27 @@ + + + + + Sale Loyalty user + + + + + + + + diff --git a/shopinvader_api_sale_loyalty/static/description/index.html b/shopinvader_api_sale_loyalty/static/description/index.html new file mode 100644 index 0000000000..3a7838468c --- /dev/null +++ b/shopinvader_api_sale_loyalty/static/description/index.html @@ -0,0 +1,443 @@ + + + + + + +Shopinvader API Sale Loyalty + + + +
+

Shopinvader API Sale Loyalty

+ + +

Beta License: AGPL-3 shopinvader/odoo-shopinvader

+

This module extends the functionality of Shopinvader to support Coupons +and Promotions from sale_loyalty core module. +It is the new version providing FastAPI services.

+

Not to be confused with shopinvader_promotion_rule, that implements +promotion programs from OCA module sale_promotion_rule, an alternative +to the core sale_loyalty module.

+

Available services:

+
    +
  • /loyalty/{code} under the loyalty router, to get all rewards claimable with a given coupon code
  • +
  • /coupon under the cart router, to apply a given coupon to the cart. Allows to specify which reward and/or which free product to offer.
  • +
  • /reward under the cart router, to apply a given reward (automatic promotion). Note that automatic promotions are applied automatically at cart update, when possible (if no choice must be done). This service allows to apply an automatic promotion for which the reward/free product choice is mandatory.
  • +
+

Table of contents

+ +
+

Usage

+

Some routes are added to the cart_router. +Please note that for this router, the /cart prefix is not automatically +added, to allow to mount it as a nested app. +But you must ensure that this prefix is added when using this router.

+

See the README of shopinvader_api_cart for more details on how to do it.

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shopinvader_api_sale_loyalty/tests/__init__.py b/shopinvader_api_sale_loyalty/tests/__init__.py new file mode 100644 index 0000000000..1d0416d0ac --- /dev/null +++ b/shopinvader_api_sale_loyalty/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_apply_coupon_or_reward +from . import test_get_rewards diff --git a/shopinvader_api_sale_loyalty/tests/common.py b/shopinvader_api_sale_loyalty/tests/common.py new file mode 100644 index 0000000000..618b97ad3d --- /dev/null +++ b/shopinvader_api_sale_loyalty/tests/common.py @@ -0,0 +1,230 @@ +# Copyright 2021 Camptocamp SA +# @author Iván Todorovich +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase +from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon + +from ..routers.loyalty import loyalty_router + + +class TestShopinvaderSaleLoyaltyCommon(FastAPITransactionCase, TestSaleCouponCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + partner = cls.env["res.partner"].create({"name": "FastAPI Loyalty Demo"}) + cls.user_with_rights = cls.env["res.users"].create( + { + "name": "Test User With Rights", + "login": "user_with_rights", + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref( + "shopinvader_api_sale_loyalty.shopinvader_loyalty_user_group" + ).id, + ], + ) + ], + } + ) + cls.default_fastapi_running_user = cls.user_with_rights + cls.default_fastapi_authenticated_partner = partner.with_user( + cls.user_with_rights + ) + cls.default_fastapi_router = loyalty_router + + def setUp(self): + super().setUp() + self.gift_product_tag = self.env["product.tag"].create({"name": "Gift Product"}) + + def _generate_coupons(self, program, qty=1): + existing_coupons = program.coupon_ids + # Create coupons + self.env["loyalty.generate.wizard"].with_context(active_id=program.id).create( + { + "coupon_qty": qty, + } + ).generate_coupons() + # Return only the created coupons + return program.coupon_ids - existing_coupons + + def _create_program_choice_reward_with_code(self, product): + return self.env["loyalty.program"].create( + { + "name": "With coupon: Buy 1 product, choose 10% on all or 25% on cheapest", + "program_type": "coupons", + "trigger": "with_code", + "applies_on": "current", + "company_id": self.env.company.id, + "rule_ids": [ + ( + 0, + 0, + { + "product_ids": [(4, product.id)], + "minimum_qty": 1, + }, + ) + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "discount", + "discount": 10, + "required_points": 1, + }, + ), + ( + 0, + 0, + { + "reward_type": "discount", + "discount": 25, + "discount_applicability": "cheapest", + "required_points": 1, + }, + ), + ], + } + ) + + def _create_program_choice_reward_auto(self, product): + return self.env["loyalty.program"].create( + { + "name": "Promotion: Buy 1 product, choose 10% on all or 25% on cheapest", + "program_type": "promotion", + "trigger": "auto", + "applies_on": "current", + "company_id": self.env.company.id, + "rule_ids": [ + ( + 0, + 0, + { + "product_ids": [(4, product.id)], + "minimum_qty": 1, + }, + ) + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "discount", + "discount": 10, + "required_points": 1, + }, + ), + ( + 0, + 0, + { + "reward_type": "discount", + "discount": 25, + "discount_applicability": "cheapest", + "required_points": 1, + }, + ), + ], + } + ) + + def _create_program_free_product_choice_with_code(self, rule_product, product_tag): + return self.env["loyalty.program"].create( + { + "name": "With code: Choose 1B or 1C free if 1A bought", + "program_type": "coupons", + "trigger": "with_code", + "applies_on": "current", + "company_id": self.env.company.id, + "rule_ids": [ + ( + 0, + 0, + { + "product_ids": [(4, rule_product.id)], + "minimum_qty": 1, + }, + ) + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "product", + "reward_product_tag_id": product_tag.id, + }, + ) + ], + } + ) + + def _create_program_free_product_choice_auto(self, rule_product, product_tag): + return self.env["loyalty.program"].create( + { + "name": "Promotion: Choose 1B or 1C free if 1A bought", + "program_type": "promotion", + "trigger": "auto", + "applies_on": "current", + "company_id": self.env.company.id, + "rule_ids": [ + ( + 0, + 0, + { + "product_ids": [(4, rule_product.id)], + "minimum_qty": 1, + }, + ) + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "product", + "reward_product_tag_id": product_tag.id, + }, + ) + ], + } + ) + + def _create_discount_code_program(self): + return self.env["loyalty.program"].create( + { + "name": "50% on order with code 'PROMOTION'", + "program_type": "promo_code", + "trigger": "with_code", + "applies_on": "current", + "company_id": self.env.company.id, + "rule_ids": [ + ( + 0, + 0, + { + "code": "PROMOTION", + }, + ) + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "discount", + "discount": 50, + "required_points": 1, + }, + ), + ], + } + ) diff --git a/shopinvader_api_sale_loyalty/tests/test_apply_coupon_or_reward.py b/shopinvader_api_sale_loyalty/tests/test_apply_coupon_or_reward.py new file mode 100644 index 0000000000..cc84896498 --- /dev/null +++ b/shopinvader_api_sale_loyalty/tests/test_apply_coupon_or_reward.py @@ -0,0 +1,755 @@ +# Copyright 2021 Camptocamp SA +# @author Iván Todorovich +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import uuid + +from requests import Response + +from odoo.exceptions import UserError +from odoo.tests.common import tagged + +from odoo.addons.shopinvader_api_cart.routers import cart_router + +from .common import TestShopinvaderSaleLoyaltyCommon + + +@tagged("post_install", "-at_install") +class TestLoyaltyCard(TestShopinvaderSaleLoyaltyCommon): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.user_with_rights.groups_id = [ + ( + 6, + 0, + [ + cls.env.ref( + "shopinvader_api_security_sale.shopinvader_sale_user_group" + ).id, + ], + ) + ] + # Archive immediate promotion program or it will be applied everywhere + cls.immediate_promotion_program.active = False + cls.cart = cls.env["sale.order"]._create_empty_cart( + cls.default_fastapi_authenticated_partner.id + ) + cls.dummy_uuid = str(uuid.uuid4()) + + def _create_promotion_program_A_B(self): + # Configure a new promotion program: when 1A and 1B are in the cart, + # 1B becomes free (100% discount on it) + return self.env["loyalty.program"].create( + { + "name": "Add 1A + 1 B in cart, 1B becomes free", + "program_type": "promotion", + "applies_on": "current", + "company_id": self.env.company.id, + "trigger": "auto", + "rule_ids": [ + ( + 0, + 0, + { + "product_ids": self.product_A, + "reward_point_amount": 1, + "reward_point_mode": "order", + "minimum_qty": 1, + }, + ), + ( + 0, + 0, + { + "product_ids": self.product_B, + "reward_point_amount": 1, + "reward_point_mode": "order", + "minimum_qty": 1, + }, + ), + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "discount", + "discount": 100, + "discount_applicability": "specific", + "discount_product_ids": [(4, self.product_B.id)], + "required_points": 2, + }, + ) + ], + } + ) + + def test_immediate_promotion_program(self): + """ + With immediate promotion program, as soon as 1A is added + in cart, 1B is automatically added and free. + """ + # Unarchive immediate promotion program for this specific case + self.immediate_promotion_program.active = True + # Test case 1 (1 A): Assert that reward is given + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 2, + "The promo offer should have been automatically applied", + ) + self.assertEqual( + len(res["programs"]), + 1, + "The promo offer should have been automatically applied", + ) + # # Test case 2 (- 1A): Assert that the reward is removed when the order + # # is modified and doesn't match the rules anymore + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + { + "uuid": self.dummy_uuid, + "product_id": self.product_A.id, + "qty": -1, + } + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 0, + "The promo reward should have been removed as the rules are not " + "matched anymore", + ) + self.assertEqual( + res["programs"], + [], + "The promo reward should have been removed as the rules are not " + "matched anymore", + ) + + def test_promotion_program(self): + promotion_program = self._create_promotion_program_A_B() + + # Test case 1 (+ 1 A): Assert that no reward is given, + # as the product B is missing + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 1, + "The promo offer shouldn't have been applied as the product B " + "isn't in the order", + ) + self.assertEqual( + res["programs"], + [], + "The promo offer shouldn't have been applied as the product B " + "isn't in the order", + ) + # Test case 2 (+ 1 B): Assert that the reward is given, + # as the product B is now in the order + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_B.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), 3, "The promo should've been applied" + ) + self.assertEqual( + len(res["programs"]), + 1, + "The promo should've been applied", + ) + self.assertEqual( + res["programs"][0]["id"], + promotion_program.id, + "The promo should've been applied", + ) + # Test case 3 (-1 A): Assert that the reward is removed when the order + # is modified and doesn't match the rules anymore + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + { + "uuid": self.dummy_uuid, + "product_id": self.product_A.id, + "qty": -1, + } + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 1, + "The promo reward should have been removed as the rules are not " + "matched anymore", + ) + self.assertEqual( + res["programs"], + [], + "The promo reward should have been removed as the rules are not " + "matched anymore", + ) + self.assertEqual(self.cart.order_line.product_id.id, self.product_B.id) + + def test_code_promotion_program(self): + promo_code = self.code_promotion_program_with_discount.rule_ids[0].code + # Buy 1 C + Enter code, 10% discount on C + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_C.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 1, + "The promo offer shouldn't have been applied as the code hasn't " + "been entered yet", + ) + self.assertEqual( + res["programs"], + [], + "The promo offer shouldn't have been applied as the code hasn't " + "been entered yet", + ) + # Enter an invalid code + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, r"This code is invalid \(fakecode\)\." + ): + data = {"code": "fakecode"} + test_client.post("/coupon", content=json.dumps(data)) + # Enter code + with self._create_test_client(router=cart_router) as test_client: + data = {"code": promo_code} + response = test_client.post("/coupon", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual( + len(self.cart.order_line.ids), + 2, + "The promo should've been applied", + ) + self.assertEqual( + res["programs"][0]["id"], + self.code_promotion_program_with_discount.id, + "The promo should've been applied", + ) + + def test_route_current_coupon(self): + """ + Test that the route /current/coupon is reachable + """ + # Buy 1 C + Enter code, 10% discount on C + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + { + "uuid": self.dummy_uuid, + "product_id": self.product_C.id, + "qty": 1, + } + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 1, + "The promo offer shouldn't have been applied as the code hasn't " + "been entered yet", + ) + self.assertEqual( + res["programs"], + [], + "The promo offer shouldn't have been applied as the code hasn't " + "been entered yet", + ) + # Enter an invalid code + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, r"This code is invalid \(fakecode\)\." + ): + data = {"code": "fakecode"} + test_client.post("/current/coupon", content=json.dumps(data)) + + def test_deprecated_route_apply_coupon(self): + """ + Test that the deprecated route /apply_coupon is still reachable. + :return: + """ + # Enter an invalid code + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, r"This code is invalid \(fakecode\)\." + ): + data = {"code": "fakecode"} + test_client.post("/apply_coupon", content=json.dumps(data)) + + def test_code_promotion_program_coupons(self): + coupon = self._generate_coupons(self.code_promotion_program) + # Buy 1 A + Enter code, 1 A is free + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + self.assertEqual( + len(self.cart.order_line), + 1, + "The coupon shouldn't have been applied as the code hasn't been entered yet", + ) + # Enter code + with self._create_test_client(router=cart_router) as test_client: + data = {"code": coupon.code} + response = test_client.post("/coupon", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual( + len(self.cart.order_line.ids), + 2, + "The coupon should've been applied", + ) + self.assertEqual(res["promo_codes"], [coupon.code]) + # Try to apply twice + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, "This program is already applied to this order." + ): + data = {"code": coupon.code} + test_client.post("/coupon", content=json.dumps(data)) + + def test_promotion_on_next_order(self): + program = self.env["loyalty.program"].create( + { + "name": "Free Product A if at least 2 articles", + "program_type": "next_order_coupons", + "trigger": "auto", + "applies_on": "future", + "company_id": self.env.company.id, + "rule_ids": [ + ( + 0, + 0, + { + "reward_point_amount": 1, + "reward_point_mode": "order", + "minimum_qty": 2, + }, + ) + ], + "reward_ids": [ + ( + 0, + 0, + { + "reward_type": "product", + "reward_product_id": self.product_A.id, + "reward_product_qty": 1, + "required_points": 1, + }, + ) + ], + } + ) + # Buy 2 B, 1 A coupon should be given + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_B.id, "qty": 2} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + res = response.json() + generated_coupons = res["generated_coupons"] + self.assertEqual(len(generated_coupons), 1) + self.assertEqual( + generated_coupons[0]["program"]["id"], + program.id, + "Coupons for next order should've been generated", + ) + + def test_reward_amount_discount(self): + self._create_discount_code_program() + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + test_client.post("/sync", content=json.dumps(data)) + with self._create_test_client(router=cart_router) as test_client: + data = {"code": "PROMOTION"} + response: Response = test_client.post("/coupon", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + cart = response.json() + self.assertAlmostEqual( + cart["reward_amount"], + -0.5 + * self.product_A.taxes_id.compute_all(self.product_A.lst_price)[ + "total_excluded" + ], + 2, + "Untaxed reward amount should be 50% of untaxed unit price of product A", + ) + self.assertAlmostEqual( + cart["reward_amount_tax_incl"], + -0.5 + * self.product_A.taxes_id.compute_all(self.product_A.lst_price)[ + "total_included" + ], + 2, + "Tax included reward amount should be 50% of tax included unit price of product A", + ) + + def test_reward_amount_free_product(self): + self.immediate_promotion_program.active = True + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + cart = response.json() + self.assertAlmostEqual( + cart["reward_amount"], + -self.product_B.taxes_id.compute_all(self.product_B.lst_price)[ + "total_excluded" + ], + 2, + "Untaxed reward amount should be untaxed unit price of product B", + ) + self.assertAlmostEqual( + cart["reward_amount_tax_incl"], + -self.product_B.taxes_id.compute_all(self.product_B.lst_price)[ + "total_included" + ], + 2, + "Tax included reward amount should be tax included unit price of product B", + ) + + def test_program_with_code_reward_choice(self): + """ + Create a new program with code that gives you the choice: if A is bought, + either you get 10% on all or 25% on the cheapest article. + + -> when adding coupon code, if reward is not specified, an error is + raised. + """ + program = self._create_program_choice_reward_with_code(self.product_A) + coupon = self._generate_coupons(program) + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, "Several rewards available. Please specify one." + ): + data = {"code": coupon.code} + test_client.post("/coupon", content=json.dumps(data)) + allowed_rewards = program.reward_ids + wrong_reward = self.env["loyalty.reward"].search( + [("id", "not in", allowed_rewards.ids)], limit=1 + ) + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, "Reward not allowed for this code." + ): + data = {"code": coupon.code, "reward_id": wrong_reward.id} + test_client.post("/coupon", content=json.dumps(data)) + + with self._create_test_client(router=cart_router) as test_client: + data = {"code": coupon.code, "reward_id": program.reward_ids[0].id} + response = test_client.post("/coupon", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + self.assertEqual( + len(self.cart.order_line), 2, "The reward should've been applied" + ) + self.assertEqual( + self.cart._get_reward_lines().product_id.name, "10% on your order" + ) + + def test_program_auto_reward_choice(self): + """ + Create a new program (automatic) that gives you the choice: if A is bought, + either you get 10% on all or 25% on the cheapest article. + + -> when updating the cart, the reward is not automatically applied + as there are several rewards linked to the program. + But the rewards are in the Sale Pydantic model, under claimable_rewards + + Then calling /reward applies the reward and removes the rewards + from the Sale Pydantic model + """ + program = self._create_program_choice_reward_auto(self.product_A) + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + self.assertEqual( + len(self.cart.order_line), + 1, + "The promotion shouldn't be applied as there is a reward choice", + ) + res = response.json() + claimable_rewards = res["claimable_rewards"] + self.assertEqual( + len(claimable_rewards), 2, "The two possible rewards should be claimable." + ) + self.assertEqual( + {claimable_rewards[0]["id"], claimable_rewards[1]["id"]}, + set(program.reward_ids.ids), + ) + + # Apply the reward + with self._create_test_client(router=cart_router) as test_client: + data = {"reward_id": claimable_rewards[0]["id"]} + response: Response = test_client.post("/reward", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 2, + "The promotion should have been applied", + ) + self.assertEqual( + res["claimable_rewards"], [], "Rewards shouldn't be claimable anymore" + ) + + def test_program_with_code_product_choice(self): + """ + Create a new program with code that gives you the choice: if A is bought, + either you get product_B for free, or product_C for free + + -> when adding coupon code, if product is not specified, an error is + raised. + """ + self.product_B.product_tag_ids = [(4, self.gift_product_tag.id)] + self.product_C.product_tag_ids = [(4, self.gift_product_tag.id)] + program = self._create_program_free_product_choice_with_code( + self.product_A, self.gift_product_tag + ) + coupon = self._generate_coupons(program) + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + test_client.post("/sync", content=json.dumps(data)) + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, "Several free products available. Please specify one." + ): + data = {"code": coupon.code} + test_client.post("/coupon", content=json.dumps(data)) + allowed_products = program.reward_ids[0].reward_product_ids + wrong_product = self.env["product.product"].search( + [("id", "not in", allowed_products.ids)], limit=1 + ) + + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, "Free product not allowed for this reward." + ): + data = {"code": coupon.code, "free_product_id": wrong_product.id} + test_client.post("/coupon", content=json.dumps(data)) + + with self._create_test_client(router=cart_router) as test_client: + data = {"code": coupon.code, "free_product_id": allowed_products[0].id} + response = test_client.post("/coupon", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + self.assertEqual( + len(self.cart.order_line), 2, "The reward should've been applied" + ) + self.assertEqual(self.cart._get_reward_lines().product_id, self.product_B) + + def test_program_auto_product_choice(self): + """ + Create a new program (automatic) that gives you the choice + between several free products + + -> when updating the cart, the reward is not automatically applied + as there is a choice to make. + + When calling /reward without specified product, the service fails. + When specifying the product, the reward is added. + """ + self.product_B.product_tag_ids = [(4, self.gift_product_tag.id)] + self.product_C.product_tag_ids = [(4, self.gift_product_tag.id)] + program = self._create_program_free_product_choice_auto( + self.product_A, self.gift_product_tag + ) + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + self.assertEqual( + len(self.cart.order_line), + 1, + "The promotion shouldn't be applied as there is a reward choice", + ) + res = response.json() + claimable_rewards = res["claimable_rewards"] + self.assertEqual( + len(claimable_rewards), 1, "There is only 1 possible claimable reward." + ) + self.assertEqual(claimable_rewards[0]["id"], program.reward_ids.id) + self.assertEqual( + set(claimable_rewards[0]["reward_product_ids"]), + set(self.gift_product_tag.product_ids.ids), + "There should be a choice between 2 claimable free products.", + ) + + # Try applying reward without specifying the product + with self._create_test_client( + router=cart_router + ) as test_client, self.assertRaisesRegex( + UserError, "Several free products available. Please specify one." + ): + data = {"reward_id": claimable_rewards[0]["id"]} + test_client.post("/reward", content=json.dumps(data)) + + # Apply the reward specifying the product + with self._create_test_client(router=cart_router) as test_client: + data = { + "reward_id": claimable_rewards[0]["id"], + "free_product_id": self.product_C.id, + } + response = test_client.post("/reward", content=json.dumps(data)) + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual( + len(self.cart.order_line), + 2, + "The promotion should have been applied", + ) + self.assertEqual(res["claimable_rewards"], []) + + def test_route_current_reward(self): + """ + Check that route /current/reward is reachable. + """ + program = self._create_program_choice_reward_auto(self.product_A) + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + self.assertEqual( + len(self.cart.order_line), + 1, + "The promotion shouldn't be applied as there is a reward choice", + ) + res = response.json() + claimable_rewards = res["claimable_rewards"] + self.assertEqual( + len(claimable_rewards), 2, "The two possible rewards should be claimable." + ) + self.assertEqual( + {claimable_rewards[0]["id"], claimable_rewards[1]["id"]}, + set(program.reward_ids.ids), + ) + with self._create_test_client(router=cart_router) as test_client: + data = {"reward_id": claimable_rewards[0]["id"]} + response: Response = test_client.post( + "/current/reward", content=json.dumps(data) + ) + self.assertEqual(response.status_code, 200) + + def test_deprecated_route_apply_reward(self): + """ + Check that deprecated route /apply_reward is still reachable. + """ + program = self._create_program_choice_reward_auto(self.product_A) + with self._create_test_client(router=cart_router) as test_client: + data = { + "transactions": [ + {"uuid": self.dummy_uuid, "product_id": self.product_A.id, "qty": 1} + ] + } + response: Response = test_client.post("/sync", content=json.dumps(data)) + self.assertEqual(response.status_code, 201) + self.assertEqual( + len(self.cart.order_line), + 1, + "The promotion shouldn't be applied as there is a reward choice", + ) + res = response.json() + claimable_rewards = res["claimable_rewards"] + self.assertEqual( + len(claimable_rewards), 2, "The two possible rewards should be claimable." + ) + self.assertEqual( + {claimable_rewards[0]["id"], claimable_rewards[1]["id"]}, + set(program.reward_ids.ids), + ) + with self._create_test_client(router=cart_router) as test_client: + data = {"reward_id": claimable_rewards[0]["id"]} + response: Response = test_client.post( + "/apply_reward", content=json.dumps(data) + ) + self.assertEqual(response.status_code, 200) diff --git a/shopinvader_api_sale_loyalty/tests/test_get_rewards.py b/shopinvader_api_sale_loyalty/tests/test_get_rewards.py new file mode 100644 index 0000000000..d0dd727c43 --- /dev/null +++ b/shopinvader_api_sale_loyalty/tests/test_get_rewards.py @@ -0,0 +1,94 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from fastapi import status +from requests import Response + +from odoo.tests.common import tagged + +from ..routers.loyalty import loyalty_router +from .common import TestShopinvaderSaleLoyaltyCommon + + +@tagged("post_install", "-at_install") +class TestLoyaltyReward(TestShopinvaderSaleLoyaltyCommon): + def test_reward_wrong_code(self) -> None: + with self._create_test_client(router=loyalty_router) as test_client: + response: Response = test_client.get("/loyalty/wrongcode") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), []) + + def test_deprecated_route_rewards(self) -> None: + """ + Check that deprecated route /rewards/{code} is still reachable. + """ + with self._create_test_client(router=loyalty_router) as test_client: + response: Response = test_client.get("/rewards/wrongcode") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), []) + + def test_code_promotion_program_reward(self): + """ + Generate coupons for code_promotion_program and check get_rewards + service. + """ + coupon = self._generate_coupons(self.code_promotion_program) + with self._create_test_client(router=loyalty_router) as test_client: + response: Response = test_client.get(f"/loyalty/{coupon.code}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + res = response.json() + self.assertEqual(len(res), 1) + reward = res[0] + self.assertEqual(reward["reward_type"], "product") + self.assertEqual(reward["reward_product_ids"], [self.product_A.id]) + + def test_code_choice_reward(self): + program = self._create_program_choice_reward_with_code(self.product_A) + coupon = self._generate_coupons(program) + with self._create_test_client(router=loyalty_router) as test_client: + response: Response = test_client.get(f"/loyalty/{coupon.code}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + res = response.json() + self.assertEqual(len(res), 2) + first_reward = res[0] + second_reward = res[1] + self.assertEqual(first_reward["reward_type"], "discount") + self.assertEqual(first_reward["discount"], 10) + self.assertEqual(first_reward["discount_mode"], "percent") + self.assertEqual(first_reward["discount_applicability"], "order") + self.assertEqual(second_reward["reward_type"], "discount") + self.assertEqual(second_reward["discount"], 25) + self.assertEqual(second_reward["discount_mode"], "percent") + self.assertEqual(second_reward["discount_applicability"], "cheapest") + + def test_free_product_choice(self): + self.product_B.product_tag_ids = [(4, self.gift_product_tag.id)] + self.product_C.product_tag_ids = [(4, self.gift_product_tag.id)] + program = self._create_program_free_product_choice_with_code( + self.product_A, self.gift_product_tag + ) + coupon = self._generate_coupons(program) + with self._create_test_client(router=loyalty_router) as test_client: + response: Response = test_client.get(f"/loyalty/{coupon.code}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + res = response.json() + self.assertEqual(len(res), 1) + reward = res[0] + self.assertEqual(reward["reward_type"], "product") + self.assertEqual(len(reward["reward_product_ids"]), 2) + self.assertEqual( + set(reward["reward_product_ids"]), {self.product_B.id, self.product_C.id} + ) + + def test_code_on_rule(self): + self._create_discount_code_program() + with self._create_test_client(router=loyalty_router) as test_client: + response: Response = test_client.get("/loyalty/PROMOTION") + self.assertEqual(response.status_code, status.HTTP_200_OK) + res = response.json() + self.assertEqual(len(res), 1) + reward = res[0] + self.assertEqual(reward["reward_type"], "discount") + self.assertEqual(reward["discount"], 50) + self.assertEqual(reward["discount_mode"], "percent") + self.assertEqual(reward["discount_applicability"], "order")