From 09e691eaa5d16b61cd9ab601121efe828d6baa1a Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 11:52:46 +0100 Subject: [PATCH 01/19] [ADD] shopinvader_unit_management --- shopinvader_unit_management/__init__.py | 1 + shopinvader_unit_management/__manifest__.py | 19 +++ .../demo/res_partner_demo.xml | 133 ++++++++++++++++++ .../models/__init__.py | 1 + .../models/res_partner.py | 124 ++++++++++++++++ shopinvader_unit_management/tests/__init__.py | 0 shopinvader_unit_management/tests/common.py | 36 +++++ .../tests/test_unit_management.py | 120 ++++++++++++++++ 8 files changed, 434 insertions(+) create mode 100644 shopinvader_unit_management/__init__.py create mode 100644 shopinvader_unit_management/__manifest__.py create mode 100644 shopinvader_unit_management/demo/res_partner_demo.xml create mode 100644 shopinvader_unit_management/models/__init__.py create mode 100644 shopinvader_unit_management/models/res_partner.py create mode 100644 shopinvader_unit_management/tests/__init__.py create mode 100644 shopinvader_unit_management/tests/common.py create mode 100644 shopinvader_unit_management/tests/test_unit_management.py 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() From 11961569fc87cd358878b9ae2905c85636fb58ad Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 11:53:19 +0100 Subject: [PATCH 02/19] [ADD] shopinvader_api_unit_member --- shopinvader_api_unit_member/__init__.py | 2 + shopinvader_api_unit_member/__manifest__.py | 27 + .../routers/__init__.py | 1 + .../routers/unit_members.py | 88 ++++ shopinvader_api_unit_member/schemas.py | 89 ++++ .../security/res_groups.xml | 19 + .../security/res_partner.xml | 33 ++ shopinvader_api_unit_member/tests/__init__.py | 1 + .../test_shopinvader_api_unit_members.py | 484 ++++++++++++++++++ 9 files changed, 744 insertions(+) create mode 100644 shopinvader_api_unit_member/__init__.py create mode 100644 shopinvader_api_unit_member/__manifest__.py create mode 100644 shopinvader_api_unit_member/routers/__init__.py create mode 100644 shopinvader_api_unit_member/routers/unit_members.py create mode 100644 shopinvader_api_unit_member/schemas.py create mode 100644 shopinvader_api_unit_member/security/res_groups.xml create mode 100644 shopinvader_api_unit_member/security/res_partner.xml create mode 100644 shopinvader_api_unit_member/tests/__init__.py create mode 100644 shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py 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, + ) From 40cf43797e3fec395f227875a9c9c99347c8c309 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 12:06:34 +0100 Subject: [PATCH 03/19] [ADD] shopinvader_api_unit_request --- shopinvader_api_unit_request/__init__.py | 3 + shopinvader_api_unit_request/__manifest__.py | 23 + .../models/__init__.py | 2 + .../models/sale_order.py | 102 ++++ .../models/sale_order_line.py | 84 +++ .../routers/__init__.py | 4 + shopinvader_api_unit_request/routers/cart.py | 41 ++ .../routers/sale_lines.py | 20 + .../routers/unit_request_lines.py | 154 +++++ shopinvader_api_unit_request/schemas.py | 65 +++ .../tests/__init__.py | 1 + .../test_shopinvader_api_unit_request.py | 533 ++++++++++++++++++ .../views/sale_order_views.xml | 63 +++ 13 files changed, 1095 insertions(+) create mode 100644 shopinvader_api_unit_request/__init__.py create mode 100644 shopinvader_api_unit_request/__manifest__.py create mode 100644 shopinvader_api_unit_request/models/__init__.py create mode 100644 shopinvader_api_unit_request/models/sale_order.py create mode 100644 shopinvader_api_unit_request/models/sale_order_line.py create mode 100644 shopinvader_api_unit_request/routers/__init__.py create mode 100644 shopinvader_api_unit_request/routers/cart.py create mode 100644 shopinvader_api_unit_request/routers/sale_lines.py create mode 100644 shopinvader_api_unit_request/routers/unit_request_lines.py create mode 100644 shopinvader_api_unit_request/schemas.py create mode 100644 shopinvader_api_unit_request/tests/__init__.py create mode 100644 shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py create mode 100644 shopinvader_api_unit_request/views/sale_order_views.xml 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..6c15268358 --- /dev/null +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -0,0 +1,154 @@ +# 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 +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')] + + + + From 350b3d535e4d1f940dbef131d4166d8246d45d03 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 11:56:13 +0100 Subject: [PATCH 04/19] [LINT] shopinvader_unit_management, shopinvader_api_unit_member, shopinvader_api_unit_request --- .../odoo/addons/shopinvader_api_unit_member | 1 + setup/shopinvader_api_unit_member/setup.py | 6 +++++ .../odoo/addons/shopinvader_api_unit_request | 1 + setup/shopinvader_api_unit_request/setup.py | 6 +++++ .../odoo/addons/shopinvader_unit_management | 1 + setup/shopinvader_unit_management/setup.py | 6 +++++ .../test_shopinvader_api_unit_members.py | 24 +++++++++---------- .../routers/unit_request_lines.py | 1 - .../views/sale_order_views.xml | 1 - 9 files changed, 33 insertions(+), 14 deletions(-) create mode 120000 setup/shopinvader_api_unit_member/odoo/addons/shopinvader_api_unit_member create mode 100644 setup/shopinvader_api_unit_member/setup.py create mode 120000 setup/shopinvader_api_unit_request/odoo/addons/shopinvader_api_unit_request create mode 100644 setup/shopinvader_api_unit_request/setup.py create mode 120000 setup/shopinvader_unit_management/odoo/addons/shopinvader_unit_management create mode 100644 setup/shopinvader_unit_management/setup.py 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/tests/test_shopinvader_api_unit_members.py b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py index 27507f634d..181793ed31 100644 --- a/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py +++ b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -179,7 +179,7 @@ def test_create_unit_member(self): """ Test to create a new unit member """ - self.assertEquals( + self.assertEqual( self.unit_1._get_unit_members(), self.manager_1_1 | self.collaborator_1_1 @@ -208,7 +208,7 @@ def test_create_unit_member(self): 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.assertEqual( self.unit_1._get_unit_members(), self.manager_1_1 | self.collaborator_1_1 @@ -220,7 +220,7 @@ def test_create_unit_member(self): ) def test_create_unit_manager(self): - self.assertEquals( + self.assertEqual( self.unit_1._get_unit_members(), self.manager_1_1 | self.collaborator_1_1 @@ -245,7 +245,7 @@ def test_create_unit_manager(self): 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.assertEqual( self.unit_1._get_unit_members(), self.manager_1_1 | self.collaborator_1_1 @@ -293,7 +293,7 @@ def test_update_unit_member(self): """ Test to update a specific unit member """ - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Manager 1.1", @@ -325,7 +325,7 @@ def test_update_unit_member(self): self.assertEqual(member["name"], "Updated Unit Member") self.assertEqual(updated_member.name, "Updated Unit Member") - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Manager 1.1", @@ -341,7 +341,7 @@ def test_update_unit_manager(self): """ Test to update a specific unit manager """ - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Manager 1.1", @@ -373,7 +373,7 @@ def test_update_unit_manager(self): self.assertEqual(member["name"], "Updated Unit Manager") self.assertEqual(updated_member.name, "Updated Unit Manager") - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Updated Unit Manager", @@ -401,7 +401,7 @@ def test_delete_unit_membere(self): """ Test to delete a specific unit member """ - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Manager 1.1", @@ -423,7 +423,7 @@ def test_delete_unit_membere(self): status.HTTP_200_OK, ) self.assertFalse(self.collaborator_1_4.active) - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Manager 1.1", @@ -438,7 +438,7 @@ def test_delete_unit_manager(self): """ Test to delete a specific unit manager """ - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Manager 1.1", @@ -461,7 +461,7 @@ def test_delete_unit_manager(self): ) self.assertFalse(self.manager_1_1.active) - self.assertEquals( + self.assertEqual( set(self.unit_1._get_unit_members().mapped("name")), { "Collaborator 1.1", diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py index 6c15268358..71565e1575 100644 --- a/shopinvader_api_unit_request/routers/unit_request_lines.py +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -16,7 +16,6 @@ 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, diff --git a/shopinvader_api_unit_request/views/sale_order_views.xml b/shopinvader_api_unit_request/views/sale_order_views.xml index 23a745f59e..b4ad796bab 100644 --- a/shopinvader_api_unit_request/views/sale_order_views.xml +++ b/shopinvader_api_unit_request/views/sale_order_views.xml @@ -24,7 +24,6 @@ > Date: Tue, 12 Mar 2024 16:09:05 +0100 Subject: [PATCH 05/19] [IMP] shopinvader_unit_management: Use fields for members/units --- .../security/res_partner.xml | 4 +- .../test_shopinvader_api_unit_members.py | 32 +++---- .../routers/unit_request_lines.py | 2 +- .../models/res_partner.py | 68 ++++----------- .../tests/test_unit_management.py | 86 +++++++------------ 5 files changed, 69 insertions(+), 123 deletions(-) diff --git a/shopinvader_api_unit_member/security/res_partner.xml b/shopinvader_api_unit_member/security/res_partner.xml index a8d0f5a810..1e7df244e9 100644 --- a/shopinvader_api_unit_member/security/res_partner.xml +++ b/shopinvader_api_unit_member/security/res_partner.xml @@ -14,7 +14,9 @@ name="groups" eval="[(4, ref('shopinvader_api_unit_member.shopinvader_unit_management_user_group'))]" /> - [('parent_id','=',authenticated_partner_id)] + ['|', ('unit_id.manager_ids','=',authenticated_partner_id), ('manager_ids','=',authenticated_partner_id)] 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 index 181793ed31..4be49eab64 100644 --- a/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py +++ b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -166,13 +166,13 @@ def test_get_manager_unit_members_wrong_type(self): response: Response = test_client.get(f"/unit/members/{self.unit_1.id}") self.assertEqual( response.status_code, - status.HTTP_403_FORBIDDEN, + status.HTTP_404_NOT_FOUND, ) 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, + status.HTTP_404_NOT_FOUND, ) def test_create_unit_member(self): @@ -180,7 +180,7 @@ def test_create_unit_member(self): Test to create a new unit member """ self.assertEqual( - self.unit_1._get_unit_members(), + self.unit_1.member_ids, self.manager_1_1 | self.collaborator_1_1 | self.collaborator_1_2 @@ -206,10 +206,10 @@ def test_create_unit_member(self): 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_id, self.unit_1) self.assertEqual(new_member.unit_profile, "collaborator") self.assertEqual( - self.unit_1._get_unit_members(), + self.unit_1.member_ids, self.manager_1_1 | self.collaborator_1_1 | self.collaborator_1_2 @@ -221,7 +221,7 @@ def test_create_unit_member(self): def test_create_unit_manager(self): self.assertEqual( - self.unit_1._get_unit_members(), + self.unit_1.member_ids, self.manager_1_1 | self.collaborator_1_1 | self.collaborator_1_2 @@ -243,10 +243,10 @@ def test_create_unit_manager(self): 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_id, self.unit_1) self.assertEqual(new_member.unit_profile, "manager") self.assertEqual( - self.unit_1._get_unit_members(), + self.unit_1.member_ids, self.manager_1_1 | self.collaborator_1_1 | self.collaborator_1_2 @@ -294,7 +294,7 @@ def test_update_unit_member(self): Test to update a specific unit member """ self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Manager 1.1", "Collaborator 1.1", @@ -326,7 +326,7 @@ def test_update_unit_member(self): self.assertEqual(updated_member.name, "Updated Unit Member") self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Manager 1.1", "Collaborator 1.1", @@ -342,7 +342,7 @@ def test_update_unit_manager(self): Test to update a specific unit manager """ self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Manager 1.1", "Collaborator 1.1", @@ -374,7 +374,7 @@ def test_update_unit_manager(self): self.assertEqual(updated_member.name, "Updated Unit Manager") self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Updated Unit Manager", "Collaborator 1.1", @@ -402,7 +402,7 @@ def test_delete_unit_membere(self): Test to delete a specific unit member """ self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Manager 1.1", "Collaborator 1.1", @@ -424,7 +424,7 @@ def test_delete_unit_membere(self): ) self.assertFalse(self.collaborator_1_4.active) self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Manager 1.1", "Collaborator 1.1", @@ -439,7 +439,7 @@ def test_delete_unit_manager(self): Test to delete a specific unit manager """ self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Manager 1.1", "Collaborator 1.1", @@ -462,7 +462,7 @@ def test_delete_unit_manager(self): self.assertFalse(self.manager_1_1.active) self.assertEqual( - set(self.unit_1._get_unit_members().mapped("name")), + set(self.unit_1.member_ids.mapped("name")), { "Collaborator 1.1", "Collaborator 1.2", diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py index 71565e1575..1b904d0df0 100644 --- a/shopinvader_api_unit_request/routers/unit_request_lines.py +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -133,7 +133,7 @@ def _get_domain_adapter(self): ( "request_partner_id", "in", - self.partner._get_unit()._get_unit_collaborators().ids, + self.partner.unit_id.collaborator_ids.ids, ), ("request_order_id", "=", False), ] diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py index 08e8bb54ee..efd818a595 100644 --- a/shopinvader_unit_management/models/res_partner.py +++ b/shopinvader_unit_management/models/res_partner.py @@ -19,54 +19,19 @@ class ResPartner(models.Model): 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.") - ) + unit_id = fields.Many2one("res.partner", related="parent_id", string="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 + 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.""" @@ -75,14 +40,13 @@ def _ensure_manager(self): def _ensure_same_unit(self, member): """Ensure the member is in the same unit.""" - if not member or member._get_unit() != self._get_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() - unit = self._get_unit() - return unit._get_unit_members() + return self.unit_id.member_ids @api.model def _get_shopinvader_unit_member(self, id): @@ -94,7 +58,9 @@ def _get_shopinvader_unit_member(self, id): @api.model def _create_shopinvader_unit_member(self, vals): self._ensure_manager() - vals["parent_id"] = self._get_unit().id + # FIXME: + vals[self._fields["unit_id"].related[0]] = 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): diff --git a/shopinvader_unit_management/tests/test_unit_management.py b/shopinvader_unit_management/tests/test_unit_management.py index 1df5861f58..01c8d9529b 100644 --- a/shopinvader_unit_management/tests/test_unit_management.py +++ b/shopinvader_unit_management/tests/test_unit_management.py @@ -11,7 +11,7 @@ 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.unit_1.member_ids, self.manager_1_1 | self.collaborator_1_1 | self.collaborator_1_2 @@ -19,102 +19,80 @@ def test_unit_management_units(self): | 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.manager_ids, self.manager_1_1) self.assertEqual( - self.unit_1._get_unit_collaborators(), + 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, ) - with self.assertRaises(AccessError): - self.unit_1._get_unit() + self.assertFalse(self.unit_1.unit_id) self.assertEqual(self.unit_2.unit_profile, "unit") self.assertEqual( - self.unit_2._get_unit_members(), + 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._get_unit_managers(), self.manager_2_1 | self.manager_2_2 - ) - self.assertEqual( - self.unit_2._get_unit_collaborators(), + self.unit_2.collaborator_ids, self.collaborator_2_1 | self.collaborator_2_2 | self.collaborator_2_3, ) - with self.assertRaises(AccessError): - self.unit_2._get_unit() + self.assertFalse(self.unit_2.unit_id) self.assertEqual(self.unit_3.unit_profile, "unit") self.assertEqual( - self.unit_3._get_unit_members(), + self.unit_3.member_ids, 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.manager_ids, self.env["res.partner"]) self.assertEqual( - self.unit_3._get_unit_collaborators(), + self.unit_3.collaborator_ids, self.collaborator_3_1 | self.collaborator_3_2 | self.collaborator_3_3, ) - with self.assertRaises(AccessError): - self.unit_3._get_unit() + 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._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.unit_4.collaborator_ids, self.env["res.partner"], ) - with self.assertRaises(AccessError): - self.unit_4._get_unit() + 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._get_unit(), self.unit_1) + self.assertEqual(self.manager_1_1.unit_id, 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.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._get_unit(), self.unit_4) + self.assertEqual(self.manager_4_2.unit_id, 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() + 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._get_unit(), self.unit_1) + self.assertEqual(self.collaborator_1_1.unit_id, 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.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._get_unit(), self.unit_3) + self.assertEqual(self.collaborator_3_3.unit_id, 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() + self.assertFalse(self.collaborator_3_3.member_ids) + self.assertFalse(self.collaborator_3_3.manager_ids) + self.assertFalse(self.collaborator_3_3.collaborator_ids) From 178271978284983be4aa828fe2b89aaa8c2d9666 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 12 Mar 2024 16:49:50 +0100 Subject: [PATCH 06/19] [IMP] shopinvader_api_unit_request: Add request_partner_id to requested sale line --- shopinvader_api_unit_request/schemas.py | 2 ++ shopinvader_unit_management/tests/test_unit_management.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/shopinvader_api_unit_request/schemas.py b/shopinvader_api_unit_request/schemas.py index 2ce422d6e5..04a02c011b 100644 --- a/shopinvader_api_unit_request/schemas.py +++ b/shopinvader_api_unit_request/schemas.py @@ -17,12 +17,14 @@ class RejectRequest(StrictExtendableBaseModel, extra="ignore"): 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 diff --git a/shopinvader_unit_management/tests/test_unit_management.py b/shopinvader_unit_management/tests/test_unit_management.py index 01c8d9529b..536a454a04 100644 --- a/shopinvader_unit_management/tests/test_unit_management.py +++ b/shopinvader_unit_management/tests/test_unit_management.py @@ -2,7 +2,6 @@ # @author Florian Mounier # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo.exceptions import AccessError from .common import TestUnitManagementCommon From b28c7ab00122f00fe9f0059ab4f10fe0f028b82a Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 10:45:41 +0100 Subject: [PATCH 07/19] [IMP] shopinvader_api_unit_request: Create manager cart if none is available --- .../routers/unit_request_lines.py | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py index 1b904d0df0..f81d557a5b 100644 --- a/shopinvader_api_unit_request/routers/unit_request_lines.py +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -3,6 +3,7 @@ # 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 @@ -37,11 +38,8 @@ async def get_request_lines( """ Get list of requested sale lines """ - count, sols = ( - env["shopinvader_api_unit_request.lines.helper"] - .new({"partner": partner}) - ._search(paging, params) - ) + 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], @@ -57,48 +55,43 @@ async def get_requested_sale_line( """ Get a specific requested sale line """ - return RequestedSaleLine.from_sale_order_line( - env["shopinvader_api_unit_request.lines.helper"] - .new({"partner": partner}) - ._get(id) - ) + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + return RequestedSaleLine.from_sale_order_line(helper._get(id)) @unit_request_line_router.post("/unit/request_lines/{id}/accept") +@unit_request_line_router.post("/unit/request_lines/{id}/accept/{uuid}") async def accept_requested_sale_line( 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 """ - sale_line = ( - env["shopinvader_api_unit_request.lines.helper"] - .new({"partner": partner}) - ._get(id) - ) - cart = env["sale.order"]._find_open_cart(partner.id) + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + sale_line = helper._get(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/{id}/reject") +@unit_request_line_router.post("/unit/request_lines/{id}/reject/{uuid}") async def reject_requested_sale_line( 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 """ - sale_line = ( - env["shopinvader_api_unit_request.lines.helper"] - .new({"partner": partner}) - ._get(id) - ) - cart = env["sale.order"]._find_open_cart(partner.id) + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + sale_line = helper._get(id) + cart = helper._get_cart(uuid) sale_line._action_reject_request(cart, data.reason) return RequestedSaleLine.from_sale_order_line(sale_line) @@ -112,11 +105,8 @@ async def reset_requested_sale_line( """ Reset a specific requested sale line """ - sale_line = ( - env["shopinvader_api_unit_request.lines.helper"] - .new({"partner": partner}) - ._get(id) - ) + helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) + sale_line = helper._get(id) sale_line._action_reset_request() return RequestedSaleLine.from_sale_order_line(sale_line) @@ -151,3 +141,9 @@ def _search(self, paging, params) -> tuple[int, SaleOrderLine]: 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 From a7d589a0aa0355bedd1932452f22ef7850474ab0 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:18:38 +0100 Subject: [PATCH 08/19] [IMP] shopinvader_api_unit_member: Use sudo to alter res_partner --- shopinvader_unit_management/models/res_partner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py index efd818a595..2d168a59c7 100644 --- a/shopinvader_unit_management/models/res_partner.py +++ b/shopinvader_unit_management/models/res_partner.py @@ -67,7 +67,7 @@ def _create_shopinvader_unit_member(self, vals): 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) + return self.sudo().create(vals) @api.model def _update_shopinvader_unit_member(self, id, vals): @@ -76,7 +76,7 @@ def _update_shopinvader_unit_member(self, id, vals): 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) + member.sudo().write(vals) return member @api.model From 05c2f4fa631eaba782830564832a8a38798bd6dd Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:41:17 +0100 Subject: [PATCH 09/19] [IMP] shopinvader_unit_management: Add doc --- shopinvader_unit_management/README.rst | 82 ++++ shopinvader_unit_management/__manifest__.py | 2 +- .../models/res_partner.py | 1 - .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 5 + .../static/description/index.html | 427 ++++++++++++++++++ 6 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 shopinvader_unit_management/README.rst create mode 100644 shopinvader_unit_management/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_unit_management/readme/DESCRIPTION.rst create mode 100644 shopinvader_unit_management/static/description/index.html diff --git a/shopinvader_unit_management/README.rst b/shopinvader_unit_management/README.rst new file mode 100644 index 0000000000..c8b7b812ca --- /dev/null +++ b/shopinvader_unit_management/README.rst @@ -0,0 +1,82 @@ +=========================== +Shopinvader Unit Management +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8b84b0911ab2e1dd5bca6a65f2d6a814183bb0ff565d3772f39aa28e24c4223d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_unit_management + :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_unit_management + :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 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 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_unit_management/__manifest__.py b/shopinvader_unit_management/__manifest__.py index e510c5d03d..d5786b73db 100644 --- a/shopinvader_unit_management/__manifest__.py +++ b/shopinvader_unit_management/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Shopinvader Unit Management", - "summary": "This module introduce the concept of 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.", diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py index 2d168a59c7..33c16bec3e 100644 --- a/shopinvader_unit_management/models/res_partner.py +++ b/shopinvader_unit_management/models/res_partner.py @@ -15,7 +15,6 @@ class ResPartner(models.Model): ("manager", "Unit Manager"), ("collaborator", "Unit Collaborator"), ], - string="Unit Profile", required=False, ) 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..0f2b2323d3 --- /dev/null +++ b/shopinvader_unit_management/static/description/index.html @@ -0,0 +1,427 @@ + + + + + + +Shopinvader Unit Management + + + +
+

