diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10184207da..3f2618485a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,7 +82,6 @@ exclude: | ^shopinvader_sale_packaging_wishlist/| ^shopinvader_sale_profile/| ^shopinvader_sale_report/| - ^shopinvader_search_engine/| ^shopinvader_wishlist/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops diff --git a/requirements.txt b/requirements.txt index d2c48b6fdb..2f9e5d024b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ extendable-pydantic>=1.1.0 extendable_pydantic>=1.0.0 extendable_pydantic>=1.1.0 +extendable_pydantic>=1.2.0 fastapi openupgradelib pydantic>=2.0.0 +unidecode diff --git a/setup/shopinvader_product/odoo/addons/shopinvader_product b/setup/shopinvader_product/odoo/addons/shopinvader_product new file mode 120000 index 0000000000..bfb8fa2403 --- /dev/null +++ b/setup/shopinvader_product/odoo/addons/shopinvader_product @@ -0,0 +1 @@ +../../../../shopinvader_product \ No newline at end of file diff --git a/setup/shopinvader_product/setup.py b/setup/shopinvader_product/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_product/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_product_description/odoo/addons/shopinvader_product_description b/setup/shopinvader_product_description/odoo/addons/shopinvader_product_description new file mode 120000 index 0000000000..38bb26c743 --- /dev/null +++ b/setup/shopinvader_product_description/odoo/addons/shopinvader_product_description @@ -0,0 +1 @@ +../../../../shopinvader_product_description \ No newline at end of file diff --git a/setup/shopinvader_product_description/setup.py b/setup/shopinvader_product_description/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_product_description/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_product_seo/odoo/addons/shopinvader_product_seo b/setup/shopinvader_product_seo/odoo/addons/shopinvader_product_seo new file mode 120000 index 0000000000..2d498fdb5e --- /dev/null +++ b/setup/shopinvader_product_seo/odoo/addons/shopinvader_product_seo @@ -0,0 +1 @@ +../../../../shopinvader_product_seo \ No newline at end of file diff --git a/setup/shopinvader_product_seo/setup.py b/setup/shopinvader_product_seo/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_product_seo/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_search_engine/odoo/addons/shopinvader_search_engine b/setup/shopinvader_search_engine/odoo/addons/shopinvader_search_engine new file mode 120000 index 0000000000..3192748534 --- /dev/null +++ b/setup/shopinvader_search_engine/odoo/addons/shopinvader_search_engine @@ -0,0 +1 @@ +../../../../shopinvader_search_engine \ No newline at end of file diff --git a/setup/shopinvader_search_engine/setup.py b/setup/shopinvader_search_engine/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_search_engine/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_product/README.rst b/shopinvader_product/README.rst new file mode 100644 index 0000000000..d8e5873460 --- /dev/null +++ b/shopinvader_product/README.rst @@ -0,0 +1,75 @@ +=================== +Shopinvader Product +=================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_product + :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_product + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/odoo-shopinvader&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon makes products (and categories) suitable for shopinvader. +Adding revelant fields and pydantic schemas for serialization. + +**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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon +* Quentin Groulard + +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_product/__init__.py b/shopinvader_product/__init__.py new file mode 100644 index 0000000000..106fc57265 --- /dev/null +++ b/shopinvader_product/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import schemas diff --git a/shopinvader_product/__manifest__.py b/shopinvader_product/__manifest__.py new file mode 100644 index 0000000000..4244906b98 --- /dev/null +++ b/shopinvader_product/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Product", + "summary": """Adds shopinvader product fields and schemas""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "base_sparse_field", + "product", + "pydantic", + "extendable", + ], + "data": ["views/product_category.xml", "views/product_template.xml"], + "demo": [ + "demo/product_category.xml", + "demo/product_attribute_value.xml", + "demo/product_product.xml", + ], + "external_dependencies": { + "python": ["extendable_pydantic>=1.2.0", "pydantic>=2.0.0", "unidecode"] + }, + "installable": True, + "development_status": "Alpha", +} diff --git a/shopinvader/demo/product_attribute_value_demo.xml b/shopinvader_product/demo/product_attribute_value.xml similarity index 100% rename from shopinvader/demo/product_attribute_value_demo.xml rename to shopinvader_product/demo/product_attribute_value.xml diff --git a/shopinvader/demo/product_category_demo.xml b/shopinvader_product/demo/product_category.xml similarity index 100% rename from shopinvader/demo/product_category_demo.xml rename to shopinvader_product/demo/product_category.xml diff --git a/shopinvader/demo/product_product_demo.xml b/shopinvader_product/demo/product_product.xml similarity index 66% rename from shopinvader/demo/product_product_demo.xml rename to shopinvader_product/demo/product_product.xml index 2d903de149..b0c707767c 100644 --- a/shopinvader/demo/product_product_demo.xml +++ b/shopinvader_product/demo/product_product.xml @@ -28,12 +28,12 @@ @@ -43,12 +43,12 @@ @@ -91,20 +91,20 @@ @@ -114,20 +114,20 @@ @@ -198,20 +198,20 @@ @@ -221,20 +221,20 @@ @@ -291,8 +291,8 @@ @@ -302,8 +302,8 @@ @@ -346,8 +346,8 @@ @@ -357,8 +357,8 @@ @@ -408,8 +408,8 @@ @@ -419,8 +419,8 @@ @@ -471,8 +471,8 @@ @@ -482,8 +482,8 @@ @@ -519,12 +519,12 @@ @@ -534,12 +534,12 @@ @@ -581,8 +581,8 @@ @@ -592,8 +592,8 @@ @@ -630,8 +630,8 @@ @@ -641,8 +641,8 @@ @@ -679,16 +679,16 @@ @@ -698,16 +698,16 @@ diff --git a/shopinvader_product/models/__init__.py b/shopinvader_product/models/__init__.py new file mode 100644 index 0000000000..f6dcc39792 --- /dev/null +++ b/shopinvader_product/models/__init__.py @@ -0,0 +1,3 @@ +from . import category +from . import product_product +from . import product_template diff --git a/shopinvader_product/models/category.py b/shopinvader_product/models/category.py new file mode 100644 index 0000000000..ab8e7464c5 --- /dev/null +++ b/shopinvader_product/models/category.py @@ -0,0 +1,96 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.osv import expression + + +class ProductCategory(models.Model): + _inherit = "product.category" + # V13 restore translate on category name... + # This code is a transversal fix and should go into a dedicated addon... + # The translate=True has been removed in + # https://github.com/odoo/odoo/pull/36717 to workaround a bug introduced + # in https://github.com/odoo/odoo/pull/16220 To avoid a bug into the seach + # on category name, we must also restore the name_get method and + # name_search + # see also https://github.com/odoo/odoo/issues/22060#issuecomment-356567683 + _rec_name = None + + name = fields.Char(translate=True) + active = fields.Boolean(default=True) + sequence = fields.Integer() + level = fields.Integer(compute="_compute_level") + + def name_get(self): + def get_names(cat): + """Return the list [cat.name, cat.parent_id.name, ...]""" + res = [] + while cat and cat.id: + res.append(cat.name) + cat = cat.parent_id + return res + + return [(cat.id, " / ".join(reversed(get_names(cat)))) for cat in self] + + @api.model + def name_search(self, name, args=None, operator="ilike", limit=100): + if not args: + args = [] + if name: + # Be sure name_search is symetric to name_get + category_names = name.split(" / ") + parents = list(category_names) + child = parents.pop() + domain = [("name", operator, child)] + if parents: + names_ids = self.name_search( + " / ".join(parents), + args=args, + operator="ilike", + limit=limit, + ) + category_ids = [name_id[0] for name_id in names_ids] + if operator in expression.NEGATIVE_TERM_OPERATORS: + categories = self.search([("id", "not in", category_ids)]) + domain = expression.OR( + [[("parent_id", "in", categories.ids)], domain] + ) + else: + domain = expression.AND( + [[("parent_id", "in", category_ids)], domain] + ) + for i in range(1, len(category_names)): + domain = [ + # fmt: off + [ + ( + "name", + operator, + " / ".join(category_names[-1 - i:]), + ) + ], + # fmt: on + domain + ] + if operator in expression.NEGATIVE_TERM_OPERATORS: + domain = expression.AND(domain) + else: + domain = expression.OR(domain) + categories = self.search(expression.AND([domain, args]), limit=limit) + else: + categories = self.search(args, limit=limit) + return categories.name_get() + + def _get_parent(self): + self.ensure_one() + return self.parent_id + + @api.depends("parent_id", "parent_id.active") + def _compute_level(self): + for record in self: + record.level = 0 + parent = record._get_parent() + while parent and parent.active: + record.level += 1 + parent = parent._get_parent() diff --git a/shopinvader_product/models/product_product.py b/shopinvader_product/models/product_product.py new file mode 100644 index 0000000000..d72b16f4a7 --- /dev/null +++ b/shopinvader_product/models/product_product.py @@ -0,0 +1,213 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from itertools import groupby + +from odoo import api, fields, models +from odoo.tools import float_compare, float_is_zero + +from odoo.addons.base_sparse_field.models.fields import Serialized + +from ..utils import float_round +from .tools import sanitize_attr_name + + +class ProductProduct(models.Model): + _inherit = "product.product" + + variant_attributes = Serialized( + compute="_compute_variant_attributes", string="Shopinvader Attributes" + ) + attribute_value_ids = fields.Many2many( + comodel_name="product.attribute.value", + compute="_compute_attribute_value_ids", + readonly=True, + ) + short_name = fields.Char(compute="_compute_names") + full_name = fields.Char(compute="_compute_names") + main = fields.Boolean(compute="_compute_main_product") + price = Serialized(compute="_compute_price", string="Shopinvader Price") + + def _compute_variant_attributes(self): + for record in self: + variant_attributes = dict() + for att_value in record.attribute_value_ids: + sanitized_key = sanitize_attr_name(att_value.attribute_id) + variant_attributes[sanitized_key] = att_value.name + record.variant_attributes = variant_attributes + + @api.depends("product_template_attribute_value_ids") + def _compute_attribute_value_ids(self): + for record in self: + record.attribute_value_ids = record.mapped( + "product_template_attribute_value_ids.product_attribute_value_id" + ) + + def _prepare_variant_name_and_short_name(self): + self.ensure_one() + attributes = self.attribute_value_ids + short_name = ", ".join(attributes.mapped("name")) + full_name = self.display_name + if short_name: + full_name += " (%s)" % short_name + return full_name, short_name + + def _compute_names(self): + for record in self: + ( + record.full_name, + record.short_name, + ) = record._prepare_variant_name_and_short_name() + + @api.model + def _get_shopinvader_product_variants(self, product_ids): + # Use sudo to bypass permissions (we don't care) + return self.sudo().search( + [("product_tmpl_id", "in", product_ids)], order="product_tmpl_id" + ) + + def _compute_main_product(self): + # Respect same order. + order_by = [x.strip() for x in self.env["product.product"]._order.split(",")] + fields_to_read = ["product_tmpl_id"] + [f.split(" ")[0] for f in order_by] + product_ids = self.mapped("product_tmpl_id").ids + _variants = self._get_shopinvader_product_variants(product_ids) + # Use `load=False` to not load template name + variants = _variants.read(fields_to_read, load=False) + var_by_product = groupby(variants, lambda x: x["product_tmpl_id"]) + + def pick_1st_variant(variants): + def get_value(record, key): + if record[key] is False and self._fields[key].type in ("char", "text"): + return "" + else: + return record[key] + + for order_key in reversed(order_by): + order_key_split = order_key.split(" ") + reverse = len(order_key_split) > 1 and order_key_split[1] == "desc" + variants.sort( + key=lambda var: get_value(var, order_key_split[0]), + reverse=reverse, + ) + return variants[0].get("id") if variants else None + + main_by_product = { + product: pick_1st_variant(list(variants)) + for product, variants in var_by_product + } + for record in self: + record.main = main_by_product.get(record.product_tmpl_id.id) == record.id + + @api.depends_context("pricelist", "default_role", "fposition", "company", "date") + def _compute_price(self): + for record in self: + record.price = record._get_all_price() + + def _get_all_price(self): + self.ensure_one() + res = {} + pricelist = self._context.get("pricelist") + default_role = self._context.get("default_role", "default") + if pricelist: + fposition = self._context.get("fposition") + company = self._context.get("company") + date = self._context.get("date") + res[default_role] = self._get_price( + pricelist=pricelist, fposition=fposition, company=company, date=date + ) + return res + + def _get_price( + self, qty=1.0, pricelist=None, fposition=None, company=None, date=None + ): + """Computes the product prices + + :param qty: The product quantity, used to apply pricelist rules. + :param pricelist: Optional. Get prices for a specific pricelist. + :param fposition: Optional. Apply fiscal position to product taxes. + :param company: Optional. + :param date: Optional. + + :returns: dict with the following keys: + + The product unitary price + True if product taxes are included in . + + If the pricelist.discount_policy is "without_discount": + The original price (before pricelist is applied). + The discounted percentage. + """ + self.ensure_one() + AccountTax = self.env["account.tax"] + product = self.record_id + # Apply company + product = product.with_company(company) if company else product + company = company or self.env.company + # Always filter taxes by the company + taxes = product.taxes_id.filtered(lambda tax: tax.company_id == company) + # Apply fiscal position + taxes = fposition.map_tax(taxes) if fposition else taxes + # Set context. Some of the methods used here depend on these values + product_context = dict( + self.env.context, + quantity=qty, + pricelist=pricelist.id if pricelist else None, + fiscal_position=fposition, + date=date, + ) + product = product.with_context(**product_context) + pricelist = pricelist.with_context(**product_context) if pricelist else None + price_unit = ( + pricelist._get_product_price(product, qty, date=date) + if pricelist + else product.lst_price + ) + price_unit = AccountTax._fix_tax_included_price_company( + price_unit, product.taxes_id, taxes, company + ) + price_dp = self.env["decimal.precision"].precision_get("Product Price") + price_unit = float_round(price_unit, price_dp) + res = { + "value": price_unit, + "tax_included": any(tax.price_include for tax in taxes), + # Default values in case price.discount_policy != "without_discount" + "original_value": price_unit, + "discount": 0.0, + } + # Handle pricelists.discount_policy == "without_discount" + if pricelist and pricelist.discount_policy == "without_discount": + # Get the price rule + price_unit, rule_id = pricelist._get_product_price_rule( + product, qty, date=date + ) + # Get the price before applying the pricelist + original_price_unit = product.lst_price + price_dp = self.env["decimal.precision"].precision_get("Product Price") + # Compute discount + if not float_is_zero( + original_price_unit, precision_digits=price_dp + ) and float_compare( + original_price_unit, price_unit, precision_digits=price_dp + ): + discount = ( + (original_price_unit - price_unit) / original_price_unit * 100 + ) + # Apply the right precision on discount + discount_dp = self.env["decimal.precision"].precision_get("Discount") + discount = float_round(discount, discount_dp) + else: + discount = 0.00 + # Compute prices + original_price_unit = AccountTax._fix_tax_included_price_company( + original_price_unit, product.taxes_id, taxes, company + ) + original_price_unit = float_round(original_price_unit, price_dp) + res.update( + { + "original_value": original_price_unit, + "discount": discount, + } + ) + return res diff --git a/shopinvader_product/models/product_template.py b/shopinvader_product/models/product_template.py new file mode 100644 index 0000000000..722578f182 --- /dev/null +++ b/shopinvader_product/models/product_template.py @@ -0,0 +1,43 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + shopinvader_categ_ids = fields.Many2many( + comodel_name="product.category", + compute="_compute_shopinvader_category", + string="Shopinvader Categories", + ) + + def _get_categories(self): + self.ensure_one() + return self.categ_id + + @api.model + def _get_parent_categories(self, categ_ids): + return self.env["product.category"].search( + [("id", "parent_of", set(categ_ids))] + ) + + @api.depends("categ_id", "categ_id.parent_id") + def _compute_shopinvader_category(self): + prod_by_categ = {} + for record in self: + categs = tuple(sorted(record._get_categories().ids)) + prod_by_categ.setdefault(categs, []).append(record.id) + # prod1.categories = catA, catB, catC + # prod2.categories = catA + # prod3.categories = catA, catB, catC + # { + # (catA.id): [prod2.id], + # (catA.id,catB.id,catC.id): [prod1.id, prod3.id] + # } + + for categ_ids, prod_ids in prod_by_categ.items(): + categories = self._get_parent_categories(categ_ids) + self.browse(prod_ids).update({"shopinvader_categ_ids": categories.ids}) diff --git a/shopinvader_product/models/tools.py b/shopinvader_product/models/tools.py new file mode 100644 index 0000000000..cee6281c84 --- /dev/null +++ b/shopinvader_product/models/tools.py @@ -0,0 +1,17 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +_logger = logging.getLogger(__name__) + +try: + from unidecode import unidecode +except ImportError: + _logger.debug("Cannot `import unidecode`.") + + +def sanitize_attr_name(attribute): + key = attribute.name + return unidecode(key.replace(" ", "_").lower()) diff --git a/shopinvader_product/readme/CONTRIBUTORS.rst b/shopinvader_product/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..100350dfe0 --- /dev/null +++ b/shopinvader_product/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Laurent Mignon +* Quentin Groulard diff --git a/shopinvader_product/readme/DESCRIPTION.rst b/shopinvader_product/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..d0bfea6130 --- /dev/null +++ b/shopinvader_product/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This addon makes products (and categories) suitable for shopinvader. +Adding revelant fields and pydantic schemas for serialization. diff --git a/shopinvader_product/schemas/__init__.py b/shopinvader_product/schemas/__init__.py new file mode 100644 index 0000000000..b1e73e7f28 --- /dev/null +++ b/shopinvader_product/schemas/__init__.py @@ -0,0 +1,2 @@ +from .category import ProductCategory, ShortProductCategory +from .product import ProductProduct, ProductTemplate, ProductTemplatePriceInfo diff --git a/shopinvader_product/schemas/category.py b/shopinvader_product/schemas/category.py new file mode 100644 index 0000000000..1dbb8aa6fe --- /dev/null +++ b/shopinvader_product/schemas/category.py @@ -0,0 +1,52 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from __future__ import annotations + +from extendable_pydantic import StrictExtendableBaseModel + + +class ShortProductCategory(StrictExtendableBaseModel): + id: int + name: str + level: int + parent: ShortProductCategory | None = None + childs: list[ShortProductCategory] = [] + + @classmethod + def _get_parent(cls, odoo_rec): + return odoo_rec.parent_id + + @classmethod + def _get_children(cls, odoo_rec): + return odoo_rec.child_id + + @classmethod + def from_product_category(cls, odoo_rec, with_parent=False, with_child=False): + obj = cls.model_construct( + id=odoo_rec.id, name=odoo_rec.name, level=odoo_rec.level + ) + if with_parent: + parent = cls._get_parent(odoo_rec) + obj.parent = ( + ShortProductCategory.from_product_category(parent, with_parent=True) + if parent + else None + ) + if with_child: + children = cls._get_children(odoo_rec) + obj.childs = [ + ShortProductCategory.from_product_category(child, with_child=True) + for child in children + ] + return obj + + +class ProductCategory(ShortProductCategory): + sequence: int | None = None + + @classmethod + def from_product_category(cls, odoo_rec): + obj = super().from_product_category(odoo_rec, with_parent=True, with_child=True) + obj.sequence = odoo_rec.sequence or None + return obj diff --git a/shopinvader_product/schemas/product.py b/shopinvader_product/schemas/product.py new file mode 100644 index 0000000000..fe62b3d66d --- /dev/null +++ b/shopinvader_product/schemas/product.py @@ -0,0 +1,54 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from typing import Any + +from extendable_pydantic import StrictExtendableBaseModel + +from .category import ShortProductCategory + + +class ProductTemplate(StrictExtendableBaseModel): + name: str + + @classmethod + def from_product_template(cls, odoo_rec): + return cls.model_construct(name=odoo_rec.display_name) + + +class ProductTemplatePriceInfo(StrictExtendableBaseModel): + value: float = 0 + tax_included: bool = False + original_value: float = 0 + discount: float = 0 + + +class ProductProduct(StrictExtendableBaseModel): + id: int + model: ProductTemplate + main: bool = False + name: str | None = None + short_name: str | None = None + variant_count: int = 0 + categories: list[ShortProductCategory] = [] + sku: str | None = None + variant_attributes: dict[str, Any] = {} + price: dict[str, ProductTemplatePriceInfo] = {} + + @classmethod + def from_product_product(cls, odoo_rec): + return cls.model_construct( + id=odoo_rec.id, + model=ProductTemplate.from_product_template(odoo_rec.product_tmpl_id), + main=odoo_rec.main, + name=odoo_rec.full_name or None, + short_name=odoo_rec.short_name or None, + variant_count=odoo_rec.product_variant_count, + categories=[ + ShortProductCategory.from_product_category(shopinvader_category) + for shopinvader_category in odoo_rec.shopinvader_categ_ids + ], + sku=odoo_rec.default_code or None, + variant_attributes=odoo_rec.variant_attributes, + price=odoo_rec.price, + ) diff --git a/shopinvader_product/static/description/index.html b/shopinvader_product/static/description/index.html new file mode 100644 index 0000000000..6efe5328c2 --- /dev/null +++ b/shopinvader_product/static/description/index.html @@ -0,0 +1,421 @@ + + + + + + +Shopinvader Product + + + +
+

