diff --git a/requirements.txt b/requirements.txt index e34c1b1dac..1045598a1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ # generated from manifests external_dependencies cerberus +extendable_pydantic>=1.0.0 +fastapi locomotivecms +pydantic>=2.0.0 python-magic python-slugify python-stdnum 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_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/__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/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..1d148a8115 --- /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/{id}") +async def get_unit_member( + partner: Annotated[ResPartner, Depends(authenticated_manager)], + id: int, +) -> UnitMember: + """ + Get a specific unit member + """ + member = partner._get_shopinvader_unit_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/{id}") +async def update_unit_member( + data: UnitMemberUpdate, + partner: Annotated[ResPartner, Depends(authenticated_manager)], + 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(id, vals) + return UnitMember.from_res_partner(member) + + +@unit_member_router.delete("/unit/members/{id}") +async def delete_unit_member( + partner: Annotated[ResPartner, Depends(authenticated_manager)], + id: int, +) -> UnitMember: + """ + Delete a specific unit member (manager or collaborator) as manager + """ + member = partner._delete_shopinvader_unit_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..a8d0f5a810 --- /dev/null +++ b/shopinvader_api_unit_member/security/res_partner.xml @@ -0,0 +1,33 @@ + + + + + Shopinvader Unit Management Endpoint rule: res partner + + + [('parent_id','=',authenticated_partner_id)] + + + + + Shopinvader Unit Management: user read/write/create partners + + + + + + + + + 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..27507f634d --- /dev/null +++ b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -0,0 +1,484 @@ +# 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 unittest import mock + +from fastapi import status +from requests import Response + +from odoo.tests.common import tagged + +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 _rollback_called_test_client(self): + with self._create_test_client() as test_client, mock.patch.object( + self.env.cr.__class__, "rollback" + ) as mock_rollback: + yield test_client + mock_rollback.assert_called_once() + + 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._rollback_called_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._rollback_called_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._rollback_called_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._rollback_called_test_client() as test_client: + response: Response = test_client.get(f"/unit/members/{self.unit_1.id}") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + with self._rollback_called_test_client() as test_client: + response: Response = test_client.get(f"/unit/members/{self.unit_2.id}") + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + ) + + def test_create_unit_member(self): + """ + Test to create a new unit member + """ + self.assertEquals( + self.unit_1._get_unit_members(), + 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._get_unit(), self.unit_1) + self.assertEqual(new_member.unit_profile, "collaborator") + self.assertEquals( + self.unit_1._get_unit_members(), + 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.assertEquals( + self.unit_1._get_unit_members(), + 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._get_unit(), self.unit_1) + self.assertEqual(new_member.unit_profile, "manager") + self.assertEquals( + self.unit_1._get_unit_members(), + 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._rollback_called_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._rollback_called_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._rollback_called_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.assertEquals( + set(self.unit_1._get_unit_members().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.assertEquals( + set(self.unit_1._get_unit_members().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.assertEquals( + set(self.unit_1._get_unit_members().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.assertEquals( + set(self.unit_1._get_unit_members().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._rollback_called_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.assertEquals( + set(self.unit_1._get_unit_members().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.assertEquals( + set(self.unit_1._get_unit_members().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.assertEquals( + set(self.unit_1._get_unit_members().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.assertEquals( + set(self.unit_1._get_unit_members().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._rollback_called_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/__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..2df791f81e --- /dev/null +++ b/shopinvader_api_unit_request/__manifest__.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). + +{ + "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", + ], + "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..840739422c --- /dev/null +++ b/shopinvader_api_unit_request/models/sale_order.py @@ -0,0 +1,102 @@ +# 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(): + message = "" + if lines["accepted"]: + message += _("Your following requests have been accepted:\n") + for line in lines["accepted"]: + message += f"{line.product_id.name} - {line.product_uom_qty}\n" + + if lines["rejected"]: + message += _("Your following requests have been rejected:\n") + for line in lines["rejected"]: + message += f"{line.product_id.name} - {line.product_uom_qty}" + if line.request_rejection_reason: + message += f": {line.request_rejection_reason}" + message += "\n" + if not message: + continue + partner.message_post( + body=message, + subject=_("Request feedback for order %s") % record.name, + subtype_id=self.env.ref("mail.mt_comment").id, + ) + + return res + + 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..f4d4ccb88a --- /dev/null +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -0,0 +1,84 @@ +# 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( + string="Request Rejected", + help="The request has been rejected.", + default=False, + ) + request_rejection_reason = fields.Text( + string="Request Rejection Reason", + help="The reason of the rejection.", + ) + request_accepted = fields.Boolean( + string="Request Accepted", + 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/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..6faca4e80e --- /dev/null +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -0,0 +1,155 @@ +# 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 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, + authenticated_partner_env, + paging, +) +from odoo.addons.fastapi.schemas import Paging + +# from odoo.addons.sale.models.sale_order_line import SaleOrderLine #v16 +from odoo.addons.sale.models.sale import SaleOrderLine # v14 +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_partner)], +) -> PagedCollection[RequestedSaleLine]: + """ + Get list of requested sale lines + """ + count, sols = ( + env["shopinvader_api_unit_request.lines.helper"] + .new({"partner": partner}) + ._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/{id}") +async def get_requested_sale_line( + id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> RequestedSaleLine: + """ + Get a specific requested sale line + """ + return RequestedSaleLine.from_sale_order_line( + env["shopinvader_api_unit_request.lines.helper"] + .new({"partner": partner}) + ._get(id) + ) + + +@unit_request_line_router.post("/unit/request_lines/{id}/accept") +async def accept_requested_sale_line( + id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> RequestedSaleLine: + """ + Accept a specific requested sale line + """ + sale_line = ( + env["shopinvader_api_unit_request.lines.helper"] + .new({"partner": partner}) + ._get(id) + ) + cart = env["sale.order"]._find_open_cart(partner.id) + sale_line._action_accept_request(cart) + return RequestedSaleLine.from_sale_order_line(sale_line) + + +@unit_request_line_router.post("/unit/request_lines/{id}/reject") +async def reject_requested_sale_line( + id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + data: RejectRequest, + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> RequestedSaleLine: + """ + Reject a specific requested sale line + """ + sale_line = ( + env["shopinvader_api_unit_request.lines.helper"] + .new({"partner": partner}) + ._get(id) + ) + cart = env["sale.order"]._find_open_cart(partner.id) + sale_line._action_reject_request(cart, data.reason) + return RequestedSaleLine.from_sale_order_line(sale_line) + + +@unit_request_line_router.post("/unit/request_lines/{id}/reset") +async def reset_requested_sale_line( + id: int, + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], +) -> RequestedSaleLine: + """ + Reset a specific requested sale line + """ + sale_line = ( + env["shopinvader_api_unit_request.lines.helper"] + .new({"partner": partner}) + ._get(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._get_unit()._get_unit_collaborators().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, + ) diff --git a/shopinvader_api_unit_request/schemas.py b/shopinvader_api_unit_request/schemas.py new file mode 100644 index 0000000000..2ce422d6e5 --- /dev/null +++ b/shopinvader_api_unit_request/schemas.py @@ -0,0 +1,65 @@ +# 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.schemas import SaleLineWithSale + + +class RejectRequest(StrictExtendableBaseModel, extra="ignore"): + reason: str | None = None + + +class RequestedSaleLine(SaleLineWithSale): + 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.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/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..484607ea4b --- /dev/null +++ b/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py @@ -0,0 +1,533 @@ +# 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 unittest import mock + +from fastapi import status +from requests import Response + +from odoo.tests.common import tagged + +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): + if len(fields) == 1: + return {item[fields[0]] for item in data["items"]} + return {tuple(item[field] for field in fields) for item in data["items"]} + + @contextmanager + def _rollback_called_test_client(self, **kwargs): + with self._create_test_client(**kwargs) as test_client, mock.patch.object( + self.env.cr.__class__, "rollback" + ) as mock_rollback: + yield test_client + mock_rollback.assert_called_once() + + 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._rollback_called_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._rollback_called_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) + + so2.order_line[:2]._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()) + 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"], 1) + + def test_sale_line_requested_as_collaborator(self): + with self._rollback_called_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) + self.assertEqual(len(self.collaborator_1_1.message_ids), 0) + self.assertEqual(len(self.collaborator_1_2.message_ids), 0) + + so.action_confirm() + + # Check that the partners have been notified + self.assertEqual(len(self.collaborator_1_1.message_ids), 1) + self.assertEqual(len(self.collaborator_1_2.message_ids), 1) + message = self.collaborator_1_1.message_ids[0] + 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 = self.collaborator_1_2.message_ids[0] + self.assertIn("Your following requests have been accepted:", message.body) + self.assertIn("product_1 - 3.0", message.body) + self.assertEqual(len(self.manager_1_1.message_ids), 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..23a745f59e --- /dev/null +++ b/shopinvader_api_unit_request/views/sale_order_views.xml @@ -0,0 +1,63 @@ + + + + + + + sale.order + + + + + + + + + + + + + + + + + + + + + + + + Requests + sale.order + tree,form,calendar,graph + + {'default_typology': 'request'} + [('typology', '=', 'request')] + + + + 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..e510c5d03d --- /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 introduce 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": [], + "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..08e8bb54ee --- /dev/null +++ b/shopinvader_unit_management/models/res_partner.py @@ -0,0 +1,124 @@ +# 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"), + ], + string="Unit Profile", + required=False, + ) + + @api.constrains("unit_profile", "parent_id") + def _check_unit_profile(self): + if self.unit_profile: + if self.unit_profile == "unit" and self.parent_id: + raise ValidationError(_("A unit can't have a parent.")) + if self.unit_profile in ["manager", "collaborator"] and not self.parent_id: + raise ValidationError( + _("A manager or a collaborator must have a parent unit.") + ) + + @api.model + def _get_unit_members(self): + self.ensure_one() + if self.unit_profile != "unit": + raise AccessError(_("This method is only available for units.")) + return self.search( + [ + ("parent_id", "=", self.id), + ("unit_profile", "in", ["manager", "collaborator"]), + ] + ) + + @api.model + def _get_unit_managers(self): + self.ensure_one() + if self.unit_profile != "unit": + raise AccessError(_("This method is only available for units.")) + return self.search( + [("parent_id", "=", self.id), ("unit_profile", "=", "manager")] + ) + + @api.model + def _get_unit_collaborators(self): + self.ensure_one() + if self.unit_profile != "unit": + raise AccessError(_("This method is only available for units.")) + return self.search( + [("parent_id", "=", self.id), ("unit_profile", "=", "collaborator")] + ) + + @api.model + def _get_unit(self): + self.ensure_one() + if self.unit_profile not in ["manager", "collaborator"]: + raise AccessError( + _("This method is only available for managers and collaborators.") + ) + return self.parent_id + + 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._get_unit() != self._get_unit(): + raise MissingError(_("Member not found")) + + @api.model + def _get_shopinvader_unit_members(self): + self._ensure_manager() + unit = self._get_unit() + return unit._get_unit_members() + + @api.model + def _get_shopinvader_unit_member(self, id): + self._ensure_manager() + member = self.browse(id) + self._ensure_same_unit(member) + return member + + @api.model + def _create_shopinvader_unit_member(self, vals): + self._ensure_manager() + vals["parent_id"] = self._get_unit().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.create(vals) + + @api.model + def _update_shopinvader_unit_member(self, id, vals): + self._ensure_manager() + member = self.browse(id) + self._ensure_same_unit(member) + if member.unit_profile not in ["collaborator", "manager"]: + raise AccessError(_("Cannot perform this action on this member")) + member.write(vals) + return member + + @api.model + def _delete_shopinvader_unit_member(self, id): + self._ensure_manager() + member = self.browse(id) + self._ensure_same_unit(member) + if member.unit_profile not in ["collaborator", "manager"]: + raise AccessError(_("Cannot perform this action on this member")) + member.active = False + return member 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..81140ced2a --- /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 SavepointCase + + +class TestUnitManagementCommon(SavepointCase): + @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..1df5861f58 --- /dev/null +++ b/shopinvader_unit_management/tests/test_unit_management.py @@ -0,0 +1,120 @@ +# 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 + +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._get_unit_members(), + 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._get_unit_managers(), self.manager_1_1) + self.assertEqual( + self.unit_1._get_unit_collaborators(), + self.collaborator_1_1 + | self.collaborator_1_2 + | self.collaborator_1_3 + | self.collaborator_1_4 + | self.collaborator_1_5, + ) + with self.assertRaises(AccessError): + self.unit_1._get_unit() + + self.assertEqual(self.unit_2.unit_profile, "unit") + self.assertEqual( + self.unit_2._get_unit_members(), + 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._get_unit_managers(), self.manager_2_1 | self.manager_2_2 + ) + self.assertEqual( + self.unit_2._get_unit_collaborators(), + self.collaborator_2_1 | self.collaborator_2_2 | self.collaborator_2_3, + ) + with self.assertRaises(AccessError): + self.unit_2._get_unit() + + self.assertEqual(self.unit_3.unit_profile, "unit") + self.assertEqual( + self.unit_3._get_unit_members(), + self.collaborator_3_1 | self.collaborator_3_2 | self.collaborator_3_3, + ) + self.assertEqual(self.unit_3._get_unit_managers(), self.env["res.partner"]) + self.assertEqual( + self.unit_3._get_unit_collaborators(), + self.collaborator_3_1 | self.collaborator_3_2 | self.collaborator_3_3, + ) + with self.assertRaises(AccessError): + self.unit_3._get_unit() + + self.assertEqual(self.unit_4.unit_profile, "unit") + self.assertEqual( + self.unit_4._get_unit_members(), self.manager_4_1 | self.manager_4_2 + ) + self.assertEqual( + self.unit_4._get_unit_managers(), self.manager_4_1 | self.manager_4_2 + ) + self.assertEqual( + self.unit_4._get_unit_collaborators(), + self.env["res.partner"], + ) + with self.assertRaises(AccessError): + self.unit_4._get_unit() + + def test_unit_management_managers(self): + self.assertEqual(self.manager_1_1.unit_profile, "manager") + self.assertEqual(self.manager_1_1._get_unit(), self.unit_1) + + with self.assertRaises(AccessError): + self.manager_1_1._get_unit_members() + with self.assertRaises(AccessError): + self.manager_1_1._get_unit_managers() + with self.assertRaises(AccessError): + self.manager_1_1._get_unit_collaborators() + + self.assertEqual(self.manager_4_2.unit_profile, "manager") + self.assertEqual(self.manager_4_2._get_unit(), self.unit_4) + + with self.assertRaises(AccessError): + self.manager_4_2._get_unit_members() + with self.assertRaises(AccessError): + self.manager_4_2._get_unit_managers() + with self.assertRaises(AccessError): + self.manager_4_2._get_unit_collaborators() + + def test_unit_management_collaborators(self): + self.assertEqual(self.collaborator_1_1.unit_profile, "collaborator") + self.assertEqual(self.collaborator_1_1._get_unit(), self.unit_1) + + with self.assertRaises(AccessError): + self.collaborator_1_1._get_unit_members() + with self.assertRaises(AccessError): + self.collaborator_1_1._get_unit_managers() + with self.assertRaises(AccessError): + self.collaborator_1_1._get_unit_collaborators() + + self.assertEqual(self.collaborator_3_3.unit_profile, "collaborator") + self.assertEqual(self.collaborator_3_3._get_unit(), self.unit_3) + + with self.assertRaises(AccessError): + self.collaborator_3_3._get_unit_members() + with self.assertRaises(AccessError): + self.collaborator_3_3._get_unit_managers() + with self.assertRaises(AccessError): + self.collaborator_3_3._get_unit_collaborators()