Shopinvader Unit Management

+ + +

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

+

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 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.

+
+
+
+ + From b072b5fc7520573ed297d8dccb330c4c68719ce9 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:41:29 +0100 Subject: [PATCH 10/19] [IMP] shopinvader_api_unit_member: Add doc --- shopinvader_api_unit_member/README.rst | 94 ++++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 12 + shopinvader_api_unit_member/readme/USAGE.rst | 1 + .../static/description/index.html | 438 ++++++++++++++++++ 5 files changed, 548 insertions(+) create mode 100644 shopinvader_api_unit_member/README.rst create mode 100644 shopinvader_api_unit_member/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_api_unit_member/readme/DESCRIPTION.rst create mode 100644 shopinvader_api_unit_member/readme/USAGE.rst create mode 100644 shopinvader_api_unit_member/static/description/index.html 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/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/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.

+
+
+
+ + From 9009c0136a258b93f8d0442a1ff15c22f12e1e4e Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:41:51 +0100 Subject: [PATCH 11/19] [IMP] shopinvader_api_unit_request: Add doc --- shopinvader_api_unit_request/README.rst | 103 ++++ .../models/sale_order_line.py | 3 - .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 21 + shopinvader_api_unit_request/readme/USAGE.rst | 1 + .../static/description/index.html | 447 ++++++++++++++++++ 6 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 shopinvader_api_unit_request/README.rst create mode 100644 shopinvader_api_unit_request/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_api_unit_request/readme/DESCRIPTION.rst create mode 100644 shopinvader_api_unit_request/readme/USAGE.rst create mode 100644 shopinvader_api_unit_request/static/description/index.html diff --git a/shopinvader_api_unit_request/README.rst b/shopinvader_api_unit_request/README.rst new file mode 100644 index 0000000000..277cb54ca1 --- /dev/null +++ b/shopinvader_api_unit_request/README.rst @@ -0,0 +1,103 @@ +============================ +Shopinvader Unit Request Api +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b5736640d8f7cc988c80ddb3a9f35df41d89f11608e79be2a4dbafd7fb89fc60 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_request + :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_request + :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 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 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_request/models/sale_order_line.py b/shopinvader_api_unit_request/models/sale_order_line.py index f4d4ccb88a..ca60ea4fd4 100644 --- a/shopinvader_api_unit_request/models/sale_order_line.py +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -29,16 +29,13 @@ class SaleOrderLine(models.Model): 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", ) 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/static/description/index.html b/shopinvader_api_unit_request/static/description/index.html new file mode 100644 index 0000000000..e0408d1c7a --- /dev/null +++ b/shopinvader_api_unit_request/static/description/index.html @@ -0,0 +1,447 @@ + + + + + + +Shopinvader Unit Request Api + + + +
+

