diff --git a/setup/shopinvader_api_unit_member/odoo/addons/shopinvader_api_unit_member b/setup/shopinvader_api_unit_member/odoo/addons/shopinvader_api_unit_member new file mode 120000 index 0000000000..056eaa83bb --- /dev/null +++ b/setup/shopinvader_api_unit_member/odoo/addons/shopinvader_api_unit_member @@ -0,0 +1 @@ +../../../../shopinvader_api_unit_member \ No newline at end of file diff --git a/setup/shopinvader_api_unit_member/setup.py b/setup/shopinvader_api_unit_member/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_api_unit_member/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_api_unit_request/odoo/addons/shopinvader_api_unit_request b/setup/shopinvader_api_unit_request/odoo/addons/shopinvader_api_unit_request new file mode 120000 index 0000000000..410817524f --- /dev/null +++ b/setup/shopinvader_api_unit_request/odoo/addons/shopinvader_api_unit_request @@ -0,0 +1 @@ +../../../../shopinvader_api_unit_request \ No newline at end of file diff --git a/setup/shopinvader_api_unit_request/setup.py b/setup/shopinvader_api_unit_request/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_api_unit_request/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_fastapi_auth_partner_api_unit_member/odoo/addons/shopinvader_fastapi_auth_partner_api_unit_member b/setup/shopinvader_fastapi_auth_partner_api_unit_member/odoo/addons/shopinvader_fastapi_auth_partner_api_unit_member new file mode 120000 index 0000000000..1d556c8fbe --- /dev/null +++ b/setup/shopinvader_fastapi_auth_partner_api_unit_member/odoo/addons/shopinvader_fastapi_auth_partner_api_unit_member @@ -0,0 +1 @@ +../../../../shopinvader_fastapi_auth_partner_api_unit_member \ No newline at end of file diff --git a/setup/shopinvader_fastapi_auth_partner_api_unit_member/setup.py b/setup/shopinvader_fastapi_auth_partner_api_unit_member/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_fastapi_auth_partner_api_unit_member/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_unit_management/odoo/addons/shopinvader_unit_management b/setup/shopinvader_unit_management/odoo/addons/shopinvader_unit_management new file mode 120000 index 0000000000..ba5e5eb0e1 --- /dev/null +++ b/setup/shopinvader_unit_management/odoo/addons/shopinvader_unit_management @@ -0,0 +1 @@ +../../../../shopinvader_unit_management \ No newline at end of file diff --git a/setup/shopinvader_unit_management/setup.py b/setup/shopinvader_unit_management/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_unit_management/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_api_unit_member/README.rst b/shopinvader_api_unit_member/README.rst new file mode 100644 index 0000000000..7950fbee2b --- /dev/null +++ b/shopinvader_api_unit_member/README.rst @@ -0,0 +1,94 @@ +=========================== +Shopinvader Api Unit Member +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5f9d059e94eb3b92ef978b3466e823c5491e7b0a9514afa64bad55e371a7ed89 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/OCA/odoo-shopinvader/tree/16.0/shopinvader_api_unit_member + :alt: OCA/odoo-shopinvader +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/odoo-shopinvader-16-0/odoo-shopinvader-16-0-shopinvader_api_unit_member + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/odoo-shopinvader&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a service to shopinvader to manage units members: managers and collaborators. + +A manager can list, create, update and delete collaborators. + +The router defines these routes: + +- `GET /unit/members` to list collaborators +- `GET /unit/members/:id` to get a collaborator +- `POST /unit/members` to create a collaborator +- `POST /unit/members/:id` to update a collaborator +- `DELETE /unit/members/:id` to delete a collaborator + + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The routes are under the `unit_member_router.py` python file in the routers folder. + +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 +~~~~~~~ + +* 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/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopinvader_api_unit_member/__init__.py b/shopinvader_api_unit_member/__init__.py new file mode 100644 index 0000000000..78ebce7be9 --- /dev/null +++ b/shopinvader_api_unit_member/__init__.py @@ -0,0 +1,2 @@ +from . import routers +from . import schemas diff --git a/shopinvader_api_unit_member/__manifest__.py b/shopinvader_api_unit_member/__manifest__.py new file mode 100644 index 0000000000..a635431696 --- /dev/null +++ b/shopinvader_api_unit_member/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Api Unit Member", + "summary": "This module adds a service to shopinvader to manage units members: " + "managers and collaborators.", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "extendable", + "extendable_fastapi", + "fastapi", + "shopinvader_unit_management", + ], + "data": [ + "security/res_groups.xml", + "security/res_partner.xml", + ], + "external_dependencies": { + "python": ["fastapi", "extendable_pydantic>=1.0.0", "pydantic>=2.0.0"] + }, + "installable": True, +} diff --git a/shopinvader_api_unit_member/readme/CONTRIBUTORS.rst b/shopinvader_api_unit_member/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a4d0ad9229 --- /dev/null +++ b/shopinvader_api_unit_member/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/shopinvader_api_unit_member/readme/DESCRIPTION.rst b/shopinvader_api_unit_member/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..6774e42ebe --- /dev/null +++ b/shopinvader_api_unit_member/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +This module adds a service to shopinvader to manage units members: managers and collaborators. + +A manager can list, create, update and delete collaborators. + +The router defines these routes: + +- `GET /unit/members` to list collaborators +- `GET /unit/members/:id` to get a collaborator +- `POST /unit/members` to create a collaborator +- `POST /unit/members/:id` to update a collaborator +- `DELETE /unit/members/:id` to delete a collaborator + diff --git a/shopinvader_api_unit_member/readme/USAGE.rst b/shopinvader_api_unit_member/readme/USAGE.rst new file mode 100644 index 0000000000..120f621ed7 --- /dev/null +++ b/shopinvader_api_unit_member/readme/USAGE.rst @@ -0,0 +1 @@ +The routes are under the `unit_member_router.py` python file in the routers folder. diff --git a/shopinvader_api_unit_member/routers/__init__.py b/shopinvader_api_unit_member/routers/__init__.py new file mode 100644 index 0000000000..d7352f9313 --- /dev/null +++ b/shopinvader_api_unit_member/routers/__init__.py @@ -0,0 +1 @@ +from .unit_members import unit_member_router diff --git a/shopinvader_api_unit_member/routers/unit_members.py b/shopinvader_api_unit_member/routers/unit_members.py new file mode 100644 index 0000000000..dad2615a58 --- /dev/null +++ b/shopinvader_api_unit_member/routers/unit_members.py @@ -0,0 +1,88 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Annotated, List + +from fastapi import APIRouter, Depends + +from odoo import _ +from odoo.exceptions import AccessError + +from odoo.addons.base.models.res_partner import Partner as ResPartner +from odoo.addons.fastapi.dependencies import authenticated_partner + +from ..schemas import UnitMember, UnitMemberCreate, UnitMemberUpdate + +# create a router +unit_member_router = APIRouter(tags=["unit"]) + + +def authenticated_manager( + partner: Annotated[ResPartner, Depends(authenticated_partner)], +) -> ResPartner: + if partner.unit_profile != "manager": + raise AccessError(_("Only a manager can perform this action.")) + return partner + + +@unit_member_router.get("/unit/members") +async def get_unit_members( + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> List[UnitMember]: + """ + Get list of unit members + """ + members = partner._get_shopinvader_unit_members() + return [UnitMember.from_res_partner(rec) for rec in members] + + +@unit_member_router.get("/unit/members/{member_id}") +async def get_unit_member( + partner: Annotated[ResPartner, Depends(authenticated_manager)], + member_id: int, +) -> UnitMember: + """ + Get a specific unit member + """ + member = partner._get_shopinvader_unit_member(member_id) + return UnitMember.from_res_partner(member) + + +@unit_member_router.post("/unit/members", status_code=201) +async def create_unit_member( + data: UnitMemberCreate, + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> UnitMember: + """ + Create a new unit member (manager or collaborator) as manager + """ + vals = data.to_res_partner_vals() + member = partner._create_shopinvader_unit_member(vals) + return UnitMember.from_res_partner(member) + + +@unit_member_router.post("/unit/members/{member_id}") +async def update_unit_member( + data: UnitMemberUpdate, + partner: Annotated[ResPartner, Depends(authenticated_manager)], + member_id: int, +) -> UnitMember: + """ + Update a specific unit member (manager or collaborator) as manager + """ + vals = data.to_res_partner_vals() + member = partner._update_shopinvader_unit_member(member_id, vals) + return UnitMember.from_res_partner(member) + + +@unit_member_router.delete("/unit/members/{member_id}") +async def delete_unit_member( + partner: Annotated[ResPartner, Depends(authenticated_manager)], + member_id: int, +) -> UnitMember: + """ + Delete a specific unit member (manager or collaborator) as manager + """ + member = partner._delete_shopinvader_unit_member(member_id) + return UnitMember.from_res_partner(member) diff --git a/shopinvader_api_unit_member/schemas.py b/shopinvader_api_unit_member/schemas.py new file mode 100644 index 0000000000..817d62987a --- /dev/null +++ b/shopinvader_api_unit_member/schemas.py @@ -0,0 +1,89 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class UnitMember(StrictExtendableBaseModel): + id: int + name: str | None = None + street: str | None = None + street2: str | None = None + zip: str | None = None + city: str | None = None + phone: str | None = None + email: str | None = None + state_id: int | None = None + country_id: int | None = None + + @classmethod + def from_res_partner(cls, odoo_rec): + return cls.model_construct( + id=odoo_rec.id, + name=odoo_rec.name or None, + street=odoo_rec.street or None, + street2=odoo_rec.street2 or None, + zip=odoo_rec.zip or None, + city=odoo_rec.city or None, + phone=odoo_rec.phone or None, + email=odoo_rec.email or None, + state_id=odoo_rec.state_id.id or None, + country_id=odoo_rec.country_id.id or None, + ) + + +class UnitMemberCreate(StrictExtendableBaseModel, extra="ignore"): + type: str | None = "collaborator" + name: str | None = None + street: str | None = None + street2: str | None = None + zip: str | None = None + city: str | None = None + phone: str | None = None + email: str | None = None + state_id: int | None = None + country_id: int | None = None + + def to_res_partner_vals(self) -> dict: + vals = { + "unit_profile": self.type, + "name": self.name, + "street": self.street, + "street2": self.street2, + "zip": self.zip, + "city": self.city, + "phone": self.phone, + "email": self.email, + "state_id": self.state_id, + "country_id": self.country_id, + } + + return vals + + +class UnitMemberUpdate(StrictExtendableBaseModel, extra="ignore"): + name: str | None = None + street: str | None = None + street2: str | None = None + zip: str | None = None + city: str | None = None + phone: str | None = None + email: str | None = None + state_id: int | None = None + country_id: int | None = None + + def to_res_partner_vals(self) -> dict: + fields = [ + "name", + "street", + "street2", + "zip", + "city", + "phone", + "email", + "state_id", + "country_id", + ] + values = self.model_dump(exclude_unset=True) + return {f: values[f] for f in fields if f in values} diff --git a/shopinvader_api_unit_member/security/res_groups.xml b/shopinvader_api_unit_member/security/res_groups.xml new file mode 100644 index 0000000000..abd5c630dd --- /dev/null +++ b/shopinvader_api_unit_member/security/res_groups.xml @@ -0,0 +1,19 @@ + + + + + Shopinvader Unit Management user + + + + + diff --git a/shopinvader_api_unit_member/security/res_partner.xml b/shopinvader_api_unit_member/security/res_partner.xml new file mode 100644 index 0000000000..1104199958 --- /dev/null +++ b/shopinvader_api_unit_member/security/res_partner.xml @@ -0,0 +1,41 @@ + + + + + Shopinvader Unit Management Endpoint rule: res partner + + + [ + '|', + ('manager_ids','=',authenticated_partner_id), + '|', + ('unit_id.manager_ids','=',authenticated_partner_id), + '|', + ('parent_id.manager_ids','=',authenticated_partner_id), + ('parent_id.unit_id.manager_ids','=',authenticated_partner_id) + ] + + + + + Shopinvader Unit Management: user read/write/create partners + + + + + + + + + diff --git a/shopinvader_api_unit_member/static/description/index.html b/shopinvader_api_unit_member/static/description/index.html new file mode 100644 index 0000000000..ee19318ce1 --- /dev/null +++ b/shopinvader_api_unit_member/static/description/index.html @@ -0,0 +1,438 @@ + + + + + + +Shopinvader Api Unit Member + + + +
+

Shopinvader Api Unit Member

+ + +

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

+

This module adds a service to shopinvader to manage units members: managers and collaborators.

+

A manager can list, create, update and delete collaborators.

+

The router defines these routes:

+
    +
  • GET /unit/members to list collaborators
  • +
  • GET /unit/members/:id to get a collaborator
  • +
  • POST /unit/members to create a collaborator
  • +
  • POST /unit/members/:id to update a collaborator
  • +
  • DELETE /unit/members/:id to delete a collaborator
  • +
+

Table of contents

+ +
+

Usage

+

The routes are under the unit_member_router.py python file in the routers folder.

+
+
+

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

+
    +
  • 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/odoo-shopinvader project on GitHub.

+

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

+
+
+
+ + diff --git a/shopinvader_api_unit_member/tests/__init__.py b/shopinvader_api_unit_member/tests/__init__.py new file mode 100644 index 0000000000..722a620468 --- /dev/null +++ b/shopinvader_api_unit_member/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shopinvader_api_unit_members diff --git a/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py new file mode 100644 index 0000000000..77aef4716d --- /dev/null +++ b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -0,0 +1,482 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from contextlib import contextmanager + +from fastapi import status +from requests import Response + +from odoo.tests.common import tagged +from odoo.tools import mute_logger + +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase +from odoo.addons.shopinvader_unit_management.tests.common import ( + TestUnitManagementCommon, +) + +from ..routers import unit_member_router + + +@tagged("post_install", "-at_install") +class TestShopinvaderApiUnitMember(FastAPITransactionCase, TestUnitManagementCommon): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.env["res.users"].create( + { + "name": "Test User", + "login": "test_user", + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref( + "shopinvader_api_unit_member." + "shopinvader_unit_management_user_group" + ).id + ], + ) + ], + } + ) + + cls.default_fastapi_authenticated_partner = cls.manager_1_1 + cls.default_fastapi_router = unit_member_router + cls.default_fastapi_app = cls.env.ref( + "fastapi.fastapi_endpoint_demo" + )._get_app() + + @contextmanager + def _create_test_client(self, **kwargs): + kwargs.setdefault("raise_server_exceptions", False) + with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: + yield test_client + + def test_get_manager_unit_members(self): + """ + Test to get the manager unit members + """ + with self._create_test_client() as test_client: + response: Response = test_client.get( + "/unit/members", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + members = response.json() + + self.assertEqual( + {member["id"] for member in members}, + set( + ( + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5 + ).ids + ), + ) + + def test_collaborator_unit_members(self): + """ + Test that a collaborator can't get the members of the unit + """ + self.default_fastapi_authenticated_partner = self.collaborator_1_1 + + with self._create_test_client() as test_client: + response: Response = test_client.get("/unit/members") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_unit_unit_members(self): + """ + Test that a unit can't get the members of the unit + """ + self.default_fastapi_authenticated_partner = self.unit_1 + + with self._create_test_client() as test_client: + response: Response = test_client.get("/unit/members") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_get_manager_unit_member(self): + """ + Test to get a manager unit member + """ + with self._create_test_client() as test_client: + response: Response = test_client.get( + f"/unit/members/{self.collaborator_1_1.id}", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + self.assertEqual(member["id"], self.collaborator_1_1.id) + self.assertEqual(member["name"], self.collaborator_1_1.name) + with self._create_test_client() as test_client: + response: Response = test_client.get( + f"/unit/members/{self.collaborator_1_2.id}", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + self.assertEqual(member["id"], self.collaborator_1_2.id) + self.assertEqual(member["name"], self.collaborator_1_2.name) + + def test_get_manager_unit_members_wrong_unit(self): + """ + Test that a manager can't access members of another unit + """ + with self._create_test_client() as test_client: + response: Response = test_client.get( + f"/unit/members/{self.collaborator_2_2.id}" + ) + self.assertEqual( + response.status_code, + status.HTTP_404_NOT_FOUND, + ) + + def test_get_manager_unit_members_wrong_type(self): + """ + Test that a manager can't access a unit + """ + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/unit/members/{self.unit_1.id}") + self.assertEqual( + response.status_code, + status.HTTP_404_NOT_FOUND, + ) + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/unit/members/{self.unit_2.id}") + self.assertEqual( + response.status_code, + status.HTTP_404_NOT_FOUND, + ) + + def test_create_unit_member(self): + """ + Test to create a new unit member + """ + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/unit/members", + data=json.dumps( + { + "name": "New Unit Member", + } + ), + ) + + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + ) + + member = response.json() + new_member = self.env["res.partner"].browse(member["id"]) + self.assertEqual(member["name"], "New Unit Member") + self.assertEqual(new_member.unit_id, self.unit_1) + self.assertEqual(new_member.unit_profile, "collaborator") + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5 + | new_member, + ) + + def test_create_unit_manager(self): + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/unit/members", + data=json.dumps({"name": "New Unit Manager", "type": "manager"}), + ) + + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + ) + + member = response.json() + new_member = self.env["res.partner"].browse(member["id"]) + self.assertEqual(member["name"], "New Unit Manager") + self.assertEqual(new_member.unit_id, self.unit_1) + self.assertEqual(new_member.unit_profile, "manager") + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5 + | new_member, + ) + + def test_create_unit_wrong_type(self): + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/unit/members", + data=json.dumps({"name": "New Unit", "type": "unit"}), + ) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/unit/members", + data=json.dumps({"name": "New Unit", "type": "unknown"}), + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + ) + + def test_create_unit_wrong_partner(self): + self.default_fastapi_authenticated_partner = self.collaborator_1_1 + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/unit/members", + data=json.dumps({"name": "New Unit", "type": "collaborator"}), + ) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_update_unit_member(self): + """ + Test to update a specific unit member + """ + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Manager 1.1", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.4", + "Collaborator 1.5", + }, + ) + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_4.id}", + data=json.dumps( + { + "name": "Updated Unit Member", + } + ), + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + updated_member = self.env["res.partner"].browse(member["id"]) + self.assertEqual(member["name"], "Updated Unit Member") + self.assertEqual(updated_member.name, "Updated Unit Member") + + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Manager 1.1", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Updated Unit Member", + "Collaborator 1.5", + }, + ) + + def test_update_unit_manager(self): + """ + Test to update a specific unit manager + """ + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Manager 1.1", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.4", + "Collaborator 1.5", + }, + ) + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/unit/members/{self.manager_1_1.id}", + data=json.dumps( + { + "name": "Updated Unit Manager", + } + ), + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + updated_member = self.env["res.partner"].browse(member["id"]) + self.assertEqual(member["name"], "Updated Unit Manager") + self.assertEqual(updated_member.name, "Updated Unit Manager") + + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Updated Unit Manager", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.4", + "Collaborator 1.5", + }, + ) + + def test_update_unit_wrong_partner(self): + self.default_fastapi_authenticated_partner = self.collaborator_1_1 + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_1.id}", + data=json.dumps({"name": "New Unit Name"}), + ) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_delete_unit_membere(self): + """ + Test to delete a specific unit member + """ + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Manager 1.1", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.4", + "Collaborator 1.5", + }, + ) + + with self._create_test_client() as test_client: + response: Response = test_client.delete( + f"/unit/members/{self.collaborator_1_4.id}", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertFalse(self.collaborator_1_4.active) + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Manager 1.1", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.5", + }, + ) + + def test_delete_unit_manager(self): + """ + Test to delete a specific unit manager + """ + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Manager 1.1", + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.4", + "Collaborator 1.5", + }, + ) + + with self._create_test_client() as test_client: + response: Response = test_client.delete( + f"/unit/members/{self.manager_1_1.id}", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + self.assertFalse(self.manager_1_1.active) + self.assertEqual( + set(self.unit_1.member_ids.mapped("name")), + { + "Collaborator 1.1", + "Collaborator 1.2", + "Collaborator 1.3", + "Collaborator 1.4", + "Collaborator 1.5", + }, + ) + + def test_delete_unit_wrong_partner(self): + self.default_fastapi_authenticated_partner = self.collaborator_1_1 + with self._create_test_client() as test_client: + response: Response = test_client.delete( + f"/unit/members/{self.collaborator_1_1.id}", + ) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) diff --git a/shopinvader_api_unit_request/README.rst b/shopinvader_api_unit_request/README.rst new file mode 100644 index 0000000000..1ad45f9d98 --- /dev/null +++ b/shopinvader_api_unit_request/README.rst @@ -0,0 +1,87 @@ +============================ +Shopinvader Unit Request Api +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:88a377a552c0c9302646a4c1af50aac30b3f08f50c475dc725370b4eed14de43 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_unit_request + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module adds the possibility to make a request from a cart as +a collaborator of a unit to be later reviewed, merged and converted into a +sale order by a unit manager. + +The `cart_router` has been extended to allow the creation of a request: + +- `POST /api/cart/request` to create a request from the current cart. +- `POST /api/cart/current/request` to create a request from the current cart. +- `POST /api/cart/:uuid/request` to create a request from a specific cart. + +The `sale_router` has been extended to allow the /api/sale_lines to also list the requested sale lines. + +A new `unit_request_line_router` has been added to manage the requests: + +- `GET /api/unit_request_lines` to list the requested lines. +- `GET /api/unit_request_lines/:id` to get a specific requested line. +- `POST /api/unit_request_lines/:id/accept` to accept a requested line. +- `POST /api/unit_request_lines/:id/reject` to reject a requested line. +- `POST /api/unit_request_lines/:id/reset` to reset the rejected status of a requested line. + +NB: The deletion of an accepted line in the manager cart will put it back in the request. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The new routes are under the `unit_request_lines.py` python file in the routers folder. + +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 +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* `Akretion `_: + + * Florian Mounier + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_api_unit_request/__init__.py b/shopinvader_api_unit_request/__init__.py new file mode 100644 index 0000000000..797e7dcb45 --- /dev/null +++ b/shopinvader_api_unit_request/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import routers +from . import schemas diff --git a/shopinvader_api_unit_request/__manifest__.py b/shopinvader_api_unit_request/__manifest__.py new file mode 100644 index 0000000000..6c3e6eec4f --- /dev/null +++ b/shopinvader_api_unit_request/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Unit Request Api", + "summary": "This module adds the possibility to make a request from a cart " + "as a collaborator of a unit to be later reviewed, merged and converted into a " + "sale order by a unit manager.", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "shopinvader_api_unit_member", + "shopinvader_api_cart", + "shopinvader_api_sale", + ], + "data": [ + "views/sale_order_views.xml", + "security/sale_management_security.xml", + ], + "installable": True, +} diff --git a/shopinvader_api_unit_request/models/__init__.py b/shopinvader_api_unit_request/models/__init__.py new file mode 100644 index 0000000000..2d7ee6c3dc --- /dev/null +++ b/shopinvader_api_unit_request/models/__init__.py @@ -0,0 +1,2 @@ +from . import sale_order +from . import sale_order_line diff --git a/shopinvader_api_unit_request/models/sale_order.py b/shopinvader_api_unit_request/models/sale_order.py new file mode 100644 index 0000000000..7f8ea536cb --- /dev/null +++ b/shopinvader_api_unit_request/models/sale_order.py @@ -0,0 +1,117 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + typology = fields.Selection(selection_add=[("request", "Request")]) + + order_line_requested_ids = fields.One2many( + "sale.order.line", "request_order_id", string="Accepted Lines" + ) + order_line_rejected_ids = fields.One2many( + "sale.order.line", "reject_order_id", string="Rejected Lines" + ) + + order_line_all_requested_ids = fields.One2many( + "sale.order.line", + compute="_compute_order_line_all_requested_ids", + string="All Requested Lines", + ) + + @api.depends("order_line", "order_line_requested_ids") + def _compute_order_line_all_requested_ids(self): + for record in self: + record.order_line_all_requested_ids = ( + record.order_line | record.order_line_requested_ids + ) + + def action_confirm_cart(self): + for record in self: + if record.typology == "request": + raise UserError(_("You can't confirm a request.")) + return super().action_confirm_cart() + + def action_confirm(self): + for record in self: + if record.state == "request": + raise UserError(_("You can't confirm a request.")) + + res = super().action_confirm() + + # Notify partners of accepted and refused requests + # Group accepted and refused by partners + request_lines_by_partner = defaultdict( + lambda: { + "accepted": self.env["sale.order.line"], + "rejected": self.env["sale.order.line"], + } + ) + + for record in self: + for line in record.order_line: + if line.request_partner_id: + # Accepted line + request_lines_by_partner[line.request_partner_id][ + "accepted" + ] |= line + for line in record.order_line_rejected_ids: + if line.request_partner_id: + # Rejected line + request_lines_by_partner[line.request_partner_id][ + "rejected" + ] |= line + + for partner, lines in request_lines_by_partner.items(): + if not lines["accepted"] and not lines["rejected"]: + continue + self._notify_partner_on_request_feedback( + partner, lines["accepted"], lines["rejected"] + ) + + return res + + def _notify_partner_on_request_feedback( + self, partner, accepted_lines, rejected_lines + ): + """Override this method to customize the notification message. + Sending a mail template for example. + + :param partner: res.partner record Concerned partner + :param accepted_lines: sale.order.line recordset Accepted lines + :param rejected_lines: sale.order.line recordset Rejected lines + """ + message = "" + if accepted_lines: + message += _("Your following requests have been accepted:\n") + for line in accepted_lines: + message += f"{line.product_id.name} - {line.product_uom_qty}\n" + + if rejected_lines: + message += _("Your following requests have been rejected:\n") + for line in rejected_lines: + message += f"{line.product_id.name} - {line.product_uom_qty}" + if line.request_rejection_reason: + message += f": {line.request_rejection_reason}" + message += "\n" + + partner.message_post( + body=message, + subject=_("Request feedback"), + subtype_id=self.env.ref("mail.mt_comment").id, + ) + + def action_request_cart(self): + for record in self: + if record.typology == "request": + # cart is already requested + continue + record.order_line._action_request() + record.write({"typology": "request"}) + return True diff --git a/shopinvader_api_unit_request/models/sale_order_line.py b/shopinvader_api_unit_request/models/sale_order_line.py new file mode 100644 index 0000000000..ca60ea4fd4 --- /dev/null +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -0,0 +1,81 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + qty_requested = fields.Float( + string="Quantity Requested", + help="Quantity requested by the collaborator in case of a request.", + default=0.0, + ) + + request_partner_id = fields.Many2one( + "res.partner", + string="Request Partner", + help="The partner who requested this line.", + ) + request_order_id = fields.Many2one( + "sale.order", + string="Request Order", + help="The order that requested this line.", + ) + reject_order_id = fields.Many2one( + "sale.order", + string="Reject Order", + help="The order that rejected this line.", + ) + request_rejected = fields.Boolean( + help="The request has been rejected.", + default=False, + ) + request_rejection_reason = fields.Text( + help="The reason of the rejection.", + ) + request_accepted = fields.Boolean( + help="The request has been accepted.", + compute="_compute_request_accepted", + ) + + @api.depends("request_order_id") + def _compute_request_accepted(self): + for record in self: + record.request_accepted = record.request_order_id + + def _action_request(self): + for record in self: + record.qty_requested = record.product_uom_qty + record.request_partner_id = record.order_id.partner_id + + def _action_accept_request(self, target_order): + for record in self: + record.request_order_id = record.order_id + record.order_id = target_order + return True + + def _action_reject_request(self, target_order, reason): + for record in self: + record.request_rejected = True + record.reject_order_id = target_order + record.request_rejection_reason = reason + return True + + def _action_reset_request(self): + for record in self: + record.request_rejected = False + record.reject_order_id = False + record.request_rejection_reason = False + return True + + def unlink(self): + for record in self: + if record.request_partner_id and record.request_order_id: + record.order_id = record.request_order_id + record.request_order_id = False + record.product_uom_qty = record.qty_requested + self -= record + + return super().unlink() diff --git a/shopinvader_api_unit_request/readme/CONTRIBUTORS.rst b/shopinvader_api_unit_request/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a4d0ad9229 --- /dev/null +++ b/shopinvader_api_unit_request/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/shopinvader_api_unit_request/readme/DESCRIPTION.rst b/shopinvader_api_unit_request/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..40aa8bda76 --- /dev/null +++ b/shopinvader_api_unit_request/readme/DESCRIPTION.rst @@ -0,0 +1,21 @@ +This module adds the possibility to make a request from a cart as +a collaborator of a unit to be later reviewed, merged and converted into a +sale order by a unit manager. + +The `cart_router` has been extended to allow the creation of a request: + +- `POST /api/cart/request` to create a request from the current cart. +- `POST /api/cart/current/request` to create a request from the current cart. +- `POST /api/cart/:uuid/request` to create a request from a specific cart. + +The `sale_router` has been extended to allow the /api/sale_lines to also list the requested sale lines. + +A new `unit_request_line_router` has been added to manage the requests: + +- `GET /api/unit_request_lines` to list the requested lines. +- `GET /api/unit_request_lines/:id` to get a specific requested line. +- `POST /api/unit_request_lines/:id/accept` to accept a requested line. +- `POST /api/unit_request_lines/:id/reject` to reject a requested line. +- `POST /api/unit_request_lines/:id/reset` to reset the rejected status of a requested line. + +NB: The deletion of an accepted line in the manager cart will put it back in the request. diff --git a/shopinvader_api_unit_request/readme/USAGE.rst b/shopinvader_api_unit_request/readme/USAGE.rst new file mode 100644 index 0000000000..c70d64a42d --- /dev/null +++ b/shopinvader_api_unit_request/readme/USAGE.rst @@ -0,0 +1 @@ +The new routes are under the `unit_request_lines.py` python file in the routers folder. diff --git a/shopinvader_api_unit_request/routers/__init__.py b/shopinvader_api_unit_request/routers/__init__.py new file mode 100644 index 0000000000..a4613084bf --- /dev/null +++ b/shopinvader_api_unit_request/routers/__init__.py @@ -0,0 +1,4 @@ +from . import sale_lines + +from .cart import cart_router +from .unit_request_lines import unit_request_line_router diff --git a/shopinvader_api_unit_request/routers/cart.py b/shopinvader_api_unit_request/routers/cart.py new file mode 100644 index 0000000000..ea486b7359 --- /dev/null +++ b/shopinvader_api_unit_request/routers/cart.py @@ -0,0 +1,41 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# 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 +from odoo.exceptions import AccessError, MissingError + +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.shopinvader_api_cart.routers import cart_router +from odoo.addons.shopinvader_schema_sale.schemas import Sale + + +def authenticated_collaborator( + partner: Annotated[ResPartner, Depends(authenticated_partner)], +) -> ResPartner: + if partner.unit_profile != "collaborator": + raise AccessError(_("Only a collaborator can perform this action.")) + return partner + + +@cart_router.post("/{uuid}/request") +@cart_router.post("/current/request") +@cart_router.post("/request") +async def request( + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated["ResPartner", Depends(authenticated_collaborator)], + uuid: UUID | None = None, +) -> Sale | None: + cart = env["sale.order"]._find_open_cart(partner.id, str(uuid) if uuid else None) + if not cart: + raise MissingError(_("No cart found")) + cart.action_request_cart() + return Sale.from_sale_order(cart) diff --git a/shopinvader_api_unit_request/routers/sale_lines.py b/shopinvader_api_unit_request/routers/sale_lines.py new file mode 100644 index 0000000000..cb3706f4d2 --- /dev/null +++ b/shopinvader_api_unit_request/routers/sale_lines.py @@ -0,0 +1,20 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ShopinvaderApiSaleSaleLineRouterHelper(models.AbstractModel): + _inherit = "shopinvader_api_sale.sale_line_router.helper" + + def _get_domain_adapter(self): + return [ + "|", + ("request_partner_id", "=", self.partner.id), + "&", + ("order_id.partner_id", "=", self.partner.id), + "|", + ("order_id.typology", "=", "sale"), + ("order_id.typology", "=", "request"), + ] diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py new file mode 100644 index 0000000000..560915076a --- /dev/null +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -0,0 +1,145 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends + +from odoo import api, fields, models + +from odoo.addons.base.models.res_partner import Partner as ResPartner +from odoo.addons.extendable_fastapi.schemas import PagedCollection +from odoo.addons.fastapi.dependencies import authenticated_partner_env, paging +from odoo.addons.fastapi.schemas import Paging +from odoo.addons.sale.models.sale_order_line import SaleOrderLine +from odoo.addons.shopinvader_api_unit_member.routers.unit_members import ( + authenticated_manager, +) +from odoo.addons.shopinvader_filtered_model.utils import FilteredModelAdapter + +from ..schemas import RejectRequest, RequestedSaleLine, RequestedSaleLineSearch + +unit_request_line_router = APIRouter(tags=["unit"]) + + +@unit_request_line_router.get("/unit/request_lines") +async def get_request_lines( + params: Annotated[RequestedSaleLineSearch, Depends()], + paging: Annotated[Paging, Depends(paging)], + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> PagedCollection[RequestedSaleLine]: + """ + Get list of requested sale lines + """ + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + count, sols = helper._search(paging, params) + return PagedCollection[RequestedSaleLine]( + count=count, + items=[RequestedSaleLine.from_sale_order_line(sol) for sol in sols], + ) + + +@unit_request_line_router.get("/unit/request_lines/{sol_id}") +async def get_requested_sale_line( + sol_id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> RequestedSaleLine: + """ + Get a specific requested sale line + """ + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + return RequestedSaleLine.from_sale_order_line(helper._get(sol_id)) + + +@unit_request_line_router.post("/unit/request_lines/{sol_id}/accept") +@unit_request_line_router.post("/unit/request_lines/{sol_id}/accept/{uuid}") +async def accept_requested_sale_line( + sol_id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], + uuid: UUID | None = None, +) -> RequestedSaleLine: + """ + Accept a specific requested sale line + """ + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + sale_line = helper._get(sol_id) + cart = helper._get_cart(uuid) + sale_line._action_accept_request(cart) + return RequestedSaleLine.from_sale_order_line(sale_line) + + +@unit_request_line_router.post("/unit/request_lines/{sol_id}/reject") +@unit_request_line_router.post("/unit/request_lines/{sol_id}/reject/{uuid}") +async def reject_requested_sale_line( + sol_id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + data: RejectRequest, + partner: Annotated[ResPartner, Depends(authenticated_manager)], + uuid: UUID | None = None, +) -> RequestedSaleLine: + """ + Reject a specific requested sale line + """ + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + sale_line = helper._get(sol_id) + cart = helper._get_cart(uuid) + sale_line._action_reject_request(cart, data.reason) + return RequestedSaleLine.from_sale_order_line(sale_line) + + +@unit_request_line_router.post("/unit/request_lines/{sol_id}/reset") +async def reset_requested_sale_line( + sol_id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> RequestedSaleLine: + """ + Reset a specific requested sale line + """ + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + sale_line = helper._get(sol_id) + sale_line._action_reset_request() + return RequestedSaleLine.from_sale_order_line(sale_line) + + +class ShopinvaderApiUnitCartSaleLineRouterHelper(models.AbstractModel): + _name = "shopinvader_api_unit_request.lines.helper" + _description = "Shopinvader Api Unit Cart Sale Line Service Helper" + + partner = fields.Many2one("res.partner") + + def _get_domain_adapter(self): + return [ + ("order_id.typology", "=", "request"), + ( + "request_partner_id", + "in", + self.partner.unit_id.collaborator_ids.ids, + ), + ("request_order_id", "=", False), + ] + + @property + def model_adapter(self) -> FilteredModelAdapter[SaleOrderLine]: + return FilteredModelAdapter[SaleOrderLine](self.env, self._get_domain_adapter()) + + def _get(self, record_id) -> SaleOrderLine: + return self.model_adapter.get(record_id) + + def _search(self, paging, params) -> tuple[int, SaleOrderLine]: + return self.model_adapter.search_with_count( + params.to_odoo_domain(self.env), + limit=paging.limit, + offset=paging.offset, + ) + + def _get_cart(self, uuid): + cart = self.env["sale.order"]._find_open_cart(self.partner.id, uuid) + if not cart: + cart = self.env["sale.order"]._create_empty_cart(self.partner.id) + return cart diff --git a/shopinvader_api_unit_request/schemas.py b/shopinvader_api_unit_request/schemas.py new file mode 100644 index 0000000000..1d29541e9f --- /dev/null +++ b/shopinvader_api_unit_request/schemas.py @@ -0,0 +1,83 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Annotated + +from extendable_pydantic import StrictExtendableBaseModel +from pydantic import Field + +from odoo import api + +from odoo.addons.shopinvader_api_sale import schemas + + +class RejectRequest(StrictExtendableBaseModel, extra="ignore"): + reason: str | None = None + + +class SaleLineWithSale(schemas.SaleLineWithSale, extends=True): + request_order_id: int | None + request_partner_id: int | None + + @classmethod + def from_sale_order_line(cls, odoo_rec): + res = super().from_sale_order_line(odoo_rec) + res.request_order_id = ( + odoo_rec.request_order_id.id if odoo_rec.request_order_id else None + ) + res.request_partner_id = ( + odoo_rec.request_partner_id.id if odoo_rec.request_partner_id else None + ) + return res + + +class RequestedSaleLine(SaleLineWithSale): + partner_id: int + request_rejected: bool + request_rejection_reason: str | None = None + + @classmethod + def from_sale_order_line(cls, odoo_rec): + res = super().from_sale_order_line(odoo_rec) + res.partner_id = odoo_rec.request_partner_id.id + res.request_rejected = odoo_rec.request_rejected + res.request_rejection_reason = odoo_rec.request_rejection_reason or None + return res + + +class RequestedSaleLineSearch(StrictExtendableBaseModel, extra="ignore"): + order_name: Annotated[ + str | None, + Field( + description="When used, the search look for any sale order lines " # noqa + "where the order name contains the given value case insensitively." # noqa + ), + ] = None + product_name: Annotated[ + str | None, + Field( + description="When used, the search look for any sale order lines " # noqa + "where the product name contains the given value case insensitively." # noqa + ), + ] = None + rejected: Annotated[ + bool | None, + Field( + description="When used, the search also includes the " # noqa + "rejected sale order lines." # noqa + ), + ] = None + + def to_odoo_domain(self, env: api.Environment): + domain = [] + if self.order_name: + domain.append(("order_id.name", "ilike", self.order_name)) + + if self.product_name: + domain.append(("product_id.name", "ilike", self.product_name)) + + if not self.rejected: + domain.append(("request_rejected", "=", False)) + + return domain diff --git a/shopinvader_api_unit_request/security/sale_management_security.xml b/shopinvader_api_unit_request/security/sale_management_security.xml new file mode 100644 index 0000000000..fc5bb43d5a --- /dev/null +++ b/shopinvader_api_unit_request/security/sale_management_security.xml @@ -0,0 +1,70 @@ + + + + + + Shopinvader Unit Management Endpoint rule: sale order + + + [('partner_id.unit_id.manager_ids','=',authenticated_partner_id)] + + + + + Shopinvader Unit Management Endpoint rule: sale order line + + + [('order_id.partner_id.unit_id.manager_ids','=',authenticated_partner_id)] + + + + + Shopinvader Unit Management: user read/write/create sale order + + + + + + + + + + Shopinvader Unit Management: user read/write/create sale order line + + + + + + + + + diff --git a/shopinvader_api_unit_request/static/description/index.html b/shopinvader_api_unit_request/static/description/index.html new file mode 100644 index 0000000000..189cdf5d52 --- /dev/null +++ b/shopinvader_api_unit_request/static/description/index.html @@ -0,0 +1,442 @@ + + + + + + +Shopinvader Unit Request Api + + + +
+

Shopinvader Unit Request Api

+ + +

Beta License: AGPL-3 shopinvader/odoo-shopinvader

+

This module adds the possibility to make a request from a cart as +a collaborator of a unit to be later reviewed, merged and converted into a +sale order by a unit manager.

+

The cart_router has been extended to allow the creation of a request:

+
    +
  • POST /api/cart/request to create a request from the current cart.
  • +
  • POST /api/cart/current/request to create a request from the current cart.
  • +
  • POST /api/cart/:uuid/request to create a request from a specific cart.
  • +
+

The sale_router has been extended to allow the /api/sale_lines to also list the requested sale lines.

+

A new unit_request_line_router has been added to manage the requests:

+
    +
  • GET /api/unit_request_lines to list the requested lines.
  • +
  • GET /api/unit_request_lines/:id to get a specific requested line.
  • +
  • POST /api/unit_request_lines/:id/accept to accept a requested line.
  • +
  • POST /api/unit_request_lines/:id/reject to reject a requested line.
  • +
  • POST /api/unit_request_lines/:id/reset to reset the rejected status of a requested line.
  • +
+

NB: The deletion of an accepted line in the manager cart will put it back in the request.

+

Table of contents

+ +
+

Usage

+

The new routes are under the unit_request_lines.py python file in the routers folder.

+
+
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shopinvader_api_unit_request/tests/__init__.py b/shopinvader_api_unit_request/tests/__init__.py new file mode 100644 index 0000000000..565bc5ea26 --- /dev/null +++ b/shopinvader_api_unit_request/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shopinvader_api_unit_request diff --git a/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py b/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py new file mode 100644 index 0000000000..8847e03ed0 --- /dev/null +++ b/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py @@ -0,0 +1,554 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from contextlib import contextmanager + +from fastapi import status +from requests import Response + +from odoo.tests.common import RecordCapturer, tagged +from odoo.tools import mute_logger + +from odoo.addons.shopinvader_api_cart.tests.common import CommonSaleCart +from odoo.addons.shopinvader_api_sale.routers import sale_line_router +from odoo.addons.shopinvader_unit_management.tests.common import ( + TestUnitManagementCommon, +) + +from ..routers import cart_router, unit_request_line_router + + +@tagged("post_install", "-at_install") +class TestShopinvaderUnitCartApi(TestUnitManagementCommon, CommonSaleCart): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.env["res.users"].create( + { + "name": "Test User", + "login": "test_user", + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref( + "shopinvader_api_unit_member." + "shopinvader_unit_management_user_group" + ).id + ], + ) + ], + } + ) + + cls.default_fastapi_authenticated_partner = cls.collaborator_1_1.with_user( + cls.default_fastapi_running_user + ) + cls.default_fastapi_router = sale_line_router + cls.sale_line_app = cls.env.ref("fastapi.fastapi_endpoint_demo")._get_app() + cls.default_fastapi_router = cart_router + cls.default_fastapi_app = cls.cart_app = cls.env.ref( + "fastapi.fastapi_endpoint_demo" + )._get_app() + + cls.cart_1_1_pending = cls.env["sale.order"]._create_empty_cart( + cls.collaborator_1_1.id + ) + cls.cart_1_1_pending.write( + { + "order_line": [ + (0, 0, {"product_id": cls.product_1.id, "product_uom_qty": 8}), + ] + } + ) + cls.cart_1_1 = cls.env["sale.order"]._create_empty_cart(cls.collaborator_1_1.id) + cls.cart_1_1.write( + { + "order_line": [ + (0, 0, {"product_id": cls.product_1.id, "product_uom_qty": 2}), + (0, 0, {"product_id": cls.product_2.id, "product_uom_qty": 6}), + ] + } + ) + cls.cart_1_1.action_request_cart() + + cls.cart_1_2 = cls.env["sale.order"]._create_empty_cart(cls.collaborator_1_2.id) + cls.cart_1_2.write( + { + "order_line": [ + (0, 0, {"product_id": cls.product_1.id, "product_uom_qty": 3}), + ] + } + ) + cls.cart_1_2.action_request_cart() + cls.cart_3_2 = cls.env["sale.order"]._create_empty_cart(cls.collaborator_3_2.id) + cls.cart_3_2.write( + { + "order_line": [ + (0, 0, {"product_id": cls.product_2.id, "product_uom_qty": 4}), + ] + } + ) + cls.cart_3_2.action_request_cart() + cls.cart_1_1_manager = cls.env["sale.order"]._create_empty_cart( + cls.manager_1_1.id + ) + cls.cart_1_1_manager.write( + { + "order_line": [ + (0, 0, {"product_id": cls.product_1.id, "product_uom_qty": 12}), + (0, 0, {"product_id": cls.product_2.id, "product_uom_qty": 5}), + ] + } + ) + + def _slice_sol(self, data, *fields): + def get(item, field): + if "." in field: + field, subfield = field.split(".") + return get(item[field], subfield) + return item[field] + + if len(fields) == 1: + return {get(item, fields[0]) for item in data["items"]} + return {tuple(get(item, field) for field in fields) for item in data["items"]} + + @contextmanager + def _create_test_client(self, **kwargs): + kwargs.setdefault("raise_server_exceptions", False) + with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: + yield test_client + + def test_cart_request_as_collaborator(self): + """ + Test to request a cart as a collaborator + """ + so = self.env["sale.order"]._create_empty_cart( + self.default_fastapi_authenticated_partner.id + ) + self.assertEqual(so.state, "draft") + self.assertEqual(so.typology, "cart") + + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/{so.uuid}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + info = response.json() + self.assertEqual(info["id"], so.id) + + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/request", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + self.assertEqual(so.typology, "request") + + def test_cart_request_as_collaborator_uuid(self): + """ + Test to request a cart as a collaborator + """ + so = self.env["sale.order"]._create_empty_cart( + self.default_fastapi_authenticated_partner.id + ) + self.assertEqual(so.state, "draft") + self.assertEqual(so.typology, "cart") + + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/{so.uuid}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + info = response.json() + self.assertEqual(info["id"], so.id) + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/{so.uuid}/request", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + self.assertEqual(so.typology, "request") + + def test_cart_request_as_manager(self): + """ + Test to request a cart as a manager + """ + self.default_fastapi_authenticated_partner = self.manager_1_1.with_user( + self.default_fastapi_running_user + ) + + self.env["sale.order"]._create_empty_cart( + self.default_fastapi_authenticated_partner.id + ) + with self._create_test_client() as test_client: + response: Response = test_client.post("/request") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_cart_request_as_unit(self): + """ + Test to request a cart as a unit + """ + self.default_fastapi_authenticated_partner = self.unit_1.with_user( + self.default_fastapi_running_user + ) + + self.env["sale.order"]._create_empty_cart( + self.default_fastapi_authenticated_partner.id + ) + with self._create_test_client() as test_client: + response: Response = test_client.post("/request") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_cart_request_saved_quantities(self): + so = self.env["sale.order"]._create_empty_cart( + self.default_fastapi_authenticated_partner.id + ) + self.assertEqual(so.state, "draft") + self.assertEqual(so.typology, "cart") + + # Add a product to the cart + so.write( + { + "order_line": [ + (0, 0, {"product_id": self.product_1.id, "product_uom_qty": 2}), + (0, 0, {"product_id": self.product_2.id, "product_uom_qty": 6}), + ] + } + ) + + self.assertEqual(so.order_line[0].product_uom_qty, 2) + self.assertEqual(so.order_line[1].product_uom_qty, 6) + self.assertEqual(so.order_line[0].qty_requested, 0) + self.assertEqual(so.order_line[1].qty_requested, 0) + + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/request", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + self.assertEqual(so.typology, "request") + self.assertEqual(so.order_line[0].product_uom_qty, 2) + self.assertEqual(so.order_line[1].product_uom_qty, 6) + self.assertEqual(so.order_line[0].qty_requested, 2) + self.assertEqual(so.order_line[1].qty_requested, 6) + + def test_sale_line_requested_flow(self): + so = self.env["sale.order"]._create_empty_cart(self.manager_2_1.id) + so1 = self.env["sale.order"]._create_empty_cart(self.collaborator_2_1.id) + so1.write( + { + "order_line": [ + (0, 0, {"product_id": self.product_1.id, "product_uom_qty": 2}), + (0, 0, {"product_id": self.product_2.id, "product_uom_qty": 6}), + ] + } + ) + so1.action_confirm_cart() + so2 = self.env["sale.order"]._create_empty_cart(self.collaborator_2_1.id) + so2.write( + { + "order_line": [ + (0, 0, {"product_id": self.product_1.id, "product_uom_qty": 1}), + (0, 0, {"product_id": self.product_2.id, "product_uom_qty": 3}), + (0, 0, {"product_id": self.product_1.id, "product_uom_qty": 4}), + ] + } + ) + + with self._create_test_client(partner=self.collaborator_2_1) as test_client: + response: Response = test_client.post(f"/{so2.uuid}/request") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + self.assertEqual(so1.typology, "sale") + self.assertEqual(so2.typology, "request") + + with self._create_test_client( + app=self.sale_line_app, + router=sale_line_router, + partner=self.collaborator_2_1, + ) as test_client: + response: Response = test_client.get("/sale_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + self.assertEqual(response.json()["count"], 5) + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_2_1, + ) as test_client: + response: Response = test_client.get("/unit/request_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + self.assertEqual(response.json()["count"], 3) + accepted_sols = so2.order_line[:2] + accepted_sols._action_accept_request(so) + + with self._create_test_client( + app=self.sale_line_app, + router=sale_line_router, + partner=self.collaborator_2_1, + ) as test_client: + response: Response = test_client.get("/sale_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + data = response.json() + self.assertEqual(data["count"], 5) + for sol in accepted_sols: + item = next(item for item in data["items"] if item["id"] == sol.id) + self.assertEqual(item["request_order_id"], so2.id) + self.assertEqual(item["request_partner_id"], self.collaborator_2_1.id) + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_2_1, + ) as test_client: + response: Response = test_client.get("/unit/request_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + self.assertEqual(response.json()["count"], 1) + + def test_sale_line_requested_as_collaborator(self): + with self._create_test_client( + app=self.sale_line_app, router=unit_request_line_router + ) as test_client: + response: Response = test_client.get("/unit/request_lines") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_sale_line_requested_filters(self): + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.get("/unit/request_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + res = response.json() + self.assertEqual(res["count"], 3) + self.assertEqual( + self._slice_sol(res, "product_id", "qty", "order.id"), + { + (self.product_1.id, 2, self.cart_1_1.id), + (self.product_2.id, 6, self.cart_1_1.id), + (self.product_1.id, 3, self.cart_1_2.id), + }, + ) + + def test_sale_line_requested_accept(self): + so = self.env["sale.order"]._create_empty_cart(self.manager_1_1.id) + + sol = self.cart_1_1.order_line.filtered( + lambda p: p.product_id == self.product_1 + ) + self.assertEqual(sol.qty_requested, 2) + self.assertEqual(sol.request_partner_id, self.collaborator_1_1) + self.assertFalse(sol.request_order_id) + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.post( + f"/unit/request_lines/{sol.id}/accept" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + self.assertEqual(sol.qty_requested, 2) + self.assertEqual(sol.request_partner_id, self.collaborator_1_1) + self.assertEqual(sol.order_id, so) + self.assertEqual(sol.request_order_id, self.cart_1_1) + self.assertFalse(sol.reject_order_id) + self.assertEqual(len(so.order_line), 1) + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.get("/unit/request_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + res = response.json() + self.assertEqual(res["count"], 2) + self.assertEqual( + self._slice_sol(res, "product_id", "qty", "order.id"), + { + (self.product_2.id, 6, self.cart_1_1.id), + (self.product_1.id, 3, self.cart_1_2.id), + }, + ) + + with self._create_test_client(partner=self.manager_1_1) as test_client: + response: Response = test_client.get(f"/{so.uuid}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + res = response.json() + self.assertEqual(res["id"], so.id) + self.assertEqual(len(res["lines"]), 1) + self.assertEqual(res["lines"][0]["product_id"], self.product_1.id) + self.assertEqual(res["lines"][0]["qty"], 2) + + def test_sale_line_requested_reject(self): + so = self.env["sale.order"]._create_empty_cart(self.manager_1_1.id) + sol = self.cart_1_1.order_line.filtered( + lambda p: p.product_id == self.product_1 + ) + self.assertEqual(sol.qty_requested, 2) + self.assertEqual(sol.request_partner_id, self.collaborator_1_1) + self.assertFalse(sol.request_order_id) + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.post( + f"/unit/request_lines/{sol.id}/reject", + data=json.dumps({"reason": "Don't Want"}), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + self.assertEqual(sol.qty_requested, 2) + self.assertEqual(sol.request_partner_id, self.collaborator_1_1) + self.assertEqual(sol.order_id, self.cart_1_1) + self.assertFalse(sol.request_order_id) + self.assertEqual(sol.reject_order_id, so) + self.assertTrue(sol.request_rejected) + self.assertEqual(sol.request_rejection_reason, "Don't Want") + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.get("/unit/request_lines") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + res = response.json() + self.assertEqual(res["count"], 2) + self.assertEqual( + self._slice_sol(res, "product_id", "qty", "order.id"), + { + (self.product_2.id, 6, self.cart_1_1.id), + (self.product_1.id, 3, self.cart_1_2.id), + }, + ) + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.get( + "/unit/request_lines", params={"rejected": True} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + res = response.json() + self.assertEqual(res["count"], 3) + self.assertEqual( + self._slice_sol( + res, + "product_id", + "qty", + "order.id", + "request_rejected", + "request_rejection_reason", + ), + { + (self.product_1.id, 2, self.cart_1_1.id, True, "Don't Want"), + (self.product_2.id, 6, self.cart_1_1.id, False, None), + (self.product_1.id, 3, self.cart_1_2.id, False, None), + }, + ) + + def test_sale_line_requested_reset(self): + so = self.env["sale.order"]._create_empty_cart(self.manager_1_1.id) + sol = self.cart_1_1.order_line.filtered( + lambda p: p.product_id == self.product_1 + ) + sol._action_reject_request(so, "Don't Want") + self.assertTrue(sol.request_rejected) + self.assertEqual(sol.request_rejection_reason, "Don't Want") + + with self._create_test_client( + app=self.sale_line_app, + router=unit_request_line_router, + partner=self.manager_1_1, + ) as test_client: + response: Response = test_client.post( + f"/unit/request_lines/{sol.id}/reset", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + self.assertFalse(sol.request_rejected) + self.assertFalse(sol.request_rejection_reason) + + def test_sale_line_unlink_accepted(self): + so = self.env["sale.order"]._create_empty_cart(self.manager_1_1.id) + sol = self.cart_1_1.order_line.filtered( + lambda p: p.product_id == self.product_1 + ) + + sol._action_accept_request(so) + sol.product_uom_qty = 1 + + self.assertEqual(sol.order_id, so) + self.assertEqual(sol.request_order_id, self.cart_1_1) + self.assertEqual(sol.request_partner_id, self.collaborator_1_1) + self.assertEqual(sol.product_uom_qty, 1) + self.assertEqual(sol.qty_requested, 2) + + sol.unlink() + + self.assertEqual(sol.order_id, self.cart_1_1) + self.assertFalse(sol.request_order_id) + self.assertEqual(sol.request_partner_id, self.collaborator_1_1) + self.assertEqual(sol.product_uom_qty, 2) + self.assertEqual(sol.qty_requested, 2) + + def test_cart_confirm_notify_collaborators(self): + so = self.env["sale.order"]._create_empty_cart(self.manager_1_1.id) + sol = self.cart_1_1.order_line.filtered( + lambda p: p.product_id == self.product_1 + ) + sol2 = self.cart_1_1.order_line.filtered( + lambda p: p.product_id == self.product_2 + ) + sol._action_accept_request(so) + sol2._action_reject_request(so, "Nope") + self.cart_1_2.order_line._action_accept_request(so) + + def mail_domain_for(partner): + return [ + ("res_id", "=", partner.id), + ("model", "=", "res.partner"), + ("message_type", "=", "notification"), + ] + + with RecordCapturer( + self.env["mail.message"], mail_domain_for(self.collaborator_1_1) + ) as messages_1_1, RecordCapturer( + self.env["mail.message"], mail_domain_for(self.collaborator_1_2) + ) as messages_1_2, RecordCapturer( + self.env["mail.message"], mail_domain_for(self.manager_1_1) + ) as messages_manager_1_1: + so.action_confirm() + + # Check that the partners have been notified + self.assertEqual(len(messages_1_1.records), 1) + self.assertEqual(len(messages_1_2.records), 1) + message = messages_1_1.records + self.assertIn("Your following requests have been accepted:", message.body) + self.assertIn("product_1 - 2.0", message.body) + self.assertIn("Your following requests have been rejected:", message.body) + self.assertIn("product_2 - 6.0: Nope", message.body) + message = messages_1_2.records + self.assertIn("Your following requests have been accepted:", message.body) + self.assertIn("product_1 - 3.0", message.body) + self.assertEqual(len(messages_manager_1_1.records), 0) diff --git a/shopinvader_api_unit_request/views/sale_order_views.xml b/shopinvader_api_unit_request/views/sale_order_views.xml new file mode 100644 index 0000000000..b4ad796bab --- /dev/null +++ b/shopinvader_api_unit_request/views/sale_order_views.xml @@ -0,0 +1,62 @@ + + + + + + + sale.order + + + + + + + + + + + + + + + + + + + + + + + + Requests + sale.order + tree,form,calendar,graph + + {'default_typology': 'request'} + [('typology', '=', 'request')] + + + + diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/README.rst b/shopinvader_fastapi_auth_partner_api_unit_member/README.rst new file mode 100644 index 0000000000..33723bfef6 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/README.rst @@ -0,0 +1,67 @@ +================================================ +Shopinvader Fastapi Auth Partner Api Unit Member +================================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:46bd8663388a1f03209619852bf7bec6c34a32457d19e688254400b19449d058 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_fastapi_auth_partner_api_unit_member + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module glues the `shopinvader_fastapi_auth_partner_api` and the `shopinvader_api_unit_member` modules. + +It adds a auth_state field to the unit member and defines this member route: + +- `POST /unit/members/:id/invite` to add an auth.partner to the unit member allowing it to sign in and send an invite email to it. + + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* `Akretion `_: + + * Florian Mounier + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/__init__.py b/shopinvader_fastapi_auth_partner_api_unit_member/__init__.py new file mode 100644 index 0000000000..797e7dcb45 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import routers +from . import schemas diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/__manifest__.py b/shopinvader_fastapi_auth_partner_api_unit_member/__manifest__.py new file mode 100644 index 0000000000..ba8ce2b372 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Fastapi Auth Partner Api Unit Member", + "summary": "This module glues the shopinvader unit member management with " + "the fastapi auth partner api adding an auth state on members and an invite " + "route.", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "shopinvader_api_unit_member", + "shopinvader_fastapi_auth_partner", + ], + "data": [ + "data/email_data.xml", + "views/auth_directory_view.xml", + ], + "auto_install": True, + "installable": True, +} diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/data/email_data.xml b/shopinvader_fastapi_auth_partner_api_unit_member/data/email_data.xml new file mode 100644 index 0000000000..12fdefd1d3 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/data/email_data.xml @@ -0,0 +1,27 @@ + + + + Auth Directory: Email Already Existing + noreply@example.org + Someone tried to invite you in an other team + {{object.partner_id.id}} + + + {{object.partner_id.lang}} + +
+ Hi + + + +

+ tried to invite you in the team + but you already have an + other account, please contact if + you want to change your team. +

+
+
+
+ +
diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/models/__init__.py b/shopinvader_fastapi_auth_partner_api_unit_member/models/__init__.py new file mode 100644 index 0000000000..bcb3373f36 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/models/__init__.py @@ -0,0 +1,2 @@ +from . import auth_directory +from . import res_partner diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/models/auth_directory.py b/shopinvader_fastapi_auth_partner_api_unit_member/models/auth_directory.py new file mode 100644 index 0000000000..6180775cba --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/models/auth_directory.py @@ -0,0 +1,19 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AuthDirectory(models.Model): + _inherit = "auth.directory" + + member_existing_template_id = fields.Many2one( + "mail.template", + "Mail Template Unit Member Already Existing", + required=False, + default=lambda self: self.env.ref( + "shopinvader_fastapi_auth_partner_api_unit_member.email_already_existing", + raise_if_not_found=False, + ), + ) diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/models/res_partner.py b/shopinvader_fastapi_auth_partner_api_unit_member/models/res_partner.py new file mode 100644 index 0000000000..016d8e255b --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/models/res_partner.py @@ -0,0 +1,88 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from ..schemas import AuthState + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + member_auth_state = fields.Selection( + selection=[ + (AuthState.none.value, "None"), + (AuthState.invited.value, "Invited"), + (AuthState.accepted.value, "Accepted"), + ], + compute="_compute_member_auth_state", + compute_sudo=True, + ) + + @api.depends("auth_partner_ids.password", "auth_partner_ids.encrypted_password") + def _compute_member_auth_state(self): + for record in self: + auth = record.auth_partner_ids + if not auth: + record.member_auth_state = AuthState.none.value + continue + + if len(auth) > 1: + _logger.warning( + "Multiple auth_partner_ids for unit member partner %s, " + "using the first one", + record, + ) + auth = auth[0] + + if auth.password or auth.encrypted_password: + record.member_auth_state = AuthState.accepted.value + else: + record.member_auth_state = AuthState.invited.value + + @api.model + def _invite_shopinvader_unit_member(self, member_id, directory): + self._ensure_manager() + member = self.browse(member_id) + self._ensure_same_unit(member) + if not member.email: + raise UserError(_("Cannot invite a member without an email")) + + auth_with_email = self.env["auth.partner"].search( + [ + ("login", "=", member.email), + ("directory_id", "=", directory.id), + ], + ) + + if auth_with_email: + # If another member with the same email is already in the directory, + if auth_with_email not in member.auth_partner_ids: + directory._send_mail_background( + "member_existing", + auth_with_email, + member=member, + manager=self, + ) + raise UserError( + # Do not leak the information that the email is already in use + _("Something went wrong, please contact the administrator"), + ) + + member_auth = auth_with_email + else: + member_auth = self.env["auth.partner"].create( + { + "partner_id": member.id, + "login": member.email, + "directory_id": directory.id, + } + ) + + member_auth._send_invite() + return member diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/readme/CONTRIBUTORS.rst b/shopinvader_fastapi_auth_partner_api_unit_member/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a4d0ad9229 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/readme/DESCRIPTION.rst b/shopinvader_fastapi_auth_partner_api_unit_member/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..4f84ce719c --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +This module glues the `shopinvader_fastapi_auth_partner_api` and the `shopinvader_api_unit_member` modules. + +It adds a auth_state field to the unit member and defines this member route: + +- `POST /unit/members/:id/invite` to add an auth.partner to the unit member allowing it to sign in and send an invite email to it. + diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/routers/__init__.py b/shopinvader_fastapi_auth_partner_api_unit_member/routers/__init__.py new file mode 100644 index 0000000000..d7352f9313 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/routers/__init__.py @@ -0,0 +1 @@ +from .unit_members import unit_member_router diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/routers/unit_members.py b/shopinvader_fastapi_auth_partner_api_unit_member/routers/unit_members.py new file mode 100644 index 0000000000..0497a4ebfa --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/routers/unit_members.py @@ -0,0 +1,30 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Annotated + +from fastapi import Depends + +from odoo.addons.base.models.res_partner import Partner as ResPartner +from odoo.addons.fastapi.dependencies import fastapi_endpoint +from odoo.addons.fastapi.models import FastapiEndpoint +from odoo.addons.shopinvader_api_unit_member.routers import unit_member_router +from odoo.addons.shopinvader_api_unit_member.routers.unit_members import ( + authenticated_manager, +) + +from ..schemas import UnitMember + + +@unit_member_router.post("/unit/members/{member_id}/invite") +async def invite_unit_member( + partner: Annotated[ResPartner, Depends(authenticated_manager)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + member_id: int, +) -> UnitMember: + """ + Invite a unit member to sign in. + """ + member = partner._invite_shopinvader_unit_member(member_id, endpoint.directory_id) + return UnitMember.from_res_partner(member) diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/schemas.py b/shopinvader_fastapi_auth_partner_api_unit_member/schemas.py new file mode 100644 index 0000000000..99c4030edf --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/schemas.py @@ -0,0 +1,23 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from enum import Enum + +from odoo.addons.shopinvader_api_unit_member.schemas import UnitMember as UnitMemberBase + + +class AuthState(str, Enum): + none = "none" + invited = "invited" + accepted = "accepted" + + +class UnitMember(UnitMemberBase, extends=True): + auth_state: AuthState + + @classmethod + def from_res_partner(cls, odoo_rec): + res = super().from_res_partner(odoo_rec) + res.auth_state = odoo_rec.member_auth_state + return res diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/static/description/index.html b/shopinvader_fastapi_auth_partner_api_unit_member/static/description/index.html new file mode 100644 index 0000000000..e76699fe25 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/static/description/index.html @@ -0,0 +1,424 @@ + + + + + + +Shopinvader Fastapi Auth Partner Api Unit Member + + + +
+

Shopinvader Fastapi Auth Partner Api Unit Member

+ + +

Beta License: AGPL-3 shopinvader/odoo-shopinvader

+

This module glues the shopinvader_fastapi_auth_partner_api and the shopinvader_api_unit_member modules.

+

It adds a auth_state field to the unit member and defines this member route:

+
    +
  • POST /unit/members/:id/invite to add an auth.partner to the unit member allowing it to sign in and send an invite email to it.
  • +
+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/tests/__init__.py b/shopinvader_fastapi_auth_partner_api_unit_member/tests/__init__.py new file mode 100644 index 0000000000..722a620468 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shopinvader_api_unit_members diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/tests/test_shopinvader_api_unit_members.py b/shopinvader_fastapi_auth_partner_api_unit_member/tests/test_shopinvader_api_unit_members.py new file mode 100644 index 0000000000..3cd0b6aa83 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -0,0 +1,266 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager +from functools import partial + +from fastapi import status +from requests import Response + +from odoo.tests.common import tagged +from odoo.tools import mute_logger + +from odoo.addons.auth_partner.tests.common import CommonTestAuthPartner +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase +from odoo.addons.fastapi.dependencies import fastapi_endpoint +from odoo.addons.shopinvader_unit_management.tests.common import ( + TestUnitManagementCommon, +) + +from ..routers import unit_member_router + + +@tagged("post_install", "-at_install") +class TestShopinvaderFastapiAuthPartnerApiUnitMember( + FastAPITransactionCase, TestUnitManagementCommon, CommonTestAuthPartner +): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, queue_job__no_delay=True)) + cls.directory.member_existing_template_id = cls.env.ref( + "shopinvader_fastapi_auth_partner_api_unit_member.email_already_existing", + ) + # Set emails + managers = [1, 2, 0, 2] + collaborators = [5, 3, 3, 0] + for unit in range(1, 5): + for manager in range(1, 1 + managers[unit - 1]): + mail = f"manager_{unit}_{manager}@example.org" + manager = getattr( + cls, + f"manager_{unit}_{manager}", + ) + manager.email = mail + for collaborator in range(1, 1 + collaborators[unit - 1]): + mail = f"collaborator_{unit}_{collaborator}@example.org" + collaborator = getattr( + cls, + f"collaborator_{unit}_{collaborator}", + ) + collaborator.email = mail + + cls.env["res.users"].create( + { + "name": "Test User", + "login": "test_user", + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref( + "shopinvader_api_unit_member." + "shopinvader_unit_management_user_group" + ).id + ], + ) + ], + } + ) + + cls.default_fastapi_authenticated_partner = cls.manager_1_1 + cls.default_fastapi_router = unit_member_router + cls.demo_app = cls.env.ref("fastapi_auth_partner.fastapi_endpoint_demo") + cls.default_fastapi_app = cls.demo_app._get_app() + cls.default_fastapi_dependency_overrides = { + fastapi_endpoint: partial(lambda a: a, cls.demo_app) + } + + @contextmanager + def _create_test_client(self, **kwargs): + kwargs.setdefault("raise_server_exceptions", False) + with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: + yield test_client + + def test_unit_auth_state(self): + self.default_fastapi_authenticated_partner = self.manager_1_1 + + with self._create_test_client() as test_client: + response: Response = test_client.get("/unit/members") + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + members = response.json() + self.assertEqual(len(members), 6) + for member in members: + self.assertEqual(member["auth_state"], "none") + + def test_invite_unit_member_as_collaborator(self): + self.default_fastapi_authenticated_partner = self.collaborator_1_1 + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_2.id}/invite" + ) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_invite_unit_member_as_manager(self): + self.default_fastapi_authenticated_partner = self.manager_1_1 + self.assertFalse(self.collaborator_1_2.auth_partner_ids) + auth_partner_len = self.env["auth.partner"].search_count([]) + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_2.id}/invite" + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertTrue(self.collaborator_1_2.auth_partner_ids) + self.assertEqual( + self.env["auth.partner"].search_count([]), auth_partner_len + 1 + ) + self.assertEqual(len(new_mails), 1) + self.assertEqual( + new_mails.subject, + "Welcome", + ) + self.assertIn( + "your account have been created", + new_mails.body, + ) + + def test_invite_existing_unit_member_as_manager(self): + self.default_fastapi_authenticated_partner = self.manager_1_1 + self.collaborator_1_2.auth_partner_ids = [ + ( + 0, + 0, + { + "login": self.collaborator_1_2.email, + "directory_id": self.directory.id, + }, + ) + ] + + self.assertTrue(self.collaborator_1_2.auth_partner_ids) + auth_partner_len = self.env["auth.partner"].search_count([]) + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_2.id}/invite" + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertTrue(self.collaborator_1_2.auth_partner_ids) + self.assertEqual(self.env["auth.partner"].search_count([]), auth_partner_len) + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.partner_ids, self.collaborator_1_2) + self.assertEqual( + new_mails.subject, + "Welcome", + ) + self.assertIn( + "your account have been created", + new_mails.body, + ) + + def test_invite_existing_other_unit_member_as_manager(self): + self.default_fastapi_authenticated_partner = self.manager_1_1 + self.collaborator_2_1.email = self.collaborator_1_2.email + self.collaborator_2_1.auth_partner_ids = [ + ( + 0, + 0, + { + "login": self.collaborator_2_1.email, + "directory_id": self.directory.id, + }, + ) + ] + + self.assertFalse(self.collaborator_1_2.auth_partner_ids) + auth_partner_len = self.env["auth.partner"].search_count([]) + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_2.id}/invite" + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertFalse(self.collaborator_1_2.auth_partner_ids) + self.assertEqual(self.env["auth.partner"].search_count([]), auth_partner_len) + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.partner_ids, self.collaborator_2_1) + self.assertEqual( + new_mails.subject, + "Someone tried to invite you in an other team", + ) + self.assertIn( + "Hi Collaborator 2.1", + new_mails.body, + ) + self.assertIn( + "Manager 1.1 tried to invite you in the team", + new_mails.body, + ) + self.assertIn( + "Unit 1 but you already have an", + new_mails.body, + ) + self.assertIn( + "other account, please contact Manager 1.1", + new_mails.body, + ) + + def test_invite_unit_auth_state(self): + self.default_fastapi_authenticated_partner = self.manager_1_1 + + with self._create_test_client() as test_client: + response: Response = test_client.get( + f"/unit/members/{self.collaborator_1_2.id}", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + self.assertEqual(member["auth_state"], "none") + + with self._create_test_client() as test_client, self.new_mails(): + response: Response = test_client.post( + f"/unit/members/{self.collaborator_1_2.id}/invite" + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + self.assertEqual(member["auth_state"], "invited") + + self.collaborator_1_2.auth_partner_ids.write({"password": "test"}) + + with self._create_test_client() as test_client: + response: Response = test_client.get( + f"/unit/members/{self.collaborator_1_2.id}", + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + member = response.json() + self.assertEqual(member["auth_state"], "accepted") diff --git a/shopinvader_fastapi_auth_partner_api_unit_member/views/auth_directory_view.xml b/shopinvader_fastapi_auth_partner_api_unit_member/views/auth_directory_view.xml new file mode 100644 index 0000000000..dbb85502b4 --- /dev/null +++ b/shopinvader_fastapi_auth_partner_api_unit_member/views/auth_directory_view.xml @@ -0,0 +1,14 @@ + + + + + auth.directory + + + + + + + + + diff --git a/shopinvader_unit_management/README.rst b/shopinvader_unit_management/README.rst new file mode 100644 index 0000000000..a14a8fe280 --- /dev/null +++ b/shopinvader_unit_management/README.rst @@ -0,0 +1,66 @@ +=========================== +Shopinvader Unit Management +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c85b9aaf1e5ec9ff4516960b20e84bae393a2240bd14e62e11d410ca7d6d6028 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_unit_management + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module introduces the concept of unit management. +The unit is a group of partners with managers and collaborators. +This module provides a simple implementation of the unit management concept. + +To cater to your needs, you can inherit the res.partner model and make the unit_profile a computed field. + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* `Akretion `_: + + * Florian Mounier + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_unit_management/__init__.py b/shopinvader_unit_management/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/shopinvader_unit_management/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/shopinvader_unit_management/__manifest__.py b/shopinvader_unit_management/__manifest__.py new file mode 100644 index 0000000000..84613e52a0 --- /dev/null +++ b/shopinvader_unit_management/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Unit Management", + "summary": "This module introduces the concept of unit management. " + "The unit is a group of partners with managers and collaborators. " + "This module provides a simple implementation of the unit management " + "concept.", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["shopinvader_address"], + "demo": [ + "demo/res_partner_demo.xml", + ], +} diff --git a/shopinvader_unit_management/demo/res_partner_demo.xml b/shopinvader_unit_management/demo/res_partner_demo.xml new file mode 100644 index 0000000000..f925e8f377 --- /dev/null +++ b/shopinvader_unit_management/demo/res_partner_demo.xml @@ -0,0 +1,133 @@ + + + + + Unit 1 + unit + + + Unit 2 + unit + + + Unit 3 + unit + + + Unit 4 + unit + + + + + Manager 1.1 + manager + + + + + Collaborator 1.1 + collaborator + + + + + Collaborator 1.2 + collaborator + + + + + Collaborator 1.3 + collaborator + + + + + Collaborator 1.4 + collaborator + + + + + Collaborator 1.5 + collaborator + + + + + Other 1.1 + + + + + Other 1.2 + + + + + + Manager 2.1 + manager + + + + + Manager 2.2 + manager + + + + + Collaborator 2.1 + collaborator + + + + + Collaborator 2.2 + collaborator + + + + + Collaborator 2.3 + collaborator + + + + + + Collaborator 3.1 + collaborator + + + + + Collaborator 3.2 + collaborator + + + + + Collaborator 3.3 + collaborator + + + + + + Manager 4.1 + manager + + + + + Manager 4.2 + manager + + + + diff --git a/shopinvader_unit_management/models/__init__.py b/shopinvader_unit_management/models/__init__.py new file mode 100644 index 0000000000..91fed54d40 --- /dev/null +++ b/shopinvader_unit_management/models/__init__.py @@ -0,0 +1 @@ +from . import res_partner diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py new file mode 100644 index 0000000000..8ea472931e --- /dev/null +++ b/shopinvader_unit_management/models/res_partner.py @@ -0,0 +1,122 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, MissingError, ValidationError + + +class ResPartner(models.Model): + _inherit = "res.partner" + + unit_profile = fields.Selection( + selection=[ + ("unit", "Unit"), + ("manager", "Unit Manager"), + ("collaborator", "Unit Collaborator"), + ], + required=False, + ) + + unit_id = fields.Many2one("res.partner", related="parent_id", string="Unit") + + manager_ids = fields.One2many( + "res.partner", "unit_id", domain=[("unit_profile", "=", "manager")] + ) + collaborator_ids = fields.One2many( + "res.partner", "unit_id", domain=[("unit_profile", "=", "collaborator")] + ) + member_ids = fields.One2many( + "res.partner", + "unit_id", + domain=[("unit_profile", "in", ["manager", "collaborator"])], + ) + + def _ensure_manager(self): + """Ensure the partner is a manager.""" + if not self.unit_profile == "manager": + raise AccessError(_("Only a manager can perform this action.")) + + def _ensure_same_unit(self, member): + """Ensure the member is in the same unit.""" + if not member or member.unit_id != self.unit_id: + raise MissingError(_("Member not found")) + + @api.model + def _get_shopinvader_unit_members(self): + self._ensure_manager() + return self.unit_id.member_ids + + @api.model + def _get_shopinvader_unit_member(self, member_id): + self._ensure_manager() + member = self.browse(member_id) + self._ensure_same_unit(member) + return member + + @api.model + def _create_shopinvader_unit_member(self, vals): + self._ensure_manager() + + # FIXME: The related field can be overriden + def get_related(field): + related = self._fields[field].related + if isinstance(related, str): + return related + return ".".join(related) + + vals[get_related("unit_id")] = self.unit_id.id + + if "unit_profile" not in vals: + vals["unit_profile"] = "collaborator" + if vals["unit_profile"] not in dict(self._fields["unit_profile"].selection): + raise ValidationError(_("Invalid member type")) + if vals["unit_profile"] not in ["collaborator", "manager"]: + raise AccessError(_("Only collaborators and managers can be created")) + return self.sudo().create(vals) + + @api.model + def _update_shopinvader_unit_member(self, member_id, vals): + self._ensure_manager() + member = self.browse(member_id) + self._ensure_same_unit(member) + if member.unit_profile not in ["collaborator", "manager"]: + raise AccessError(_("Cannot perform this action on this member")) + member.sudo().write(vals) + return member + + @api.model + def _delete_shopinvader_unit_member(self, member_id): + self._ensure_manager() + member = self.browse(member_id) + self._ensure_same_unit(member) + if member.unit_profile not in ["collaborator", "manager"]: + raise AccessError(_("Cannot perform this action on this member")) + member.sudo().active = False + return member + + # Address overrides + def _get_shopinvader_invoicing_addresses(self) -> "ResPartner": + # A unit member invoice on unit + if self.unit_id: + return self.unit_id._get_shopinvader_invoicing_addresses() + return super()._get_shopinvader_invoicing_addresses() + + def _get_shopinvader_delivery_addresses(self) -> "ResPartner": + # A unit member deliver at unit + if self.unit_id: + return self.unit_id._get_shopinvader_delivery_addresses() + return super()._get_shopinvader_delivery_addresses() + + def _get_shopinvader_invoicing_address(self, address_id: int) -> "ResPartner": + if self.unit_id: + raise AccessError(_("Cannot alter a unit invoicing address")) + return super()._get_shopinvader_invoicing_address(address_id) + + def _get_shopinvader_delivery_address(self, address_id: int) -> "ResPartner": + if self.unit_id: + address = self.unit_id._get_shopinvader_delivery_address(address_id) + if address: + self._ensure_manager() + return address + return super()._get_shopinvader_delivery_address(address_id) diff --git a/shopinvader_unit_management/readme/CONTRIBUTORS.rst b/shopinvader_unit_management/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a4d0ad9229 --- /dev/null +++ b/shopinvader_unit_management/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/shopinvader_unit_management/readme/DESCRIPTION.rst b/shopinvader_unit_management/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..42df1f2850 --- /dev/null +++ b/shopinvader_unit_management/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module introduces the concept of unit management. +The unit is a group of partners with managers and collaborators. +This module provides a simple implementation of the unit management concept. + +To cater to your needs, you can inherit the res.partner model and make the unit_profile a computed field. diff --git a/shopinvader_unit_management/static/description/index.html b/shopinvader_unit_management/static/description/index.html new file mode 100644 index 0000000000..23a5cea897 --- /dev/null +++ b/shopinvader_unit_management/static/description/index.html @@ -0,0 +1,423 @@ + + + + + + +Shopinvader Unit Management + + + +
+

Shopinvader Unit Management

+ + +

Beta License: AGPL-3 shopinvader/odoo-shopinvader

+

This module introduces the concept of unit management. +The unit is a group of partners with managers and collaborators. +This module provides a simple implementation of the unit management concept.

+

To cater to your needs, you can inherit the res.partner model and make the unit_profile a computed field.

+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shopinvader_unit_management/tests/__init__.py b/shopinvader_unit_management/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopinvader_unit_management/tests/common.py b/shopinvader_unit_management/tests/common.py new file mode 100644 index 0000000000..0e58c3b552 --- /dev/null +++ b/shopinvader_unit_management/tests/common.py @@ -0,0 +1,36 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestUnitManagementCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Set up demo data: + managers = [1, 2, 0, 2] + collaborators = [5, 3, 3, 0] + for unit in range(1, 5): + setattr( + cls, + f"unit_{unit}", + cls.env.ref(f"shopinvader_unit_management.unit_{unit}"), + ) + for manager in range(1, 1 + managers[unit - 1]): + setattr( + cls, + f"manager_{unit}_{manager}", + cls.env.ref( + f"shopinvader_unit_management.unit_{unit}_manager_{manager}" + ), + ) + for collaborator in range(1, 1 + collaborators[unit - 1]): + setattr( + cls, + f"collaborator_{unit}_{collaborator}", + cls.env.ref( + f"shopinvader_unit_management.unit_{unit}_collaborator_{collaborator}" + ), + ) diff --git a/shopinvader_unit_management/tests/test_unit_management.py b/shopinvader_unit_management/tests/test_unit_management.py new file mode 100644 index 0000000000..a4043f3582 --- /dev/null +++ b/shopinvader_unit_management/tests/test_unit_management.py @@ -0,0 +1,310 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, MissingError + +from .common import TestUnitManagementCommon + + +class TestUnitManagement(TestUnitManagementCommon): + def test_unit_management_units(self): + self.assertEqual(self.unit_1.unit_profile, "unit") + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + self.assertEqual(self.unit_1.manager_ids, self.manager_1_1) + self.assertEqual( + self.unit_1.collaborator_ids, + self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + self.assertFalse(self.unit_1.unit_id) + + self.assertEqual(self.unit_2.unit_profile, "unit") + self.assertEqual( + self.unit_2.member_ids, + self.manager_2_1 + | self.manager_2_2 + | self.collaborator_2_1 + | self.collaborator_2_2 + | self.collaborator_2_3, + ) + self.assertEqual(self.unit_2.manager_ids, self.manager_2_1 | self.manager_2_2) + self.assertEqual( + self.unit_2.collaborator_ids, + self.collaborator_2_1 | self.collaborator_2_2 | self.collaborator_2_3, + ) + self.assertFalse(self.unit_2.unit_id) + + self.assertEqual(self.unit_3.unit_profile, "unit") + self.assertEqual( + self.unit_3.member_ids, + self.collaborator_3_1 | self.collaborator_3_2 | self.collaborator_3_3, + ) + self.assertEqual(self.unit_3.manager_ids, self.env["res.partner"]) + self.assertEqual( + self.unit_3.collaborator_ids, + self.collaborator_3_1 | self.collaborator_3_2 | self.collaborator_3_3, + ) + self.assertFalse(self.unit_3.unit_id) + + self.assertEqual(self.unit_4.unit_profile, "unit") + self.assertEqual(self.unit_4.member_ids, self.manager_4_1 | self.manager_4_2) + self.assertEqual(self.unit_4.manager_ids, self.manager_4_1 | self.manager_4_2) + self.assertEqual( + self.unit_4.collaborator_ids, + self.env["res.partner"], + ) + self.assertFalse(self.unit_4.unit_id) + + def test_unit_management_managers(self): + self.assertEqual(self.manager_1_1.unit_profile, "manager") + self.assertEqual(self.manager_1_1.unit_id, self.unit_1) + + self.assertFalse(self.manager_1_1.member_ids) + self.assertFalse(self.manager_1_1.manager_ids) + self.assertFalse(self.manager_1_1.collaborator_ids) + + self.assertEqual(self.manager_4_2.unit_profile, "manager") + self.assertEqual(self.manager_4_2.unit_id, self.unit_4) + + self.assertFalse(self.manager_4_2.member_ids) + self.assertFalse(self.manager_4_2.manager_ids) + self.assertFalse(self.manager_4_2.collaborator_ids) + + def test_unit_management_collaborators(self): + self.assertEqual(self.collaborator_1_1.unit_profile, "collaborator") + self.assertEqual(self.collaborator_1_1.unit_id, self.unit_1) + + self.assertFalse(self.collaborator_1_1.member_ids) + self.assertFalse(self.collaborator_1_1.manager_ids) + self.assertFalse(self.collaborator_1_1.collaborator_ids) + + self.assertEqual(self.collaborator_3_3.unit_profile, "collaborator") + self.assertEqual(self.collaborator_3_3.unit_id, self.unit_3) + + self.assertFalse(self.collaborator_3_3.member_ids) + self.assertFalse(self.collaborator_3_3.manager_ids) + self.assertFalse(self.collaborator_3_3.collaborator_ids) + + def test_unit_invoicing_addresses(self): + self.assertEqual( + self.collaborator_1_1._get_shopinvader_invoicing_addresses(), + self.unit_1, + ) + + def test_unit_delivery_addresses_partner(self): + self.assertFalse( + self.collaborator_1_1._get_shopinvader_delivery_addresses(), + ) + + # Create a delivery address for the collaborator + self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.collaborator_1_1.id, + } + ) + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + self.assertFalse( + self.collaborator_1_1._get_shopinvader_delivery_addresses(), + ) + + def test_unit_update_invoicing_address(self): + with self.assertRaises(AccessError): + self.collaborator_1_1._update_shopinvader_invoicing_address( + {"name": "New Name"}, self.collaborator_1_1.id + ) + with self.assertRaises(AccessError): + self.collaborator_1_1._update_shopinvader_invoicing_address( + {"name": "New Name"}, self.unit_1.id + ) + + def test_unit_update_invoicing_address_manager(self): + with self.assertRaises(AccessError): + self.collaborator_1_1._update_shopinvader_invoicing_address( + {"name": "New Name"}, self.manager_1_1.id + ) + with self.assertRaises(AccessError): + self.collaborator_1_1._update_shopinvader_invoicing_address( + {"name": "New Name"}, self.unit_1.id + ) + + def test_unit_delivery_addresses_unit(self): + self.assertFalse( + self.collaborator_1_1._get_shopinvader_delivery_addresses(), + ) + + # Create a delivery address for the collaborator + address_unit = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.unit_1.id, + } + ) + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + self.assertEqual( + self.collaborator_1_1._get_shopinvader_delivery_addresses(), + address_unit, + ) + + def test_unit_delivery_addresses_both(self): + self.assertFalse( + self.collaborator_1_1._get_shopinvader_delivery_addresses(), + ) + + # Create a delivery address for both the collaborator and the unit + self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.collaborator_1_1.id, + } + ) + address_unit = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.unit_1.id, + } + ) + self.assertEqual( + self.unit_1.member_ids, + self.manager_1_1 + | self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + self.assertEqual( + self.collaborator_1_1._get_shopinvader_delivery_addresses(), + address_unit, + ) + + def test_unit_update_delivery_address(self): + # Create a delivery address for both the collaborator and the unit + address = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.collaborator_1_1.id, + } + ) + address_unit = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.unit_1.id, + } + ) + + with self.assertRaises(MissingError): + self.collaborator_1_1._update_shopinvader_delivery_address( + {"name": "New Name"}, address.id + ) + + with self.assertRaises(AccessError): + self.collaborator_1_1._update_shopinvader_delivery_address( + {"name": "New Name"}, address_unit.id + ) + + def test_unit_update_delivery_address_manager(self): + # Create a delivery address for both the collaborator and the unit + address = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.manager_1_1.id, + } + ) + address_unit = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.unit_1.id, + } + ) + + with self.assertRaises(MissingError): + self.manager_1_1._update_shopinvader_delivery_address( + {"name": "New Name"}, address.id + ) + + self.manager_1_1._update_shopinvader_delivery_address( + {"name": "New Name"}, address_unit.id + ) + self.assertEqual(address_unit.name, "New Name") + + def test_unit_delete_delivery_address(self): + # Create a delivery address for both the collaborator and the unit + address = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.collaborator_1_1.id, + } + ) + address_unit = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.unit_1.id, + } + ) + + with self.assertRaises(MissingError): + self.collaborator_1_1._delete_shopinvader_delivery_address(address.id) + with self.assertRaises(AccessError): + self.collaborator_1_1._delete_shopinvader_delivery_address(address_unit.id) + + def test_unit_delete_delivery_address_manager(self): + # Create a delivery address for both the collaborator and the unit + address = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.manager_1_1.id, + } + ) + address_unit = self.env["res.partner"].create( + { + "name": "Delivery Address", + "type": "delivery", + "parent_id": self.unit_1.id, + } + ) + + with self.assertRaises(MissingError): + self.manager_1_1._delete_shopinvader_delivery_address(address.id) + + self.manager_1_1._delete_shopinvader_delivery_address(address_unit.id) + self.assertFalse(address_unit.active)