Shopinvader Product

+ + +

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

+

This addon makes products (and categories) suitable for shopinvader. +Adding revelant fields and pydantic schemas for serialization.

+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

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.

+
+
+
+ + diff --git a/shopinvader_product/tests/__init__.py b/shopinvader_product/tests/__init__.py new file mode 100644 index 0000000000..740ea4f2a1 --- /dev/null +++ b/shopinvader_product/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_category_schema +from . import test_product diff --git a/shopinvader_product/tests/test_category_schema.py b/shopinvader_product/tests/test_category_schema.py new file mode 100644 index 0000000000..bcac0d8322 --- /dev/null +++ b/shopinvader_product/tests/test_category_schema.py @@ -0,0 +1,36 @@ +# Copyright 2020 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi + +from odoo.tests import TransactionCase + +from ..schemas.category import ProductCategory + + +class TestShopinvaderCategoryBase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cat_obj = cls.env["product.category"] + + cls.cat_level1 = cat_obj.create({"name": "Category Level 1"}) + cls.cat_level2 = cat_obj.create( + {"name": "Category Level 2", "parent_id": cls.cat_level1.id} + ) + cls.cat_level3 = cat_obj.create( + {"name": "Category Level 3", "parent_id": cls.cat_level2.id} + ) + + +class TestShopinvaderCategory(TestShopinvaderCategoryBase): + def test_category(self): + res = ProductCategory.from_product_category(self.cat_level3).model_dump() + self.assertEqual(res["level"], 2) + self.assertEqual(res["parent"]["name"], "Category Level 2") + self.assertEqual(res["parent"]["parent"]["name"], "Category Level 1") + + res = ProductCategory.from_product_category(self.cat_level1).model_dump() + self.assertEqual(res["level"], 0) + self.assertEqual(len(res["childs"]), 1) + self.assertEqual(res["childs"][0]["name"], "Category Level 2") + self.assertEqual(res["childs"][0]["childs"][0]["name"], "Category Level 3") diff --git a/shopinvader_product/tests/test_product.py b/shopinvader_product/tests/test_product.py new file mode 100644 index 0000000000..0b49215641 --- /dev/null +++ b/shopinvader_product/tests/test_product.py @@ -0,0 +1,42 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Benoît GUILLOT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import TransactionCase + + +class ProductCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.template = cls.env.ref("shopinvader_product.product_template_chair_vortex") + cls.variant = cls.env.ref( + "shopinvader_product.product_product_chair_vortex_white" + ) + + def test_product_shopinvader_categories(self): + self.assertEqual(len(self.variant.shopinvader_categ_ids), 3) + + def test_variant_attributes(self): + attr_dict = {"color": "blue"} + self.assertDictEqual(self.variant.variant_attributes, attr_dict) + + def test_main_product(self): + variants = self.template.product_variant_ids + main_variant = variants.filtered(lambda v: v.main) + self.assertEqual(len(main_variant), 1) + self.assertNotIn( + True, + variants.filtered(lambda x: x.id != main_variant.id).mapped("main"), + ) + # change order + main_variant.default_code = "ZZZZZZZ" + main_variant.name = "ZZZZZZ" + variants.invalidate_recordset() + main_variant1 = variants.filtered(lambda v: v.main) + self.assertNotEqual(main_variant, main_variant1) + self.assertEqual(len(main_variant1), 1) + self.assertNotIn( + True, + variants.filtered(lambda x: x.id != main_variant1.id).mapped("main"), + ) diff --git a/shopinvader_product/utils.py b/shopinvader_product/utils.py new file mode 100644 index 0000000000..a842a47855 --- /dev/null +++ b/shopinvader_product/utils.py @@ -0,0 +1,14 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tools import float_repr + + +def float_round(value, dp): + # be carefull odoo rounding implementation do not return the shortest + # representation of a float this mean if the price_unit is 211.70 + # you will have 211.70000000000002 + # See issue: https://github.com/shopinvader/odoo-shopinvader/issues/1041 + # Odony exemple: https://gist.github.com/odony/5269a695545902e7e23e761e20a9ec8c + return float(float_repr(value, dp)) diff --git a/shopinvader_product/views/product_category.xml b/shopinvader_product/views/product_category.xml new file mode 100644 index 0000000000..f201d9b7c8 --- /dev/null +++ b/shopinvader_product/views/product_category.xml @@ -0,0 +1,30 @@ + + + + + + product.category.form (in shopinvader_product) + product.category + + + + + + + + + + + + product.category.tree (in shopinvader_product) + product.category + + + + + + + + + diff --git a/shopinvader_product/views/product_template.xml b/shopinvader_product/views/product_template.xml new file mode 100644 index 0000000000..9c3d17c72e --- /dev/null +++ b/shopinvader_product/views/product_template.xml @@ -0,0 +1,17 @@ + + + + + + product.template.form (in shopinvader_product) + product.template + + + + + + + + + diff --git a/shopinvader_product_description/README.rst b/shopinvader_product_description/README.rst new file mode 100644 index 0000000000..86402c6bfb --- /dev/null +++ b/shopinvader_product_description/README.rst @@ -0,0 +1,74 @@ +=============================== +Shopinvader Product Description +=============================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_product_description + :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_product_description + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/odoo-shopinvader&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Description fields for Shopinvader product and product category + +**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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon +* Quentin Groulard + +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_product_description/__init__.py b/shopinvader_product_description/__init__.py new file mode 100644 index 0000000000..106fc57265 --- /dev/null +++ b/shopinvader_product_description/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import schemas diff --git a/shopinvader_product_description/__manifest__.py b/shopinvader_product_description/__manifest__.py new file mode 100644 index 0000000000..47d541d16d --- /dev/null +++ b/shopinvader_product_description/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Product Description", + "summary": """Description fields for Shopinvader""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["product_sale_description", "shopinvader_product"], + "data": ["views/product_category.xml"], + "demo": [], + "development_status": "Alpha", +} diff --git a/shopinvader_product_description/models/__init__.py b/shopinvader_product_description/models/__init__.py new file mode 100644 index 0000000000..53553f3f2d --- /dev/null +++ b/shopinvader_product_description/models/__init__.py @@ -0,0 +1 @@ +from . import product_category diff --git a/shopinvader_product_description/models/product_category.py b/shopinvader_product_description/models/product_category.py new file mode 100644 index 0000000000..94f284773c --- /dev/null +++ b/shopinvader_product_description/models/product_category.py @@ -0,0 +1,13 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductCategory(models.Model): + + _inherit = "product.category" + + subtitle = fields.Char() + short_description = fields.Html() + description = fields.Html() diff --git a/shopinvader_product_description/readme/CONTRIBUTORS.rst b/shopinvader_product_description/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..100350dfe0 --- /dev/null +++ b/shopinvader_product_description/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Laurent Mignon +* Quentin Groulard diff --git a/shopinvader_product_description/readme/DESCRIPTION.rst b/shopinvader_product_description/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..919f9975fd --- /dev/null +++ b/shopinvader_product_description/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Description fields for Shopinvader product and product category diff --git a/shopinvader_product_description/schemas/__init__.py b/shopinvader_product_description/schemas/__init__.py new file mode 100644 index 0000000000..5f9169b872 --- /dev/null +++ b/shopinvader_product_description/schemas/__init__.py @@ -0,0 +1,2 @@ +from .category import ProductCategory +from .product import ProductProduct diff --git a/shopinvader_product_description/schemas/category.py b/shopinvader_product_description/schemas/category.py new file mode 100644 index 0000000000..23580b95e7 --- /dev/null +++ b/shopinvader_product_description/schemas/category.py @@ -0,0 +1,20 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.shopinvader_product.schemas import ( + ProductCategory as BaseProductCategory, +) + + +class ProductCategory(BaseProductCategory, extends=True): + subtitle: str | None = None + description: str | None = None + short_description: str | None = None + + @classmethod + def from_product_category(cls, odoo_rec): + obj = super().from_product_category(odoo_rec) + obj.subtitle = odoo_rec.subtitle or None + obj.description = odoo_rec.description or None + obj.short_description = odoo_rec.short_description or None + return obj diff --git a/shopinvader_product_description/schemas/product.py b/shopinvader_product_description/schemas/product.py new file mode 100644 index 0000000000..bf402332b9 --- /dev/null +++ b/shopinvader_product_description/schemas/product.py @@ -0,0 +1,16 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.shopinvader_product.schemas import ProductProduct as BaseProductProduct + + +class ProductProduct(BaseProductProduct, extends=True): + short_description: str | None = None + description: str | None = None + + @classmethod + def from_product_product(cls, odoo_rec): + obj = super().from_product_product(odoo_rec) + obj.short_description = odoo_rec.description_sale_short or None + obj.description = odoo_rec.description_sale_long or None + return obj diff --git a/shopinvader_product_description/static/description/index.html b/shopinvader_product_description/static/description/index.html new file mode 100644 index 0000000000..bf8bbdc1e5 --- /dev/null +++ b/shopinvader_product_description/static/description/index.html @@ -0,0 +1,420 @@ + + + + + + +Shopinvader Product Description + + + +
+