Shopinvader Unit Request Api

+ + +

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

+

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 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.

+
+
+
+ + From 0124ca502eb66f7d545ab4e7f83937aa4b4a5c78 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:45:40 +0100 Subject: [PATCH 12/19] [LINT] shopinvader_unit_management, shopinvader_api_unit_member, shopinvader_api_unit_request --- .../routers/unit_members.py | 18 ++++++------ .../routers/unit_request_lines.py | 28 +++++++++---------- .../models/res_partner.py | 12 ++++---- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/shopinvader_api_unit_member/routers/unit_members.py b/shopinvader_api_unit_member/routers/unit_members.py index 1d148a8115..dad2615a58 100644 --- a/shopinvader_api_unit_member/routers/unit_members.py +++ b/shopinvader_api_unit_member/routers/unit_members.py @@ -37,15 +37,15 @@ async def get_unit_members( return [UnitMember.from_res_partner(rec) for rec in members] -@unit_member_router.get("/unit/members/{id}") +@unit_member_router.get("/unit/members/{member_id}") async def get_unit_member( partner: Annotated[ResPartner, Depends(authenticated_manager)], - id: int, + member_id: int, ) -> UnitMember: """ Get a specific unit member """ - member = partner._get_shopinvader_unit_member(id) + member = partner._get_shopinvader_unit_member(member_id) return UnitMember.from_res_partner(member) @@ -62,27 +62,27 @@ async def create_unit_member( return UnitMember.from_res_partner(member) -@unit_member_router.post("/unit/members/{id}") +@unit_member_router.post("/unit/members/{member_id}") async def update_unit_member( data: UnitMemberUpdate, partner: Annotated[ResPartner, Depends(authenticated_manager)], - id: int, + 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(id, vals) + member = partner._update_shopinvader_unit_member(member_id, vals) return UnitMember.from_res_partner(member) -@unit_member_router.delete("/unit/members/{id}") +@unit_member_router.delete("/unit/members/{member_id}") async def delete_unit_member( partner: Annotated[ResPartner, Depends(authenticated_manager)], - id: int, + member_id: int, ) -> UnitMember: """ Delete a specific unit member (manager or collaborator) as manager """ - member = partner._delete_shopinvader_unit_member(id) + member = partner._delete_shopinvader_unit_member(member_id) return UnitMember.from_res_partner(member) diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py index f81d557a5b..fd75a3d4c5 100644 --- a/shopinvader_api_unit_request/routers/unit_request_lines.py +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -46,9 +46,9 @@ async def get_request_lines( ) -@unit_request_line_router.get("/unit/request_lines/{id}") +@unit_request_line_router.get("/unit/request_lines/{sol_id}") async def get_requested_sale_line( - id: int, + sol_id: int, env: Annotated[api.Environment, Depends(authenticated_partner_env)], partner: Annotated[ResPartner, Depends(authenticated_manager)], ) -> RequestedSaleLine: @@ -56,13 +56,13 @@ async def get_requested_sale_line( 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(id)) + return RequestedSaleLine.from_sale_order_line(helper._get(sol_id)) -@unit_request_line_router.post("/unit/request_lines/{id}/accept") -@unit_request_line_router.post("/unit/request_lines/{id}/accept/{uuid}") +@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( - id: int, + sol_id: int, env: Annotated[api.Environment, Depends(authenticated_partner_env)], partner: Annotated[ResPartner, Depends(authenticated_manager)], uuid: UUID | None = None, @@ -71,16 +71,16 @@ async def accept_requested_sale_line( Accept a specific requested sale line """ helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) - sale_line = helper._get(id) + 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/{id}/reject") -@unit_request_line_router.post("/unit/request_lines/{id}/reject/{uuid}") +@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( - id: int, + sol_id: int, env: Annotated[api.Environment, Depends(authenticated_partner_env)], data: RejectRequest, partner: Annotated[ResPartner, Depends(authenticated_manager)], @@ -90,15 +90,15 @@ async def reject_requested_sale_line( Reject a specific requested sale line """ helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) - sale_line = helper._get(id) + 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/{id}/reset") +@unit_request_line_router.post("/unit/request_lines/{sol_id}/reset") async def reset_requested_sale_line( - id: int, + sol_id: int, env: Annotated[api.Environment, Depends(authenticated_partner_env)], partner: Annotated[ResPartner, Depends(authenticated_manager)], ) -> RequestedSaleLine: @@ -106,7 +106,7 @@ async def reset_requested_sale_line( Reset a specific requested sale line """ helper = env["shopinvader_api_unit_request.lines.helper"].new({"partner": partner}) - sale_line = helper._get(id) + sale_line = helper._get(sol_id) sale_line._action_reset_request() return RequestedSaleLine.from_sale_order_line(sale_line) diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py index 33c16bec3e..be1714bb02 100644 --- a/shopinvader_unit_management/models/res_partner.py +++ b/shopinvader_unit_management/models/res_partner.py @@ -48,9 +48,9 @@ def _get_shopinvader_unit_members(self): return self.unit_id.member_ids @api.model - def _get_shopinvader_unit_member(self, id): + def _get_shopinvader_unit_member(self, member_id): self._ensure_manager() - member = self.browse(id) + member = self.browse(member_id) self._ensure_same_unit(member) return member @@ -69,9 +69,9 @@ def _create_shopinvader_unit_member(self, vals): return self.sudo().create(vals) @api.model - def _update_shopinvader_unit_member(self, id, vals): + def _update_shopinvader_unit_member(self, member_id, vals): self._ensure_manager() - member = self.browse(id) + 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")) @@ -79,9 +79,9 @@ def _update_shopinvader_unit_member(self, id, vals): return member @api.model - def _delete_shopinvader_unit_member(self, id): + def _delete_shopinvader_unit_member(self, member_id): self._ensure_manager() - member = self.browse(id) + 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")) From a9bae4746393de0bc3faecd4dbbb4500524a56d0 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 28 Mar 2024 09:33:20 +0100 Subject: [PATCH 13/19] [IMP] shopinvader_api_unit_request: Add rrules for sale.order request lines --- shopinvader_api_unit_request/README.rst | 36 +++------- shopinvader_api_unit_request/__manifest__.py | 1 + .../routers/unit_request_lines.py | 8 +-- .../security/sale_management_security.xml | 70 +++++++++++++++++++ .../static/description/index.html | 17 ++--- 5 files changed, 89 insertions(+), 43 deletions(-) create mode 100644 shopinvader_api_unit_request/security/sale_management_security.xml diff --git a/shopinvader_api_unit_request/README.rst b/shopinvader_api_unit_request/README.rst index 277cb54ca1..1ad45f9d98 100644 --- a/shopinvader_api_unit_request/README.rst +++ b/shopinvader_api_unit_request/README.rst @@ -7,7 +7,7 @@ Shopinvader Unit Request Api !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:b5736640d8f7cc988c80ddb3a9f35df41d89f11608e79be2a4dbafd7fb89fc60 + !! source digest: sha256:88a377a552c0c9302646a4c1af50aac30b3f08f50c475dc725370b4eed14de43 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -16,17 +16,11 @@ Shopinvader Unit Request Api .. |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_request - :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_request - :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| +.. |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 @@ -63,10 +57,10 @@ The new routes are under the `unit_request_lines.py` python file in the routers Bug Tracker =========== -Bugs are tracked on `GitHub Issues `_. +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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -88,16 +82,6 @@ Contributors 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. +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. -You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. +You are welcome to contribute. diff --git a/shopinvader_api_unit_request/__manifest__.py b/shopinvader_api_unit_request/__manifest__.py index 2df791f81e..6c3e6eec4f 100644 --- a/shopinvader_api_unit_request/__manifest__.py +++ b/shopinvader_api_unit_request/__manifest__.py @@ -18,6 +18,7 @@ ], "data": [ "views/sale_order_views.xml", + "security/sale_management_security.xml", ], "installable": True, } diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py index fd75a3d4c5..560915076a 100644 --- a/shopinvader_api_unit_request/routers/unit_request_lines.py +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -11,11 +11,7 @@ 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.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 ( @@ -33,7 +29,7 @@ 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)], + partner: Annotated[ResPartner, Depends(authenticated_manager)], ) -> PagedCollection[RequestedSaleLine]: """ Get list of requested sale lines 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 index e0408d1c7a..189cdf5d52 100644 --- a/shopinvader_api_unit_request/static/description/index.html +++ b/shopinvader_api_unit_request/static/description/index.html @@ -367,9 +367,9 @@

Shopinvader Unit Request Api

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:b5736640d8f7cc988c80ddb3a9f35df41d89f11608e79be2a4dbafd7fb89fc60 +!! source digest: sha256:88a377a552c0c9302646a4c1af50aac30b3f08f50c475dc725370b4eed14de43 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

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

+

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.

@@ -408,10 +408,10 @@

Usage

Bug Tracker

-

Bugs are tracked on GitHub Issues. +

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.

+feedback.

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

@@ -433,13 +433,8 @@

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.

+

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

+

You are welcome to contribute.

From aa0a3691ba85d800e1e9364c1f72e762ac7a2d82 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 15 Oct 2024 11:10:08 +0200 Subject: [PATCH 14/19] [IMP] shopinvader_unit_api_request: Extract notification for request feedback --- .../models/sale_order.py | 51 ++++++++++++------- .../models/res_partner.py | 2 +- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/shopinvader_api_unit_request/models/sale_order.py b/shopinvader_api_unit_request/models/sale_order.py index 840739422c..7f8ea536cb 100644 --- a/shopinvader_api_unit_request/models/sale_order.py +++ b/shopinvader_api_unit_request/models/sale_order.py @@ -69,29 +69,44 @@ def action_confirm(self): ] |= 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: + if not lines["accepted"] and not lines["rejected"]: continue - partner.message_post( - body=message, - subject=_("Request feedback for order %s") % record.name, - subtype_id=self.env.ref("mail.mt_comment").id, + 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": diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py index be1714bb02..4fd403ec89 100644 --- a/shopinvader_unit_management/models/res_partner.py +++ b/shopinvader_unit_management/models/res_partner.py @@ -85,5 +85,5 @@ def _delete_shopinvader_unit_member(self, 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.active = False + member.sudo().active = False return member From aab2f260988f9e82019a69fa1d1dcbbb44b8ca5d Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 16 Oct 2024 11:42:06 +0200 Subject: [PATCH 15/19] [IMP] shopinvader_unit_management: Include unit addresses --- .../security/res_partner.xml | 12 +- .../test_shopinvader_api_unit_members.py | 56 +++-- .../test_shopinvader_api_unit_request.py | 90 ++++++-- shopinvader_unit_management/README.rst | 36 +-- shopinvader_unit_management/__manifest__.py | 2 +- .../models/res_partner.py | 37 ++- .../static/description/index.html | 24 +- shopinvader_unit_management/tests/common.py | 4 +- .../tests/test_unit_management.py | 213 ++++++++++++++++++ 9 files changed, 386 insertions(+), 88 deletions(-) diff --git a/shopinvader_api_unit_member/security/res_partner.xml b/shopinvader_api_unit_member/security/res_partner.xml index 1e7df244e9..1104199958 100644 --- a/shopinvader_api_unit_member/security/res_partner.xml +++ b/shopinvader_api_unit_member/security/res_partner.xml @@ -14,9 +14,15 @@ name="groups" eval="[(4, ref('shopinvader_api_unit_member.shopinvader_unit_management_user_group'))]" /> - ['|', ('unit_id.manager_ids','=',authenticated_partner_id), ('manager_ids','=',authenticated_partner_id)] + [ + '|', + ('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) + ] 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 index 4be49eab64..4678127e87 100644 --- a/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py +++ b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -4,12 +4,12 @@ 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.tools import mute_logger from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase from odoo.addons.shopinvader_unit_management.tests.common import ( @@ -51,12 +51,36 @@ def setUpClass(cls) -> None: )._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: + def _create_test_client(self, **kwargs): + kwargs.setdefault("raise_server_exceptions", False) + if not kwargs["raise_server_exceptions"]: + + def handle_error(request, exc): + from odoo.addons.fastapi.fastapi_dispatcher import FastApiDispatcher + + def make_json_response(body, status, headers): + import logging + + from starlette.responses import JSONResponse + + _logger = logging.getLogger(__name__) + + response = JSONResponse(body, status_code=status) + if status == 500: + _logger.error("Error in test request", exc_info=exc) + if headers: + response.headers.update(headers) + return response + + request.make_json_response = make_json_response + return FastApiDispatcher(request).handle_error(exc) + + kwargs.get("app", self.default_fastapi_app).exception_handlers[ + Exception + ] = handle_error + + with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: yield test_client - mock_rollback.assert_called_once() def test_get_manager_unit_members(self): """ @@ -94,7 +118,7 @@ def test_collaborator_unit_members(self): """ self.default_fastapi_authenticated_partner = self.collaborator_1_1 - with self._rollback_called_test_client() as test_client: + with self._create_test_client() as test_client: response: Response = test_client.get("/unit/members") self.assertEqual( response.status_code, @@ -107,7 +131,7 @@ def test_unit_unit_members(self): """ self.default_fastapi_authenticated_partner = self.unit_1 - with self._rollback_called_test_client() as test_client: + with self._create_test_client() as test_client: response: Response = test_client.get("/unit/members") self.assertEqual( response.status_code, @@ -149,7 +173,7 @@ 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: + with self._create_test_client() as test_client: response: Response = test_client.get( f"/unit/members/{self.collaborator_2_2.id}" ) @@ -162,13 +186,13 @@ 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: + 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._rollback_called_test_client() as test_client: + 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, @@ -257,7 +281,7 @@ def test_create_unit_manager(self): ) def test_create_unit_wrong_type(self): - with self._rollback_called_test_client() as test_client: + with self._create_test_client() as test_client: response: Response = test_client.post( "/unit/members", data=json.dumps({"name": "New Unit", "type": "unit"}), @@ -267,7 +291,7 @@ def test_create_unit_wrong_type(self): status.HTTP_403_FORBIDDEN, ) - with self._rollback_called_test_client() as test_client: + with self._create_test_client() as test_client: response: Response = test_client.post( "/unit/members", data=json.dumps({"name": "New Unit", "type": "unknown"}), @@ -279,7 +303,7 @@ def test_create_unit_wrong_type(self): 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: + with self._create_test_client() as test_client: response: Response = test_client.post( "/unit/members", data=json.dumps({"name": "New Unit", "type": "collaborator"}), @@ -387,7 +411,7 @@ def test_update_unit_manager(self): 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: + 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"}), @@ -474,7 +498,7 @@ def test_delete_unit_manager(self): 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: + with self._create_test_client() as test_client: response: Response = test_client.delete( f"/unit/members/{self.collaborator_1_1.id}", ) 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 index 484607ea4b..2f812e2223 100644 --- a/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py +++ b/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py @@ -4,12 +4,12 @@ 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.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 @@ -107,17 +107,47 @@ def setUpClass(cls) -> None: ) 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 {item[fields[0]] for item in data["items"]} - return {tuple(item[field] for field in fields) for item in data["items"]} + 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 _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: + def _create_test_client(self, **kwargs): + kwargs.setdefault("raise_server_exceptions", False) + if not kwargs["raise_server_exceptions"]: + + def handle_error(request, exc): + from odoo.addons.fastapi.fastapi_dispatcher import FastApiDispatcher + + def make_json_response(body, status, headers): + import logging + + from starlette.responses import JSONResponse + + _logger = logging.getLogger(__name__) + + response = JSONResponse(body, status_code=status) + if status == 500: + _logger.error("Error in test request", exc_info=exc) + if headers: + response.headers.update(headers) + return response + + request.make_json_response = make_json_response + return FastApiDispatcher(request).handle_error(exc) + + kwargs.get("app", self.default_fastapi_app).exception_handlers[ + Exception + ] = handle_error + + with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: yield test_client - mock_rollback.assert_called_once() def test_cart_request_as_collaborator(self): """ @@ -178,7 +208,7 @@ def test_cart_request_as_manager(self): self.env["sale.order"]._create_empty_cart( self.default_fastapi_authenticated_partner.id ) - with self._rollback_called_test_client() as test_client: + with self._create_test_client() as test_client: response: Response = test_client.post("/request") self.assertEqual( response.status_code, @@ -196,7 +226,7 @@ def test_cart_request_as_unit(self): self.env["sale.order"]._create_empty_cart( self.default_fastapi_authenticated_partner.id ) - with self._rollback_called_test_client() as test_client: + with self._create_test_client() as test_client: response: Response = test_client.post("/request") self.assertEqual( response.status_code, @@ -305,7 +335,7 @@ def test_sale_line_requested_flow(self): self.assertEqual(response.json()["count"], 1) def test_sale_line_requested_as_collaborator(self): - with self._rollback_called_test_client( + 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") @@ -325,7 +355,7 @@ def test_sale_line_requested_filters(self): res = response.json() self.assertEqual(res["count"], 3) self.assertEqual( - self._slice_sol(res, "product_id", "qty", "order_id"), + 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), @@ -370,7 +400,7 @@ def test_sale_line_requested_accept(self): res = response.json() self.assertEqual(res["count"], 2) self.assertEqual( - self._slice_sol(res, "product_id", "qty", "order_id"), + 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), @@ -424,7 +454,7 @@ def test_sale_line_requested_reject(self): res = response.json() self.assertEqual(res["count"], 2) self.assertEqual( - self._slice_sol(res, "product_id", "qty", "order_id"), + 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), @@ -447,7 +477,7 @@ def test_sale_line_requested_reject(self): res, "product_id", "qty", - "order_id", + "order.id", "request_rejected", "request_rejection_reason", ), @@ -514,20 +544,32 @@ def test_cart_confirm_notify_collaborators(self): 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() + 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(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.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 = self.collaborator_1_2.message_ids[0] + 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(self.manager_1_1.message_ids), 0) + self.assertEqual(len(messages_manager_1_1.records), 0) diff --git a/shopinvader_unit_management/README.rst b/shopinvader_unit_management/README.rst index c8b7b812ca..a14a8fe280 100644 --- a/shopinvader_unit_management/README.rst +++ b/shopinvader_unit_management/README.rst @@ -7,7 +7,7 @@ Shopinvader Unit Management !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:8b84b0911ab2e1dd5bca6a65f2d6a814183bb0ff565d3772f39aa28e24c4223d + !! source digest: sha256:c85b9aaf1e5ec9ff4516960b20e84bae393a2240bd14e62e11d410ca7d6d6028 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -16,17 +16,11 @@ Shopinvader Unit Management .. |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_unit_management - :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_unit_management - :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| +.. |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. @@ -42,10 +36,10 @@ To cater to your needs, you can inherit the res.partner model and make the unit_ Bug Tracker =========== -Bugs are tracked on `GitHub Issues `_. +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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -67,16 +61,6 @@ Contributors 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. +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. -You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. +You are welcome to contribute. diff --git a/shopinvader_unit_management/__manifest__.py b/shopinvader_unit_management/__manifest__.py index d5786b73db..84613e52a0 100644 --- a/shopinvader_unit_management/__manifest__.py +++ b/shopinvader_unit_management/__manifest__.py @@ -12,7 +12,7 @@ "license": "AGPL-3", "author": "Akretion", "website": "https://github.com/shopinvader/odoo-shopinvader", - "depends": [], + "depends": ["shopinvader_address"], "demo": [ "demo/res_partner_demo.xml", ], diff --git a/shopinvader_unit_management/models/res_partner.py b/shopinvader_unit_management/models/res_partner.py index 4fd403ec89..8ea472931e 100644 --- a/shopinvader_unit_management/models/res_partner.py +++ b/shopinvader_unit_management/models/res_partner.py @@ -57,8 +57,15 @@ def _get_shopinvader_unit_member(self, member_id): @api.model def _create_shopinvader_unit_member(self, vals): self._ensure_manager() - # FIXME: - vals[self._fields["unit_id"].related[0]] = self.unit_id.id + + # 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" @@ -87,3 +94,29 @@ def _delete_shopinvader_unit_member(self, member_id): 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/static/description/index.html b/shopinvader_unit_management/static/description/index.html index 0f2b2323d3..23a5cea897 100644 --- a/shopinvader_unit_management/static/description/index.html +++ b/shopinvader_unit_management/static/description/index.html @@ -9,10 +9,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +276,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +302,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,9 +368,9 @@

Shopinvader Unit Management

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:8b84b0911ab2e1dd5bca6a65f2d6a814183bb0ff565d3772f39aa28e24c4223d +!! source digest: sha256:c85b9aaf1e5ec9ff4516960b20e84bae393a2240bd14e62e11d410ca7d6d6028 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

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

+

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.

@@ -388,10 +389,10 @@

Shopinvader Unit Management

Bug Tracker

-

Bugs are tracked on GitHub Issues. +

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.

+feedback.

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

@@ -413,13 +414,8 @@

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.

+

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

+

You are welcome to contribute.

diff --git a/shopinvader_unit_management/tests/common.py b/shopinvader_unit_management/tests/common.py index 81140ced2a..0e58c3b552 100644 --- a/shopinvader_unit_management/tests/common.py +++ b/shopinvader_unit_management/tests/common.py @@ -2,10 +2,10 @@ # @author Florian Mounier # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo.tests.common import SavepointCase +from odoo.tests.common import TransactionCase -class TestUnitManagementCommon(SavepointCase): +class TestUnitManagementCommon(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() diff --git a/shopinvader_unit_management/tests/test_unit_management.py b/shopinvader_unit_management/tests/test_unit_management.py index 536a454a04..a4043f3582 100644 --- a/shopinvader_unit_management/tests/test_unit_management.py +++ b/shopinvader_unit_management/tests/test_unit_management.py @@ -2,6 +2,7 @@ # @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 @@ -95,3 +96,215 @@ def test_unit_management_collaborators(self): 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) From f5492d534c09fa5b13a7e0400342b34913342678 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 16 Oct 2024 12:32:36 +0200 Subject: [PATCH 16/19] [FIX] shopinvader_api_unit_member: Use framework/pull/464 instead of hack --- .../test_shopinvader_api_unit_members.py | 26 ------------------- 1 file changed, 26 deletions(-) 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 index 4678127e87..77aef4716d 100644 --- a/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py +++ b/shopinvader_api_unit_member/tests/test_shopinvader_api_unit_members.py @@ -53,32 +53,6 @@ def setUpClass(cls) -> None: @contextmanager def _create_test_client(self, **kwargs): kwargs.setdefault("raise_server_exceptions", False) - if not kwargs["raise_server_exceptions"]: - - def handle_error(request, exc): - from odoo.addons.fastapi.fastapi_dispatcher import FastApiDispatcher - - def make_json_response(body, status, headers): - import logging - - from starlette.responses import JSONResponse - - _logger = logging.getLogger(__name__) - - response = JSONResponse(body, status_code=status) - if status == 500: - _logger.error("Error in test request", exc_info=exc) - if headers: - response.headers.update(headers) - return response - - request.make_json_response = make_json_response - return FastApiDispatcher(request).handle_error(exc) - - kwargs.get("app", self.default_fastapi_app).exception_handlers[ - Exception - ] = handle_error - with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: yield test_client From db25c8cb9d120efccd3d6d81aee10948f4ccfe16 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 16 Oct 2024 12:32:47 +0200 Subject: [PATCH 17/19] [FIX] shopinvader_api_unit_request: Use framework/pull/464 instead of hack --- .../test_shopinvader_api_unit_request.py | 26 ------------------- 1 file changed, 26 deletions(-) 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 index 2f812e2223..cabce59691 100644 --- a/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py +++ b/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py @@ -120,32 +120,6 @@ def get(item, field): @contextmanager def _create_test_client(self, **kwargs): kwargs.setdefault("raise_server_exceptions", False) - if not kwargs["raise_server_exceptions"]: - - def handle_error(request, exc): - from odoo.addons.fastapi.fastapi_dispatcher import FastApiDispatcher - - def make_json_response(body, status, headers): - import logging - - from starlette.responses import JSONResponse - - _logger = logging.getLogger(__name__) - - response = JSONResponse(body, status_code=status) - if status == 500: - _logger.error("Error in test request", exc_info=exc) - if headers: - response.headers.update(headers) - return response - - request.make_json_response = make_json_response - return FastApiDispatcher(request).handle_error(exc) - - kwargs.get("app", self.default_fastapi_app).exception_handlers[ - Exception - ] = handle_error - with mute_logger("httpx"), super()._create_test_client(**kwargs) as test_client: yield test_client From 0bcede4a35361035205d74fc5fafac6861177688 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 18 Oct 2024 21:51:12 +0200 Subject: [PATCH 18/19] [ADD] shopinvader_fastapi_auth_partner_api_unit_member --- ...vader_fastapi_auth_partner_api_unit_member | 1 + .../setup.py | 6 + .../README.rst | 67 +++ .../__init__.py | 3 + .../__manifest__.py | 24 + .../data/email_data.xml | 27 ++ .../models/__init__.py | 2 + .../models/auth_directory.py | 19 + .../models/res_partner.py | 88 ++++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 6 + .../routers/__init__.py | 1 + .../routers/unit_members.py | 30 ++ .../schemas.py | 23 + .../static/description/index.html | 424 ++++++++++++++++++ .../tests/__init__.py | 1 + .../test_shopinvader_api_unit_members.py | 266 +++++++++++ .../views/auth_directory_view.xml | 14 + 18 files changed, 1005 insertions(+) create mode 120000 setup/shopinvader_fastapi_auth_partner_api_unit_member/odoo/addons/shopinvader_fastapi_auth_partner_api_unit_member create mode 100644 setup/shopinvader_fastapi_auth_partner_api_unit_member/setup.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/README.rst create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/__init__.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/__manifest__.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/data/email_data.xml create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/models/__init__.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/models/auth_directory.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/models/res_partner.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/readme/DESCRIPTION.rst create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/routers/__init__.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/routers/unit_members.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/schemas.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/static/description/index.html create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/tests/__init__.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/tests/test_shopinvader_api_unit_members.py create mode 100644 shopinvader_fastapi_auth_partner_api_unit_member/views/auth_directory_view.xml 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/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 + + + + + + + + + From 9c3a46897928deb7eb27d4880102a9be7eb85a0f Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 29 Oct 2024 13:08:28 +0100 Subject: [PATCH 19/19] [IMP] shopinvader_api_unit_request: Add request_order_id and request_partner_id to sale lines --- shopinvader_api_unit_request/schemas.py | 18 +++++++++++++++++- .../tests/test_shopinvader_api_unit_request.py | 11 ++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/shopinvader_api_unit_request/schemas.py b/shopinvader_api_unit_request/schemas.py index 04a02c011b..1d29541e9f 100644 --- a/shopinvader_api_unit_request/schemas.py +++ b/shopinvader_api_unit_request/schemas.py @@ -9,13 +9,29 @@ from odoo import api -from odoo.addons.shopinvader_api_sale.schemas import SaleLineWithSale +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 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 index cabce59691..8847e03ed0 100644 --- a/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py +++ b/shopinvader_api_unit_request/tests/test_shopinvader_api_unit_request.py @@ -287,8 +287,8 @@ def test_sale_line_requested_flow(self): 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) + accepted_sols = so2.order_line[:2] + accepted_sols._action_accept_request(so) with self._create_test_client( app=self.sale_line_app, @@ -297,7 +297,12 @@ def test_sale_line_requested_flow(self): ) 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) + 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,