From 03c22dbba4800bb13e58159fc5eec76e514ab11e Mon Sep 17 00:00:00 2001 From: geomer198 Date: Sun, 30 Oct 2022 01:47:16 +0300 Subject: [PATCH] [ADD] stock_available_portal: Module added. --- .../odoo/addons/stock_available_portal | 1 + setup/stock_available_portal/setup.py | 6 + stock_available_portal/README.rst | 3 + stock_available_portal/__init__.py | 2 + stock_available_portal/__manifest__.py | 17 ++ .../controllers/__init__.py | 1 + stock_available_portal/controllers/portal.py | 156 ++++++++++++++++++ stock_available_portal/models/__init__.py | 2 + .../models/product_product.py | 54 ++++++ .../models/res_config_settings.py | 17 ++ stock_available_portal/readme/CONFIGURE.rst | 6 + .../readme/CONTRIBUTORS.rst | 1 + stock_available_portal/readme/DESCRIPTION.rst | 4 + stock_available_portal/readme/USAGE.rst | 1 + stock_available_portal/tests/__init__.py | 2 + .../tests/test_portal_product.py | 152 +++++++++++++++++ .../tests/test_product_product.py | 112 +++++++++++++ .../views/product_portal_templates.xml | 121 ++++++++++++++ .../views/res_config_settings_views.xml | 39 +++++ 19 files changed, 697 insertions(+) create mode 120000 setup/stock_available_portal/odoo/addons/stock_available_portal create mode 100644 setup/stock_available_portal/setup.py create mode 100644 stock_available_portal/README.rst create mode 100644 stock_available_portal/__init__.py create mode 100644 stock_available_portal/__manifest__.py create mode 100644 stock_available_portal/controllers/__init__.py create mode 100644 stock_available_portal/controllers/portal.py create mode 100644 stock_available_portal/models/__init__.py create mode 100644 stock_available_portal/models/product_product.py create mode 100644 stock_available_portal/models/res_config_settings.py create mode 100644 stock_available_portal/readme/CONFIGURE.rst create mode 100644 stock_available_portal/readme/CONTRIBUTORS.rst create mode 100644 stock_available_portal/readme/DESCRIPTION.rst create mode 100644 stock_available_portal/readme/USAGE.rst create mode 100644 stock_available_portal/tests/__init__.py create mode 100644 stock_available_portal/tests/test_portal_product.py create mode 100644 stock_available_portal/tests/test_product_product.py create mode 100644 stock_available_portal/views/product_portal_templates.xml create mode 100644 stock_available_portal/views/res_config_settings_views.xml diff --git a/setup/stock_available_portal/odoo/addons/stock_available_portal b/setup/stock_available_portal/odoo/addons/stock_available_portal new file mode 120000 index 000000000..dfb205395 --- /dev/null +++ b/setup/stock_available_portal/odoo/addons/stock_available_portal @@ -0,0 +1 @@ +../../../../stock_available_portal \ No newline at end of file diff --git a/setup/stock_available_portal/setup.py b/setup/stock_available_portal/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_available_portal/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_available_portal/README.rst b/stock_available_portal/README.rst new file mode 100644 index 000000000..21a6c7f25 --- /dev/null +++ b/stock_available_portal/README.rst @@ -0,0 +1,3 @@ +========================= +Stock available to Portal +========================= diff --git a/stock_available_portal/__init__.py b/stock_available_portal/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/stock_available_portal/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/stock_available_portal/__manifest__.py b/stock_available_portal/__manifest__.py new file mode 100644 index 000000000..fa44f9928 --- /dev/null +++ b/stock_available_portal/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "Stock Available In Portal", + "version": "14.0.1.0.0", + "author": "Cetmix, Odoo Community Association (OCA)", + "summary": "Show product quantity available to promise in portal", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "development_status": "Production/Stable", + "category": "Hidden", + "depends": ["stock_available", "portal"], + "maintainers": ["geomer198", "CetmixGitDrone"], + "license": "AGPL-3", + "data": [ + "views/product_portal_templates.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, +} diff --git a/stock_available_portal/controllers/__init__.py b/stock_available_portal/controllers/__init__.py new file mode 100644 index 000000000..8c3feb6f5 --- /dev/null +++ b/stock_available_portal/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/stock_available_portal/controllers/portal.py b/stock_available_portal/controllers/portal.py new file mode 100644 index 000000000..3e6701c9f --- /dev/null +++ b/stock_available_portal/controllers/portal.py @@ -0,0 +1,156 @@ +from collections import OrderedDict + +from odoo import _, http +from odoo.http import request +from odoo.osv.expression import OR + +from odoo.addons.portal.controllers.portal import ( + CustomerPortal, + get_records_pager, + pager as portal_pager, +) + + +class PortalProduct(CustomerPortal): + def _get_product_searchbar_sorting(self): + return { + "date": {"label": _("Create Date"), "order": "create_date desc"}, + "name": {"label": _("Name"), "order": "name"}, + "default_code": {"label": _("SKU"), "order": "default_code"}, + } + + def _get_product_searchbar_filters(self): + return { + "all": {"label": _("All"), "domain": []}, + } + + def _get_searchbar_inputs(self): + return { + "all": {"input": "all", "label": _("Search in All")}, + "default_code": {"input": "default_code", "label": _("Search in SKU")}, + "name": {"input": "display_name", "label": _("Search in Product Name")}, + } + + def _get_search_domain(self, search_in, search): + search_domain = [] + if search_in in ("name", "all"): + search_domain = OR([search_domain, [("name", "ilike", search)]]) + if search_in in ("default_code", "all"): + search_domain = OR([search_domain, [("default_code", "ilike", search)]]) + return search_domain + + def _prepare_portal_layout_values(self): + values = super(PortalProduct, self)._prepare_portal_layout_values() + Product = request.env["product.product"] + values.update(access_to_products=Product.check_product_portal_access()) + return values + + def _prepare_home_portal_values(self, counters): + values = super(PortalProduct, self)._prepare_home_portal_values(counters) + if "product_count" in counters: + Product = request.env["product.product"] + values.update(product_count=len(Product.get_portal_products(False))) + return values + + def _product_get_page_view_values(self, product, access_token, **kwargs): + values = {"page_name": "product", "product": product} + history = request.session.get("my_products_history", []) + values.update(get_records_pager(history, product.sudo())) + return self._get_page_view_values( + product, access_token, values, "my_products_history", False, **kwargs + ) + + @http.route( + ["/my/products", "/my/products/page/"], + type="http", + auth="user", + website=True, + ) + def portal_my_products( + self, + page=1, + date_begin=None, + date_end=None, + sortby=None, + filterby=None, + search=None, + search_in="all", + **kw + ): + values = self._prepare_portal_layout_values() + if not values.get("access_to_products", False): + return request.redirect("/my/home") + Product = request.env["product.product"] + + searchbar_sorting = self._get_product_searchbar_sorting() + searchbar_filters = self._get_product_searchbar_filters() + searchbar_inputs = self._get_searchbar_inputs() + + domain = [] + + if not sortby: + sortby = "date" + order = searchbar_sorting[sortby]["order"] + if not filterby: + filterby = "all" + domain += searchbar_filters[filterby]["domain"] + if search and search_in: + domain += self._get_search_domain(search_in, search) + if date_begin and date_end: + domain += [ + ("create_date", ">", date_begin), + ("create_date", "<=", date_end), + ] + + product_count = len(Product.get_portal_products(domain)) + + pager = portal_pager( + url="/my/products", + url_args={ + "date_begin": date_begin, + "date_end": date_end, + "search_in": search_in, + "search": search, + "filterby": filterby, + "sortby": sortby, + }, + total=product_count, + page=page, + step=self._items_per_page, + ) + + products = Product.get_portal_products( + domain, order=order, limit=self._items_per_page, offset=pager["offset"] + ) + + request.session["my_products_history"] = products.ids[:100] + + values.update( + { + "date": date_begin, + "products": products, + "page_name": "product", + "pager": pager, + "default_url": "/my/products", + "searchbar_sortings": searchbar_sorting, + "search_in": search_in, + "search": search, + "filterby": filterby, + "searchbar_inputs": searchbar_inputs, + "sortby": sortby, + "searchbar_filters": OrderedDict(sorted(searchbar_filters.items())), + "date_end": date_end, + } + ) + return request.render("stock_available_portal.portal_my_products", values) + + @http.route( + ["/my/products/"], type="http", auth="user", website=True + ) + def portal_product_page(self, product_id, access_token=None, **kw): + Product = request.env["product.product"] + product = Product.get_portal_products([("id", "=", product_id)]) + if Product.check_product_portal_access() and product: + values = self._product_get_page_view_values(product, access_token, **kw) + return request.render("stock_available_portal.portal_my_product", values) + return request.redirect("/my/home") diff --git a/stock_available_portal/models/__init__.py b/stock_available_portal/models/__init__.py new file mode 100644 index 000000000..e0848eaa7 --- /dev/null +++ b/stock_available_portal/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_product +from . import res_config_settings diff --git a/stock_available_portal/models/product_product.py b/stock_available_portal/models/product_product.py new file mode 100644 index 000000000..58dce78c2 --- /dev/null +++ b/stock_available_portal/models/product_product.py @@ -0,0 +1,54 @@ +from odoo import api, models +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "portal.mixin"] + + def _compute_access_url(self): + """Compute access url for portal view""" + for rec in self: + rec.access_url = "/my/products/{}".format(rec.id) + + @api.model + def get_config_domain(self, key): + """Get domain using config key""" + try: + return safe_eval(self.env["ir.config_parameter"].sudo().get_param(key, [])) + except (TypeError, SyntaxError, NameError, ValueError): + return [] + + @api.model + def check_product_portal_access(self): + """Check access to portal products for user""" + self = self.sudo() + user_has_group = self.env.user.has_group + if not ( + user_has_group("base.group_system") or user_has_group("base.group_portal") + ): + return False + domain = self.get_config_domain("stock_available_portal.portal_visible_users") + return ( + bool( + self.env["res.users"].search( + expression.AND([domain, [("id", "=", self.env.user.id)]]), limit=1 + ) + ) + if domain or domain == [] + else False + ) + + @api.model + def get_portal_products(self, filter_domain, **kwargs): + """Get portal products""" + self = self.sudo() + if not self.check_product_portal_access(): + return self + domain = self.get_config_domain( + "stock_available_portal.portal_visible_products_domain" + ) + if not domain and domain != []: + return self + return self.search(expression.AND([domain, filter_domain]), **kwargs) diff --git a/stock_available_portal/models/res_config_settings.py b/stock_available_portal/models/res_config_settings.py new file mode 100644 index 000000000..136af852c --- /dev/null +++ b/stock_available_portal/models/res_config_settings.py @@ -0,0 +1,17 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + portal_visible_products_domain = fields.Char( + string="Portal Visible Products", + help="Domain which defines which products can be visible on portal. " + "By default all products.", + config_parameter="stock_available_portal.portal_visible_products_domain", + ) + portal_visible_users = fields.Char( + string="Visible to Users", + help="Domain which defines which users can see products. By default all users.", + config_parameter="stock_available_portal.portal_visible_users", + ) diff --git a/stock_available_portal/readme/CONFIGURE.rst b/stock_available_portal/readme/CONFIGURE.rst new file mode 100644 index 000000000..875d3beab --- /dev/null +++ b/stock_available_portal/readme/CONFIGURE.rst @@ -0,0 +1,6 @@ +Open **General Settings** > **Inventory**. + +Configure the following filters in **Stock available to promise** section: + +* **Portal Visible Products** defines which products will be visible in portal +* **Visible to Users** defines which users have access to products in portal diff --git a/stock_available_portal/readme/CONTRIBUTORS.rst b/stock_available_portal/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..6ced4abaa --- /dev/null +++ b/stock_available_portal/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Cetmix diff --git a/stock_available_portal/readme/DESCRIPTION.rst b/stock_available_portal/readme/DESCRIPTION.rst new file mode 100644 index 000000000..b2da2fdee --- /dev/null +++ b/stock_available_portal/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module extends the functionality of **Stock available to promise**. +It allows to show "Quantity available to promise" of your products in customer portal. +A new menu "Products" is added to the Portal view. So your portal users can see your current inventory. +You can configure which products are visible in portal and which users have access to these products. diff --git a/stock_available_portal/readme/USAGE.rst b/stock_available_portal/readme/USAGE.rst new file mode 100644 index 000000000..a02dc0a3b --- /dev/null +++ b/stock_available_portal/readme/USAGE.rst @@ -0,0 +1 @@ +Log into portal. Products are listed under the **Products** menu. diff --git a/stock_available_portal/tests/__init__.py b/stock_available_portal/tests/__init__.py new file mode 100644 index 000000000..07a5819cc --- /dev/null +++ b/stock_available_portal/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_portal_product +from . import test_product_product diff --git a/stock_available_portal/tests/test_portal_product.py b/stock_available_portal/tests/test_portal_product.py new file mode 100644 index 000000000..58f6bfa24 --- /dev/null +++ b/stock_available_portal/tests/test_portal_product.py @@ -0,0 +1,152 @@ +from odoo.tests import tagged + +from odoo.addons.base.tests.common import HttpCaseWithUserPortal +from odoo.addons.stock_available_portal.controllers.portal import PortalProduct + + +@tagged("post_install", "-at_install") +class TestWebsitePriceListHttp(HttpCaseWithUserPortal): + def test_access_portal_products(self): + product = self.env["product.product"].search([], limit=1) + session = self.authenticate("portal", "portal") + r = self.url_open("/my/products") + self.assertEqual(r.status_code, 200, msg="Status code must be equal to 200") + self.assertEqual( + r.request.path_url, + "/my/products", + msg="Url path must be equal to '/my/products'", + ) + r = self.url_open("/my/products?search_in=all&search=test") + self.assertEqual(r.status_code, 200, msg="Status code must be equal to 200") + self.assertEqual( + r.request.path_url, + "/my/products?search_in=all&search=test", + msg="Url path must be equal to '/my/products?search_in=all&search=test'", + ) + r = self.url_open("/my/products/{}".format(product.id)) + self.assertEqual(r.status_code, 200, msg="Status code must be equal to 200") + self.assertEqual( + r.request.path_url, + "/my/products/{}".format(product.id), + msg="Url path must be equal to '/my/products/{}'".format(product.id), + ) + self.env["ir.config_parameter"].set_param( + "stock_available_portal.portal_visible_users", "[('id', '=', 1)]" + ) + r = self.url_open("/my/products") + self.assertEqual(r.status_code, 200, msg="Status code must be equal to 200") + self.assertEqual( + r.request.path_url, "/my/home", msg="Url path must be equal to '/my/home'" + ) + r = self.url_open("/my/products/{}".format(product.id)) + self.assertEqual(r.status_code, 200, msg="Status code must be equal to 200") + self.assertEqual( + r.request.path_url, "/my/home", msg="Url path must be equal to '/my/home'" + ) + self.env["ir.config_parameter"].set_param( + "stock_available_portal.portal_visible_users", + "[('id', '=', {})]".format(session.uid), + ) + r = self.url_open("/my/products") + self.assertEqual(r.status_code, 200, msg="Status code must be equal to 200") + self.assertEqual( + r.request.path_url, + "/my/products", + msg="Url path must be equal to '/my/products'", + ) + + def test_get_product_searchbar_sorting(self): + obj = PortalProduct() + searched_bar = obj._get_product_searchbar_sorting() + keys = list(searched_bar.keys()) + expected_keys = ["date", "name", "default_code"] + self.assertListEqual(keys, expected_keys, msg="Keys list must be the same") + + date_obj = searched_bar.get("date") + self.assertEqual( + date_obj.get("order"), + "create_date desc", + msg="Order must be equal to 'create_date desc'", + ) + name_obj = searched_bar.get("name") + self.assertEqual( + name_obj.get("order"), "name", msg="Order must be equal to 'name'" + ) + default_code_obj = searched_bar.get("default_code") + self.assertEqual( + default_code_obj.get("order"), + "default_code", + msg="Order must be equal to 'default_code'", + ) + + def test_get_product_searchbar_filters(self): + obj = PortalProduct() + searchbar_filter = obj._get_product_searchbar_filters() + keys = list(searchbar_filter.keys()) + expected_keys = ["all"] + self.assertListEqual(keys, expected_keys, msg="Keys list must be the same") + all_obj = searchbar_filter.get("all") + self.assertEqual(all_obj.get("domain"), [], msg="Domain must be empty") + + def test_get_searchbar_inputs(self): + obj = PortalProduct() + searchbar_input = obj._get_searchbar_inputs() + keys = list(searchbar_input.keys()) + expected_keys = ["all", "default_code", "name"] + self.assertListEqual(keys, expected_keys, msg="Keys list must be the same") + all_obj = searchbar_input.get("all") + self.assertEqual( + all_obj.get("input"), "all", msg="Input value must be equal to 'all'" + ) + default_code_obj = searchbar_input.get("default_code") + self.assertEqual( + default_code_obj.get("input"), + "default_code", + msg="Input value must be equal to 'default_code'", + ) + name_obj = searchbar_input.get("name") + self.assertEqual( + name_obj.get("input"), + "display_name", + msg="Input value must be equal to 'display_name'", + ) + + def test_get_search_domain(self): + obj = PortalProduct() + self.assertEqual( + obj._get_search_domain(None, None), + [], + msg="Function must be return empty list", + ) + self.assertEqual( + obj._get_search_domain("other", "other"), + [], + msg="Function must br return empty list", + ) + expected_domain = [("name", "ilike", "test")] + self.assertEqual( + obj._get_search_domain("name", "test"), + expected_domain, + msg="Domains must be the same", + ) + expected_domain = [("default_code", "ilike", "test")] + self.assertEqual( + obj._get_search_domain("default_code", "test"), + expected_domain, + msg="Domains must be the same", + ) + expected_domain = [ + "|", + ("name", "ilike", "test"), + ("default_code", "ilike", "test"), + ] + self.assertEqual( + obj._get_search_domain("all", "test"), + expected_domain, + msg="Domains must be the same", + ) + + def test_prepare_home_portal_values(self): + obj = PortalProduct() + result = obj._prepare_home_portal_values([]) + self.assertEqual(result, {}, msg="Function must be return empty dict") diff --git a/stock_available_portal/tests/test_product_product.py b/stock_available_portal/tests/test_product_product.py new file mode 100644 index 000000000..ecd4ad64c --- /dev/null +++ b/stock_available_portal/tests/test_product_product.py @@ -0,0 +1,112 @@ +from odoo.tests import TransactionCase + + +class TestProductProducts(TransactionCase): + def setUp(self): + super(TestProductProducts, self).setUp() + ProductProduct = self.env["product.product"] + ResPartner = self.env["res.partner"] + ResUser = self.env["res.users"] + self.portal_group_id = self.ref("base.group_portal") + self._icp_sudo = self.env["ir.config_parameter"].sudo() + self.visible_product_key = ( + "stock_available_portal.portal_visible_products_domain" + ) + self.visible_user_key = "stock_available_portal.portal_visible_users" + self.test_user = ResUser.create( + { + "login": "test@odoo.com", + "partner_id": ResPartner.create({"name": "test partner"}).id, + } + ) + self.product_test = ProductProduct.create( + { + "name": "Test Product", + } + ) + self.Product = ProductProduct.with_user(self.test_user) + + def test_computing_access_url(self): + Product = self.env["product.product"] + product_test_1 = Product.create({"name": "Test Product #1"}) + format_text = "/my/products/{}" + self.assertEqual( + product_test_1.access_url, + format_text.format(product_test_1.id), + msg="Product access url must be equal to '/my/products/{}'".format( + product_test_1.id + ), + ) + product_test_2 = Product.create({"name": "Test Product #2"}) + self.assertEqual( + product_test_2.access_url, + format_text.format(product_test_2.id), + msg="Product access url must be equal to '/my/products/{}'".format( + product_test_2.id + ), + ) + + def test_get_config_domain(self): + self._icp_sudo.set_param(self.visible_user_key, "") + value = self.Product.get_config_domain(self.visible_user_key) + self.assertEqual(value, [], msg="Value must be equal to empty list") + self.assertIsInstance(value, list, msg="Value type must be 'list' type") + + self._icp_sudo.set_param(self.visible_user_key, "[]") + value = self.Product.get_config_domain(self.visible_user_key) + self.assertEqual(value, [], msg="Value must be equal to []") + self.assertIsInstance(value, list, msg="Value type must be 'list' type") + + self._icp_sudo.set_param(self.visible_user_key, '[("id", "=", 1)]') + value = self.Product.get_config_domain(self.visible_user_key) + self.assertTrue(value, msg="Value must be True") + self.assertIsInstance(value, list, msg="Value type must be 'list' type") + + def test_access_user_to_portal_products(self): + self._icp_sudo.set_param(self.visible_user_key, "") + access = self.Product.check_product_portal_access() + self.assertFalse(access, msg="Access must be False") + self._icp_sudo.set_param(self.visible_user_key, "[]") + access = self.Product.check_product_portal_access() + self.assertFalse(access, msg="Access must be False") + + self.test_user.write({"groups_id": [(6, 0, [self.portal_group_id])]}) + + access = self.Product.check_product_portal_access() + self.assertTrue(access, msg="Access must be True") + + self._icp_sudo.set_param( + self.visible_user_key, "[('id', '=', {})]".format(self.test_user.id) + ) + access = self.Product.check_product_portal_access() + self.assertTrue(access, msg="Access must be True") + + self._icp_sudo.set_param(self.visible_user_key, "[('id', '=', 1)]") + access = self.Product.check_product_portal_access() + self.assertFalse(access, msg="Access must be False") + + def test_get_portal_products(self): + self.test_user.write({"groups_id": [(6, 0, [self.portal_group_id])]}) + self._icp_sudo.set_param(self.visible_user_key, "[]") + self._icp_sudo.set_param(self.visible_product_key, "") + expected_products = self.Product.sudo().search([]) + products = self.Product.get_portal_products([]) + self.assertItemsEqual(expected_products, products, msg="Items must be Equal") + self._icp_sudo.set_param(self.visible_product_key, "[]") + products = self.Product.get_portal_products([]) + self.assertItemsEqual(expected_products, products, msg="Items must be Equal") + self._icp_sudo.set_param( + self.visible_product_key, "[('id', '=', {})]".format(self.product_test.id) + ) + products = self.Product.get_portal_products([]) + self.assertEqual( + products, + self.product_test, + msg="Recordset must be equal to 'Test Product' record", + ) + self._icp_sudo.set_param(self.visible_product_key, "[('id', '=', -1)]") + products = self.Product.get_portal_products([]) + self.assertFalse(products, msg="Recordset must be empty") + self._icp_sudo.set_param(self.visible_user_key, "[('id', '=', 1)]") + products = self.Product.get_portal_products([]) + self.assertFalse(products, msg="Recordset must be empty") diff --git a/stock_available_portal/views/product_portal_templates.xml b/stock_available_portal/views/product_portal_templates.xml new file mode 100644 index 000000000..a41f7cdb1 --- /dev/null +++ b/stock_available_portal/views/product_portal_templates.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + diff --git a/stock_available_portal/views/res_config_settings_views.xml b/stock_available_portal/views/res_config_settings_views.xml new file mode 100644 index 000000000..c9992f508 --- /dev/null +++ b/stock_available_portal/views/res_config_settings_views.xml @@ -0,0 +1,39 @@ + + + + + res.config.settings.stock.available.portal + res.config.settings + + + +
+
+
+
+
+
+
+
+ + + + + + +