From e74bcdb698beaa8abceb415a70c246258dd9ff73 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 11:52:46 +0100 Subject: [PATCH 01/28] [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 269ac2ccf902c6a3ac3f62b76dc441c360432d9f Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 11:53:19 +0100 Subject: [PATCH 02/28] [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 322d38b062cb280fee6ca5ad4bdf407fcb9714f3 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 12:06:34 +0100 Subject: [PATCH 03/28] [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 0daac7cfff926526c6758328831961066c442b55 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 7 Mar 2024 11:56:13 +0100 Subject: [PATCH 04/28] [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/28] [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 1412dca26773a439da9ae4d6702d24859eb16d01 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 12 Mar 2024 16:49:50 +0100 Subject: [PATCH 06/28] [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 acd52948ed35768dca34e0906707f622a6dfa1ee Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 10:45:41 +0100 Subject: [PATCH 07/28] [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 57b4679acdb959924bbf7fa5f88671d69e3bd4cc Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:18:38 +0100 Subject: [PATCH 08/28] [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 5919b7b2964870858307d073a69ee4ff822370d1 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:41:17 +0100 Subject: [PATCH 09/28] [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 8d5031bf71a4ecda5341fdd55f58d769217e605c Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:41:29 +0100 Subject: [PATCH 10/28] [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 d080057f9a8194b254bede4b645ebc30f554a932 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:41:51 +0100 Subject: [PATCH 11/28] [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 068c7ca5c77324baa137b5a5fa8ec2d4eb4ae4e5 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 13 Mar 2024 11:45:40 +0100 Subject: [PATCH 12/28] [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 43bed424a4621cdc0e1c769c7f93153650a7e1df Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 28 Mar 2024 09:33:20 +0100 Subject: [PATCH 13/28] [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 d596eda0333cbe8a3b50f1d4aa786ec9a70b15b5 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 15 Oct 2024 11:10:08 +0200 Subject: [PATCH 14/28] [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 6060e990618bc3a0c433459bda560dcfb1fb5c76 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 16 Oct 2024 11:42:06 +0200 Subject: [PATCH 15/28] [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 73c0689e745daf0d1a195bad46e0ea2a255de298 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 16 Oct 2024 12:32:36 +0200 Subject: [PATCH 16/28] [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 11c8e406044ec824c00712ef3762bb4354fcf10a Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 16 Oct 2024 12:32:47 +0200 Subject: [PATCH 17/28] [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 ebccfac08f064b19d8044e3ea2ff7a15335ae996 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 18 Oct 2024 21:51:12 +0200 Subject: [PATCH 18/28] [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 946cd06fce7f7042913ce768af558cc329fe9b33 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 29 Oct 2024 13:08:28 +0100 Subject: [PATCH 19/28] [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, From f55383a88d20dd881d3e8ac57719408d735a6c2f Mon Sep 17 00:00:00 2001 From: Kev-Roche Date: Tue, 25 Feb 2025 20:00:36 +0100 Subject: [PATCH 20/28] [IMP] request_order_id with index=True --- shopinvader_api_unit_request/models/sale_order_line.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopinvader_api_unit_request/models/sale_order_line.py b/shopinvader_api_unit_request/models/sale_order_line.py index ca60ea4fd4..e92bb817f6 100644 --- a/shopinvader_api_unit_request/models/sale_order_line.py +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -22,6 +22,7 @@ class SaleOrderLine(models.Model): "sale.order", string="Request Order", help="The order that requested this line.", + index=True, ) reject_order_id = fields.Many2one( "sale.order", From b4736ed996fea2dff9f75d9146f4af74a5f70500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Thu, 27 Feb 2025 13:39:51 +0100 Subject: [PATCH 21/28] shopinvader_api_unit_request: add missing index --- shopinvader_api_unit_request/models/sale_order_line.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopinvader_api_unit_request/models/sale_order_line.py b/shopinvader_api_unit_request/models/sale_order_line.py index e92bb817f6..9e4e2ffd39 100644 --- a/shopinvader_api_unit_request/models/sale_order_line.py +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -17,6 +17,7 @@ class SaleOrderLine(models.Model): "res.partner", string="Request Partner", help="The partner who requested this line.", + index=True, ) request_order_id = fields.Many2one( "sale.order", @@ -28,6 +29,7 @@ class SaleOrderLine(models.Model): "sale.order", string="Reject Order", help="The order that rejected this line.", + index=True, ) request_rejected = fields.Boolean( help="The request has been rejected.", From 76aed54b6b87954e3d51e2e7f32cf87546494ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Tue, 11 Mar 2025 12:29:12 +0100 Subject: [PATCH 22/28] shopinvader_api_unit_request: fix compatibility with sale_exception --- shopinvader_api_unit_request/models/sale_order.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shopinvader_api_unit_request/models/sale_order.py b/shopinvader_api_unit_request/models/sale_order.py index 7f8ea536cb..fc299fd4f7 100644 --- a/shopinvader_api_unit_request/models/sale_order.py +++ b/shopinvader_api_unit_request/models/sale_order.py @@ -45,6 +45,13 @@ def action_confirm(self): res = super().action_confirm() + # Glue for sale_exception compatibility + # Be carefull sale_exception will return a pop up + # and do not validate the sale, in this case we should not + # send the email as the sale is not confirmed + if res is not True: + return res + # Notify partners of accepted and refused requests # Group accepted and refused by partners request_lines_by_partner = defaultdict( From de5a454671b68bf0c2a7aee1efad70ac504fbda0 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 31 Mar 2025 10:16:25 +0200 Subject: [PATCH 23/28] [FIX] shopinvader_api_unit_request: Request sols as root Prevent acl errors with some modules --- shopinvader_api_unit_request/models/sale_order_line.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shopinvader_api_unit_request/models/sale_order_line.py b/shopinvader_api_unit_request/models/sale_order_line.py index 9e4e2ffd39..b950a35e78 100644 --- a/shopinvader_api_unit_request/models/sale_order_line.py +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -49,32 +49,32 @@ def _compute_request_accepted(self): record.request_accepted = record.request_order_id def _action_request(self): - for record in self: + for record in self.sudo(): 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: + for record in self.sudo(): 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: + for record in self.sudo(): 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: + for record in self.sudo(): record.request_rejected = False record.reject_order_id = False record.request_rejection_reason = False return True def unlink(self): - for record in self: + for record in self.sudo(): if record.request_partner_id and record.request_order_id: record.order_id = record.request_order_id record.request_order_id = False From bbcbb3c49759813044688b75052fa6b1922877df Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 3 Apr 2025 11:34:06 +0200 Subject: [PATCH 24/28] [IMP] shopinvader_api_unit_request: Relax sale RRule a bit to include unit sales too --- .../security/sale_management_security.xml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/shopinvader_api_unit_request/security/sale_management_security.xml b/shopinvader_api_unit_request/security/sale_management_security.xml index fc5bb43d5a..3180efc9c3 100644 --- a/shopinvader_api_unit_request/security/sale_management_security.xml +++ b/shopinvader_api_unit_request/security/sale_management_security.xml @@ -13,10 +13,9 @@ name="groups" eval="[(4, ref('shopinvader_api_unit_member.shopinvader_unit_management_user_group'))]" /> - [('partner_id.unit_id.manager_ids','=',authenticated_partner_id)] - + ['|', + ('partner_id.unit_id.manager_ids','=',authenticated_partner_id), + ('partner_id.manager_ids','=',authenticated_partner_id)] @@ -28,10 +27,9 @@ name="groups" eval="[(4, ref('shopinvader_api_unit_member.shopinvader_unit_management_user_group'))]" /> - [('order_id.partner_id.unit_id.manager_ids','=',authenticated_partner_id)] - + ['|', + ('order_id.partner_id.unit_id.manager_ids','=',authenticated_partner_id), + ('order_id.partner_id.manager_ids','=',authenticated_partner_id)] From 94e0474b0b187789de5ed34672f3628cdd41e799 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 10 Apr 2025 16:52:43 +0200 Subject: [PATCH 25/28] [IMP] shopinvader_api_unit_request: Add missing fields on sale line router --- shopinvader_api_unit_request/schemas.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopinvader_api_unit_request/schemas.py b/shopinvader_api_unit_request/schemas.py index 1d29541e9f..b7e928f735 100644 --- a/shopinvader_api_unit_request/schemas.py +++ b/shopinvader_api_unit_request/schemas.py @@ -19,6 +19,8 @@ class RejectRequest(StrictExtendableBaseModel, extra="ignore"): class SaleLineWithSale(schemas.SaleLineWithSale, extends=True): request_order_id: int | None request_partner_id: int | None + request_rejected: bool + request_rejection_reason: str | None = None @classmethod def from_sale_order_line(cls, odoo_rec): @@ -29,20 +31,18 @@ def from_sale_order_line(cls, odoo_rec): res.request_partner_id = ( odoo_rec.request_partner_id.id if odoo_rec.request_partner_id else None ) + res.request_rejected = odoo_rec.request_rejected + res.request_rejection_reason = odoo_rec.request_rejection_reason or None return res class RequestedSaleLine(SaleLineWithSale): partner_id: int - request_rejected: bool - request_rejection_reason: str | None = None @classmethod def from_sale_order_line(cls, odoo_rec): res = super().from_sale_order_line(odoo_rec) res.partner_id = odoo_rec.request_partner_id.id - res.request_rejected = odoo_rec.request_rejected - res.request_rejection_reason = odoo_rec.request_rejection_reason or None return res From fd7d7864c76c1cb29333e8d904e32f37f38fef5b Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 2 Jun 2025 13:13:28 +0200 Subject: [PATCH 26/28] [FIX] shopinvader_api_unit_request: Fix according to upstream changes --- shopinvader_api_unit_request/README.rst | 4 ++-- .../readme/DESCRIPTION.rst | 4 ++-- .../static/description/index.html | 10 ++++----- .../test_shopinvader_api_unit_request.py | 22 +++++++++++-------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/shopinvader_api_unit_request/README.rst b/shopinvader_api_unit_request/README.rst index 1ad45f9d98..9c9ad0e3d4 100644 --- a/shopinvader_api_unit_request/README.rst +++ b/shopinvader_api_unit_request/README.rst @@ -23,7 +23,7 @@ Shopinvader Unit Request Api |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 +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: @@ -32,7 +32,7 @@ The `cart_router` has been extended to allow the creation of a request: - `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. +The `sale_router` has been extended to allow the /api/sales/lines to also list the requested sale lines. A new `unit_request_line_router` has been added to manage the requests: diff --git a/shopinvader_api_unit_request/readme/DESCRIPTION.rst b/shopinvader_api_unit_request/readme/DESCRIPTION.rst index 40aa8bda76..382fa782ea 100644 --- a/shopinvader_api_unit_request/readme/DESCRIPTION.rst +++ b/shopinvader_api_unit_request/readme/DESCRIPTION.rst @@ -1,5 +1,5 @@ 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 +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: @@ -8,7 +8,7 @@ The `cart_router` has been extended to allow the creation of a request: - `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. +The `sale_router` has been extended to allow the /api/sales/lines to also list the requested sale lines. A new `unit_request_line_router` has been added to manage the requests: diff --git a/shopinvader_api_unit_request/static/description/index.html b/shopinvader_api_unit_request/static/description/index.html index 189cdf5d52..fa6aa30de0 100644 --- a/shopinvader_api_unit_request/static/description/index.html +++ b/shopinvader_api_unit_request/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,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 +275,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 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -379,7 +379,7 @@

Shopinvader Unit Request Api

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

    +

    The sale_router has been extended to allow the /api/sales/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.
    • 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 8847e03ed0..987ffcdc84 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 @@ -275,7 +275,7 @@ def test_sale_line_requested_flow(self): router=sale_line_router, partner=self.collaborator_2_1, ) as test_client: - response: Response = test_client.get("/sale_lines") + response: Response = test_client.get("/sales/lines") self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) self.assertEqual(response.json()["count"], 5) @@ -295,7 +295,7 @@ def test_sale_line_requested_flow(self): router=sale_line_router, partner=self.collaborator_2_1, ) as test_client: - response: Response = test_client.get("/sale_lines") + response: Response = test_client.get("/sales/lines") self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) data = response.json() self.assertEqual(data["count"], 5) @@ -531,13 +531,17 @@ def mail_domain_for(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: + 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 From 0ff576e4c549f9b82af14e12630c58b76d7a8e03 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 24 Apr 2026 17:09:20 +0200 Subject: [PATCH 27/28] [IMP] shopinvader_api_unit_request: Add request partner filter + sort --- .../models/sale_order_line.py | 2 + .../routers/unit_request_lines.py | 1 + shopinvader_api_unit_request/schemas.py | 43 +++++++++++++++++++ shopinvader_filtered_model/utils.py | 8 ++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/shopinvader_api_unit_request/models/sale_order_line.py b/shopinvader_api_unit_request/models/sale_order_line.py index b950a35e78..f46d27d695 100644 --- a/shopinvader_api_unit_request/models/sale_order_line.py +++ b/shopinvader_api_unit_request/models/sale_order_line.py @@ -12,6 +12,7 @@ class SaleOrderLine(models.Model): help="Quantity requested by the collaborator in case of a request.", default=0.0, ) + date_order = fields.Datetime(related="order_id.date_order") request_partner_id = fields.Many2one( "res.partner", @@ -19,6 +20,7 @@ class SaleOrderLine(models.Model): help="The partner who requested this line.", index=True, ) + request_partner_name = fields.Char(related="request_partner_id.name") request_order_id = fields.Many2one( "sale.order", string="Request Order", diff --git a/shopinvader_api_unit_request/routers/unit_request_lines.py b/shopinvader_api_unit_request/routers/unit_request_lines.py index 560915076a..39a3836634 100644 --- a/shopinvader_api_unit_request/routers/unit_request_lines.py +++ b/shopinvader_api_unit_request/routers/unit_request_lines.py @@ -136,6 +136,7 @@ def _search(self, paging, params) -> tuple[int, SaleOrderLine]: params.to_odoo_domain(self.env), limit=paging.limit, offset=paging.offset, + order=params.to_odoo_order(), ) def _get_cart(self, uuid): diff --git a/shopinvader_api_unit_request/schemas.py b/shopinvader_api_unit_request/schemas.py index b7e928f735..22fa7bbb66 100644 --- a/shopinvader_api_unit_request/schemas.py +++ b/shopinvader_api_unit_request/schemas.py @@ -68,6 +68,14 @@ class RequestedSaleLineSearch(StrictExtendableBaseModel, extra="ignore"): "rejected sale order lines." # noqa ), ] = None + request_partner_name: Annotated[ + str | None, + Field( + description="When used, the search look for any sale order lines " # noqa + "where the request partner name contains the given value case insensitively." # noqa + ), + ] = None + sort_by: str | None = None def to_odoo_domain(self, env: api.Environment): domain = [] @@ -80,4 +88,39 @@ def to_odoo_domain(self, env: api.Environment): if not self.rejected: domain.append(("request_rejected", "=", False)) + if self.request_partner_name: + domain.append( + ("request_partner_id.name", "ilike", self.request_partner_name) + ) + return domain + + @staticmethod + def _parse_sort_by(sort_by: str): + # Order are of the form 'date_order.desc,partner_name.asc,id' + sep = "." + return { + sort.split(sep)[0]: ( + "desc" + if sep in sort and sort.split(sep)[1].lower() == "desc" + else "asc" + ) + for sort in (sort_by or "").split(",") + } + + def to_odoo_order(self): + if self.sort_by: + sorts = self._parse_sort_by(self.sort_by) + + sort_fields = { + "request_partner_name": "request_partner_name", + } + + return ",".join( + [ + sort_fields[field] + (f" {order}" if order == "desc" else "") + for field, order in sorts.items() + if field in sort_fields + ] + ) + return "date_order desc" diff --git a/shopinvader_filtered_model/utils.py b/shopinvader_filtered_model/utils.py index 5573fe9177..2e3378b819 100644 --- a/shopinvader_filtered_model/utils.py +++ b/shopinvader_filtered_model/utils.py @@ -34,14 +34,14 @@ def get(self, record_id: int) -> T: else: raise MissingError(_("The record do not exist")) - def search(self, domain: list) -> T: + def search(self, domain: list, **kwargs) -> T: domain = expression.AND([self._base_domain, domain]) - return self._model.search(domain) + return self._model.search(domain, **kwargs) - def search_with_count(self, domain: list, limit, offset) -> tuple[int, T]: + def search_with_count(self, domain: list, limit, offset, **kwargs) -> tuple[int, T]: domain = expression.AND([self._base_domain, domain]) count = self._model.search_count(domain) - return count, self._model.search(domain, limit=limit, offset=offset) + return count, self._model.search(domain, limit=limit, offset=offset, **kwargs) def get_orig_class(obj): From b18371b4db1f265df1e446d75ccf408c6b2a7b66 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 2 Jul 2026 12:30:03 +0200 Subject: [PATCH 28/28] [IMP] shopinvader_api_unit_request: Extend sale order to unit addresses --- .../security/sale_management_security.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopinvader_api_unit_request/security/sale_management_security.xml b/shopinvader_api_unit_request/security/sale_management_security.xml index 3180efc9c3..a540e8f051 100644 --- a/shopinvader_api_unit_request/security/sale_management_security.xml +++ b/shopinvader_api_unit_request/security/sale_management_security.xml @@ -14,8 +14,8 @@ eval="[(4, ref('shopinvader_api_unit_member.shopinvader_unit_management_user_group'))]" /> ['|', - ('partner_id.unit_id.manager_ids','=',authenticated_partner_id), - ('partner_id.manager_ids','=',authenticated_partner_id)] + ('commercial_partner_id.unit_id.manager_ids','=',authenticated_partner_id), + ('commercial_partner_id.manager_ids','=',authenticated_partner_id)] @@ -28,8 +28,8 @@ eval="[(4, ref('shopinvader_api_unit_member.shopinvader_unit_management_user_group'))]" /> ['|', - ('order_id.partner_id.unit_id.manager_ids','=',authenticated_partner_id), - ('order_id.partner_id.manager_ids','=',authenticated_partner_id)] + ('order_id.commercial_partner_id.unit_id.manager_ids','=',authenticated_partner_id), + ('order_id.commercial_partner_id.manager_ids','=',authenticated_partner_id)]