[ADD] stock_available_portal: Module added.

This commit is contained in:
geomer198
2022-10-30 01:47:16 +03:00
parent 14922b7c6d
commit 03c22dbba4
19 changed files with 697 additions and 0 deletions

View File

@@ -0,0 +1 @@
../../../../stock_available_portal

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1,3 @@
=========================
Stock available to Portal
=========================

View File

@@ -0,0 +1,2 @@
from . import controllers
from . import models

View File

@@ -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,
}

View File

@@ -0,0 +1 @@
from . import portal

View File

@@ -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/<int: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/<int:product_id>"], 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")

View File

@@ -0,0 +1,2 @@
from . import product_product
from . import res_config_settings

View File

@@ -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)

View File

@@ -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",
)

View File

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

View File

@@ -0,0 +1 @@
* Cetmix <https://cetmix.com>

View File

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

View File

@@ -0,0 +1 @@
Log into portal. Products are listed under the **Products** menu.

View File

@@ -0,0 +1,2 @@
from . import test_portal_product
from . import test_product_product

View File

@@ -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")

View File

@@ -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")

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template
id="portal_my_home_menu_product"
name="Portal layout : product menu entries"
inherit_id="portal.portal_breadcrumbs"
priority="30"
>
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
<li
t-if="page_name == 'product'"
t-attf-class="breadcrumb-item #{'active ' if not product else ''}"
>
<a
t-if="product"
t-attf-href="/my/products?{{ keep_query() }}"
>My Products
</a>
<t t-else="">My Products</t>
</li>
<li t-if="product" class="breadcrumb-item active">
<t t-esc="product.name" t-if="product.name != '/'" />
<t t-else="">
<em>Draft Product</em>
</t>
</li>
</xpath>
</template>
<template
id="portal_my_home_product"
name="My Products"
inherit_id="portal.portal_my_home"
customize_show="True"
priority="30"
>
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry" t-if="access_to_products">
<t t-set="title">My Products</t>
<t t-set="url" t-value="'/my/products'" />
<t t-set="placeholder_count" t-value="'product_count'" />
</t>
</xpath>
</template>
<template id="portal_my_products" name="My Products">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True" />
<t t-call="portal.portal_searchbar">
<t t-set="title">Products</t>
</t>
<t t-if="not products or not access_to_products">
<div class="alert alert-warning mt8" role="alert">
There are no products.
</div>
</t>
<t t-if="products and access_to_products" t-call="portal.portal_table">
<thead>
<tr class="active">
<th>Product</th>
<th class="text-right">Immediately Usable Qty</th>
</tr>
</thead>
<tbody>
<tr t-foreach="products" t-as="product">
<td>
<a
t-attf-href="/my/products/#{product.id}?{{ keep_query() }}"
t-field="product.display_name"
/>
</td>
<td class="text-right">
<span t-esc="product.immediately_usable_qty" />
</td>
</tr>
</tbody>
</t>
</t>
</template>
<template id="portal_my_product" name="My Product">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_record_layout">
<t t-set="card_header">
<h5 class="mb-0">
<span t-field="product.display_name" class="text-truncate" />
</h5>
</t>
<t t-set="card_body">
<div class="row">
<div class="col-12 col-md-6">
<p>
<strong>Immediately Usable Qty: </strong>
<span t-field="product.immediately_usable_qty" />
</p>
<p>
<strong>Sales Description:</strong>
<br />
<span t-field="product.description_sale" />
</p>
</div>
<div
t-if="product.image_128"
class="col-12 col-md-6 mb-2 mb-md-0"
>
<img
t-if="product.image_128"
t-att-src="image_data_uri(product.image_128)"
class="mr-3 text-center o_image_128_max float-right"
alt="Product"
/>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_stock_configuration" model="ir.ui.view">
<field name="name">res.config.settings.stock.available.portal</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="stock_available.view_stock_configuration" />
<field name="arch" type="xml">
<xpath
expr="//field[@name='module_stock_available_immediately']/../../.."
position="inside"
>
<div class="col-xs-12 col-md-12 o_setting_box">
<div class="o_setting_left_pane" />
<div class="o_setting_right_pane">
<label for="portal_visible_products_domain" />
<field
name="portal_visible_products_domain"
widget="domain"
options="{'model': 'product.product'}"
/>
</div>
<div class="o_setting_left_pane" />
<div class="o_setting_right_pane">
<label for="portal_visible_users" />
<field
name="portal_visible_users"
widget="domain"
options="{'model': 'res.users'}"
/>
</div>
</div>
</xpath>
</field>
</record>
</odoo>