Shopinvader Product Description

+ + +

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

+

Description fields for Shopinvader product and product category

+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

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.

+
+
+
+ + diff --git a/shopinvader_product_description/views/product_category.xml b/shopinvader_product_description/views/product_category.xml new file mode 100644 index 0000000000..dd35b4a8ac --- /dev/null +++ b/shopinvader_product_description/views/product_category.xml @@ -0,0 +1,25 @@ + + + + + + product.category.form (in shopinvader_product_description) + product.category + + + + + + + + + + + + + + + diff --git a/shopinvader_product_seo/README.rst b/shopinvader_product_seo/README.rst new file mode 100644 index 0000000000..0b72dd34f9 --- /dev/null +++ b/shopinvader_product_seo/README.rst @@ -0,0 +1,74 @@ +======================= +Shopinvader Product Seo +======================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_product_seo + :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_product_seo + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/odoo-shopinvader&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +SEO fields for Shopinvader product and product category + +**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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon +* Quentin Groulard + +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_product_seo/__init__.py b/shopinvader_product_seo/__init__.py new file mode 100644 index 0000000000..106fc57265 --- /dev/null +++ b/shopinvader_product_seo/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import schemas diff --git a/shopinvader_product_seo/__manifest__.py b/shopinvader_product_seo/__manifest__.py new file mode 100644 index 0000000000..5d79b5dbdb --- /dev/null +++ b/shopinvader_product_seo/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Product Seo", + "summary": """SEO fields for Shopinvader""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["shopinvader_product"], + "data": ["views/product_category.xml", "views/product_template.xml"], + "demo": [], + "development_status": "Alpha", +} diff --git a/shopinvader_product_seo/models/__init__.py b/shopinvader_product_seo/models/__init__.py new file mode 100644 index 0000000000..9bf141d408 --- /dev/null +++ b/shopinvader_product_seo/models/__init__.py @@ -0,0 +1,3 @@ +from . import seo_title_mixin +from . import product_category +from . import product_template diff --git a/shopinvader_product_seo/models/product_category.py b/shopinvader_product_seo/models/product_category.py new file mode 100644 index 0000000000..9e4ab30042 --- /dev/null +++ b/shopinvader_product_seo/models/product_category.py @@ -0,0 +1,12 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductCategory(models.Model): + _name = "product.category" + _inherit = ["product.category", "seo.title.mixin"] + + meta_description = fields.Char() + meta_keywords = fields.Char() diff --git a/shopinvader_product_seo/models/product_template.py b/shopinvader_product_seo/models/product_template.py new file mode 100644 index 0000000000..e61c04d4f4 --- /dev/null +++ b/shopinvader_product_seo/models/product_template.py @@ -0,0 +1,12 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductTemplate(models.Model): + _name = "product.template" + _inherit = ["product.template", "seo.title.mixin"] + + meta_description = fields.Char() + meta_keywords = fields.Char() diff --git a/shopinvader_product_seo/models/seo_title_mixin.py b/shopinvader_product_seo/models/seo_title_mixin.py new file mode 100644 index 0000000000..76ced10c87 --- /dev/null +++ b/shopinvader_product_seo/models/seo_title_mixin.py @@ -0,0 +1,48 @@ +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class SEOTitleMixin(models.AbstractModel): + """ + Abstract model used to define the seo_title and manual_seo_title fields. + """ + + _name = "seo.title.mixin" + _description = "SEO Title Mixin" + + seo_title = fields.Char( + string="SEO Title", + compute="_compute_seo_title", + inverse="_inverse_seo_title", + help="If you specify a custom value and you want to rollback to the " + "default value, just let the field blank.", + ) + manual_seo_title = fields.Char(string="Manual SEO Title") + + def _build_seo_title(self): + """ + Build the SEO Title of the current recordset. + :return: str + """ + self.ensure_one() + return self.display_name + + def _inverse_seo_title(self): + """ + When the seo_title is updated manually, we have to save it into + the manual_seo_title. + :return: + """ + for record in self: + record.manual_seo_title = record.seo_title + + @api.depends("manual_seo_title") + def _compute_seo_title(self): + """ + Compute the value of the seo_title field + :return: + """ + for record in self: + title = record.manual_seo_title or record._build_seo_title() + record.seo_title = title diff --git a/shopinvader_product_seo/readme/CONTRIBUTORS.rst b/shopinvader_product_seo/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..100350dfe0 --- /dev/null +++ b/shopinvader_product_seo/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Laurent Mignon +* Quentin Groulard diff --git a/shopinvader_product_seo/readme/DESCRIPTION.rst b/shopinvader_product_seo/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..4f36c6b741 --- /dev/null +++ b/shopinvader_product_seo/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +SEO fields for Shopinvader product and product category diff --git a/shopinvader_product_seo/schemas/__init__.py b/shopinvader_product_seo/schemas/__init__.py new file mode 100644 index 0000000000..5f9169b872 --- /dev/null +++ b/shopinvader_product_seo/schemas/__init__.py @@ -0,0 +1,2 @@ +from .category import ProductCategory +from .product import ProductProduct diff --git a/shopinvader_product_seo/schemas/category.py b/shopinvader_product_seo/schemas/category.py new file mode 100644 index 0000000000..fdc316559b --- /dev/null +++ b/shopinvader_product_seo/schemas/category.py @@ -0,0 +1,20 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.shopinvader_product.schemas import ( + ProductCategory as BaseProductCategory, +) + + +class ProductCategory(BaseProductCategory, extends=True): + seo_title: str | None = None + meta_keywords: str | None = None + meta_description: str | None = None + + @classmethod + def from_product_category(cls, odoo_rec): + obj = super().from_product_category(odoo_rec) + obj.seo_title = odoo_rec.seo_title or None + obj.meta_keywords = odoo_rec.meta_keywords or None + obj.meta_description = odoo_rec.meta_description or None + return obj diff --git a/shopinvader_product_seo/schemas/product.py b/shopinvader_product_seo/schemas/product.py new file mode 100644 index 0000000000..283b141ae0 --- /dev/null +++ b/shopinvader_product_seo/schemas/product.py @@ -0,0 +1,18 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.shopinvader_product.schemas import ProductProduct as BaseProductProduct + + +class ProductProduct(BaseProductProduct, extends=True): + seo_title: str | None = None + meta_keywords: str | None = None + meta_description: str | None = None + + @classmethod + def from_product_product(cls, odoo_rec): + obj = super().from_product_product(odoo_rec) + obj.seo_title = odoo_rec.seo_title or None + obj.meta_keywords = odoo_rec.meta_keywords or None + obj.meta_description = odoo_rec.meta_description or None + return obj diff --git a/shopinvader_product_seo/static/description/index.html b/shopinvader_product_seo/static/description/index.html new file mode 100644 index 0000000000..528ce02670 --- /dev/null +++ b/shopinvader_product_seo/static/description/index.html @@ -0,0 +1,420 @@ + + + + + + +Shopinvader Product Seo + + + +
+

Shopinvader Product Seo

+ + +

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

+

SEO fields for Shopinvader product and product category

+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

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.

+
+
+
+ + diff --git a/shopinvader_product_seo/views/product_category.xml b/shopinvader_product_seo/views/product_category.xml new file mode 100644 index 0000000000..2355014e8a --- /dev/null +++ b/shopinvader_product_seo/views/product_category.xml @@ -0,0 +1,21 @@ + + + + + + product.category.form (in shopinvader_product_seo) + product.category + + + + + + + + + + + + + diff --git a/shopinvader_product_seo/views/product_template.xml b/shopinvader_product_seo/views/product_template.xml new file mode 100644 index 0000000000..0c1e6b4db9 --- /dev/null +++ b/shopinvader_product_seo/views/product_template.xml @@ -0,0 +1,21 @@ + + + + + + product.template.form (in shopinvader_product_seo) + product.template + + + + + + + + + + + + + diff --git a/shopinvader_search_engine/__init__.py b/shopinvader_search_engine/__init__.py index 0650744f6b..72ad062781 100644 --- a/shopinvader_search_engine/__init__.py +++ b/shopinvader_search_engine/__init__.py @@ -1 +1,3 @@ from . import models +from . import schemas +from . import tools diff --git a/shopinvader_search_engine/__manifest__.py b/shopinvader_search_engine/__manifest__.py index bf05b38660..2379b56f8c 100644 --- a/shopinvader_search_engine/__manifest__.py +++ b/shopinvader_search_engine/__manifest__.py @@ -5,23 +5,17 @@ { "name": "Shopinvader Catalog Search Engine Connector", - "version": "14.0.1.4.1", + "version": "16.0.1.0.0", "author": "Akretion", - "development_status": "Production/Stable", "website": "https://github.com/shopinvader/odoo-shopinvader", "license": "AGPL-3", "category": "Generic Modules", "depends": [ - "shopinvader", - "connector_search_engine", - "base_technical_user", + "search_engine_serializer_pydantic", + "shopinvader_product", ], - "data": [ - "views/shopinvader_backend_view.xml", - "views/shopinvader_variant_view.xml", - "views/shopinvader_category_view.xml", - "data/queue_job_function_data.xml", - ], - "installable": False, + "data": [], + "installable": True, "application": True, + "development_status": "Alpha", } diff --git a/shopinvader_search_engine/data/queue_job_function_data.xml b/shopinvader_search_engine/data/queue_job_function_data.xml deleted file mode 100644 index 364444ed6a..0000000000 --- a/shopinvader_search_engine/data/queue_job_function_data.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - jobify_recompute_json - - - - - recompute_json - - - - - synchronize - - - - - jobify_recompute_json - - - - - recompute_json - - - - - synchronize - - - diff --git a/shopinvader_search_engine/models/__init__.py b/shopinvader_search_engine/models/__init__.py index 236bd35fa3..3f9066f927 100644 --- a/shopinvader_search_engine/models/__init__.py +++ b/shopinvader_search_engine/models/__init__.py @@ -1,5 +1,5 @@ -from . import shopinvader_backend -from . import shopinvader_se_binding -from . import shopinvader_variant -from . import shopinvader_category +from . import product_category +from . import product_product +from . import product_template from . import se_index +from . import se_indexable_record diff --git a/shopinvader_search_engine/models/product_category.py b/shopinvader_search_engine/models/product_category.py new file mode 100644 index 0000000000..3ba20e46ce --- /dev/null +++ b/shopinvader_search_engine/models/product_category.py @@ -0,0 +1,36 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductCategory(models.Model): + _name = "product.category" + _inherit = ["product.category", "se.indexable.record"] + + shopinvader_parent_id = fields.Many2one( + "product.category", + "Shopinvader Parent", + compute="_compute_parent_category", + ) + shopinvader_child_ids = fields.Many2many( + "product.category", + "Shopinvader Childs", + compute="_compute_child_category", + ) + + @api.depends_context("index") + @api.depends("parent_id", "parent_id.se_binding_ids") + def _compute_parent_category(self): + for record in self: + record.shopinvader_parent_id = record.parent_id._filter_by_index() + + @api.depends_context("index") + @api.depends("child_id", "child_id.se_binding_ids") + def _compute_child_category(self): + for record in self: + record.shopinvader_child_ids = record.child_id._filter_by_index() + + def _get_parent(self): + self.ensure_one() + return self.shopinvader_parent_id diff --git a/shopinvader_search_engine/models/product_product.py b/shopinvader_search_engine/models/product_product.py new file mode 100644 index 0000000000..ceed246879 --- /dev/null +++ b/shopinvader_search_engine/models/product_product.py @@ -0,0 +1,19 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "se.indexable.record"] + + @api.model + def _get_shopinvader_product_variants(self, product_ids): + variants = super()._get_shopinvader_product_variants(product_ids) + variants = variants._filter_by_index() + return variants + + @api.depends_context("index") + def _compute_main_product(self): + return super()._compute_main_product() diff --git a/shopinvader_search_engine/models/product_template.py b/shopinvader_search_engine/models/product_template.py new file mode 100644 index 0000000000..71569264c4 --- /dev/null +++ b/shopinvader_search_engine/models/product_template.py @@ -0,0 +1,25 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def _get_categories(self): + self.ensure_one() + categories = super()._get_categories() + categories = categories._filter_by_index() + return categories + + @api.model + def _get_parent_categories(self, categ_ids): + categories = super()._get_parent_categories(categ_ids) + categories = categories._filter_by_index() + return categories + + @api.depends_context("index") + @api.depends("categ_id", "categ_id.parent_id") + def _compute_shopinvader_category(self): + return super()._compute_shopinvader_category() diff --git a/shopinvader_search_engine/models/se_index.py b/shopinvader_search_engine/models/se_index.py index 06c176600c..5544d8089d 100644 --- a/shopinvader_search_engine/models/se_index.py +++ b/shopinvader_search_engine/models/se_index.py @@ -1,24 +1,50 @@ -# Copyright 2013 Akretion (http://www.akretion.com) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError -from odoo import api, fields, models +from ..tools.category_serializer import ProductCategoryShopinvaderSerializer +from ..tools.product_serializer import ProductProductShopinvaderSerializer class SeIndex(models.Model): _inherit = "se.index" - is_valid = fields.Char(compute="_compute_is_valid") + serializer_type = fields.Selection( + selection_add=[ + ("shopinvader_category_exports", "Shopinvader Category"), + ("shopinvader_product_exports", "Shopinvader Product"), + ], + ondelete={ + "shopinvader_category_exports": "cascade", + "shopinvader_product_exports": "cascade", + }, + ) - @api.depends("lang_id", "model_id") - @api.depends_context("shopinvader_backend_id") - def _compute_is_valid(self): - backend = self.env["shopinvader.backend"] - backend_id = self.env.context.get("shopinvader_backend_id") - if backend_id: - backend = backend.browse(backend_id) - for rec in self: - if not rec.lang_id or not backend: - rec.is_valid = True - continue - rec.is_valid = rec.lang_id.id in backend.lang_ids.ids + @api.constrains("model_id", "serializer_type") + def _check_model(self): + category_model = self.env["ir.model"].search( + [("model", "=", "product.category")], limit=1 + ) + product_model = self.env["ir.model"].search( + [("model", "=", "product.product")], limit=1 + ) + for se_index in self: + if ( + se_index.serializer_type == "shopinvader_category_exports" + and se_index.model_id != category_model + ) or ( + se_index.serializer_type == "shopinvader_product_exports" + and se_index.model_id != product_model + ): + raise ValidationError(_("'Serializer Type' must match 'Model'")) + + def _get_serializer(self): + self.ensure_one() + if self.serializer_type == "shopinvader_category_exports": + return ProductCategoryShopinvaderSerializer() + elif self.serializer_type == "shopinvader_product_exports": + return ProductProductShopinvaderSerializer() + else: + return super()._get_serializer() diff --git a/shopinvader_search_engine/models/se_indexable_record.py b/shopinvader_search_engine/models/se_indexable_record.py new file mode 100644 index 0000000000..9d3a4a213d --- /dev/null +++ b/shopinvader_search_engine/models/se_indexable_record.py @@ -0,0 +1,17 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + + +class SeIndexableRecord(models.AbstractModel): + + _inherit = "se.indexable.record" + + def _filter_by_index(self): + index = self._context.get("index", False) + records = self + if index: + records = records.filtered( + lambda rec, index=index: index in rec.se_binding_ids.mapped("index_id") + ) + return records diff --git a/shopinvader_search_engine/models/shopinvader_backend.py b/shopinvader_search_engine/models/shopinvader_backend.py deleted file mode 100644 index b1fe0200a9..0000000000 --- a/shopinvader_search_engine/models/shopinvader_backend.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2017 Akretion (http://www.akretion.com). -# @author Sébastien BEAU -# Copyright 2021 Camptocamp (http://www.camptocamp.com) -# Simone Orsi -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import api, fields, models - -_logger = logging.getLogger(__name__) - - -class ShopinvaderBackend(models.Model): - _inherit = "shopinvader.backend" - - se_backend_id = fields.Many2one( - comodel_name="se.backend", - string="Search Engine Backend", - help="Search Engine backend configuration to use", - ) - index_ids = fields.One2many("se.index", related="se_backend_id.index_ids") - - @api.model - def _get_default_models(self): - domain = self.env["se.index"]._model_id_domain() - return self.env["ir.model"].search(domain) - - def force_recompute_all_binding_index(self): - self.sudo_tech().mapped("se_backend_id.index_ids").force_recompute_all_binding() - return True - - def force_batch_export_index(self): - for index in self.sudo_tech().mapped("se_backend_id.index_ids"): - index.force_batch_export() - return True - - def clear_index(self): - for index in self.mapped("se_backend_id.index_ids"): - index.clear_index() - return True - - def add_missing_index(self): - _logger.warning("DEPRECATED: add_missing_index, use action_add_missing_indexes") - self.action_add_missing_indexes() - - def action_add_missing_indexes(self): - self.ensure_one() - if not self.se_backend_id: - return - self._add_missing_indexes() - - def _add_missing_indexes(self): - ir_models = self._get_default_models() - new_indexes = self.env["se.index"].browse() - - # Process lang agnostic indexes first - lang_agnostic_ir_models = ir_models.browse() - for ir_model in ir_models: - model_class = self.env[ir_model.model] - if getattr(model_class, "_se_index_lang_agnostic", False): - lang_agnostic_ir_models |= ir_model - new_indexes |= self._create_missing_index(ir_model) - - # Process lang-specific indexes - for ir_model in ir_models: - if ir_model.id in lang_agnostic_ir_models.ids: - continue - for lang in self.lang_ids: - new_indexes |= self._create_missing_index(ir_model, lang_record=lang) - return new_indexes - - def _create_missing_index(self, ir_model, lang_record=None): - new_index = self.env["se.index"] - ir_export = self._get_index_export_config(ir_model) - if not ir_export: - _logger.debug("Cannot create index automatically: no ir.export found.") - return new_index - - if self._check_index_exists(ir_model, lang_record=lang_record): - return new_index - - values = self._get_create_index_values( - ir_model, ir_export, lang_record=lang_record - ) - return self.env["se.index"].create(values) - - def _get_index_export_config(self, ir_model): - return self.env["ir.exports"].search( - [("resource", "=", ir_model.model)], limit=1 - ) - - def _check_index_exists(self, ir_model, lang_record=None): - exists_domain = [("model_id", "=", ir_model.id)] - if lang_record: - exists_domain.append(("lang_id", "=", lang_record.id)) - return bool(self.index_ids.filtered_domain(exists_domain)) - - def _get_create_index_values(self, ir_model, ir_export, lang_record=None): - return { - "backend_id": self.se_backend_id.id, - "model_id": ir_model.id, - "lang_id": lang_record.id if lang_record else False, - "exporter_id": ir_export.id, - } - - def force_resynchronize_index(self): - self.mapped("se_backend_id.index_ids").resynchronize_all_bindings() - - def export_index_settings(self): - self.mapped("se_backend_id.index_ids").export_settings() diff --git a/shopinvader_search_engine/models/shopinvader_category.py b/shopinvader_search_engine/models/shopinvader_category.py deleted file mode 100644 index a6466b73d8..0000000000 --- a/shopinvader_search_engine/models/shopinvader_category.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright 2017 Akretion (http://www.akretion.com). -# @author Sébastien BEAU -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import models - - -class ShopinvaderCategory(models.Model): - _inherit = ["shopinvader.category", "shopinvader.se.binding"] - _name = "shopinvader.category" - _description = "Shopinvader Category" diff --git a/shopinvader_search_engine/models/shopinvader_se_binding.py b/shopinvader_search_engine/models/shopinvader_se_binding.py deleted file mode 100644 index 28c71d14e5..0000000000 --- a/shopinvader_search_engine/models/shopinvader_se_binding.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 2021 Akretion (https://www.akretion.com). -# @author Sébastien BEAU -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from odoo import api, fields, models - - -class ShopinvaderSeBinding(models.AbstractModel): - _name = "shopinvader.se.binding" - _inherit = "se.binding" - _description = "Shopinvader Se Binding" - - index_id = fields.Many2one(compute="_compute_index", store=True, required=False) - - @api.depends( - "backend_id.se_backend_id", - "backend_id.se_backend_id.index_ids", - "lang_id", - ) - def _compute_index(self): - se_backends = self.mapped("backend_id.se_backend_id") - langs = self.mapped("lang_id") - domain = [ - ("backend_id", "in", se_backends.ids), - ("model_id.model", "=", self._name), - ("lang_id", "in", langs.ids), - ] - indexes = self.env["se.index"].search(domain) - for record in self: - index = indexes.filtered( - lambda i, r=record: r.backend_id.se_backend_id == i.backend_id - and r.lang_id == i.lang_id - ) - record.index_id = fields.first(index) - - def _is_indexed(self): - self.ensure_one() - return self.index_id and super()._is_indexed() diff --git a/shopinvader_search_engine/models/shopinvader_variant.py b/shopinvader_search_engine/models/shopinvader_variant.py deleted file mode 100644 index aabfc042fe..0000000000 --- a/shopinvader_search_engine/models/shopinvader_variant.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2017 Akretion (http://www.akretion.com). -# @author Sébastien BEAU -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import models - - -class ShopinvaderVariant(models.Model): - _inherit = ["shopinvader.variant", "shopinvader.se.binding"] - _name = "shopinvader.variant" - _description = "Shopinvader Variant" - - def _get_shop_data(self): - """Use pre-computed index data.""" - return self.get_export_data() diff --git a/shopinvader_search_engine/readme/CONFIGURATION.rst b/shopinvader_search_engine/readme/CONFIGURATION.rst index 51273866d1..b77aa8e722 100644 --- a/shopinvader_search_engine/readme/CONFIGURATION.rst +++ b/shopinvader_search_engine/readme/CONFIGURATION.rst @@ -1,3 +1,2 @@ -Usually, you will not install this module directly. -It will be a dependency of another module like -'shopinvader_algolia'. +To work, this module requires a concrete implementation of a search engine. +The *connector_elasticsearch* module provides a connector for Elasticsearch. diff --git a/shopinvader_search_engine/readme/CONTRIBUTORS.rst b/shopinvader_search_engine/readme/CONTRIBUTORS.rst index 4d7e90ed3b..a0e1c1a42c 100644 --- a/shopinvader_search_engine/readme/CONTRIBUTORS.rst +++ b/shopinvader_search_engine/readme/CONTRIBUTORS.rst @@ -3,3 +3,4 @@ * Benoît GUILLOT * Raphaël Reverdy * Denis Roussel +* Quentin Groulard diff --git a/shopinvader_search_engine/readme/DESCRIPTION.rst b/shopinvader_search_engine/readme/DESCRIPTION.rst index 241228fc96..56a314ee68 100644 --- a/shopinvader_search_engine/readme/DESCRIPTION.rst +++ b/shopinvader_search_engine/readme/DESCRIPTION.rst @@ -1,2 +1,2 @@ -This module is a technical module to implement batch export of data to external indexation services. -The first concrete implementation allows to export data to Algolia. +This module is a technical module to implement batch export of data to external +indexation services. diff --git a/shopinvader_search_engine/readme/ROADMAP.rst b/shopinvader_search_engine/readme/ROADMAP.rst index 320370b370..eddde271f3 100644 --- a/shopinvader_search_engine/readme/ROADMAP.rst +++ b/shopinvader_search_engine/readme/ROADMAP.rst @@ -1 +1,2 @@ -* harmonize/simplify names: Connector, backend... +* Export price on product +* Export SEO title on product diff --git a/shopinvader_search_engine/readme/newsfragments/.gitignore b/shopinvader_search_engine/readme/newsfragments/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopinvader_search_engine/readme/newsfragments/1390.feature b/shopinvader_search_engine/readme/newsfragments/1390.feature new file mode 100644 index 0000000000..d5ebf604ed --- /dev/null +++ b/shopinvader_search_engine/readme/newsfragments/1390.feature @@ -0,0 +1,21 @@ +A complete refactoring has been done to the code. This refactoring was driven by +the following goals: + +* Make the code more readable and maintainable. +* Put in place a way to validate data exported to the indexes +* Ease the work of frontend developers by providing a schema for the data + exported to the indexes. + +Some technical choices have been made to achieve these goals: + +* We removed the need to force the developer to define a specific binding model + for each model that needs to be indexed. +* We defined serializers based on Pydantic models. This choice allows you to + validate the data, generate the documentation and the schema of the data + exported to the indexes. It also makes the serialization mechanism more + explicit and easier to understand. +* We defined more fine-grained modules. + +If you need to add additional information to the data exported to the indexes, +you only need to extends the Pydantic models by adding your additional fields +and extending the method initializing the model from an odoo record. diff --git a/shopinvader_search_engine/schemas/__init__.py b/shopinvader_search_engine/schemas/__init__.py new file mode 100644 index 0000000000..d6c19015bc --- /dev/null +++ b/shopinvader_search_engine/schemas/__init__.py @@ -0,0 +1 @@ +from .category import ShortProductCategory diff --git a/shopinvader_search_engine/schemas/category.py b/shopinvader_search_engine/schemas/category.py new file mode 100644 index 0000000000..ee008eba71 --- /dev/null +++ b/shopinvader_search_engine/schemas/category.py @@ -0,0 +1,16 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.shopinvader_product.schemas import ( + ShortProductCategory as BaseShortProductCategory, +) + + +class ShortProductCategory(BaseShortProductCategory, extends=True): + @classmethod + def _get_parent(cls, odoo_rec): + return odoo_rec.shopinvader_parent_id + + @classmethod + def _get_children(cls, odoo_rec): + return odoo_rec.shopinvader_child_ids diff --git a/shopinvader_search_engine/tests/__init__.py b/shopinvader_search_engine/tests/__init__.py index b5668d29ca..040760fc23 100644 --- a/shopinvader_search_engine/tests/__init__.py +++ b/shopinvader_search_engine/tests/__init__.py @@ -1,3 +1,2 @@ -from . import test_delete_product -from . import test_action_server -from . import test_backend +from . import test_category_binding +from . import test_product_binding diff --git a/shopinvader_search_engine/tests/common.py b/shopinvader_search_engine/tests/common.py new file mode 100644 index 0000000000..f32b600549 --- /dev/null +++ b/shopinvader_search_engine/tests/common.py @@ -0,0 +1,38 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo_test_helper import FakeModelLoader + +from odoo.addons.connector_search_engine.tests.common import TestSeBackendCaseBase +from odoo.addons.extendable.tests.common import ExtendableMixin + + +class TestBindingIndexBase(TestSeBackendCaseBase, FakeModelLoader, ExtendableMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Load fake models ->/ + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from odoo.addons.connector_search_engine.tests.models import ( + FakeSeAdapter, + SeBackend, + ) + + cls.loader.update_registry([SeBackend]) + cls.binding_model = cls.env["se.binding"] + cls.se_index_model = cls.env["se.index"] + cls.backend_model = cls.env["se.backend"] + cls.backend = cls.backend_model.create( + {"name": "Fake SE", "tech_name": "fake_se", "backend_type": "fake"} + ) + + cls.se_adapter = FakeSeAdapter + + cls.init_extendable_registry() + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + cls.reset_extendable_registry() + super().tearDownClass() diff --git a/shopinvader_search_engine/tests/test_action_server.py b/shopinvader_search_engine/tests/test_action_server.py deleted file mode 100644 index 8664c92fb1..0000000000 --- a/shopinvader_search_engine/tests/test_action_server.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2020 Akretion (http://www.akretion.com). -# @author Sébastien BEAU -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.addons.queue_job.tests.common import JobMixin -from odoo.addons.shopinvader.tests.common import ProductCommonCase - - -class ActionServerCase(ProductCommonCase, JobMixin): - def test_action_server_on_product_template(self): - job_counter = self.job_counter() - # we take the number of variant linked => the number of created jobs - bindings = self.env["shopinvader.product"].search([], limit=4) - variant_length = len(bindings.mapped("shopinvader_variant_ids")) - action = self.env.ref( - "shopinvader_search_engine.action_recompute_shopinvader_product_on_template" - ) - action_context = action.with_context( - active_model="product.template", - active_ids=bindings.mapped("record_id").ids, - ) - action_context.run() - job = job_counter.search_created() - self.assertEqual(job_counter.count_created(), 1) - self.assertEqual( - job.display_name, - f"Batch task of {variant_length} for recomputing shopinvader.variant json", - ) - - def test_action_server_on_product_category(self): - self.backend.bind_all_category() - job_counter = self.job_counter() - bindings = self.env["shopinvader.category"].search([], limit=4) - action = self.env.ref( - "shopinvader_search_engine.action_recompute_shopinvader_category" - ) - action_context = action.with_context( - active_model="product.category", - active_ids=bindings.mapped("record_id").ids, - ) - action_context.run() - job = job_counter.search_created() - self.assertEqual(job_counter.count_created(), 1) - self.assertEqual( - job.display_name, - "Batch task of 4 for recomputing shopinvader.category json", - ) diff --git a/shopinvader_search_engine/tests/test_backend.py b/shopinvader_search_engine/tests/test_backend.py deleted file mode 100644 index 28625162e5..0000000000 --- a/shopinvader_search_engine/tests/test_backend.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright 2021 Camptocamp (http://www.camptocamp.com) -# Simone Orsi -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo_test_helper import FakeModelLoader - -from odoo import fields, models -from odoo.tests import SavepointCase - -from odoo.addons.shopinvader.tests.common import _install_lang_odoo - - -class BackendCaseBase(SavepointCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Load fake models ->/ - cls.loader = FakeModelLoader(cls.env, cls.__module__) - cls.loader.backup_registry() - from odoo.addons.connector_search_engine.tests.models import SeBackendFake - - class AgnosticBinding(models.Model): - # `aaa` to keep it on top when sorting ;) - _name = "shopinvader.aaa.test.agnostic.binding" - _inherit = "se.binding" - _se_index_lang_agnostic = True - - name = fields.Char() - - cls.AgnosticBinding = AgnosticBinding - cls.loader.update_registry((SeBackendFake, AgnosticBinding)) - # ->/ Load fake models - - cls.se_backend = ( - cls.env[SeBackendFake._name].create({"name": "Fake SE"}).se_backend_id - ) - cls.backend = cls.env.ref("shopinvader.backend_1") - cls.backend.se_backend_id = cls.se_backend - cls.prod_export = cls.env.ref("shopinvader.ir_exp_shopinvader_variant") - cls.categ_export = cls.env.ref("shopinvader.ir_exp_shopinvader_category") - cls.ir_model_model = cls.env["ir.model"] - cls.variant_model = cls.ir_model_model._get("shopinvader.variant") - cls.categ_model = cls.ir_model_model._get("shopinvader.category") - cls.agnostic_model = cls.ir_model_model._get(cls.AgnosticBinding._name) - cls.lang_en = cls.backend.lang_ids - cls.lang_fr = _install_lang_odoo(cls.env, "base.lang_fr") - - @classmethod - def tearDownClass(cls): - cls.loader.restore_registry() - super().tearDownClass() - - -class TestBackend(BackendCaseBase): - def test_create_missing_indexes_1_lang(self): - self.assertFalse(self.backend.index_ids) - self.backend.action_add_missing_indexes() - # AgnosticBinding does not generate any index since there's no export rec - expected = [ - { - "model_id": self.categ_model.id, - "lang_id": self.lang_en.id, - "exporter_id": self.categ_export.id, - }, - { - "model_id": self.variant_model.id, - "lang_id": self.lang_en.id, - "exporter_id": self.prod_export.id, - }, - ] - indexes = self.backend.index_ids.sorted(lambda x: x.model_id.model) - self.assertRecordValues(indexes, expected) - - def test_create_missing_indexes_2_langs(self): - self.assertFalse(self.backend.index_ids) - self.backend.lang_ids += self.lang_fr - self.backend.action_add_missing_indexes() - expected = [ - { - "model_id": self.categ_model.id, - "lang_id": self.lang_en.id, - "exporter_id": self.categ_export.id, - }, - { - "model_id": self.categ_model.id, - "lang_id": self.lang_fr.id, - "exporter_id": self.categ_export.id, - }, - { - "model_id": self.variant_model.id, - "lang_id": self.lang_en.id, - "exporter_id": self.prod_export.id, - }, - { - "model_id": self.variant_model.id, - "lang_id": self.lang_fr.id, - "exporter_id": self.prod_export.id, - }, - ] - indexes = self.backend.index_ids.sorted( - lambda x: (x.model_id.model, x.lang_id.code) - ) - self.assertRecordValues(indexes, expected) - - def test_create_missing_indexes_2_langs_with_agnostic_index(self): - self.assertTrue(self.env[self.AgnosticBinding._name]._se_index_lang_agnostic) - export = self.env["ir.exports"].create( - {"name": "Agnostic", "resource": self.AgnosticBinding._name} - ) - self.assertFalse(self.backend.index_ids) - self.backend.lang_ids += self.lang_fr - self.backend.action_add_missing_indexes() - expected = [ - { - "model_id": self.agnostic_model.id, - "lang_id": False, - "exporter_id": export.id, - }, - { - "model_id": self.categ_model.id, - "lang_id": self.lang_en.id, - "exporter_id": self.categ_export.id, - }, - { - "model_id": self.categ_model.id, - "lang_id": self.lang_fr.id, - "exporter_id": self.categ_export.id, - }, - { - "model_id": self.variant_model.id, - "lang_id": self.lang_en.id, - "exporter_id": self.prod_export.id, - }, - { - "model_id": self.variant_model.id, - "lang_id": self.lang_fr.id, - "exporter_id": self.prod_export.id, - }, - ] - indexes = self.backend.index_ids.sorted( - lambda x: (x.model_id.model, x.lang_id.code) - ) - self.assertRecordValues(indexes, expected) diff --git a/shopinvader_search_engine/tests/test_category_binding.py b/shopinvader_search_engine/tests/test_category_binding.py new file mode 100644 index 0000000000..a92fb89567 --- /dev/null +++ b/shopinvader_search_engine/tests/test_category_binding.py @@ -0,0 +1,68 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestBindingIndexBase + + +class TestCategoryBinding(TestBindingIndexBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.setup_records() + + @classmethod + def _prepare_index_values(cls, backend=None): + backend = backend or cls.backend + return { + "name": "Category Index", + "backend_id": backend.id, + "model_id": cls.env["ir.model"] + .search([("model", "=", "product.category")], limit=1) + .id, + "lang_id": cls.env.ref("base.lang_en").id, + "serializer_type": "shopinvader_category_exports", + } + + @classmethod + def setup_records(cls, backend=None): + backend = backend or cls.backend + # create an index for category model + cls.se_index = cls.se_index_model.create(cls._prepare_index_values(backend)) + # create a binding + category alltogether + cls.category = cls.env["product.category"].create({"name": "Test category"}) + cls.category_binding = cls.category._add_to_index(cls.se_index) + + def test_serialize(self): + self.category_binding.recompute_json() + data = self.category_binding.data + self.assertEqual(data["id"], self.category.id) + self.assertEqual(data["name"], self.category.name) + self.assertEqual(data["level"], 0) + + def _create_parent_category(self): + parent_category = self.env["product.category"].create( + {"name": "Parent category"} + ) + self.category.parent_id = parent_category + return parent_category + + def test_serialize_hierarchy_parent_not_in_index(self): + self._create_parent_category() + self.category_binding.with_context(index=self.se_index).recompute_json() + self.assertFalse(self.category_binding.data["parent"]) + + def test_serialize_hierarchy_parent_in_index(self): + parent_category = self._create_parent_category() + self.category.invalidate_model() + parent_binding = parent_category._add_to_index(self.se_index) + self.category_binding.with_context(index=self.se_index).recompute_json() + parent_data = self.category_binding.data["parent"] + self.assertEqual(parent_data["id"], parent_category.id) + self.assertEqual(parent_data["name"], "Parent category") + self.assertEqual(parent_data["level"], 0) + parent_binding.with_context(index=self.se_index).recompute_json() + self.assertEqual(len(parent_binding.data["childs"]), 1) + child_data = parent_binding.data["childs"][0] + self.assertEqual(child_data["id"], self.category.id) + self.assertEqual(child_data["name"], "Test category") + self.assertEqual(child_data["level"], 1) diff --git a/shopinvader_search_engine/tests/test_delete_product.py b/shopinvader_search_engine/tests/test_delete_product.py deleted file mode 100644 index b974405e40..0000000000 --- a/shopinvader_search_engine/tests/test_delete_product.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2019 Akretion (http://www.akretion.com). -# @author Sébastien BEAU -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.tests import SavepointCase - - -class BindingCase(SavepointCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.template = cls.env["product.template"].create({"name": "Test"}) - cls.product = cls.template.product_variant_ids - cls.shopinvader_product = ( - cls.env["shopinvader.product"] - .with_context(map_children=True) - .create( - { - "record_id": cls.template.id, - "backend_id": cls.env.ref("shopinvader.backend_1").id, - "lang_id": cls.env.ref("base.lang_en").id, - } - ) - ) - cls.shopinvader_variant = cls.shopinvader_product.shopinvader_variant_ids - - -class BindingDoneCase(BindingCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.shopinvader_variant.write({"sync_state": "done"}) - - def test_unlink_shopinvader_product(self): - self.shopinvader_product.unlink() - - def test_unlink_product_product(self): - self.product.unlink() - - def test_unlink_product_template(self): - self.template.unlink() - - -class BindingInactiveDoneCase(BindingCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.shopinvader_variant.active = False - cls.shopinvader_variant.sync_state = "done" - - def test_unlink_shopinvader_product(self): - self.shopinvader_product.unlink() - - def test_unlink_product_product(self): - self.product.unlink() - - def test_unlink_product_template(self): - self.template.unlink() diff --git a/shopinvader_search_engine/tests/test_product_binding.py b/shopinvader_search_engine/tests/test_product_binding.py new file mode 100644 index 0000000000..92949c213b --- /dev/null +++ b/shopinvader_search_engine/tests/test_product_binding.py @@ -0,0 +1,79 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestBindingIndexBase + + +class TestProductBinding(TestBindingIndexBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.setup_records() + + @classmethod + def _prepare_index_values(cls, backend=None): + backend = backend or cls.backend + return { + "name": "Product Index", + "backend_id": backend.id, + "model_id": cls.env["ir.model"] + .search([("model", "=", "product.product")], limit=1) + .id, + "lang_id": cls.env.ref("base.lang_en").id, + "serializer_type": "shopinvader_product_exports", + } + + @classmethod + def setup_records(cls, backend=None): + backend = backend or cls.backend + # create an index for product model + cls.se_index = cls.se_index_model.create(cls._prepare_index_values(backend)) + # create a binding + product alltogether + cls.product = cls.env.ref( + "shopinvader_product.product_product_chair_vortex_white" + ) + cls.product_binding = cls.product._add_to_index(cls.se_index) + cls.product_expected = {"id": cls.product.id, "name": cls.product.name} + + def test_serialize(self): + self.product_binding.recompute_json() + data = self.product_binding.data + self.assertEqual(data["id"], self.product.id) + self.assertEqual(data["model"], {"name": self.product.product_tmpl_id.name}) + self.assertEqual(data["name"], self.product.full_name) + self.assertEqual(data["short_name"], "blue") + self.assertEqual(data["variant_count"], 2) + self.assertEqual(data["sku"], "VORCHAIR-001") + self.assertEqual(data["variant_attributes"], {"color": "blue"}) + self.assertEqual(data["price"], {}) + + def test_serialize_categories_not_in_index(self): + self.product_binding.with_context(index=self.se_index).recompute_json() + self.assertFalse(self.product_binding.data["categories"]) + + def test_serialize_categories_in_index(self): + self.product.categ_id._add_to_index(self.se_index) + self.product_binding.with_context(index=self.se_index).recompute_json() + self.assertEqual(len(self.product_binding.data["categories"]), 1) + category_data = self.product_binding.data["categories"][0] + self.assertEqual(category_data["id"], self.product.categ_id.id) + self.assertEqual(category_data["name"], self.product.categ_id.name) + self.assertEqual(category_data["level"], 0) + + def test_main_variant_index(self): + variants = self.product.product_tmpl_id.product_variant_ids + for variant in variants: + variant._add_to_index(self.se_index) + main_variant = variants.with_context(index=self.se_index).filtered("main") + self.assertEqual(len(main_variant), 1) + main_variant_binding = main_variant.se_binding_ids[0] + main_variant_binding.with_context(index=self.se_index).recompute_json() + self.assertTrue(main_variant_binding.data["main"]) + + main_variant.se_binding_ids.unlink() + main_variant2 = variants.with_context(index=self.se_index).filtered("main") + self.assertEqual(len(main_variant2), 1) + self.assertNotEqual(main_variant, main_variant2) + main_variant2_binding = main_variant2.se_binding_ids[0] + main_variant2_binding.with_context(index=self.se_index).recompute_json() + self.assertTrue(main_variant2_binding.data["main"]) diff --git a/shopinvader_search_engine/tools/__init__.py b/shopinvader_search_engine/tools/__init__.py new file mode 100644 index 0000000000..e335f46883 --- /dev/null +++ b/shopinvader_search_engine/tools/__init__.py @@ -0,0 +1,2 @@ +from . import category_serializer +from . import product_serializer diff --git a/shopinvader_search_engine/tools/category_serializer.py b/shopinvader_search_engine/tools/category_serializer.py new file mode 100644 index 0000000000..43a6bd8397 --- /dev/null +++ b/shopinvader_search_engine/tools/category_serializer.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.search_engine_serializer_pydantic.tools.serializer import ( + PydanticModelSerializer, +) +from odoo.addons.shopinvader_product.schemas.category import ProductCategory + + +class ProductCategoryShopinvaderSerializer(PydanticModelSerializer): + def get_model_class(self): + return ProductCategory + + def serialize(self, record): + return self.get_model_class().from_product_category(record).model_dump() diff --git a/shopinvader_search_engine/tools/product_serializer.py b/shopinvader_search_engine/tools/product_serializer.py new file mode 100644 index 0000000000..47b29c0529 --- /dev/null +++ b/shopinvader_search_engine/tools/product_serializer.py @@ -0,0 +1,14 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.addons.search_engine_serializer_pydantic.tools.serializer import ( + PydanticModelSerializer, +) +from odoo.addons.shopinvader_product.schemas.product import ProductProduct + + +class ProductProductShopinvaderSerializer(PydanticModelSerializer): + def get_model_class(self): + return ProductProduct + + def serialize(self, record): + return self.get_model_class().from_product_product(record).model_dump() diff --git a/shopinvader_search_engine/views/shopinvader_backend_view.xml b/shopinvader_search_engine/views/shopinvader_backend_view.xml deleted file mode 100644 index 23a29b29da..0000000000 --- a/shopinvader_search_engine/views/shopinvader_backend_view.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - shopinvader.backend - - - - - - - - - - - - - - - - - - - - -