diff --git a/requirements.txt b/requirements.txt index 8167d2784d..b5ce4cb763 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # generated from manifests external_dependencies +cerberus extendable-pydantic>=1.2.0 extendable_pydantic>=1.0.0 extendable_pydantic>=1.2.0 diff --git a/sale_cart/models/sale_order.py b/sale_cart/models/sale_order.py index 97364eb755..982bbc8f16 100644 --- a/sale_cart/models/sale_order.py +++ b/sale_cart/models/sale_order.py @@ -10,17 +10,26 @@ class SaleOrder(models.Model): typology = fields.Selection([("sale", "Sale"), ("cart", "Cart")], default="sale") + def _confirm_cart(self): + self.ensure_one() + self.write({"typology": "sale"}) + def action_confirm_cart(self): for record in self: if record.typology == "sale": # cart is already confirmed continue - record.write({"typology": "sale"}) + record._confirm_cart() return True + def _confirm_sale(self): + self.ensure_one() + if self.typology != "sale": + self.typology = "sale" + def action_confirm(self): res = super(SaleOrder, self).action_confirm() for record in self: - if record.state != "draft" and record.typology != "sale": - record.typology = "sale" + if record.state != "draft": + record._confirm_sale() return res diff --git a/setup/shopinvader_restapi/odoo/addons/shopinvader_restapi b/setup/shopinvader_restapi/odoo/addons/shopinvader_restapi new file mode 120000 index 0000000000..4d1698ada0 --- /dev/null +++ b/setup/shopinvader_restapi/odoo/addons/shopinvader_restapi @@ -0,0 +1 @@ +../../../../shopinvader_restapi \ No newline at end of file diff --git a/setup/shopinvader_restapi/setup.py b/setup/shopinvader_restapi/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_restapi/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_api_address/tests/test_shopinvader_address_api.py b/shopinvader_api_address/tests/test_shopinvader_address_api.py index 4df5841643..461df5b53c 100644 --- a/shopinvader_api_address/tests/test_shopinvader_address_api.py +++ b/shopinvader_api_address/tests/test_shopinvader_address_api.py @@ -216,7 +216,7 @@ def test_update_billing_address_vat(self): "city": "Waterloo", "country_id": self.env.ref("base.be").id, "street": "rue test", - "vat": "test_vat", + "vat": "BE0477472701", } with self._create_test_client(router=address_router) as test_client: @@ -234,7 +234,7 @@ def test_update_billing_address_vat(self): address = response_json - self.assertEqual(address.get("vat"), "test_vat") + self.assertEqual(address.get("vat"), "BE0477472701") self.assertEqual(address.get("vat"), self.test_partner.vat) def test_create_shipping_address(self): diff --git a/shopinvader_restapi/README.rst b/shopinvader_restapi/README.rst new file mode 100644 index 0000000000..87ee0b29d9 --- /dev/null +++ b/shopinvader_restapi/README.rst @@ -0,0 +1,113 @@ +=========== +Shopinvader +=========== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/14.0/shopinvader + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This is shopinvader the odoo module for the new generation of e-commerce. + +ShopInvader is an ecommerce software to create and manage easily your online store with Odoo. + +This is the Odoo side of the `Shopinvader E-commerce Solution`_. + +.. _Shopinvader E-commerce Solution: https://shopinvader.com + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +* Customer validation limitation + +Customer validation is global: enable/disable affects all websites, if you have more than one. + +Technical +~~~~~~~~~ + +* Create methods should be rewritten to support multi +* The logic to bind / unbind products and categories should be implemented as + component in place of wizard. + Previously it was possible to work with in-memory record of the wizard to + call the same logic from within odoo. In Odoo 13 it's no more the case. + That means that to rebind thousand of records we must create thousand of + rows into the database to reuse the logic provided by the wizard. +* On product.category the name is no more translatable in V13. + This functionality has been restored into shopinvader. + This should be moved into a dedicated addon + +Changelog +========= + +10.0.1.0.0 (2017-04-11) +~~~~~~~~~~~~~~~~~~~~~~~ + +* First real version : [REF] rename project to the real name : shoptor is dead long live to shopinvader", 2017-04-11) + +12.0.1.0.0 (2019-05-10) +~~~~~~~~~~~~~~~~~~~~~~~ + +* [12.0][MIG] shopinvader + +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 +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Sebastien BEAU +* Simone Orsi +* Laurent Mignon +* Raphaël Reverdy +* Kevin Khao + +Other credits +~~~~~~~~~~~~~ + +The development of this module has been financially supported by: + +* Akretion +* Adaptoo +* Encresdubuit +* Abilis +* Camptocamp +* Cosanum + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_restapi/__init__.py b/shopinvader_restapi/__init__.py new file mode 100644 index 0000000000..a350f8c80a --- /dev/null +++ b/shopinvader_restapi/__init__.py @@ -0,0 +1,5 @@ +from . import components +from . import models +from . import services +from . import wizards +from .hooks import pre_init_hook diff --git a/shopinvader_restapi/__manifest__.py b/shopinvader_restapi/__manifest__.py new file mode 100644 index 0000000000..4fece7e2c2 --- /dev/null +++ b/shopinvader_restapi/__manifest__.py @@ -0,0 +1,63 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Shopinvader", + "summary": "Shopinvader", + "version": "16.0.1.0.0", + "category": "e-commerce", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "author": "Akretion,ACSONE SA/NV", + "license": "AGPL-3", + "application": True, + "installable": True, + "external_dependencies": {"python": ["cerberus", "unidecode"], "bin": []}, + "depends": [ + "base_rest", + "jsonifier", + "base_sparse_field_list_support", + "base_vat", + "component_event", + "sale", + "sale_cart", + "sale_discount_display_amount", + "server_environment", + "onchange_helper", + "queue_job", + "mail", + "base", + ], + "data": [ + "security/shopinvader_security.xml", + "security/ir.model.access.csv", + "security/shopinvader_backend_security.xml", + "security/shopinvader_partner_security.xml", + "wizards/shopinvader_partner_binding.xml", + "views/shopinvader_menu.xml", + "views/shopinvader_cart_step_view.xml", + "views/shopinvader_partner_view.xml", + "views/res_config_settings.xml", + "views/sale_view.xml", + "views/shopinvader_sale_view.xml", + "views/partner_view.xml", + "views/shopinvader_backend_view.xml", + "data/res_partner.xml", + "data/cart_step.xml", + "data/mail_activity_data.xml", + "data/queue_job_channel_data.xml", + "data/queue_job_function_data.xml", + ], + "demo": [ + "demo/account_demo.xml", + "demo/pricelist_demo.xml", + "demo/backend_demo.xml", + "demo/partner_demo.xml", + "demo/sale_demo.xml", + "demo/email_demo.xml", + "demo/notification_demo.xml", + ], + "qweb": [], + "pre_init_hook": "pre_init_hook", + "development_status": "Alpha", +} diff --git a/shopinvader_restapi/components/__init__.py b/shopinvader_restapi/components/__init__.py new file mode 100644 index 0000000000..8b94e63bc8 --- /dev/null +++ b/shopinvader_restapi/components/__init__.py @@ -0,0 +1,3 @@ +from . import core +from . import access_info +from . import service_context_provider diff --git a/shopinvader_restapi/components/access_info.py b/shopinvader_restapi/components/access_info.py new file mode 100644 index 0000000000..9cb75c5fa5 --- /dev/null +++ b/shopinvader_restapi/components/access_info.py @@ -0,0 +1,80 @@ +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class PartnerAccess(Component): + """Define access rules to partner from client side.""" + + _name = "shopinvader.partner.access" + _inherit = "base.shopinvader.component" + _usage = "access.info" + _apply_on = "res.partner" + + @property + def service_work(self): + return getattr(self.work, "service_work", None) + + @property + def partner(self): + return getattr(self.work, "partner", None) + + @property + def invader_partner(self): + return getattr(self.work, "invader_partner", None) + + @property + def partner_user(self): + return getattr(self.work, "partner_user", self.partner) + + @property + def invader_partner_user(self): + return getattr(self.work, "invader_partner_user", self.partner) + + def is_main_partner(self): + return self.partner == self.partner_user + + def is_owner(self, partner_id): + return partner_id == self.partner_user.id + + def for_profile(self, partner_id): + info = {"read": True, "update": True, "delete": False} + if not self.is_main_partner() and partner_id != self.partner_user.id: + info["update"] = False + return info + + def for_address(self, address_id): + info = {"read": True, "update": True, "delete": True} + if self.partner_user is not None: + if not self.is_main_partner(): + if not self.is_owner(address_id): + info.update({"read": True, "update": False, "delete": False}) + else: + # only main partner can delete your address + info["delete"] = False + return info + + def permissions(self): + """Current user permissions mapping. + + :returns: a dictionary in the format + + {$component_usage: {$method: $permission_flag}} + """ + if self.partner is None: + return {"address": {}, "cart": {}} + return { + # scope: permissions + "addresses": { + # can create addresses only if profile partner is enabled + "create": self.invader_partner.is_shopinvader_active, + }, + "cart": { + # can hit the button to add to cart + "add_item": self.invader_partner.is_shopinvader_active, + # can go on w/ checkout steps + "update_item": self.invader_partner.is_shopinvader_active, + }, + } diff --git a/shopinvader_restapi/components/core.py b/shopinvader_restapi/components/core.py new file mode 100644 index 0000000000..43744f76b7 --- /dev/null +++ b/shopinvader_restapi/components/core.py @@ -0,0 +1,19 @@ +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class BaseShopinvaderComponent(AbstractComponent): + """Base Shopinvader Component. + + All components of this module and inheriting ones should inherit from it. + """ + + _name = "base.shopinvader.component" + _collection = "shopinvader.backend" + + @property + def backend(self): + return self.collection diff --git a/shopinvader_restapi/components/service_context_provider.py b/shopinvader_restapi/components/service_context_provider.py new file mode 100644 index 0000000000..33bd19fae5 --- /dev/null +++ b/shopinvader_restapi/components/service_context_provider.py @@ -0,0 +1,64 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo.addons.component.core import Component + +from ..utils import get_partner_work_context + + +class ShopinvaderServiceContextProvider(Component): + _name = "shopinvader.service.context.provider" + _inherit = "base.rest.service.context.provider" + _collection = "shopinvader.backend" + + def _find_partner(self, backend, partner_email): + partner_domain = [ + ("partner_email", "=", partner_email), + ("backend_id", "=", backend.id), + ] + return self.env["shopinvader.partner"].search(partner_domain, limit=2) + + def _validate_partner(self, backend, partner): + return backend._validate_partner(partner) + + def _get_shopinvader_session(self): + # HTTP_SESS are data that are store in the shopinvader session + # and forwarded to odoo at each request + # it allow to access to some specific field of the user session + # By security always force typing + # Note: rails cookies store session are serveless ;) + return { + "cart_id": int(self.request.httprequest.environ.get("HTTP_SESS_CART_ID", 0)) + } + + def _get_shopinvader_partner(self): + """Get the partner requesting the api + + At this stage, the partner returned by this method must be the shopinvader + partner... + """ + return self.env["shopinvader.partner"].browse() + + def _get_backend(self): + """Get the requested shopinvader backend instance""" + website_unique_key = self.request.httprequest.environ.get( + "HTTP_WEBSITE_UNIQUE_KEY" + ) + if website_unique_key: + return self.env["shopinvader.backend"]._get_from_website_unique_key( + website_unique_key + ) + backend = self.env["shopinvader.backend"].search([], limit=2) + if len(backend) == 1: + return backend + return self.env["shopinvader.backend"].browse() + + def _get_component_context(self): + res = super(ShopinvaderServiceContextProvider, self)._get_component_context() + res.update( + { + "shopinvader_session": self._get_shopinvader_session(), + "shopinvader_backend": self._get_backend(), + } + ) + res.update(get_partner_work_context(self._get_shopinvader_partner())) + return res diff --git a/shopinvader_restapi/data/cart_step.xml b/shopinvader_restapi/data/cart_step.xml new file mode 100644 index 0000000000..bd2c7fac68 --- /dev/null +++ b/shopinvader_restapi/data/cart_step.xml @@ -0,0 +1,29 @@ + + + + + My cart + cart_index + + + + Login + cart_login + + + + Address + cart_address + + + + Checkout + cart_checkout + + + + Finish + cart_end + + + diff --git a/shopinvader_restapi/data/mail_activity_data.xml b/shopinvader_restapi/data/mail_activity_data.xml new file mode 100644 index 0000000000..a29ebe12d6 --- /dev/null +++ b/shopinvader_restapi/data/mail_activity_data.xml @@ -0,0 +1,9 @@ + + + + Shop - Validate customer + fa-tasks + 3 + warning + + diff --git a/shopinvader_restapi/data/queue_job_channel_data.xml b/shopinvader_restapi/data/queue_job_channel_data.xml new file mode 100644 index 0000000000..860b40fe68 --- /dev/null +++ b/shopinvader_restapi/data/queue_job_channel_data.xml @@ -0,0 +1,14 @@ + + + shopinvader + + + + notification + + + + bind_products + + + diff --git a/shopinvader_restapi/data/queue_job_function_data.xml b/shopinvader_restapi/data/queue_job_function_data.xml new file mode 100644 index 0000000000..2a6e8940b7 --- /dev/null +++ b/shopinvader_restapi/data/queue_job_function_data.xml @@ -0,0 +1,23 @@ + + + + send + + + + + bind_selected_products + + + + + bind_single_product + + + diff --git a/shopinvader_restapi/data/res_partner.xml b/shopinvader_restapi/data/res_partner.xml new file mode 100644 index 0000000000..6bd76a83e2 --- /dev/null +++ b/shopinvader_restapi/data/res_partner.xml @@ -0,0 +1,8 @@ + + + + + Anonymous + + + diff --git a/shopinvader_restapi/demo/account_demo.xml b/shopinvader_restapi/demo/account_demo.xml new file mode 100644 index 0000000000..1535e36048 --- /dev/null +++ b/shopinvader_restapi/demo/account_demo.xml @@ -0,0 +1,70 @@ + + + + + Tax inc + + percent + sale + + + + + Tax exc + + percent + sale + + + + Tax exempt + + percent + sale + + + + Default + + + + + + Business + + + + + + + + + + + + + World + + + + + Exempt + + + + + + + + + + + + ShopInvader Analytic Plan + + + + ShopInvader Analytic + + + diff --git a/shopinvader_restapi/demo/backend_demo.xml b/shopinvader_restapi/demo/backend_demo.xml new file mode 100644 index 0000000000..9b50126065 --- /dev/null +++ b/shopinvader_restapi/demo/backend_demo.xml @@ -0,0 +1,28 @@ + + + + + Demo Shopinvader Website + + + + + + + + + Demo Shopinvader Website 2 + + + + + + + + diff --git a/shopinvader_restapi/demo/email_demo.xml b/shopinvader_restapi/demo/email_demo.xml new file mode 100644 index 0000000000..3886f77898 --- /dev/null +++ b/shopinvader_restapi/demo/email_demo.xml @@ -0,0 +1,83 @@ + + + + + Cart notification + {{ (object.user_id.email or '') }} + Cart notification {{ object.name }} + {{ object.partner_id.id }} + + + {{ object.partner_id.lang }} + + + + + Sale notification + {{ (object.user_id.email or '') }} + Sale notification {{ object.name }} + {{ object.partner_id.id }} + + + {{ object.partner_id.lang }} + + + + + Invoice notification + {{ (object.user_id.email or '') }} + Invoice notification {{ object.name }} + {{ object.partner_id.id }} + + + {{ object.partner_id.lang }} + + + + + Welcome notification + {{ (object.user_id.email or '') }} + Welcome notification {{ object.name }} + {{ object.id }} + + + {{ object.lang }} + + + + + Customer updated notification + {{ (object.user_id.email or '') }} + Notification {{ object.name }} - Customer modified + {{ object.id }} + + + {{ object.lang }} + + + + + Address created notification + {{ (object.user_id.email or '') }} + Notification {{ object.name }} - Address created + {{ object.id }} + + + {{ object.lang }} + + + + + Address updated notification + {{ (object.user_id.email or '') }} + Notification {{ object.name }} - Address modified + {{ object.id }} + + + {{ object.lang }} + + + + diff --git a/shopinvader_restapi/demo/notification_demo.xml b/shopinvader_restapi/demo/notification_demo.xml new file mode 100644 index 0000000000..0ca6f22496 --- /dev/null +++ b/shopinvader_restapi/demo/notification_demo.xml @@ -0,0 +1,65 @@ + + + + + + cart_confirmation + + + + + + + sale_confirmation + + + + + + + invoice_open + + + + + + + new_customer_welcome + + + + + + + customer_updated + + + + + + + address_created + + + + + + + address_updated + + + + + diff --git a/shopinvader_restapi/demo/partner_demo.xml b/shopinvader_restapi/demo/partner_demo.xml new file mode 100644 index 0000000000..13e01ecb5b --- /dev/null +++ b/shopinvader_restapi/demo/partner_demo.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + Osiris + osiris@shopinvader.com + pré du haut + Aurec sur Loire + 43110 + + + + + + + UG9uZXk= + + + + + + other + Osiris + chemin du bois + Aurec sur Loire + 43110 + + + + + + other + Osiris + holliday street + New York + 11102 + + + + + Anubis + anubis@shopinvader.com + 3841 18th St + San Francisco + CA 94114 + + + + + + Y2hhY2Fs + + + + + + other + Anubis + 43 Rue de Liège + Paris + 75008 + + + + diff --git a/shopinvader_restapi/demo/pricelist_demo.xml b/shopinvader_restapi/demo/pricelist_demo.xml new file mode 100644 index 0000000000..3a0208c8dd --- /dev/null +++ b/shopinvader_restapi/demo/pricelist_demo.xml @@ -0,0 +1,29 @@ + + + + + + Business Pricelist + + + + + list_price + + Default Business Pricelist Line + + percentage + + + + + + + + + diff --git a/shopinvader_restapi/demo/sale_demo.xml b/shopinvader_restapi/demo/sale_demo.xml new file mode 100644 index 0000000000..00fc7f3bd9 --- /dev/null +++ b/shopinvader_restapi/demo/sale_demo.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + cart + DEMO_ORDER_1 + + + + + Laptop E5023 + + 3 + + 2950.00 + + + + + Motherboard I9P57 + + 5 + + 145.00 + + + + + Processor Core i5 2.70 Ghz + + 2 + + 65.00 + + + + + + + + + + cart + DEMO_ORDER_2 + + + + + Graphics Card + + 3 + + 885.0 + + + + + Laptop E5023 + + 2 + + 2950.0 + + + + + + + + + + cart + DEMO_ORDER_3 + + + + + Computer Case + + 3 + + 25.00 + + + + + HDD SH-1 + + 2 + + 975.0 + + + diff --git a/shopinvader_restapi/hooks.py b/shopinvader_restapi/hooks.py new file mode 100644 index 0000000000..6f321a1166 --- /dev/null +++ b/shopinvader_restapi/hooks.py @@ -0,0 +1,427 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +RESTAPI_XML_IDS = ( + "access_shopinvader_backend_manage", + "access_shopinvader_backend_read", + "access_shopinvader_cart_step", + "access_shopinvader_cart_step_employee", + "access_shopinvader_notification_employee", + "access_shopinvader_notification_manager", + "access_shopinvader_partner_binding", + "access_shopinvader_partner_binding_line", + "access_shopinvader_partner_edit", + "access_shopinvader_partner_read", + "access_shopinvader_url_manager", + "account_analytic_0", + "action_cart", + "action_sale", + "act_open_shopinvader_cart_step_view", + "act_open_shopinvader_cart_step_view_tree", + "act_open_shopinvader_partner_view", + "act_open_shopinvader_partner_view_form", + "act_open_shopinvader_partner_view_tree", + "anonymous", + "backend_1", + "backend_2", + "cart_address", + "cart_checkout", + "cart_end", + "cart_index", + "cart_login", + "channel_shopinvader", + "channel_shopinvader_bind_products", + "channel_shopinvader_notification", + "constraint_shopinvader_backend_unique_website_unique_key", + "constraint_shopinvader_partner_email_uniq", + "constraint_shopinvader_partner_record_uniq", + "country_group_1", + "email_address_created_notification", + "email_address_updated_notification", + "email_cart_notification", + "email_customer_updated_notification", + "email_invoice_notification", + "email_new_customer_welcome_notification", + "email_sale_notification", + "field_account_bank_statement_line__shopinvader_backend_id", + "field_account_move__display_name", + "field_account_move__id", + "field_account_move____last_update", + "field_account_move__shopinvader_backend_id", + "field_account_payment__shopinvader_backend_id", + "field_res_config_settings__display_name", + "field_res_config_settings__id", + "field_res_config_settings____last_update", + "field_res_config_settings__shopinvader_no_partner_duplicate", + "field_res_partner__address_type", + "field_res_partner__display_name", + "field_res_partner__has_shopinvader_user", + "field_res_partner__id", + "field_res_partner__is_shopinvader_active", + "field_res_partner____last_update", + "field_res_partner__opt_in", + "field_res_partner__parent_has_shopinvader_user", + "field_res_partner__shopinvader_bind_ids", + "field_res_users__address_type", + "field_res_users__has_shopinvader_user", + "field_res_users__is_shopinvader_active", + "field_res_users__opt_in", + "field_res_users__parent_has_shopinvader_user", + "field_res_users__shopinvader_bind_ids", + "field_sale_order__current_step_id", + "field_sale_order__display_name", + "field_sale_order__done_step_ids", + "field_sale_order__id", + "field_sale_order__last_external_update_date", + "field_sale_order____last_update", + "field_sale_order_line__display_name", + "field_sale_order_line__id", + "field_sale_order_line____last_update", + "field_sale_order__shopinvader_backend_id", + "field_sale_order__shopinvader_state", + "field_sale_order__typology", + "field_shopinvader_backend__account_analytic_id", + "field_shopinvader_backend__allowed_country_ids", + "field_shopinvader_backend__anonymous_partner_id", + "field_shopinvader_backend__cart_checkout_address_policy", + "field_shopinvader_backend__clear_cart_options", + "field_shopinvader_backend__company_id", + "field_shopinvader_backend__create_date", + "field_shopinvader_backend__create_uid", + "field_shopinvader_backend__currency_ids", + "field_shopinvader_backend__customer_default_role", + "field_shopinvader_backend__display_name", + "field_shopinvader_backend__filter_ids", + "field_shopinvader_backend__frontend_data_source", + "field_shopinvader_backend__id", + "field_shopinvader_backend__invoice_access_open", + "field_shopinvader_backend__invoice_linked_to_sale_only", + "field_shopinvader_backend__invoice_report_id", + "field_shopinvader_backend__invoice_settings", + "field_shopinvader_backend__lang_ids", + "field_shopinvader_backend____last_update", + "field_shopinvader_backend__name", + "field_shopinvader_backend__nbr_cart", + "field_shopinvader_backend__nbr_sale", + "field_shopinvader_backend__notification_ids", + "field_shopinvader_backend__partner_industry_ids", + "field_shopinvader_backend__partner_title_ids", + "field_shopinvader_backend__pricelist_id", + "field_shopinvader_backend__sale_settings", + "field_shopinvader_backend__salesman_notify_create", + "field_shopinvader_backend__salesman_notify_update", + "field_shopinvader_backend__sequence_id", + "field_shopinvader_backend__server_env_defaults", + "field_shopinvader_backend__tech_name", + "field_shopinvader_backend__website_public_name", + "field_shopinvader_backend__website_unique_key", + "field_shopinvader_backend__write_date", + "field_shopinvader_backend__write_uid", + "field_shopinvader_cart_step__code", + "field_shopinvader_cart_step__create_date", + "field_shopinvader_cart_step__create_uid", + "field_shopinvader_cart_step__display_name", + "field_shopinvader_cart_step__id", + "field_shopinvader_cart_step____last_update", + "field_shopinvader_cart_step__name", + "field_shopinvader_cart_step__write_date", + "field_shopinvader_cart_step__write_uid", + "field_shopinvader_notification__backend_id", + "field_shopinvader_notification__create_date", + "field_shopinvader_notification__create_uid", + "field_shopinvader_notification__display_name", + "field_shopinvader_notification__id", + "field_shopinvader_notification____last_update", + "field_shopinvader_notification__model_id", + "field_shopinvader_notification__notification_type", + "field_shopinvader_notification__template_id", + "field_shopinvader_notification__write_date", + "field_shopinvader_notification__write_uid", + "field_shopinvader_partner__active", + "field_shopinvader_partner__active_lang_count", + "field_shopinvader_partner__activity_date_deadline", + "field_shopinvader_partner__activity_exception_decoration", + "field_shopinvader_partner__activity_exception_icon", + "field_shopinvader_partner__activity_ids", + "field_shopinvader_partner__activity_state", + "field_shopinvader_partner__activity_summary", + "field_shopinvader_partner__activity_type_icon", + "field_shopinvader_partner__activity_type_id", + "field_shopinvader_partner__activity_user_id", + "field_shopinvader_partner__additional_info", + "field_shopinvader_partner__address_type", + "field_shopinvader_partner__backend_id", + "field_shopinvader_partner__bank_account_count", + "field_shopinvader_partner__bank_ids", + "field_shopinvader_partner__barcode", + "field_shopinvader_partner_binding__binding_lines", + "field_shopinvader_partner_binding__create_date", + "field_shopinvader_partner_binding__create_uid", + "field_shopinvader_partner_binding__display_name", + "field_shopinvader_partner_binding__id", + "field_shopinvader_partner_binding____last_update", + "field_shopinvader_partner_binding_line__bind", + "field_shopinvader_partner_binding_line__create_date", + "field_shopinvader_partner_binding_line__create_uid", + "field_shopinvader_partner_binding_line__display_name", + "field_shopinvader_partner_binding_line__email", + "field_shopinvader_partner_binding_line__id", + "field_shopinvader_partner_binding_line____last_update", + "field_shopinvader_partner_binding_line__partner_id", + "field_shopinvader_partner_binding_line__shopinvader_partner_binding_id", + "field_shopinvader_partner_binding_line__write_date", + "field_shopinvader_partner_binding_line__write_uid", + "field_shopinvader_partner_binding__shopinvader_backend_id", + "field_shopinvader_partner_binding__write_date", + "field_shopinvader_partner_binding__write_uid", + "field_shopinvader_partner__category_id", + "field_shopinvader_partner__channel_ids", + "field_shopinvader_partner__child_ids", + "field_shopinvader_partner__city", + "field_shopinvader_partner__color", + "field_shopinvader_partner__comment", + "field_shopinvader_partner__commercial_company_name", + "field_shopinvader_partner__commercial_partner_id", + "field_shopinvader_partner__company_id", + "field_shopinvader_partner__company_name", + "field_shopinvader_partner__company_type", + "field_shopinvader_partner__contact_address", + "field_shopinvader_partner__contract_ids", + "field_shopinvader_partner__country_id", + "field_shopinvader_partner__create_date", + "field_shopinvader_partner__create_uid", + "field_shopinvader_partner__credit", + "field_shopinvader_partner__credit_limit", + "field_shopinvader_partner__currency_id", + "field_shopinvader_partner__customer_rank", + "field_shopinvader_partner__date", + "field_shopinvader_partner__debit", + "field_shopinvader_partner__debit_limit", + "field_shopinvader_partner__display_name", + "field_shopinvader_partner__email", + "field_shopinvader_partner__email_formatted", + "field_shopinvader_partner__email_normalized", + "field_shopinvader_partner__employee", + "field_shopinvader_partner__external_id", + "field_shopinvader_partner__function", + "field_shopinvader_partner__has_shopinvader_user", + "field_shopinvader_partner__has_unreconciled_entries", + "field_shopinvader_partner__id", + "field_shopinvader_partner__image_1024", + "field_shopinvader_partner__image_128", + "field_shopinvader_partner__image_1920", + "field_shopinvader_partner__image_256", + "field_shopinvader_partner__image_512", + "field_shopinvader_partner__im_status", + "field_shopinvader_partner__industry_id", + "field_shopinvader_partner__invoice_ids", + "field_shopinvader_partner__invoice_warn", + "field_shopinvader_partner__invoice_warn_msg", + "field_shopinvader_partner__is_blacklisted", + "field_shopinvader_partner__is_company", + "field_shopinvader_partner__is_shopinvader_active", + "field_shopinvader_partner__journal_item_count", + "field_shopinvader_partner__lang", + "field_shopinvader_partner__last_time_entries_checked", + "field_shopinvader_partner____last_update", + "field_shopinvader_partner__message_attachment_count", + "field_shopinvader_partner__message_bounce", + "field_shopinvader_partner__message_channel_ids", + "field_shopinvader_partner__message_follower_ids", + "field_shopinvader_partner__message_has_error", + "field_shopinvader_partner__message_has_error_counter", + "field_shopinvader_partner__message_has_sms_error", + "field_shopinvader_partner__message_ids", + "field_shopinvader_partner__message_is_follower", + "field_shopinvader_partner__message_main_attachment_id", + "field_shopinvader_partner__message_needaction", + "field_shopinvader_partner__message_needaction_counter", + "field_shopinvader_partner__message_partner_ids", + "field_shopinvader_partner__message_unread", + "field_shopinvader_partner__message_unread_counter", + "field_shopinvader_partner__mobile", + "field_shopinvader_partner__mobile_blacklisted", + "field_shopinvader_partner__my_activity_date_deadline", + "field_shopinvader_partner__name", + "field_shopinvader_partner__opt_in", + "field_shopinvader_partner__parent_has_shopinvader_user", + "field_shopinvader_partner__parent_id", + "field_shopinvader_partner__parent_name", + "field_shopinvader_partner__partner_email", + "field_shopinvader_partner__partner_gid", + "field_shopinvader_partner__partner_latitude", + "field_shopinvader_partner__partner_longitude", + "field_shopinvader_partner__partner_share", + "field_shopinvader_partner__payment_token_count", + "field_shopinvader_partner__payment_token_ids", + "field_shopinvader_partner__phone", + "field_shopinvader_partner__phone_blacklisted", + "field_shopinvader_partner__phone_sanitized", + "field_shopinvader_partner__phone_sanitized_blacklisted", + "field_shopinvader_partner__property_account_payable_id", + "field_shopinvader_partner__property_account_position_id", + "field_shopinvader_partner__property_account_receivable_id", + "field_shopinvader_partner__property_payment_term_id", + "field_shopinvader_partner__property_product_pricelist", + "field_shopinvader_partner__property_supplier_payment_term_id", + "field_shopinvader_partner__record_id", + "field_shopinvader_partner__ref", + "field_shopinvader_partner__ref_company_ids", + "field_shopinvader_partner__role", + "field_shopinvader_partner__sale_order_count", + "field_shopinvader_partner__sale_order_ids", + "field_shopinvader_partner__sale_warn", + "field_shopinvader_partner__sale_warn_msg", + "field_shopinvader_partner__same_vat_partner_id", + "field_shopinvader_partner__self", + "field_shopinvader_partner__shopinvader_bind_ids", + "field_shopinvader_partner__signup_expiration", + "field_shopinvader_partner__signup_token", + "field_shopinvader_partner__signup_type", + "field_shopinvader_partner__signup_url", + "field_shopinvader_partner__signup_valid", + "field_shopinvader_partner__state_id", + "field_shopinvader_partner__street", + "field_shopinvader_partner__street2", + "field_shopinvader_partner__supplier_rank", + "field_shopinvader_partner__sync_date", + "field_shopinvader_partner__team_id", + "field_shopinvader_partner__title", + "field_shopinvader_partner__total_invoiced", + "field_shopinvader_partner__trust", + "field_shopinvader_partner__type", + "field_shopinvader_partner__tz", + "field_shopinvader_partner__tz_offset", + "field_shopinvader_partner__user_id", + "field_shopinvader_partner__user_ids", + "field_shopinvader_partner__vat", + "field_shopinvader_partner__website", + "field_shopinvader_partner__website_message_ids", + "field_shopinvader_partner__write_date", + "field_shopinvader_partner__write_uid", + "field_shopinvader_partner__zip", + "field_track_external_mixin__display_name", + "field_track_external_mixin__id", + "field_track_external_mixin__last_external_update_date", + "field_track_external_mixin____last_update", + "fiscal_position_0", + "fiscal_position_1", + "fiscal_position_2", + "group_shopinvader_manager", + "group_shopinvader_partner_binding", + "item_1", + "job_function_shopinvader_bind_selected_products", + "job_function_shopinvader_bind_single_product", + "job_function_shopinvader_notification_send", + "mail_activity_review_customer", + "menu_cart", + "menu_sale", + "menu_shopinvader_cart_step", + "menu_shopinvader_config", + "menu_shopinvader_config_cart", + "menu_shopinvader_config_partners", + "menu_shopinvader_config_technical", + "menu_shopinvader_orders", + "menu_shopinvader_partner", + "menu_shopinvader_root", + "model_account_move", + "model_res_config_settings", + "model_res_partner", + "model_sale_order", + "model_sale_order_line", + "model_shopinvader_backend", + "model_shopinvader_cart_step", + "model_shopinvader_notification", + "model_shopinvader_partner", + "model_shopinvader_partner_binding", + "model_shopinvader_partner_binding_line", + "model_track_external_mixin", + "module_category_shopinvader", + "partner_1", + "partner_1_address_1", + "partner_1_address_2", + "partner_2", + "partner_2_address_1", + "position_tax_1", + "position_tax_2", + "pricelist_1", + "res_config_settings_view_form", + "res_partner_view_form", + "sale_order_1", + "sale_order_2", + "sale_order_3", + "sale_order_line_1", + "sale_order_line_2", + "sale_order_line_3", + "sale_order_line_4", + "sale_order_line_5", + "sale_order_line_6", + "sale_order_line_7", + "sale_order_view_form", + "selection__res_partner__address_type__address", + "selection__res_partner__address_type__profile", + "selection__sale_order__shopinvader_state__cancel", + "selection__sale_order__shopinvader_state__pending", + "selection__sale_order__shopinvader_state__processing", + "selection__sale_order__shopinvader_state__shipped", + "selection__sale_order__typology__cart", + "selection__sale_order__typology__sale", + "selection__shopinvader_backend__cart_checkout_address_policy__invoice_defaults_to_shipping", # noqa: B950 + "selection__shopinvader_backend__cart_checkout_address_policy__no_defaults", + "selection__shopinvader_backend__clear_cart_options__cancel", + "selection__shopinvader_backend__clear_cart_options__clear", + "selection__shopinvader_backend__clear_cart_options__delete", + "selection__shopinvader_backend__frontend_data_source__search_engine", + "selection__shopinvader_backend__salesman_notify_create__", + "selection__shopinvader_backend__salesman_notify_create__address", + "selection__shopinvader_backend__salesman_notify_create__all", + "selection__shopinvader_backend__salesman_notify_create__company", + "selection__shopinvader_backend__salesman_notify_create__company_and_user", + "selection__shopinvader_backend__salesman_notify_create__user", + "selection__shopinvader_backend__salesman_notify_update__", + "selection__shopinvader_backend__salesman_notify_update__address", + "selection__shopinvader_backend__salesman_notify_update__all", + "selection__shopinvader_backend__salesman_notify_update__company", + "selection__shopinvader_backend__salesman_notify_update__company_and_user", + "selection__shopinvader_backend__salesman_notify_update__user", + "shopinvader_backend_comp_rule", + "shopinvader_cart_step_view_search", + "shopinvader_cart_step_view_tree", + "shopinvader_config_settings_act_window", + "shopinvader_config_settings_menu", + "shopinvader_notification_address_created", + "shopinvader_notification_address_updated", + "shopinvader_notification_cart", + "shopinvader_notification_customer_updated", + "shopinvader_notification_invoice", + "shopinvader_notification_new_customer_welcome", + "shopinvader_notification_sale", + "shopinvader_partner_1", + "shopinvader_partner_2", + "shopinvader_partner_binding_act_window", + "shopinvader_partner_binding_form_view", + "shopinvader_partner_comp_rule", + "shopinvader_partner_view_form", + "shopinvader_partner_view_search", + "shopinvader_partner_view_tree", + "tax_1", + "tax_2", + "tax_3", + "view_order_tree", + "view_quotation_tree", + "view_res_partner_filter", + "view_sales_order_filter", + "view_shop_order_tree", + "view_shop_quotation_tree", +) + + +def pre_init_hook(cr): + cr.execute( + """UPDATE ir_model_data + SET module='shopinvader_restapi' + WHERE name in %s + AND module='shopinvader'""", + (RESTAPI_XML_IDS,), + ) diff --git a/shopinvader_restapi/i18n/shopinvader.pot b/shopinvader_restapi/i18n/shopinvader.pot new file mode 100644 index 0000000000..1fb32abdcb --- /dev/null +++ b/shopinvader_restapi/i18n/shopinvader.pot @@ -0,0 +1,4721 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopinvader +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_address_created_notification +msgid " A new address has been added to your account" +msgstr "" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_cart_notification +msgid " Thanks for your card " +msgstr "" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_sale_notification +msgid " We are processing your sale " +msgstr "" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_new_customer_welcome_notification +msgid " Welcome " +msgstr "" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_address_updated_notification +msgid " Your address has been modified" +msgstr "" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_invoice_notification +msgid " Your invoice have been generated " +msgstr "" + +#. module: shopinvader +#: model:mail.template,body_html:shopinvader.email_customer_updated_notification +msgid " Your profile has been modified" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__meeting_count +msgid "# Meetings" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__product_variant_count +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_variant_count +msgid "# Product Variants" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__product_count +msgid "# Products" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__wishlists_count +msgid "# Wishlists" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "- 0: none" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "- 1: parent_category1" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "- 2: parent_category1/child_category2" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "- 3: parent_category1/child_category2/child_category3" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Allowed countries in delivery addresses" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Available languages in the website." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Levels of categories to automatically bind such as:" +msgstr "" + +#. module: shopinvader +#: model:ir.model.constraint,message:shopinvader.constraint_shopinvader_category_record_uniq +msgid "A category can only have one binding by backend." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__description_sale +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__description_sale +msgid "" +"A description of the Product that you want to communicate to your customers." +" This description will be copied to every Sales Order, Delivery Order and " +"Customer Invoice/Credit Note" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__associate_member +msgid "" +"A member with whom you want to associate your membership.It will consider " +"the membership state of the associated member." +msgstr "" + +#. module: shopinvader +#: model:ir.model.constraint,message:shopinvader.constraint_shopinvader_partner_record_uniq +msgid "A partner can only have one binding by backend." +msgstr "" + +#. module: shopinvader +#: model:ir.model.constraint,message:shopinvader.constraint_shopinvader_product_record_uniq +msgid "A product can only have one binding by backend and lang." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__type +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__type +msgid "" +"A storable product is a product for which you manage stock. The Inventory app has to be installed.\n" +"A consumable product is a product for which stock is not managed.\n" +"A service is a non-material product you provide." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_account_payable_id +msgid "Account Payable" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_account_receivable_id +msgid "Account Receivable" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_needaction +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_needaction +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__clear_cart_options +msgid "" +"Action to execute on the cart when the front want to clear the current cart:\n" +"- Delete: delete the cart (and items);\n" +"- Clear: keep the cart but remove items;\n" +"- Cancel: The cart is canceled but kept into the database.\n" +"When a quotation is not validated, habitually it's not removed but cancelled. It could be also useful if you want to keep cart for statistics reasons. A new cart is created automatically when the customer will add a new item." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_category__active +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__active +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__active +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__active +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__active +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Active" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__active_lang_count +msgid "Active Lang Count" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_ids +msgid "Activities" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_exception_decoration +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_exception_decoration +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_state +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_state +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_state +msgid "Activity State" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_type_icon +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_type_icon +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__additional_info +msgid "Additional info" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__res_partner__address_type__address +msgid "Address" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__type +msgid "Address Type" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Address created" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Address updated" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_create__address +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_update__address +msgid "Addresses only" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__alert_time +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__alert_time +msgid "Alert Time" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__lang +msgid "" +"All the emails and documents sent to this contact will be translated in this" +" language." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__allowed_country_ids +msgid "Allowed Country" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_tv_cbinet_ameriwood +#: model:product.template,name:shopinvader.product_product_tv_cbinet_ameriwood_product_template +msgid "Ameriwood Home 1783213COM - Meuble TV" +msgstr "" + +#. module: shopinvader +#: model:ir.model.constraint,message:shopinvader.constraint_shopinvader_partner_email_uniq +msgid "An email must be uniq per backend." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__account_analytic_id +msgid "Analytic account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__anonymous_partner_id +msgid "Anonymous Partner" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_31 +msgid "Appliances" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_binding_form_view +msgid "Apply" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__associate_member +msgid "Associate Member" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_attachment_count +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_attachment_count +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__variant_attribute_id +msgid "Attribute" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__attribute_set_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__attribute_set_id +msgid "Attribute Set" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__attribute_value_ids +msgid "Attribute Value" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_template_attribute_value_ids +msgid "Attribute Values" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__automatic_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__automatic_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__automatic_url_key +msgid "Automatic Url Key" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__available_in_pos +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__available_in_pos +msgid "Available in POS" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__partner_industry_ids +msgid "Available partner industries" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__partner_title_ids +msgid "Available partner titles" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__shopinvader_backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__backend_id +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_view_search +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_search +msgid "Backend" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_product_template_form +msgid "Backends" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__bank_account_count +msgid "Bank" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__bank_ids +msgid "Banks" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__barcode +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__barcode +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__barcode +msgid "Barcode" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__based_on +msgid "Based On" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__use_time +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__use_time +msgid "Best Before Time" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__bind +msgid "Bind" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_binding_wizard_form_view +msgid "Bind Categories" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Bind Category" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_binding_wizard_form_view +msgid "Bind Products" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Bind all category" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Bind all product" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_product_template_form +msgid "Bindings" +msgstr "" + +#. module: shopinvader +#: model:product.template.attribute.value,name:shopinvader.product_template_chair_mid_century_attribute_color_blue +#: model:product.template.attribute.value,name:shopinvader.product_template_thelma_attribute_color_red +msgid "Black" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__is_blacklisted +msgid "Blacklist" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__mobile_blacklisted +msgid "Blacklisted Phone Is Mobile" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__phone_blacklisted +msgid "Blacklisted Phone is Phone" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_bounce +msgid "Bounce" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.product_product_form_view +msgid "Bound" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__product_brand_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_brand_id +msgid "Brand" +msgstr "" + +#. module: shopinvader +#: model:product.pricelist,name:shopinvader.pricelist_1 +msgid "Business Pricelist" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__can_image_1024_be_zoomed +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__can_image_1024_be_zoomed +msgid "Can Image 1024 be zoomed" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__can_image_variant_1024_be_zoomed +msgid "Can Variant Image 1024 be zoomed" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__purchase_ok +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__purchase_ok +msgid "Can be Purchased" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sale_ok +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sale_ok +msgid "Can be Sold" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/address.py:0 +#, python-format +msgid "Can not delete the partner account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__sale_order__shopinvader_state__cancel +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__clear_cart_options__cancel +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_binding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_unbinding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_binding_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_binding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_unbinding_wizard_form_view +msgid "Cancel" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__membership_cancel +msgid "Cancel Membership Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__sale_order__typology__cart +#: model:ir.ui.menu,name:shopinvader_restapi.menu_shopinvader_config_cart +msgid "Cart" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Cart Confirmation" +msgstr "" + +#. module: shopinvader +#: model:ir.ui.menu,name:shopinvader.menu_shopinvader_cart_step +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_cart_step_view_search +msgid "Cart Step" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__cart_checkout_address_policy +msgid "Cart address behavior" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Cart ask by email" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Cart configuration" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_cart_notification +msgid "Cart notification ${object.name}" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.action_cart +#: model:ir.ui.menu,name:shopinvader.menu_cart +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Carts" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__product_category_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__shopinvader_category_ids +#: model:ir.ui.menu,name:shopinvader.menu_shopinvader_category +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Categories" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Category" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__category_binding_level +msgid "Category Binding Level" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__category_root_binding_level +msgid "Category Root Binding Level" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__route_from_categ_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__route_from_categ_ids +msgid "Category Routes" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__pos_categ_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__pos_categ_id +msgid "Category used in the Point of Sale." +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_27 +msgid "Chaise" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_24 +msgid "Chambre" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__channel_ids +msgid "Channels" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__is_company +msgid "Check if the contact is a company, otherwise it is a person" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__membership +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__membership +msgid "Check if the product is eligible for membership." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__to_weight +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__to_weight +msgid "" +"Check if the product should be weighted using the hardware scale " +"integration." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__available_in_pos +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__available_in_pos +msgid "Check if you want this product to appear in the Point of Sale." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__employee +msgid "Check this box if this contact is an Employee." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__child_autobinding +msgid "Child Autobinding" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__child_id +msgid "Child Categories" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__city +msgid "City" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__clear_cart_options__clear +msgid "Clear" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__clear_cart_options +msgid "Clear cart" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__code +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Code" +msgstr "" + +#. module: shopinvader +#: model:product.filter,name:shopinvader.product_filter_2 +msgid "Color" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__color +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__color +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__color +msgid "Color Index" +msgstr "" + +#. module: shopinvader +#: model_terms:product.filter,help:shopinvader.product_filter_2 +msgid "Color of the product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__combination_indices +msgid "Combination Indices" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__commercial_partner_id +msgid "Commercial Entity" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_create__company_and_user +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_update__company_and_user +msgid "Companies and simple users" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__ref_company_ids +msgid "Companies that refers to partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_create__all +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_update__all +msgid "Companies, simple users and addresses" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__company_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__company_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__company_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__company_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__company_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__company_id +msgid "Company" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__company_name +msgid "Company Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__commercial_company_name +msgid "Company Name Entity" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__main_mailing_list_id +msgid "Company Newsletter" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__main_mailing_list_subscription_id +msgid "Company Newsletter Subscription" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__main_mailing_list_subscription_date +msgid "Company Newsletter Subscription Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__main_mailing_list_subscription_state +msgid "Company Newsletter Subscription State" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__main_mailing_list_unsubscription_date +msgid "Company Newsletter Unsubscription Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__company_type +msgid "Company Type" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_gid +msgid "Company database ID" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__company_group_id +msgid "Company group" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__company_group_member_ids +msgid "Company group members" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_create__company +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_update__company +msgid "Company users only" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__contact_address +msgid "Complete Address" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__complete_name +msgid "Complete Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: shopinvader +#: model:ir.ui.menu,name:shopinvader_restapi.menu_shopinvader_config +msgid "Configuration" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_res_partner +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__child_ids +msgid "Contact" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_tv_cabinet_concept_design_white +#: model:product.template,name:shopinvader.product_template_tv_cabinet_concept_design +msgid "Convenience Concepts Designs2Go Oslo - meuble TV" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__standard_price +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__standard_price +msgid "Cost" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__cost_currency_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__cost_currency_id +msgid "Cost Currency" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_cost_method +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__cost_method +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__cost_method +msgid "Costing Method" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__payment_token_count +msgid "Count Payment Token" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_bounce +msgid "Counter of the number of bounced emails for this contact" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_stock_account_input_categ_id +msgid "" +"Counterpart journal items for all incoming stock moves will be posted in this account, unless there is a specific valuation account\n" +" set on the source location. This is the default value for all products in this category. It can also directly be set on each product." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Countries" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__country_id +msgid "Country" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__create_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__create_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__credit_limit +msgid "Credit Limit" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_30 +msgid "Cuisine" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__currency_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__currency_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__currency_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__currency_id +msgid "Currency" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__current_step_id +msgid "Current Cart Step" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__membership_state +msgid "Current Membership Status" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__qty_available +msgid "" +"Current quantity of products.\n" +"In a context with a single Stock Location, this includes goods stored at this Location, or any of its children.\n" +"In a context with a single Warehouse, this includes goods stored in the Stock Location of this Warehouse, or any of its children.\n" +"stored in the Stock Location of the Warehouse of this Shop, or any of its children.\n" +"Otherwise, this includes goods stored in any Stock Location with 'internal' type." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__customer_default_role +msgid "Customer Default Role" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sale_delay +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sale_delay +msgid "Customer Lead Time" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_stock_customer +msgid "Customer Location" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_payment_term_id +msgid "Customer Payment Terms" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__customer_rank +msgid "Customer Rank" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__partner_ref +msgid "Customer Ref" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__taxes_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__taxes_id +msgid "Customer Taxes" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Customer configuration" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Customer updated" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__date +msgid "Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__membership_start +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__membership_date_from +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__membership_date_from +msgid "Date from which membership becomes active." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__membership_cancel +msgid "Date on which membership has been cancelled" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__membership_stop +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__membership_date_to +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__membership_date_to +msgid "Date until which membership remains active." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__attribute_set_id +msgid "Default Attribute Set" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_delivery_carrier_id +msgid "Default delivery method used in sales orders." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__supplier_taxes_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__supplier_taxes_id +msgid "Default taxes used when buying the product." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__taxes_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__taxes_id +msgid "Default taxes used when selling the product." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__uom_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__uom_id +msgid "Default unit of measure used for all stock operations." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__uom_po_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__uom_po_id +msgid "" +"Default unit of measure used for purchase orders. It must be in the same " +"category as the default unit of measure." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__stock_state_threshold +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__stock_state_threshold +msgid "" +"Define custom value under which the stock state will pass from 'In Stock' to" +" 'In Limited Stock' State. If not set, Odoo will use the value defined in " +"the product category. If no value is defined in product category, it will " +"use the value defined for the company" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__cart_checkout_address_policy +msgid "" +"Define how the cart address will be handled in the checkout step:\n" +"- No defaults: client will pass shipping and invoicing address together or in separated calls. No automatic value for non passed addresses will be set;\n" +"- Invoice address defaults to shipping: if the client does not pass the invoice address explicitly the shipping one will be used as invoice address as well.\n" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__category_binding_level +msgid "" +"Define if the product binding should also bind related categories and how many related parents.\n" +"Set 0 (or less) to disable the category auto-binding.\n" +"Set 1 to auto-bind the direct category.\n" +"Set 2 to auto-bind the direct category and his parent.\n" +"etc." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__category_root_binding_level +msgid "" +"Define the starting level for root categories when auto-binding.This is " +"typically handy when you want to have some root categories for internal " +"organization only (eg: All / Saleable) but you don't want them to appear in " +"the shop.Works for both 'Bind all products' and 'Bind all categories'" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__seller_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__seller_ids +msgid "Define vendor pricelists." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__trust +msgid "Degree of trust you have in this debtor" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__clear_cart_options__delete +msgid "Delete" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Delivery" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_delivery_carrier_id +msgid "Delivery Method" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__sale_delay +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__sale_delay +msgid "" +"Delivery lead time, in days. It's the number of days, promised to the " +"customer, between the confirmation of the sales order and the delivery." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__route_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__route_ids +msgid "" +"Depending on the modules installed, this will allow you to define the route " +"of the product: whether it will be bought, manufactured, replenished on " +"order, etc." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__description +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_form +msgid "Description" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_form_view +msgid "Description and Seo" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__description_pickingout +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__description_pickingout +msgid "Description on Delivery Orders" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__description_picking +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__description_picking +msgid "Description on Picking" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__description_pickingin +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__description_pickingin +msgid "Description on Receptions" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__sequence +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__sequence +msgid "Determine the display order in the frontend shop" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Developer" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_account_move__display_name +#: model:ir.model.fields,field_description:shopinvader.field_product_category__display_name +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__display_name +#: model:ir.model.fields,field_description:shopinvader.field_product_product__display_name +#: model:ir.model.fields,field_description:shopinvader.field_product_template__display_name +#: model:ir.model.fields,field_description:shopinvader.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__display_name +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__display_name +#: model:ir.model.fields,field_description:shopinvader.field_sale_order_line__display_name +#: model:ir.model.fields,field_description:shopinvader.field_seo_title_mixin__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__display_name +#: model:ir.model.fields,field_description:shopinvader.field_track_external_mixin__display_name +msgid "Display Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__one_invoice_per_order +msgid "Do not group sale order into one invoice." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant_binding_wizard__run_immediately +msgid "Do not schedule jobs." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_unbinding_wizard_form_view +msgid "Do you really want to unbind these categories ?" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_unbinding_wizard_form_view +msgid "Do you really want to unbind these products ?" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__done_step_ids +msgid "Done Cart Step" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_partner.py:0 +#, python-format +msgid "Edit %s" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.res_partner_view_form +msgid "Edit in form" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__partner_share +msgid "" +"Either customer (not a user), either shared user. Indicated the current " +"partner is a customer without access or with a limited access created for " +"sharing data." +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_226 +msgid "Electronique et informatique" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__email +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__email +msgid "Email" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/res_partner.py:0 +#, python-format +msgid "Email must be unique: The following mails are not unique: %s" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__employee +msgid "Employee" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_product_filter__path +msgid "" +"Enforce external filter key used for indexing and search.Being a path, you " +"can specify a dotted path to an inner value. Eg: supplier = {id: 1, name: " +"'Foo'} -> `supplier.name`" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__tracking +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__tracking +msgid "Ensure the traceability of a storable product in your warehouse." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_account_expense_categ_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__property_account_expense_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__property_account_expense_id +msgid "Expense Account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__expense_policy +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__expense_policy +msgid "" +"Expenses and vendor bills can be re-invoiced to a customer.With this option," +" a validated expense can be re-invoice to a customer at its cost or sales " +"price." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__use_expiration_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__use_expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__expiration_time +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__expiration_time +msgid "Expiration Time" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__external_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__external_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__external_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__external_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__external_id +msgid "External ID" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__categ_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__categ_ids +msgid "Extra Categories" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__field_id +#: model:ir.model.fields.selection,name:shopinvader.selection__product_filter__based_on__field +msgid "Field" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__phone_sanitized +msgid "" +"Field used to store sanitized phone number. Helps speeding up searches and " +"comparisons." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_category__filter_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__filter_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__filter_ids +#: model_terms:ir.ui.view,arch_db:shopinvader.product_category_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.product_filter_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.product_filter_view_tree +msgid "Filter" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Filters" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__firstname +msgid "First name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_account_position_id +msgid "Fiscal Position" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_follower_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_follower_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_channel_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_channel_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_partner_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_partner_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__activity_type_icon +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__activity_type_icon +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__removal_strategy_id +msgid "Force Removal Strategy" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__virtual_available +msgid "Forecast Quantity" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__virtual_available +msgid "" +"Forecast quantity (computed as Quantity On Hand - Outgoing + Incoming)\n" +"In a context with a single Stock Location, this includes goods stored in this location, or any of its children.\n" +"In a context with a single Warehouse, this includes goods stored in the Stock Location of this Warehouse, or any of its children.\n" +"Otherwise, this includes goods stored in any Stock Location with 'internal' type." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__free_qty +msgid "" +"Forecast quantity (computed as Quantity On Hand - reserved quantity)\n" +"In a context with a single Stock Location, this includes goods stored in this location, or any of its children.\n" +"In a context with a single Warehouse, this includes goods stored in the Stock Location of this Warehouse, or any of its children.\n" +"Otherwise, this includes goods stored in any Stock Location with 'internal' type." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__virtual_available +msgid "Forecasted Quantity" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__email_formatted +msgid "Format email address \"Name \"" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__email_formatted +msgid "Formatted Email" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__free_member +msgid "Free Member" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__free_qty +msgid "Free To Use Quantity " +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__frontend_data_source +msgid "Frontend Data Source" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__full_name +msgid "Full Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_latitude +msgid "Geo Latitude" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_longitude +msgid "Geo Longitude" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__invoice_access_open +msgid "Give customer access to open invoices as well as the paid ones." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__packaging_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__packaging_ids +msgid "Gives the different ways to package the same product." +msgstr "" + +#. module: shopinvader +#: model:product.attribute.value,name:shopinvader.product_attribute_value_color_grey +#: model:product.template.attribute.value,name:shopinvader.product_template_armchair_mid_century_attribute_color_grey +#: model:product.template.attribute.value,name:shopinvader.product_template_thelma_attribute_color_grey +#: model:product.template.attribute.value,name:shopinvader.product_template_tv_cabinet_shaker_wood_attribute_color_grey +msgid "Grey" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_view_search +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_search +msgid "Group By" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__hs_code +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__hs_code +msgid "HS Code" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__has_shopinvader_user +#: model:ir.model.fields,field_description:shopinvader.field_res_users__has_shopinvader_user +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__has_shopinvader_user +msgid "Has Shopinvader User" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__has_unreconciled_entries +msgid "Has Unreconciled Entries" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__help +#: model_terms:ir.ui.view,arch_db:shopinvader.product_filter_view_form +msgid "Help" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_account_move__id +#: model:ir.model.fields,field_description:shopinvader.field_product_category__id +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__id +#: model:ir.model.fields,field_description:shopinvader.field_product_product__id +#: model:ir.model.fields,field_description:shopinvader.field_product_template__id +#: model:ir.model.fields,field_description:shopinvader.field_res_config_settings__id +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__id +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__id +#: model:ir.model.fields,field_description:shopinvader.field_sale_order_line__id +#: model:ir.model.fields,field_description:shopinvader.field_seo_title_mixin__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__id +#: model:ir.model.fields,field_description:shopinvader.field_track_external_mixin__id +msgid "ID" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__im_status +msgid "IM Status" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_exception_icon +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_exception_icon +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__activity_exception_icon +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__activity_exception_icon +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_needaction +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_unread +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_needaction +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_unread +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_needaction +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_has_error +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_has_sms_error +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_has_error +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_has_sms_error +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_has_error +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__use_shopinvader_product_name +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__use_shopinvader_product_name +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__use_shopinvader_product_name +msgid "" +"If checked, use the specific shopinvader display name for products instead " +"of the original product name." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.res_config_settings_view_form +msgid "" +"If checked, when a binding is created for a backend, we first\n" +" try to find a partner with the same email and if found we link\n" +" the new binding to the first partner found. Otherwise we always\n" +" create a new partner." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_res_config_settings__shopinvader_no_partner_duplicate +msgid "" +"If checked, when a binding is created for a backend, we first try to find a " +"partner with the same email and if found we link the new binding to the " +"first partner found. Otherwise we always create a new partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__team_id +msgid "" +"If set, this Sales Team will be used for sales and assignments related to " +"this partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__is_blacklisted +msgid "" +"If the email address is on the blacklist, the contact won't receive mass " +"mailing anymore, from any list" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__phone_sanitized_blacklisted +msgid "" +"If the sanitized phone number is on the blacklist, the contact won't receive" +" mass mailing sms anymore, from any list" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category_binding_wizard__child_autobinding +msgid "" +"If this option is check, the childs of selected categories will be " +"automatically binded" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_seo_title_mixin__seo_title +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__seo_title +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__seo_title +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__seo_title +msgid "" +"If you specify a custom value and you want to rollback to the default value," +" just let the field blank." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__image_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__image_1920 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_1920 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_1920 +msgid "Image" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__image_1024 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_1024 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_1024 +msgid "Image 1024" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__image_128 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_128 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_128 +msgid "Image 128" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__image_256 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_256 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_256 +msgid "Image 256" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__image_512 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_512 +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_512 +msgid "Image 512" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_ids +msgid "Images" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__standard_price +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__standard_price +msgid "" +"In Standard Price & AVCO: value of the product (automatically computed in AVCO).\n" +" In FIFO: value of the next unit that will leave the stock (automatically computed).\n" +" Used to value the product when the purchase cost is not known (e.g. inventory adjustment).\n" +" Used to compute margins on sale orders." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Inactive" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_account_income_categ_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__property_account_income_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__property_account_income_id +msgid "Income Account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__incoming_qty +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__incoming_qty +msgid "Incoming" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__mobile_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a mobile number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__phone_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a phone number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__industry_id +msgid "Industry" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Internal Notification" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__default_code +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__default_code +msgid "Internal Reference" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__barcode +msgid "International Article Number used for product identification." +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/service.py:0 +#, python-format +msgid "Invalid scope %s, error: %s" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/cart.py:0 +#, python-format +msgid "Invalid step code %s" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__property_stock_inventory +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__property_stock_inventory +msgid "Inventory Location" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_valuation +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__valuation +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__valuation +msgid "Inventory Valuation" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__invoice_warn +msgid "Invoice" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__type +msgid "" +"Invoice & Delivery addresses are used in sales orders. Private addresses are" +" only visible by authorized users." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__invoice_settings +msgid "Invoice Settings" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Invoice Validated" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_invoice_id +msgid "Invoice address" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__cart_checkout_address_policy__invoice_defaults_to_shipping +msgid "Invoice address defaults to shipping" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_invoice_notification +msgid "Invoice notification ${object.number}" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Invoice report" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Invoice send email" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Invoice visibility" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__invoice_ids +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Invoices" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__invoicing_mode +msgid "Invoicing Mode" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__invoice_policy +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__invoice_policy +msgid "Invoicing Policy" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_is_follower +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_is_follower +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__is_product_variant +msgid "Is Product Variant" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__is_shopinvader_active +msgid "Is Shopinvader Active" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__is_company +msgid "Is a Company" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__has_configurable_attributes +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__has_configurable_attributes +msgid "Is a configurable product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__is_product_variant +msgid "Is a product variant" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__membership_state +msgid "" +"It indicates the membership state.\n" +"-Non Member: A partner who has not applied for any membership.\n" +"-Cancelled Member: A member who has cancelled his membership.\n" +"-Old Member: A member whose membership date has expired.\n" +"-Waiting Member: A member who has applied for the membership and whose invoice is going to be created.\n" +"-Invoiced Member: A member whose invoice has been created.\n" +"-Paying member: A member who has paid the membership fee." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__function +msgid "Job Position" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__journal_item_count +msgid "Journal Items" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__property_account_income_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__property_account_income_id +msgid "" +"Keep this field empty to use the default value from the product category." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__property_account_expense_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__property_account_expense_id +msgid "" +"Keep this field empty to use the default value from the product category. If" +" anglo-saxon accounting with automated valuation method is configured, the " +"expense account on the product category will be used." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__lang_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__lang_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__lang_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__lang_id +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Lang" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__lang_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__lang_ids +msgid "Langs" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__lang +msgid "Language" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Languages" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__last_external_update_date +#: model:ir.model.fields,field_description:shopinvader.field_track_external_mixin__last_external_update_date +msgid "Last External Update Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_account_move____last_update +#: model:ir.model.fields,field_description:shopinvader.field_product_category____last_update +#: model:ir.model.fields,field_description:shopinvader.field_product_filter____last_update +#: model:ir.model.fields,field_description:shopinvader.field_product_product____last_update +#: model:ir.model.fields,field_description:shopinvader.field_product_template____last_update +#: model:ir.model.fields,field_description:shopinvader.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:shopinvader.field_res_partner____last_update +#: model:ir.model.fields,field_description:shopinvader.field_sale_order____last_update +#: model:ir.model.fields,field_description:shopinvader.field_sale_order_line____last_update +#: model:ir.model.fields,field_description:shopinvader.field_seo_title_mixin____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard____last_update +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard____last_update +#: model:ir.model.fields,field_description:shopinvader.field_track_external_mixin____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__write_uid +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_unbinding_wizard__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__write_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__lastname +msgid "Last name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__calendar_last_notif_ack +msgid "Last notification marked as read from base Calendar" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__sync_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__sync_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__sync_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sync_date +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sync_date +msgid "Last synchronization date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__last_time_entries_checked +msgid "" +"Last time the invoices & payments matching was performed for this partner. " +"It is set either if there's not at least an unreconciled debit and an " +"unreconciled credit or if you click the \"Done\" button." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__last_time_entries_checked +msgid "Latest Invoices & Payments Matching Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__level +msgid "Level" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__binding_lines +msgid "Lines" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category_binding_wizard__lang_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant_binding_wizard__lang_ids +msgid "" +"List of langs for which a binding must exists. If not specified, the list of" +" langs defined on the backend is used." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Localization" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__location_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__location_id +msgid "Location" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_backend +msgid "Locomotive CMS Backend" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__template_id +msgid "Mail Template" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__main +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_form_view +msgid "Main" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_main_attachment_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_main_attachment_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__main_image_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__main_image_id +msgid "Main Image" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_medium_url +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_medium_url +msgid "Main medium image URL" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__image_small_url +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_small_url +msgid "Main small image URL" +msgstr "" + +#. module: shopinvader +#: model:res.groups,name:shopinvader_restapi.group_shopinvader_manager +msgid "Manager" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_seo_title_mixin__manual_seo_title +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__manual_seo_title +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__manual_seo_title +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__manual_seo_title +msgid "Manual SEO Title" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__manual_stock_state_threshold +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__manual_stock_state_threshold +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__manual_stock_state_threshold +msgid "Manual Stock State Threshold" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__manual_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__manual_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__manual_url_key +msgid "Manual Url Key" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_valuation +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__valuation +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__valuation +msgid "" +"Manual: The accounting entries to value the inventory are not posted automatically.\n" +" Automated: An accounting entry is automatically created to value the inventory when a product enters or leaves the company.\n" +" " +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__service_type +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__service_type +msgid "" +"Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n" +"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n" +"Create a task and track hours: Create a task on the sales order validation and track the work hours." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__manufactured_for_partner_ids +msgid "Manufactured for" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__mailing_contact_id +msgid "Mass Mailing Contact" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__media_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__media_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__media_ids +msgid "Media" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__meeting_ids +msgid "Meetings" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__member_lines +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__membership +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__membership +msgid "Membership" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__membership_amount +msgid "Membership Amount" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__membership_stop +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__membership_date_to +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__membership_date_to +msgid "Membership End Date" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__membership_start +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__membership_date_from +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__membership_date_from +msgid "Membership Start Date" +msgstr "" + +#. module: shopinvader +#: model:product.filter,name:shopinvader.product_filter_1 +msgid "Memory" +msgstr "" + +#. module: shopinvader +#: model_terms:product.filter,help:shopinvader.product_filter_1 +msgid "Memory of the product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_has_error +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_has_error +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__invoice_warn_msg +msgid "Message for Invoice" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__sale_warn_msg +msgid "Message for Sales Order" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sale_line_warn_msg +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sale_line_warn_msg +msgid "Message for Sales Order Line" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__picking_warn_msg +msgid "Message for Stock Picking" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_ids +msgid "Messages" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__meta_description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__meta_description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__meta_description +msgid "Meta Description" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__meta_keywords +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__meta_keywords +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__meta_keywords +msgid "Meta Keywords" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_21 +msgid "Meuble" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_29 +msgid "Meuble TV" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_tv_cabinet_cmcharper_brown +#: model:product.template,name:shopinvader.product_template_tv_cabinet_cmcharper +msgid "Meuble TV 40 cmHARPER" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_tv_cabinet_shaker_wood_brown +#: model:product.product,name:shopinvader.product_product_tv_cabinet_shaker_wood_grey +#: model:product.template,name:shopinvader.product_template_tv_cabinet_shaker_wood +msgid "Meuble TV Warm Shaker bois massif 183cm" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_chair_mid_century_black +#: model:product.product,name:shopinvader.product_product_chair_mid_century_blue +#: model:product.product,name:shopinvader.product_product_chair_mid_century_red +#: model:product.product,name:shopinvader.product_product_chair_mid_century_white +#: model:product.template,name:shopinvader.product_template_chair_mid_century +msgid "Mid Century Modern Eames Chair with Molded Arms and Wood Legs" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__min_sellable_qty +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__min_sellable_qty +msgid "Min Sellable Qty" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__orderpoint_ids +msgid "Minimum Stock Rules" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__min_sellable_qty +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__min_sellable_qty +msgid "" +"Minimum sellable quantity, according to the available packagings, if Only " +"Sell by Packaging is set." +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/cart.py:0 +#, python-format +msgid "Missing feature to clear the cart!" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__mobile +msgid "Mobile" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__model_id +msgid "Model" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__my_activity_date_deadline +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__my_activity_date_deadline +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_category__name +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_cart_step__name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__name +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__shopinvader_name +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__shopinvader_name +msgid "Name for shopinvader, if not set the product name will be used." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__sequence_id +msgid "Naming policy for orders and carts" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__nbr_cart +msgid "Nbr Cart" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__nbr_category +msgid "Nbr Category" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__nbr_sale +msgid "Nbr Sale" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__nbr_variant +msgid "Nbr Variant" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "New customer Welcome" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_date_deadline +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_date_deadline +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_summary +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_summary +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_type_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_type_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/abstract_download.py:0 +#, python-format +msgid "No content found for %s" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__cart_checkout_address_policy__no_defaults +msgid "No defaults" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_res_config_settings__shopinvader_no_partner_duplicate +msgid "No partner duplicate" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_create__ +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_update__ +msgid "None" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__email_normalized +msgid "Normalized Email" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__comment +msgid "Notes" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__notification_ids +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Notification" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_address_created_notification +msgid "Notification ${object.name} - Address created" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_address_updated_notification +msgid "Notification ${object.name} - Address modified" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_customer_updated_notification +msgid "Notification ${object.name} - Customer modified" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_notification__notification_type +msgid "Notification Type" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Notifications" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_backend.py:0 +#, python-format +msgid "Notify %s for %s,%s" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_needaction_counter +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_needaction_counter +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__nbr_product +msgid "Number of bound products" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__expiration_time +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__expiration_time +msgid "" +"Number of days after the receipt of the products (from the vendor or in " +"stock after production) after which the goods may become dangerous and must " +"not be consumed. It will be computed on the lot/serial number." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__alert_time +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__alert_time +msgid "" +"Number of days before the Expiration Date after which an alert should be " +"raised on the lot/serial number. It will be computed on the lot/serial " +"number." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__removal_time +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__removal_time +msgid "" +"Number of days before the Expiration Date after which the goods should be " +"removed from the stock. It will be computed on the lot/serial number." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__use_time +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__use_time +msgid "" +"Number of days before the Expiration Date after which the goods starts " +"deteriorating, without being dangerous yet. It will be computed on the " +"lot/serial number." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_has_error_counter +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_has_error_counter +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_needaction_counter +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_needaction_counter +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_has_error_counter +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_has_error_counter +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__pricelist_item_count +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__pricelist_item_count +msgid "Number of price rules" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__message_unread_counter +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__message_unread_counter +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__one_invoice_per_order +msgid "One invoice per order" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__invoice_linked_to_sale_only +msgid "Only sale invoices" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sell_only_by_packaging +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sell_only_by_packaging +msgid "Only sell by packaging" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__invoice_linked_to_sale_only +msgid "Only serve invoices that are linked to a sale order." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__invoice_access_open +msgid "Open invoices" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__opportunity_ids +msgid "Opportunities" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__opportunity_count +msgid "Opportunity" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__opt_in +#: model:ir.model.fields,field_description:shopinvader.field_res_users__opt_in +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__opt_in +msgid "Opt In" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_product_template_form +msgid "Options" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__invoice_policy +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__invoice_policy +msgid "" +"Ordered Quantity: Invoice quantities ordered by the customer.\n" +"Delivered Quantity: Invoice quantities delivered to the customer." +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.action_sale +#: model:ir.ui.menu,name:shopinvader.menu_sale +#: model:ir.ui.menu,name:shopinvader_restapi.menu_shopinvader_orders +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Orders" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/sale.py:0 +#, python-format +msgid "Orders that have been delivered or invoiced cannot be edited." +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_227 +msgid "Ordinateur, Tablettes & Accessoires" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__outgoing_qty +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__outgoing_qty +msgid "Outgoing" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_228 +msgid "PC Portable" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__packaging_contained_mapping +msgid "Packaging Contained Mapping" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_view_search +msgid "Parent" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__parent_id +msgid "Parent Category" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__parent_has_shopinvader_user +#: model:ir.model.fields,field_description:shopinvader.field_res_users__parent_has_shopinvader_user +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__parent_has_shopinvader_user +msgid "Parent Has Shopinvader User" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__parent_path +msgid "Parent Path" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__parent_name +msgid "Parent name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__record_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__partner_id +msgid "Partner" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/customer.py:0 +#, python-format +msgid "Partner %s is not a customer" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__contract_ids +msgid "Partner Contracts" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_email +msgid "Partner Email" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__same_vat_partner_id +msgid "Partner with same Tax ID" +msgstr "" + +#. module: shopinvader +#: model:ir.ui.menu,name:shopinvader_restapi.menu_shopinvader_config_partners +#: model:ir.ui.menu,name:shopinvader.menu_shopinvader_partner +msgid "Partners" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__path +msgid "Path" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__debit_limit +msgid "Payable Limit" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_payment_method_id +msgid "Payment Method" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__payment_token_ids +msgid "Payment Tokens" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__sale_order__shopinvader_state__pending +msgid "Pending" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__phone +msgid "Phone" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__phone_sanitized_blacklisted +msgid "Phone Blacklisted" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__pos_categ_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__pos_categ_id +msgid "Point of Sale Category" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__pos_order_ids +msgid "Pos Order" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__pos_order_count +msgid "Pos Order Count" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_payment_method_id +msgid "" +"Preferred payment method when paying this vendor. This is used to filter " +"vendor bills by preferred payment method to register payments in mass. Use " +"cases: create bank files for batch wires, check runs." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__price +msgid "Price" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__list_price +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__lst_price +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__list_price +msgid "Price at which the product is sold to customers." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__pricelist_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_product_pricelist +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__pricelist_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__pricelist_id +msgid "Pricelist" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__sale_order__shopinvader_state__processing +msgid "Processing" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__x_processor +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__x_processor +msgid "Processor" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_product_product +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__product_variant_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_variant_id +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_kanban_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_kanban_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_tree_view +msgid "Product" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/cart.py:0 +#, python-format +msgid "Product %s is not allowed" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__attribute_line_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__attribute_line_ids +msgid "Product Attributes" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_product_category +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__categ_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__categ_id +msgid "Product Category" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_product_filter +msgid "Product Filter" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__product_template_link_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_template_link_ids +msgid "Product Links" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__product_template_link_count +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_template_link_count +msgid "Product Links Count" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__packaging_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__packaging_ids +msgid "Product Packages" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_product_template +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_tmpl_id +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +msgid "Product Template" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__type +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__type +msgid "Product Type" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_variant_link_ids +msgid "Product Variant Links" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.product_filter_action +#: model:ir.ui.menu,name:shopinvader.menu_product_filter_action +msgid "Product filter" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/product_filter.py:0 +#, python-format +msgid "Product filter ID=%d is based on field: requires a field!" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/product_filter.py:0 +#, python-format +msgid "" +"Product filter ID=%d is based on variant attribute: requires an attribute!" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__seasonal_config_id +msgid "Product seasonal configuration" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__tmpl_record_id +msgid "Product template" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__property_stock_production +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__property_stock_production +msgid "Production Location" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__product_variant_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_variant_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__product_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_unbinding_wizard__shopinvader_variant_ids +#: model:ir.ui.menu,name:shopinvader_restapi.menu_shopinvader_config_products +#: model:ir.ui.menu,name:shopinvader.menu_shopinvader_products +#: model:ir.ui.menu,name:shopinvader.shopinvader_product_menu +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Products" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__res_partner__address_type__profile +msgid "Profile" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__anonymous_partner_id +msgid "Provide partner settings for unlogged users (i.e. fiscal position)" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__lst_price +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__lst_price +msgid "Public Price" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__website_public_name +msgid "" +"Public name of your backend/website.\n" +" Used for products name referencing." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__description_purchase +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__description_purchase +msgid "Purchase Description" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__uom_po_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__uom_po_id +msgid "Purchase Unit of Measure" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__putaway_rule_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__putaway_rule_ids +msgid "Putaway Rules" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__qty_available +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__qty_available +msgid "Quantity On Hand" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__quantity_svl +msgid "Quantity Svl" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__incoming_qty +msgid "" +"Quantity of planned incoming products.\n" +"In a context with a single Stock Location, this includes goods arriving to this Location, or any of its children.\n" +"In a context with a single Warehouse, this includes goods arriving to the Stock Location of this Warehouse, or any of its children.\n" +"Otherwise, this includes goods arriving to any Stock Location with 'internal' type." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__outgoing_qty +msgid "" +"Quantity of planned outgoing products.\n" +"In a context with a single Stock Location, this includes goods leaving this Location, or any of its children.\n" +"In a context with a single Warehouse, this includes goods leaving the Stock Location of this Warehouse, or any of its children.\n" +"Otherwise, this includes goods leaving any Stock Location with 'internal' type." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__visible_qty_configurator +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__visible_qty_configurator +msgid "Quantity visible in configurator" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_26 +msgid "Rangement" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__expense_policy +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__expense_policy +msgid "Re-Invoice Expenses" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__visible_expense_policy +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__visible_expense_policy +msgid "Re-Invoice Policy visible" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__record_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__record_id +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Record" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_form +msgid "Redirect Url" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_binding__redirect_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__redirect_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__redirect_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__redirect_url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__redirect_url_key +msgid "Redirect Url Keys" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__redirect_url_url_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__redirect_url_url_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__redirect_url_url_ids +msgid "Redirect Url Url" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__ref +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__code +msgid "Reference" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__parent_id +msgid "Related Company" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__record_id +msgid "Related Product" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Related records" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__removal_time +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__removal_time +msgid "Removal Time" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__reordering_max_qty +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__reordering_max_qty +msgid "Reordering Max Qty" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__reordering_min_qty +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__reordering_min_qty +msgid "Reordering Min Qty" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__nbr_reordering_rules +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__nbr_reordering_rules +msgid "Reordering Rules" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__responsible_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__responsible_id +msgid "Responsible" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__activity_user_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__activity_user_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__sell_only_by_packaging +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__sell_only_by_packaging +msgid "" +"Restrict the usage of this product on sale order lines without packaging " +"defined" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__role +msgid "Role" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__route_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__route_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__route_ids +msgid "Routes" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__has_available_route_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__has_available_route_ids +msgid "Routes can be selected on this product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__run_immediately +msgid "Run Immediately" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_form +msgid "SEO" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_seo_title_mixin__seo_title +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__seo_title +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__seo_title +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__seo_title +msgid "SEO Title" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_seo_title_mixin +msgid "SEO Title Mixin" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_has_sms_error +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_has_sms_error +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_tv_stand_wood_and_glass_white +#: model:product.template,name:shopinvader.product_template_tv_stand_wood_and_glass +msgid "ST-160B Wood and Glass TV Stand with Hidden Wheels for Sizes up to 70\"" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__sale_order__typology__sale +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Sale" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Sale Confirmation" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__sale_order_count +msgid "Sale Order Count" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__sale_settings +msgid "Sale Settings" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_notification.py:0 +#, python-format +msgid "Sale ask by email" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Sale configuration" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_sale_notification +msgid "Sale notification ${object.name}" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__description_sale +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__description_sale +msgid "Sales Description" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_sale_order +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__sale_order_ids +msgid "Sales Order" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_sale_order_line +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sale_line_warn +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sale_line_warn +msgid "Sales Order Line" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__list_price +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__list_price +msgid "Sales Price" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__team_id +msgid "Sales Team" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__sale_warn +msgid "Sales Warnings" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__salesman_notify_create +msgid "Salesman Notify Create" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__salesman_notify_update +msgid "Salesman Notify Update" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__user_id +msgid "Salesperson" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_25 +msgid "Salle de bain" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_23 +msgid "Salle à manger" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_22 +msgid "Salon" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__phone_sanitized +msgid "Sanitized Number" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_coffee_table_caftman_brown +#: model:product.template,name:shopinvader.product_template_coffee_table_caftman +msgid "Sauder 420011 Coffee Table, Furniture, Craftsman Oak" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_park_entertainment +#: model:product.template,name:shopinvader.product_product_park_entertainment_product_template +msgid "Sauder Harvey Park Entertainment Credenza, chêne clair" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__frontend_data_source__search_engine +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Search engine" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__product_brand_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__product_brand_id +msgid "Select a brand for this product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__invoice_report_id +msgid "" +"Select a specific report for invoice download, if none are selected default " +"shopinvader implementation is used." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__categ_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__categ_id +msgid "Select category for the current product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__free_member +msgid "Select if you want to give free membership." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_binding_form_view +msgid "" +"Select which partners should belong to the Shopinvader backend in the list below.\n" +" The email address of each selected contact must be valid and unique.\n" +" Partners already binded are ignored." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__invoice_warn +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__picking_warn +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__sale_warn +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__sale_line_warn +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__sale_line_warn +msgid "" +"Selecting the \"Warning\" option will notify user with the message, " +"Selecting \"Blocking Message\" will throw an exception with the message and " +"block the flow. The Message has to be written in the next field." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__self +msgid "Self" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__notification_ids +msgid "Send mail for predefined events" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_filter__sequence +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__sequence_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__sequence +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sequence +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sequence +msgid "Sequence" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__removal_strategy_id +msgid "" +"Set a specific removal strategy that will be used regardless of the source " +"location for this product category" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_config_settings_act_window +#: model:ir.ui.menu,name:shopinvader.shopinvader_config_settings_menu +msgid "Settings" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_share +msgid "Share Partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__sale_order__shopinvader_state__shipped +msgid "Shipped" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__partner_delivery_id +msgid "Shipping address" +msgstr "" + +#. module: shopinvader +#: model:mail.activity.type,name:shopinvader.mail_activity_review_customer +msgid "Shop - Validate customer" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__is_shopinvader_active +#: model:ir.model.fields,field_description:shopinvader.field_res_users__is_shopinvader_active +msgid "Shop enabled" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_product_template_form +msgid "ShopInvader" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category_binding_wizard__backend_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant_binding_wizard__backend_id +msgid "ShopInvader Backend" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_product__shopinvader_backend_ids +#: model:ir.model.fields,field_description:shopinvader.field_product_template__shopinvader_backend_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__shopinvader_backend_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_backend_ids +msgid "ShopInvader Backends" +msgstr "" + +#. module: shopinvader +#: model:ir.module.category,name:shopinvader.module_category_shopinvader +#: model:ir.ui.menu,name:shopinvader.menu_shopinvader_root +#: model_terms:ir.ui.view,arch_db:shopinvader.product_category_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.res_partner_view_form +msgid "Shopinvader" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__address_type +#: model:ir.model.fields,field_description:shopinvader.field_res_users__address_type +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__address_type +msgid "Shopinvader Address Type" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_attributes +msgid "Shopinvader Attributes" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_account_bank_statement_line__shopinvader_backend_id +#: model:ir.model.fields,field_description:shopinvader.field_account_move__shopinvader_backend_id +#: model:ir.model.fields,field_description:shopinvader.field_account_payment__shopinvader_backend_id +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_search_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Shopinvader Backend" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_binding +#: model:ir.model.fields,field_description:shopinvader.field_product_category__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_product_product__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_product_template__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_res_partner__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_res_users__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__shopinvader_bind_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_bind_ids +msgid "Shopinvader Binding" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.act_open_shopinvader_cart_step_view +#: model:ir.model,name:shopinvader.model_shopinvader_cart_step +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_cart_step_view_tree +msgid "Shopinvader Cart Step" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_sales_order_filter +msgid "Shopinvader Carts" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__shopinvader_categ_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_categ_ids +msgid "Shopinvader Categories" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.act_open_shopinvader_category_view +#: model:ir.model,name:shopinvader.model_shopinvader_category +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_tree +msgid "Shopinvader Category" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_category_binding_wizard_act_window +msgid "Shopinvader Category Binding Wizard" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_category_unbinding_wizard_act_window +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_unbinding_wizard_form_view +msgid "Shopinvader Category Unbinding Wizard" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__shopinvader_child_ids +msgid "Shopinvader Child" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__shopinvader_display_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_display_name +msgid "Shopinvader Display Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__shopinvader_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_name +msgid "Shopinvader Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_notification +msgid "Shopinvader Notification" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_sales_order_filter +msgid "Shopinvader Orders" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__shopinvader_parent_id +msgid "Shopinvader Parent" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.act_open_shopinvader_partner_view +#: model:ir.model,name:shopinvader.model_shopinvader_partner +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_view_form +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_view_search +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_partner_view_tree +msgid "Shopinvader Partner" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_partner_binding_act_window +msgid "Shopinvader Partner Binding Wizard" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__price +msgid "Shopinvader Price" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_product_act_window +#: model:ir.model,name:shopinvader.model_shopinvader_product +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_product_id +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_search +msgid "Shopinvader Product" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__shopinvader_state +msgid "Shopinvader State" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_variant +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__shopinvader_variant_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__shopinvader_variant_ids +msgid "Shopinvader Variant" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_variant_binding_wizard_act_window +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_binding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_binding_wizard_form_view +msgid "Shopinvader Variant Binding Wizard" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_count +msgid "Shopinvader Variant Count" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_variant_unbinding_wizard_act_window +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_unbinding_wizard_form_view +msgid "Shopinvader Variant Unbinding Wizard" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.shopinvader_variant_act_window +msgid "Shopinvader Variants" +msgstr "" + +#. module: shopinvader +#: model:ir.actions.act_window,name:shopinvader.action_shopinvader_backend +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_backend_view_form +msgid "Shopinvader Website" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding__shopinvader_backend_id +msgid "Shopinvader backend" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_product_product__is_shopinvader_binded +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__is_shopinvader_binded +msgid "Shopinvader binded" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_partner_binding +#: model:res.groups,name:shopinvader.group_shopinvader_partner_binding +msgid "Shopinvader partner binding" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_partner_binding_line +msgid "Shopinvader partner binding line" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.res_partner_view_form +msgid "Shopinvader users" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_res_partner_filter +msgid "Shopinvader: active user" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.view_res_partner_filter +msgid "Shopinvader: addresses" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__short_description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__short_description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__short_description +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.view_shopinvader_category_form +msgid "Short Description" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__short_name +msgid "Short Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__show_on_hand_qty_status_button +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__show_on_hand_qty_status_button +msgid "Show On Hand Qty Status Button" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__signup_expiration +msgid "Signup Expiration" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__signup_token +msgid "Signup Token" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__signup_type +msgid "Signup Token Type" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__signup_valid +msgid "Signup Token is Valid" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__signup_url +msgid "Signup URL" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_create__user +#: model:ir.model.fields.selection,name:shopinvader.selection__shopinvader_backend__salesman_notify_update__user +msgid "Simple users only" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__sales_count +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__sales_count +msgid "Sold" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__invoice_report_id +msgid "Specific report" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_cost_method +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__cost_method +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__cost_method +msgid "" +"Standard Price: The products are valued at their standard cost defined on the product.\n" +" Average Cost (AVCO): The products are valued at weighted average cost.\n" +" First In First Out (FIFO): The products are valued supposing those that enter the company first will also leave it first.\n" +" " +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__hs_code +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__hs_code +msgid "" +"Standardized code for international shipping and goods declaration. At the " +"moment, only used for the FedEx shipping provider." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__state_id +msgid "State" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__activity_state +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__activity_state +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_stock_account_input_categ_id +msgid "Stock Input Account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_stock_journal +msgid "Stock Journal" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__stock_move_ids +msgid "Stock Move" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_stock_account_output_categ_id +msgid "Stock Output Account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__picking_warn +msgid "Stock Picking" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__stock_quant_ids +msgid "Stock Quant" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__stock_state +msgid "Stock State" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__stock_state_threshold +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__stock_state_threshold +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__stock_state_threshold +msgid "Stock State Threshold" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__property_stock_valuation_account_id +msgid "Stock Valuation Account" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__stock_valuation_layer_ids +msgid "Stock Valuation Layer" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__street +msgid "Street" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__street2 +msgid "Street2" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__subtitle +msgid "Subtitle" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__supplier_rank +msgid "Supplier Rank" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_round_table_mid_century_white +#: model:product.template,name:shopinvader.product_template_round_table_mid_century +msgid "Table ronde style mid-century plateau laqué" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_tv_cabinet_mid_century_brown +#: model:product.template,name:shopinvader.product_template_tv_cabinet_mid_century +msgid "Table style Mid-Century Modern Chêne" +msgstr "" + +#. module: shopinvader +#: model:product.category,name:shopinvader.product_category_28 +msgid "Tables" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__category_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__tag_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__tag_ids +msgid "Tags" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__vat +msgid "Tax ID" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__tech_name +msgid "Tech Name" +msgstr "" + +#. module: shopinvader +#: model:ir.ui.menu,name:shopinvader_restapi.menu_shopinvader_config_technical +msgid "Technical" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__x_technical_description +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__x_technical_description +msgid "Technical Description" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__valid_product_template_attribute_line_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__valid_product_template_attribute_line_ids +msgid "Technical compute" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__frontend_data_source +msgid "Technical field to control form fields appeareance" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_product_product__is_shopinvader_binded +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__is_shopinvader_binded +msgid "" +"Technical field to know if this product is related by a(at least one) " +"shopinvader backend" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__packaging_contained_mapping +msgid "Technical field to store contained packaging. " +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__pricelist_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__pricelist_id +msgid "" +"Technical field. Used for searching on pricelists, not stored in database." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__main_mailing_list_id +msgid "Technical field: The company's Newsletter mailing list." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__main_mailing_list_subscription_id +msgid "" +"Technical field: The company's newsletter subscription for this partner." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__stock_move_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__stock_quant_ids +msgid "Technical: used to compute quantities." +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_search_view +msgid "Template" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__vat +msgid "" +"The Tax Identification Number. Complete it if the contact is subjected to " +"government taxes. Used in some legal statements." +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/cart.py:0 +#, python-format +msgid "The cart does not exists or does not belong to you!" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__stock_state_threshold +msgid "" +"The custom value under which the stock state of the products of this " +"category will pass from 'In Stock' to 'In Limited Stock' State. If not set, " +"Odoo will use the threshold defined at the company level." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_account_expense_categ_id +msgid "" +"The expense is accounted for when a vendor bill is validated, except in " +"anglo-saxon accounting with perpetual inventory valuation in which case the " +"expense (Cost of Goods Sold account) is recognized at the customer invoice " +"validation." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_account_position_id +msgid "" +"The fiscal position determines the taxes/accounts used for this contact." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__user_id +msgid "The internal user in charge of this contact." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__mailing_contact_id +msgid "The mailing list contact that matches this partner's email." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__pos_order_count +msgid "The number of point of sales orders related to this customer" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__product_count +msgid "" +"The number of products under this category (Does not consider the children " +"categories)" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__has_unreconciled_entries +msgid "" +"The partner has at least one unreconciled debit and credit since last time " +"the invoices & payments matching was performed." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__membership_amount +msgid "The price negotiated by the partner" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/service.py:0 +#, python-format +msgid "The record %s %s does not exist" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__lst_price +msgid "" +"The sale price is managed from the product template. Click on the 'Configure" +" Variants' button to set the extra attribute prices." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_stock_customer +msgid "" +"The stock location used as destination when sending goods to this contact." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_stock_supplier +msgid "" +"The stock location used as source when receiving goods from this contact." +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/wizards/shopinvader_partner_binding_line.py:0 +#, python-format +msgid "" +"The unbind is not implemented.\n" +"If you want to continue, please delete lines where the bind field is not ticked." +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_thelma_black +#: model:product.product,name:shopinvader.product_product_thelma_grey +#: model:product.product,name:shopinvader.product_product_thelma_red +#: model:product.template,name:shopinvader.product_template_thelma +msgid "Thelma" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_account_payable_id +msgid "" +"This account will be used instead of the default one as the payable account " +"for the current partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_account_receivable_id +msgid "" +"This account will be used instead of the default one as the receivable " +"account for the current partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_account_income_categ_id +msgid "This account will be used when validating a customer invoice." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_res_partner__is_shopinvader_active +#: model:ir.model.fields,help:shopinvader.field_res_users__is_shopinvader_active +msgid "This address is enabled to be used for Shopinvader." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__account_analytic_id +msgid "" +"This analytic account will be used to fill the field on the sale order " +"created." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__email_normalized +msgid "" +"This field is used to search on email address as the primary email field can" +" contain more than strictly an email address." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__website_unique_key +msgid "" +"This identifier may be provided by each REST request through a WEBSITE-" +"UNIQUE-KEY http header to identify the target backend. If not provided by " +"the request and if there is only one backend, it will be used by default. " +"Otherwise it is possible to override the _get_backend method into the " +"service context provider component. The shopinvader_auth_api_key and " +"shopinvader_auth_jwt addons provides a fallback mechanism in such a case." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__price_extra +msgid "This is the sum of the extra price of all attributes" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/sale.py:0 +#, python-format +msgid "This order cannot be cancelled" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_res_partner__parent_has_shopinvader_user +#: model:ir.model.fields,help:shopinvader.field_res_users__parent_has_shopinvader_user +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__parent_has_shopinvader_user +msgid "This partner belongs to Shopinvader user." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_res_partner__has_shopinvader_user +#: model:ir.model.fields,help:shopinvader.field_res_users__has_shopinvader_user +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__has_shopinvader_user +msgid "This partner has at least a Shopinvader user." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_supplier_payment_term_id +msgid "" +"This payment term will be used instead of the default one for purchase " +"orders and vendor bills" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_payment_term_id +msgid "" +"This payment term will be used instead of the default one for sales orders " +"and customer invoices" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__property_product_pricelist +msgid "" +"This pricelist will be used, instead of the default one, for sales to the " +"current partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__property_stock_production +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__property_stock_production +msgid "" +"This stock location will be used, instead of the default one, as the source " +"location for stock moves generated by manufacturing orders." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__property_stock_inventory +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__property_stock_inventory +msgid "" +"This stock location will be used, instead of the default one, as the source " +"location for stock moves generated when you do an inventory." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__responsible_id +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__responsible_id +msgid "" +"This user will be responsible of the next activities related to logistic " +"operations for this product." +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/models/shopinvader_backend.py:0 +#: model:ir.model.constraint,message:shopinvader.constraint_shopinvader_backend_unique_website_unique_key +#, python-format +msgid "This website unique key already exists in database" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner_binding_line__bind +msgid "Tick to bind the partner to the backend. Untick to unbind it." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__tz +msgid "Timezone" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__tz_offset +msgid "Timezone offset" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__title +msgid "Title" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__to_weight +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__to_weight +msgid "To Weigh With Scale" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__total_invoiced +msgid "Total Invoiced" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__debit +msgid "Total Payable" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__credit +msgid "Total Receivable" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__credit +msgid "Total amount this customer owes you." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__debit +msgid "Total amount you have to pay to this vendor." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__total_route_ids +msgid "Total routes" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__service_type +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__service_type +msgid "Track Service" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_track_external_mixin +msgid "Track external update" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__tracking +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__tracking +msgid "Tracking" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__activity_exception_decoration +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__activity_exception_decoration +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_sale_order__typology +msgid "Typology" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_unbinding_wizard_form_view +msgid "Unbind Categories" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_unbinding_wizard_form_view +msgid "Unbind Products" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_backend__tech_name +msgid "Unique name for technical purposes. Eg: server env keys." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__uom_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__uom_id +msgid "Unit of Measure" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__uom_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__uom_name +msgid "Unit of Measure Name" +msgstr "" + +#. module: shopinvader +#: model:product.product,uom_name:shopinvader.product_product_armchair_mid_century_blue +#: model:product.product,uom_name:shopinvader.product_product_armchair_mid_century_grey +#: model:product.product,uom_name:shopinvader.product_product_armchair_mid_century_red +#: model:product.product,uom_name:shopinvader.product_product_armchair_mid_century_yellow +#: model:product.product,uom_name:shopinvader.product_product_chair_mid_century_black +#: model:product.product,uom_name:shopinvader.product_product_chair_mid_century_blue +#: model:product.product,uom_name:shopinvader.product_product_chair_mid_century_red +#: model:product.product,uom_name:shopinvader.product_product_chair_mid_century_white +#: model:product.product,uom_name:shopinvader.product_product_chair_vortex_blue +#: model:product.product,uom_name:shopinvader.product_product_chair_vortex_white +#: model:product.product,uom_name:shopinvader.product_product_coffee_table_caftman_brown +#: model:product.product,uom_name:shopinvader.product_product_park_entertainment +#: model:product.product,uom_name:shopinvader.product_product_round_table_mid_century_white +#: model:product.product,uom_name:shopinvader.product_product_table_walmut +#: model:product.product,uom_name:shopinvader.product_product_thelma_black +#: model:product.product,uom_name:shopinvader.product_product_thelma_grey +#: model:product.product,uom_name:shopinvader.product_product_thelma_red +#: model:product.product,uom_name:shopinvader.product_product_tv_cabinet_cmcharper_brown +#: model:product.product,uom_name:shopinvader.product_product_tv_cabinet_concept_design_white +#: model:product.product,uom_name:shopinvader.product_product_tv_cabinet_mid_century_brown +#: model:product.product,uom_name:shopinvader.product_product_tv_cabinet_shaker_wood_brown +#: model:product.product,uom_name:shopinvader.product_product_tv_cabinet_shaker_wood_grey +#: model:product.product,uom_name:shopinvader.product_product_tv_cbinet_ameriwood +#: model:product.product,uom_name:shopinvader.product_product_tv_stand_wood_and_glass_white +#: model:product.template,uom_name:shopinvader.product_product_park_entertainment_product_template +#: model:product.template,uom_name:shopinvader.product_product_tv_cbinet_ameriwood_product_template +#: model:product.template,uom_name:shopinvader.product_template_armchair_mid_century +#: model:product.template,uom_name:shopinvader.product_template_chair_mid_century +#: model:product.template,uom_name:shopinvader.product_template_chair_vortex +#: model:product.template,uom_name:shopinvader.product_template_coffee_table_caftman +#: model:product.template,uom_name:shopinvader.product_template_round_table_mid_century +#: model:product.template,uom_name:shopinvader.product_template_thelma +#: model:product.template,uom_name:shopinvader.product_template_tv_cabinet_cmcharper +#: model:product.template,uom_name:shopinvader.product_template_tv_cabinet_concept_design +#: model:product.template,uom_name:shopinvader.product_template_tv_cabinet_mid_century +#: model:product.template,uom_name:shopinvader.product_template_tv_cabinet_shaker_wood +#: model:product.template,uom_name:shopinvader.product_template_tv_stand_wood_and_glass +msgid "Units" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_unread +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_unread +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__message_unread_counter +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__message_unread_counter +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_form_view +msgid "Url" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__url_builder +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__url_builder +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__url_builder +msgid "Url Builder" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__url_url_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__url_url_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__url_url_ids +msgid "Url Url" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__url_key +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__url_key +msgid "Url key" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__use_shopinvader_product_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__use_shopinvader_product_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__use_shopinvader_product_name +msgid "Use Shopinvader product display name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__barcode +msgid "Use a barcode to identify this contact." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__user_ids +msgid "Users" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__currency_id +msgid "Utility field to express amount currency" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__valid_product_template_attribute_line_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__valid_product_template_attribute_line_ids +msgid "Valid Product Attribute Lines" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__value_svl +msgid "Value Svl" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_product_form_view +msgid "Variant" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields.selection,name:shopinvader.selection__product_filter__based_on__variant_attribute +msgid "Variant Attribute" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_variant_1920 +msgid "Variant Image" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_variant_1024 +msgid "Variant Image 1024" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_variant_128 +msgid "Variant Image 128" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_variant_256 +msgid "Variant Image 256" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__image_variant_512 +msgid "Variant Image 512" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_image_ids +msgid "Variant Images" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_media_ids +msgid "Variant Media" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__price_extra +msgid "Variant Price Extra" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__variant_seller_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_seller_ids +msgid "Variant Seller" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_image_medium_url +msgid "Variant main medium image URL" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__variant_image_small_url +msgid "Variant main small image URL" +msgstr "" + +#. module: shopinvader +#: model:ir.ui.menu,name:shopinvader.shopinvader_variant_menu +msgid "Variants" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__product_product_link_count +msgid "Variants Links Count" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_stock_supplier +msgid "Vendor Location" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__property_supplier_payment_term_id +msgid "Vendor Payment Terms" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__supplier_taxes_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__supplier_taxes_id +msgid "Vendor Taxes" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__seller_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__seller_ids +msgid "Vendors" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_category__video_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__video_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__video_ids +msgid "Video" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__volume +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__volume +msgid "Volume" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__volume_uom_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__volume_uom_name +msgid "Volume unit of measure label" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_chair_vortex_blue +#: model:product.product,name:shopinvader.product_product_chair_vortex_white +#: model:product.template,name:shopinvader.product_template_chair_vortex +msgid "Vortex Side Chair" +msgstr "" + +#. module: shopinvader +#: model:product.product,name:shopinvader.product_product_armchair_mid_century_blue +#: model:product.product,name:shopinvader.product_product_armchair_mid_century_grey +#: model:product.product,name:shopinvader.product_product_armchair_mid_century_red +#: model:product.product,name:shopinvader.product_product_armchair_mid_century_yellow +#: model:product.product,name:shopinvader.product_product_table_walmut +#: model:product.template,name:shopinvader.product_template_armchair_mid_century +msgid "" +"Walnut Side End Table Nightstand with Storage Drawer Solid Wood Legs Living Room Furniture\n" +" " +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__warehouse_id +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__warehouse_id +msgid "Warehouse" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__website +msgid "Website Link" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__website_message_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__website_message_ids +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__website_public_name +msgid "Website Public Name" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_backend__website_unique_key +msgid "Website Unique Key" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__website_message_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__website_message_ids +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: shopinvader +#: model:ir.ui.menu,name:shopinvader.menu_shopinvader_backend +msgid "Websites" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__weight +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__weight +msgid "Weight" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__weight_uom_name +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__weight_uom_name +msgid "Weight unit of measure label" +msgstr "" + +#. module: shopinvader +#: model:mail.template,subject:shopinvader.email_new_customer_welcome_notification +msgid "Welcome notification ${object.name}" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_stock_valuation_account_id +msgid "" +"When automated inventory valuation is enabled on a product, this account " +"will hold the current value of the products." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_stock_account_output_categ_id +msgid "" +"When doing automated inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account,\n" +" unless there is a specific valuation account set on the destination location. This is the default value for all products in this category.\n" +" It can also directly be set on each product." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_category__property_stock_journal +msgid "" +"When doing automated inventory valuation, this is the Accounting Journal in " +"which entries will be automatically posted when stock moves are processed." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_partner__tz +msgid "" +"When printing documents and exporting/importing data, time values are computed according to this timezone.\n" +"If the timezone is not set, UTC (Coordinated Universal Time) is used.\n" +"Anywhere else, time values are computed according to the time offset of your web client." +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,help:shopinvader.field_shopinvader_product__use_expiration_date +#: model:ir.model.fields,help:shopinvader.field_shopinvader_variant__use_expiration_date +msgid "" +"When this box is ticked, you have the possibility to specify dates to manage" +" product expiration, on the product and on the corresponding lot/serial " +"numbers" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner_binding_line__shopinvader_partner_binding_id +msgid "Wizard" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_category_binding_wizard +msgid "Wizard to bind categories to a shopinvader categories" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_variant_binding_wizard +msgid "Wizard to bind products to a shopinvader catalogue" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_category_unbinding_wizard +msgid "Wizard to unbind categories from a shopinvader backend" +msgstr "" + +#. module: shopinvader +#: model:ir.model,name:shopinvader.model_shopinvader_variant_unbinding_wizard +msgid "Wizard to unbind products from a shopinvader backend" +msgstr "" + +#. module: shopinvader +#: model:res.country.group,name:shopinvader.country_group_1 +msgid "World" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_product__x_linux_compatible +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_variant__x_linux_compatible +msgid "X Linux Compatible" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/customer.py:0 +#, python-format +msgid "You must provide a partner" +msgstr "" + +#. module: shopinvader +#: model:ir.model.fields,field_description:shopinvader.field_shopinvader_partner__zip +msgid "Zip" +msgstr "" + +#. module: shopinvader +#: model:product.attribute.value,name:shopinvader.product_attribute_value_color_blue +#: model:product.template.attribute.value,name:shopinvader.product_template_armchair_mid_century_attribute_color_blue +#: model:product.template.attribute.value,name:shopinvader.product_template_chair_mid_century_attribute_color_white +#: model:product.template.attribute.value,name:shopinvader.product_template_chair_vortex_attribute_color_white +msgid "blue" +msgstr "" + +#. module: shopinvader +#: model:product.attribute.value,name:shopinvader.product_attribute_value_color_brown +#: model:product.template.attribute.value,name:shopinvader.product_template_coffee_table_caftman_attribute_color_brown +#: model:product.template.attribute.value,name:shopinvader.product_template_tv_cabinet_cmcharper_attribute_color_brown +#: model:product.template.attribute.value,name:shopinvader.product_template_tv_cabinet_mid_century_attribute_color_brown +#: model:product.template.attribute.value,name:shopinvader.product_template_tv_cabinet_shaker_wood_attribute_color_brown +msgid "brown" +msgstr "" + +#. module: shopinvader +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_binding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_category_unbinding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_binding_wizard_form_view +#: model_terms:ir.ui.view,arch_db:shopinvader.shopinvader_variant_unbinding_wizard_form_view +msgid "or" +msgstr "" + +#. module: shopinvader +#: model:product.attribute.value,name:shopinvader.product_attribute_value_color_red +#: model:product.template.attribute.value,name:shopinvader.product_template_armchair_mid_century_attribute_color_red +#: model:product.template.attribute.value,name:shopinvader.product_template_chair_mid_century_attribute_color_black +#: model:product.template.attribute.value,name:shopinvader.product_template_thelma_attribute_color_black +msgid "red" +msgstr "" + +#. module: shopinvader +#: model:product.attribute.value,name:shopinvader.product_attribute_value_color_white +#: model:product.template.attribute.value,name:shopinvader.product_template_chair_mid_century_attribute_color_red +#: model:product.template.attribute.value,name:shopinvader.product_template_chair_vortex_attribute_color_blue +#: model:product.template.attribute.value,name:shopinvader.product_template_round_table_mid_century_attribute_color_blue +#: model:product.template.attribute.value,name:shopinvader.product_template_tv_cabinet_concept_design_attribute_color_white +#: model:product.template.attribute.value,name:shopinvader.product_template_tv_stand_wood_and_glass_attribute_color_blue +msgid "white" +msgstr "" + +#. module: shopinvader +#: model:product.attribute.value,name:shopinvader.product_attribute_value_color_yellow +#: model:product.template.attribute.value,name:shopinvader.product_template_armchair_mid_century_attribute_color_yellow +msgid "yellow" +msgstr "" + +#. module: shopinvader +#: code:addons/shopinvader/services/partner_mixin.py:0 +#, python-format +msgid "{addr_type} {mode} '{name}' needs review" +msgstr "" diff --git a/shopinvader_restapi/models/__init__.py b/shopinvader_restapi/models/__init__.py new file mode 100644 index 0000000000..9b88a37246 --- /dev/null +++ b/shopinvader_restapi/models/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import track_external_mixin +from . import sale +from . import account_move +from . import shopinvader_backend +from . import shopinvader_cart_step +from . import shopinvader_partner +from . import shopinvader_notification +from . import res_config_settings +from . import res_partner diff --git a/shopinvader_restapi/models/account_move.py b/shopinvader_restapi/models/account_move.py new file mode 100644 index 0000000000..abd884ae9e --- /dev/null +++ b/shopinvader_restapi/models/account_move.py @@ -0,0 +1,20 @@ +# 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 fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + shopinvader_backend_id = fields.Many2one( + "shopinvader.backend", "Shopinvader Backend" + ) + + def _post(self, soft=True): + res = super(AccountMove, self)._post(soft=soft) + for record in self: + backend = record.shopinvader_backend_id + if record.move_type == "out_invoice" and backend: + backend._send_notification("invoice_open", record) + return res diff --git a/shopinvader_restapi/models/res_config_settings.py b/shopinvader_restapi/models/res_config_settings.py new file mode 100644 index 0000000000..6404c7fa3d --- /dev/null +++ b/shopinvader_restapi/models/res_config_settings.py @@ -0,0 +1,19 @@ +# Copyright 2018 ACSONE SA/NV +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + shopinvader_no_partner_duplicate = fields.Boolean( + string="No partner duplicate", + config_parameter="shopinvader.no_partner_duplicate", + help="If checked, when a binding is created for a backend, we first " + "try to find a partner with the same email and if found we link " + "the new binding to the first partner found. Otherwise we always " + "create a new partner", + ) diff --git a/shopinvader_restapi/models/res_partner.py b/shopinvader_restapi/models/res_partner.py new file mode 100644 index 0000000000..3db14c9775 --- /dev/null +++ b/shopinvader_restapi/models/res_partner.py @@ -0,0 +1,155 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.misc import str2bool + + +class ResPartner(models.Model): + _inherit = "res.partner" + + shopinvader_bind_ids = fields.One2many( + "shopinvader.partner", "record_id", string="Shopinvader Binding" + ) + # TODO: this field should be renamed to `shopinvader_type` + # and should be valued like: + # - profile = main account + # - address = just an address + # that belongs to a partner with shopinvader_type == 'profile' + # - user or childuser = has a shop user and is child of a profile + # This way we can get rid of many computations like + # `parent_has_shopinvader_user` or `has_shopinvader_user` + address_type = fields.Selection( + selection=[("profile", "Profile"), ("address", "Address")], + string="Shopinvader Address Type", + compute="_compute_address_type", + store=True, + ) + # In europe we use more the opt_in + opt_in = fields.Boolean(compute="_compute_opt_in", inverse="_inverse_opt_in") + is_shopinvader_active = fields.Boolean( + string="Shop enabled", + help="This address is enabled to be used for Shopinvader.", + default=True, + ) + has_shopinvader_user = fields.Boolean( + help="This partner has at least a Shopinvader user.", + compute="_compute_has_shopinvader_user", + compute_sudo=True, + store=True, + ) + parent_has_shopinvader_user = fields.Boolean( + related="parent_id.has_shopinvader_user", + string="Parent Has Shopinvader User", + help="This partner belongs to Shopinvader user.", + store=True, + ) + + @api.model + def _is_partner_duplicate_prevented(self): + get_param = self.env["ir.config_parameter"].sudo().get_param + return str2bool(get_param("shopinvader.no_partner_duplicate")) + + @api.constrains("email", "shopinvader_bind_ids") + def _check_unique_email(self): + if not self._is_partner_duplicate_prevented(): + return True + self.env["res.partner"].flush_model(["email", "shopinvader_bind_ids"]) + self.env.cr.execute( + """ + SELECT + email + FROM ( + SELECT + rp.id, + rp.email, + ROW_NUMBER() OVER (PARTITION BY rp.email) AS Row + FROM + res_partner rp + WHERE rp.email is not null + and rp.active = True + and EXISTS (SELECT FROM shopinvader_partner sp WHERE sp.record_id = rp.id) + ) dups + WHERE dups.Row > 1; + """ + ) + duplicate_emails = {r[0] for r in self.env.cr.fetchall()} + invalid_emails = [e for e in self.mapped("email") if e in duplicate_emails] + if invalid_emails: + raise ValidationError( + _("Email must be unique: The following " "mails are not unique: %s") + % ", ".join(invalid_emails) + ) + + @api.depends("is_blacklisted") + def _compute_opt_in(self): + for record in self: + record.opt_in = not record.is_blacklisted + + def _inverse_opt_in(self): + blacklist_model = self.env["mail.blacklist"] + for record in self: + if record.opt_in: + blacklist_model._remove(record.email) + else: + blacklist_model._add(record.email) + + @api.depends("shopinvader_bind_ids") + def _compute_has_shopinvader_user(self): + for record in self: + record.has_shopinvader_user = bool(record.shopinvader_bind_ids) + + @api.depends("parent_id") + def _compute_address_type(self): + for partner in self: + if partner.parent_id: + partner.address_type = "address" + else: + partner.address_type = "profile" + + def write(self, vals): + super(ResPartner, self).write(vals) + if "country_id" in vals: + carts = ( + self.env["sale.order"] + .sudo() + .search( + [ + ("typology", "=", "cart"), + ("partner_shipping_id", "in", self.ids), + ] + ) + ) + for cart in carts: + # Trigger a write on cart to recompute the + # fiscal position if needed + cart.sudo().write_with_onchange( + {"partner_shipping_id": cart.partner_shipping_id.id} + ) + return True + + def addr_type_display(self): + return self._fields["address_type"].convert_to_export(self.address_type, self) + + def get_shop_partner(self, backend): + """Retrieve current partner customer account. + + By default is the same user's partner. + Hook here to provide your own behavior. + + This partner is used to provide main account information client side + and to assign the partner to the sale order / cart. + + :return: res.partner record. + """ + return self + + def _get_invader_partner(self, backend): + """Get bound partner matching backend.""" + domain = [("backend_id", "=", backend.id)] + return self.shopinvader_bind_ids.filtered_domain(domain) diff --git a/shopinvader_restapi/models/sale.py b/shopinvader_restapi/models/sale.py new file mode 100644 index 0000000000..ea3371701b --- /dev/null +++ b/shopinvader_restapi/models/sale.py @@ -0,0 +1,122 @@ +# 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 + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "track.external.mixin"] + + shopinvader_backend_id = fields.Many2one("shopinvader.backend", "Backend") + current_step_id = fields.Many2one( + "shopinvader.cart.step", "Current Cart Step", readonly=True + ) + done_step_ids = fields.Many2many( + comodel_name="shopinvader.cart.step", + string="Done Cart Step", + readonly=True, + ) + # TODO move this in an extra OCA module + shopinvader_state = fields.Selection( + [ + ("cancel", "Cancel"), + ("pending", "Pending"), + ("processing", "Processing"), + ("shipped", "Shipped"), + ], + compute="_compute_shopinvader_state", + store=True, + ) + + def _get_shopinvader_state(self): + self.ensure_one() + if self.state == "cancel": + return "cancel" + elif self.state == "done": + return "shipped" + elif self.state == "draft": + return "pending" + else: + return "processing" + + def _compute_shopinvader_state_depends(self): + return ("state",) + + @api.depends(lambda self: self._compute_shopinvader_state_depends()) + def _compute_shopinvader_state(self): + # simple way to have more human friendly name for + # the sale order on the website + for record in self: + record.shopinvader_state = record._get_shopinvader_state() + + def _prepare_invoice(self): + res = super(SaleOrder, self)._prepare_invoice() + res["shopinvader_backend_id"] = self.shopinvader_backend_id.id + return res + + def _confirm_cart(self): + self.ensure_one() + res = super()._confirm_cart() + if self.shopinvader_backend_id: + self.shopinvader_backend_id._send_notification("cart_confirmation", self) + return res + + def _confirm_sale(self): + self.ensure_one() + res = super()._confirm_sale() + if self.shopinvader_backend_id: + self.shopinvader_backend_id._send_notification("sale_confirmation", self) + return res + + def reset_price_tax(self): + for record in self: + record.order_line.reset_price_tax() + + def _need_price_update(self, vals): + for field in ["fiscal_position_id", "pricelist_id"]: + if field in vals and self[field].id != vals[field]: + return True + return False + + def write_with_onchange(self, vals): + self.ensure_one() + # Playing onchange on one2many is not really really clean + # all value are returned even if their are not modify + # Moreover "convert_to_onchange" in field.py add (5,) that + # will drop the order_line + # so it's better to drop the key order_line and run the onchange + # on line manually + reset_price = False + new_vals = self.play_onchanges(vals, vals.keys()) + new_vals.pop("order_line", None) + vals.update(new_vals) + reset_price = self._need_price_update(vals) + self.write(vals) + if reset_price: + self.reset_price_tax() + return True + + def _send_order_confirmation_mail(self): + non_shopinvader_orders = self.env["sale.order"].browse() + for order in self: + # Only send emails through shopinvader notifications + # When order are done from website + if not order.shopinvader_backend_id: + non_shopinvader_orders |= order + return super(SaleOrder, non_shopinvader_orders)._send_order_confirmation_mail() + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def reset_price_tax(self): + for line in self: + line._compute_tax_id() + line._compute_discount() diff --git a/shopinvader_restapi/models/shopinvader_backend.py b/shopinvader_restapi/models/shopinvader_backend.py new file mode 100644 index 0000000000..282c7085ab --- /dev/null +++ b/shopinvader_restapi/models/shopinvader_backend.py @@ -0,0 +1,339 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# Copyright 2020 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import hashlib +import os + +from odoo import _, api, fields, models, tools +from odoo.osv import expression + +from odoo.addons.base_sparse_field.models.fields import Serialized + + +class ShopinvaderBackend(models.Model): + _name = "shopinvader.backend" + _inherit = [ + "collection.base", + "server.env.techname.mixin", + "server.env.mixin", + ] + _description = "Shopinvader Backend" + + name = fields.Char(required=True) + company_id = fields.Many2one( + "res.company", + "Company", + required=True, + default=lambda s: s._default_company_id(), + ) + notification_ids = fields.One2many( + "shopinvader.notification", + "backend_id", + "Notification", + help="Send mail for predefined events", + ) + nbr_cart = fields.Integer(compute="_compute_nbr_sale_order") + nbr_sale = fields.Integer(compute="_compute_nbr_sale_order") + allowed_country_ids = fields.Many2many( + comodel_name="res.country", string="Allowed Country" + ) + anonymous_partner_id = fields.Many2one( + "res.partner", + "Anonymous Partner", + help=("Provide partner settings for unlogged users " "(i.e. fiscal position)"), + required=True, + default=lambda self: self.env.ref("shopinvader_restapi.anonymous"), + ) + sequence_id = fields.Many2one( + "ir.sequence", "Sequence", help="Naming policy for orders and carts" + ) + lang_ids = fields.Many2many("res.lang", string="Lang") + pricelist_id = fields.Many2one( + "product.pricelist", + string="Pricelist", + default=lambda self: self._default_pricelist_id(), + ) + account_analytic_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Analytic account", + help="This analytic account will be used to fill the " + "field on the sale order created.", + ) + website_public_name = fields.Char( + help="Public name of your backend/website.\n" + " Used for products name referencing." + ) + clear_cart_options = fields.Selection( + selection=[ + ("delete", "Delete"), + ("clear", "Clear"), + ("cancel", "Cancel"), + ], + required=True, + string="Clear cart", + default="clear", + help="Action to execute on the cart when the front want to clear the " + "current cart:\n" + "- Delete: delete the cart (and items);\n" + "- Clear: keep the cart but remove items;\n" + "- Cancel: The cart is canceled but kept into the database.\n" + "When a quotation is not validated, habitually it's not removed " + "but cancelled. " + "It could be also useful if you want to keep cart for " + "statistics reasons. A new cart is created automatically when the " + "customer will add a new item.", + ) + cart_checkout_address_policy = fields.Selection( + selection=[ + ("no_defaults", "No defaults"), + ( + "invoice_defaults_to_shipping", + "Invoice address defaults to shipping", + ), + ], + default="no_defaults", + required=True, + string="Cart address behavior", + help="Define how the cart address will be handled in the checkout step:\n" + "- No defaults: client will pass shipping and invoicing address" + " together or in separated calls." + " No automatic value for non passed addresses will be set;\n" + "- Invoice address defaults to shipping:" + " if the client does not pass the invoice address explicitly " + " the shipping one will be used as invoice address as well.\n", + ) + partner_title_ids = fields.Many2many( + "res.partner.title", + string="Available partner titles", + default=lambda self: self._default_partner_title_ids(), + ) + partner_industry_ids = fields.Many2many( + "res.partner.industry", + string="Available partner industries", + default=lambda self: self._default_partner_industry_ids(), + ) + # Sale settings aggregator. + # Use this for `sparse` attribute of sale related settings + # that do not require a real field. + sale_settings = Serialized( + # Default values on the sparse fields work only for create + # and does not provide defaults for existing records. + default={} + ) + # Invoice settings aggregator. + # Use this for `sparse` attribute of invoice related settings + # that do not require a real field. + invoice_settings = Serialized( + default={ + "invoice_linked_to_sale_only": True, + "invoice_access_open": False, + } + ) + # TODO: move to portal mode? + invoice_linked_to_sale_only = fields.Boolean( + default=True, + string="Only sale invoices", + help="Only serve invoices that are linked to a sale order.", + sparse="invoice_settings", + ) + invoice_access_open = fields.Boolean( + default=False, + string="Open invoices", + help="Give customer access to open invoices as well as the paid ones.", + sparse="invoice_settings", + ) + invoice_report_id = fields.Many2one( + comodel_name="ir.actions.report", + domain=lambda self: self._get_invoice_report_id_domain(), + string="Specific report", + help="Select a specific report for invoice download, if none are selected " + "default shopinvader implementation is used.", + ) + customer_default_role = fields.Char( + compute="_compute_customer_default_role", + ) + salesman_notify_create = fields.Selection( + selection=[ + ("", "None"), + ("all", "Companies, simple users and addresses"), + ("company", "Company users only"), + ("user", "Simple users only"), + ("company_and_user", "Companies and simple users"), + ("address", "Addresses only"), + ], + default="company", + ) + salesman_notify_update = fields.Selection( + selection=[ + ("", "None"), + ("all", "Companies, simple users and addresses"), + ("company", "Company users only"), + ("user", "Simple users only"), + ("company_and_user", "Companies and simple users"), + ("address", "Addresses only"), + ], + default="", + ) + website_unique_key = fields.Char( + required=True, + copy=False, + help="This identifier may be provided by each REST request through " + "a WEBSITE-UNIQUE-KEY http header to identify the target backend. " + "If not provided by the request and if there is only one backend, " + "it will be used by default. Otherwise it is possible to override the " + "_get_backend method into the service context provider component. " + "The shopinvader_auth_api_key and shopinvader_auth_jwt addons " + "provides a fallback mechanism in such a case.", + default=lambda self: self._default_website_unique_key(), + ) + currency_ids = fields.Many2many(comodel_name="res.currency", string="Currency") + + frontend_data_source = fields.Selection( + # TODO @simahawk: this value it's here because the form in core module + # already adds a "search engine" page. + # I'm not sure it should stay here, it should probalby go to `s_search_engine`. + selection=[("search_engine", "Search engine")], + help="Technical field to control form fields appeareance", + default="search_engine", + ) + _sql_constraints = [ + ( + "unique_website_unique_key", + "unique(website_unique_key)", + _("This website unique key already exists in database"), + ) + ] + + @api.model + def _default_company_id(self): + return self.env.company + + @api.model + def _default_pricelist_id(self): + return self.env.ref("product.list0") + + @api.model + def _default_partner_title_ids(self): + return self.env["res.partner.title"].search([]) + + @api.model + def _default_partner_industry_ids(self): + return self.env["res.partner.industry"].search([]) + + @api.model + def _default_website_unique_key(self): + return hashlib.pbkdf2_hmac( + "sha256", os.urandom(32), os.urandom(32), 100000 + ).hex() + + def _compute_customer_default_role(self): + for rec in self: + rec.customer_default_role = "default" + + def _get_invoice_report_id_domain(self): + return [ + ( + "binding_model_id", + "=", + self.env.ref("account.model_account_move").id, + ) + ] + + @api.model + def _to_compute_nbr_sale_order(self): + """Get a dict to compute the number of sale order. + + The dict is build like this: + {field_name: domain} + """ + return { + "nbr_cart": [("typology", "=", "cart")], + "nbr_sale": [("typology", "=", "sale")], + } + + def _compute_nbr_sale_order(self): + domain = [("shopinvader_backend_id", "in", self.ids)] + for fname, fdomain in self._to_compute_nbr_sale_order().items(): + res = self.env["sale.order"].read_group( + domain=expression.AND([domain, fdomain]), + fields=["shopinvader_backend_id"], + groupby=["shopinvader_backend_id"], + lazy=False, + ) + counts = {r["shopinvader_backend_id"][0]: r["__count"] for r in res} + for rec in self: + rec[fname] = counts.get(rec.id, 0) + + def _send_notification(self, notification, record): + self.ensure_one() + record.ensure_one() + notifs = self.env["shopinvader.notification"].search( + [ + ("backend_id", "=", self.id), + ("notification_type", "=", notification), + ] + ) + description = _("Notify {notification} for {name},{id}").format( + notification=notification, + name=record._name, + id=record.id, + ) + for notif in notifs: + notif.with_delay(description=description).send(record.id) + return True + + def _extract_configuration(self): + return {} + + def _get_backend_pricelist(self): + """The pricelist configure by this backend.""" + # There must be a pricelist somehow: safe fallback to default Odoo one + return self.pricelist_id or self._default_pricelist_id() + + def _get_customer_default_pricelist(self): + """Retrieve pricelist to be used for brand new customer record.""" + return self._get_backend_pricelist() + + def _get_partner_pricelist(self, partner): + """Retrieve pricelist for given res.partner record.""" + # Normally we should return partner.property_product_pricelist + # but by default the shop must use the same pricelist for all customers + # because products' prices are computed only by backend pricelist. + # Nevertheless, this is a good point to hook to + # if a different behavior per partner is required. + return None + + def _get_cart_pricelist(self, partner=None): + """Retrieve pricelist to be used for the cart. + + NOTE: if you change this behavior be aware that + the prices displayed on the cart might differ + from the ones showed on product details. + This is because product info comes from indexes + which are completely agnostic in regard to specific partner info. + """ + pricelist = self._get_backend_pricelist() + if partner: + pricelist = self._get_partner_pricelist(partner) or pricelist + return pricelist + + def _validate_partner(self, shopinvader_partner): + """Hook to validate partners when required.""" + return True + + @api.model + @tools.ormcache("self._uid", "website_unique_key") + def _get_id_from_website_unique_key(self, website_unique_key): + return self.search([("website_unique_key", "=", website_unique_key)]).id + + @api.model + def _get_from_website_unique_key(self, website_unique_key): + return self.browse(self._get_id_from_website_unique_key(website_unique_key)) + + def write(self, values): + if "website_unique_key" in values: + self._get_id_from_website_unique_key.clear_cache(self.env[self._name]) + return super(ShopinvaderBackend, self).write(values) diff --git a/shopinvader_restapi/models/shopinvader_cart_step.py b/shopinvader_restapi/models/shopinvader_cart_step.py new file mode 100644 index 0000000000..e06bff4a3e --- /dev/null +++ b/shopinvader_restapi/models/shopinvader_cart_step.py @@ -0,0 +1,16 @@ +# 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 + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class ShopinvaderCartStep(models.Model): + _name = "shopinvader.cart.step" + _description = "Shopinvader Cart Step" + + name = fields.Char(required=True) + code = fields.Char(required=True) diff --git a/shopinvader_restapi/models/shopinvader_notification.py b/shopinvader_restapi/models/shopinvader_notification.py new file mode 100644 index 0000000000..be581197e4 --- /dev/null +++ b/shopinvader_restapi/models/shopinvader_notification.py @@ -0,0 +1,94 @@ +# 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 +from odoo.tools.translate import _ + + +class ShopinvaderNotification(models.Model): + _name = "shopinvader.notification" + _description = "Shopinvader Notification" + + backend_id = fields.Many2one("shopinvader.backend", "Backend", required=False) + notification_type = fields.Selection( + selection="_selection_notification_type", + required=True, + ) + model_id = fields.Many2one("ir.model", "Model", required=True, ondelete="cascade") + template_id = fields.Many2one("mail.template", "Mail Template", required=True) + + def _selection_notification_type(self): + notifications = self._get_all_notification() + return [(key, notifications[key]["name"]) for key in notifications] + + def _get_all_notification(self): + return { + "cart_confirmation": { + "name": _("Cart Confirmation"), + "model": "sale.order", + }, + "cart_send_email": { + "name": _("Cart ask by email"), + "model": "sale.order", + }, + "sale_send_email": { + "name": _("Sale ask by email"), + "model": "sale.order", + }, + "sale_confirmation": { + "name": _("Sale Confirmation"), + "model": "sale.order", + }, + "invoice_open": { + "name": _("Invoice Validated"), + "model": "account.move", + }, + "invoice_send_email": { + "name": _("Invoice send email"), + "model": "account.move", + }, + "new_customer_welcome": { + "name": _("New customer Welcome"), + "model": "res.partner", + }, + "customer_updated": { + "name": _("Customer updated"), + "model": "res.partner", + }, + "address_created": { + "name": _("Address created"), + "model": "res.partner", + }, + "address_updated": { + "name": _("Address updated"), + "model": "res.partner", + }, + } + + @api.onchange("notification_type") + def on_notification_type_change(self): + self.ensure_one() + notifications = self._get_all_notification() + if self.notification_type: + model = notifications[self.notification_type].get("model") + if model: + self.model_id = self.env["ir.model"].search([("model", "=", model)]) + return {"domain": {"model_id": [("id", "=", self.model_id.id)]}} + else: + return {"domain": {"model_id": []}} + + def send(self, record_id): + self.ensure_one() + return ( + self.sudo() + .template_id.with_context(**self._get_template_context()) + .send_mail(record_id) + ) + + def _get_template_context(self): + return { + "notification_type": self.notification_type, + "shopinvader_backend": self.backend_id, + "website_name": self.backend_id.website_public_name, + } diff --git a/shopinvader_restapi/models/shopinvader_partner.py b/shopinvader_restapi/models/shopinvader_partner.py new file mode 100644 index 0000000000..19e1cc7116 --- /dev/null +++ b/shopinvader_restapi/models/shopinvader_partner.py @@ -0,0 +1,194 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# Copyright 2020 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import _, api, fields, models + + +class ShopinvaderPartner(models.Model): + _name = "shopinvader.partner" + _description = "Shopinvader Partner" + _inherits = {"res.partner": "record_id"} + _check_company_auto = True + + record_id = fields.Many2one( + "res.partner", + string="Partner", + required=True, + ondelete="restrict", + check_company=True, + ) + partner_email = fields.Char( + related="record_id.email", + required=True, + store=True, + string="Partner Email", + ) + role = fields.Char(compute="_compute_role") + # Common interface to mimic the same behavior as res.partner. + # On the binding we have a selection for the state + # and we can set the value for each backend. + # On addresses is relevant only if the record is enabled or not for the shop. + # Having the same field on both models allows to use simple conditions to check. + # The compute methods offers a hook to modify the behavior. + is_shopinvader_active = fields.Boolean(compute="_compute_is_shopinvader_active") + backend_id = fields.Many2one("shopinvader.backend", string="Backend", required=True) + sync_date = fields.Datetime(string="Last synchronization date") + external_id = fields.Char(string="External ID") + + def _compute_is_shopinvader_active_depends(self): + return () + + @api.depends(lambda self: self._compute_is_shopinvader_active_depends()) + def _compute_is_shopinvader_active(self): + for rec in self: + rec.is_shopinvader_active = rec._is_shopinvader_user_active() + + def _is_shopinvader_user_active(self): + return True + + _sql_constraints = [ + ( + "record_uniq", + "unique(backend_id, record_id, partner_email)", + "A partner can only have one binding by backend.", + ), + ( + "email_uniq", + "unique(backend_id, partner_email)", + "An email must be uniq per backend.", + ), + ] + + def _compute_role_depends(self): + return ("backend_id", "backend_id.customer_default_role") + + @api.depends(lambda self: self._compute_role_depends()) + def _compute_role(self): + for rec in self: + rec.role = rec._get_role() + + def _get_role(self): + return self.backend_id.customer_default_role + + @api.model_create_multi + def create(self, vals_list): + new_vals_list = [] + for vals in vals_list: + new_vals_list.append(self._prepare_create_params(vals)) + return super(ShopinvaderPartner, self).create(new_vals_list) + + @api.model + def _prepare_create_params(self, vals): + # The new partner should be marked as customer + # Exactly when a partner is created from Sale menu + # The partner is marked as customer via the action's context + vals.update({"customer_rank": 1}) + # As we want to have a SQL contraint on customer email + # we have to set it manually to avoid to raise the constraint + # at the creation of the element + if not vals.get("partner_email"): + vals["partner_email"] = vals.get("email") + if not vals.get("partner_email") and vals.get("record_id"): + vals["partner_email"] = ( + self.env["res.partner"].browse(vals["record_id"]).email + ) + if not vals.get("record_id"): + vals["record_id"] = self._get_or_create_partner(vals).id + return vals + + @api.model + def _get_or_create_partner(self, vals): + partner = self.env["res.partner"].browse() + if partner._is_partner_duplicate_prevented(): + domain = self._get_unique_partner_domain(vals) + partner = partner.search(domain, limit=1) + if partner: + # here we check if one of the given value is different than those + # on partner. If true, we create a child partner to keep the + # given informations + if not self._is_same_partner_value(partner, vals): + self._create_child_partner(partner, vals) + return partner + partner_values = vals.copy() + keys = partner_values.keys() + # Some fields are related to shopinvader.partner and doesn't exist + # in res.partner + values = {} + for k in keys: + if k in partner._fields: + values.update({k: partner_values[k]}) + return partner.create(values) + + @api.model + def _get_unique_partner_domain(self, vals): + """ """ + email = vals["email"] + return [("email", "=", email)] + + @api.model + def _is_same_partner_value(self, partner, vals): + """we check if one of the given value is different than values + of the given partner + """ + keys_to_check = self._is_same_partner_value_keys_to_check(partner, vals) + data = partner._convert_to_write(partner._cache) + for key in keys_to_check: + if data[key] != vals[key]: + return False + return True + + def _is_same_partner_value_keys_to_check(self, partner, vals): + skip_keys = self._is_same_partner_value_skip_keys(partner) + keys_to_check = [] + for key in vals.keys(): + if key in skip_keys or key not in partner: + continue + keys_to_check.append(key) + # pylint: disable=pointless-statement + partner[key] # make sure key is cached + return keys_to_check + + def _is_same_partner_value_skip_keys(self, partner): + """Take control of keys to ignore for the match.""" + return ("backend_id", "partner_email") + + @api.model + def _create_child_partner(self, parent, vals): + vals = self._prepare_create_child_params(parent, vals) + return self.env["res.partner"].create(vals) + + @api.model + def _prepare_create_child_params(self, parent, vals): + v = vals.copy() + v["parent_id"] = parent.id + if not v.get("type"): + v["type"] = "other" + v.pop("email") + partner_fields = self.env["res.partner"]._fields + # remove unknow partner fields + for f in [i for i in v.keys()]: + if f not in partner_fields: + v.pop(f) + return v + + def action_edit_in_form(self): + self.ensure_one() + form_xid = self.env.context.get( + "form_view_ref", "shopinvader.shopinvader_partner_view_form" + ) + view = self.env.ref(form_xid) + return { + "name": _("Edit %s") % self.name, + "type": "ir.actions.act_window", + "view_type": "form", + "res_model": self._name, + "views": [(view.id, "form")], + "view_id": view.id, + "target": "new", + "res_id": self.id, + "context": dict(self.env.context), + } diff --git a/shopinvader_restapi/models/track_external_mixin.py b/shopinvader_restapi/models/track_external_mixin.py new file mode 100644 index 0000000000..dc932ef67c --- /dev/null +++ b/shopinvader_restapi/models/track_external_mixin.py @@ -0,0 +1,31 @@ +# Copyright 2020 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class TrackExternalMixin(models.AbstractModel): + _name = "track.external.mixin" + _description = "Track external update" + + last_external_update_date = fields.Datetime() + + def is_rest_request(self): + return self.env.context.get("shopinvader_request", False) + + def _fill_last_external_update_date(self, vals): + # The given dict (vals) could be a frozendict so we have to create a new one + if self.is_rest_request(): + vals = vals.copy() + vals.update({"last_external_update_date": fields.Datetime.now()}) + return vals + + def write(self, vals): + vals = self._fill_last_external_update_date(vals) + return super().write(vals) + + @api.model_create_multi + def create(self, values_list): + new_vals = [] + for vals in values_list: + new_vals.append(self._fill_last_external_update_date(vals)) + return super().create(values_list) diff --git a/shopinvader_restapi/readme/CONTRIBUTORS.rst b/shopinvader_restapi/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..df383b6b71 --- /dev/null +++ b/shopinvader_restapi/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* Sebastien BEAU +* Simone Orsi +* Laurent Mignon +* Raphaël Reverdy +* Kevin Khao +* Quentin Groulard diff --git a/shopinvader_restapi/readme/CREDITS.rst b/shopinvader_restapi/readme/CREDITS.rst new file mode 100644 index 0000000000..1052b2d564 --- /dev/null +++ b/shopinvader_restapi/readme/CREDITS.rst @@ -0,0 +1,9 @@ +The development of this module has been financially supported by: + +* Akretion +* Adaptoo +* Encresdubuit +* Abilis +* Camptocamp +* Cosanum +* ACSONE diff --git a/shopinvader_restapi/readme/DESCRIPTION.rst b/shopinvader_restapi/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..df5f62737d --- /dev/null +++ b/shopinvader_restapi/readme/DESCRIPTION.rst @@ -0,0 +1,23 @@ +This addon provides the legacy REST api provided by the *shopinvader* addon +in previous versions. + +From version 16.0, a rework of the REST api has been started. This rework +has as goal to provide a more consistent and documented API. This rework is also +based on a new technical stack (FastAPI_, Pydantic_, extendable_pydantic_, ..). + +Some endpoints provided by the legacy API are already available in the new API. + +* ``cart``: The *shopinvader_api_cart* addon provides the new API for the cart + management. +* ``address``: The *shopinvader_api_address* addon provides the new API for the + address management. + +Others endpoints will be available in the future. + +The *shopinvader_v2_app_demo* addon highlights how you can aggregate the new +API and the legacy API in the same Odoo instance but also how you can combine +the different parts of the new API into a single application. + +.. _FastAPI: https://fastapi.tiangolo.com/ +.. _Pydantic: https://pydantic-docs.helpmanual.io/ +.. _extendable_pydantic: https://pypi.org/project/extendable-pydantic/ diff --git a/shopinvader_restapi/security/ir.model.access.csv b/shopinvader_restapi/security/ir.model.access.csv new file mode 100644 index 0000000000..db7308dae1 --- /dev/null +++ b/shopinvader_restapi/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_shopinvader_backend_manage,shopinvader_backend shopinvader manage,model_shopinvader_backend,shopinvader_restapi.group_shopinvader_manager,1,1,1,1 +access_shopinvader_backend_read,shopinvader_backend shopinvader user,model_shopinvader_backend,base.group_user,1,0,0,0 +access_shopinvader_partner_edit,shopinvader_partner edit,model_shopinvader_partner,shopinvader_restapi.group_shopinvader_partner_binding,1,1,1,1 +access_shopinvader_partner_read,shopinvader_partner read,model_shopinvader_partner,base.group_user,1,0,0,0 +access_shopinvader_cart_step,access_shopinvader_cart_step,model_shopinvader_cart_step,shopinvader_restapi.group_shopinvader_manager,1,1,1,1 +access_shopinvader_cart_step_employee,access_shopinvader_cart_step_employee,model_shopinvader_cart_step,base.group_user,1,0,0,0 +access_shopinvader_notification_manager,access_shopinvader_notification_manager,model_shopinvader_notification,shopinvader_restapi.group_shopinvader_manager,1,1,1,1 +access_shopinvader_notification_employee,access_shopinvader_notification_employee,model_shopinvader_notification,base.group_user,1,0,0,0 +access_shopinvader_partner_binding,access_shopinvader_partner_binding,model_shopinvader_partner_binding,base.group_user,1,1,1,1 +access_shopinvader_partner_binding_line,access_shopinvader_partner_binding_line,model_shopinvader_partner_binding_line,base.group_user,1,1,1,1 diff --git a/shopinvader_restapi/security/shopinvader_backend_security.xml b/shopinvader_restapi/security/shopinvader_backend_security.xml new file mode 100644 index 0000000000..ccc4ea7745 --- /dev/null +++ b/shopinvader_restapi/security/shopinvader_backend_security.xml @@ -0,0 +1,16 @@ + + + + + + + Shopinvader Backend multi-company + + + + ['|', ('company_id','=',False), ('company_id','in',company_ids)] + + + + diff --git a/shopinvader_restapi/security/shopinvader_partner_security.xml b/shopinvader_restapi/security/shopinvader_partner_security.xml new file mode 100644 index 0000000000..62f85bd5f2 --- /dev/null +++ b/shopinvader_restapi/security/shopinvader_partner_security.xml @@ -0,0 +1,16 @@ + + + + + + + Shopinvader Partner multi-company + + + + ['|', ('company_id','=',False), ('company_id','in',company_ids)] + + + + diff --git a/shopinvader_restapi/security/shopinvader_security.xml b/shopinvader_restapi/security/shopinvader_security.xml new file mode 100644 index 0000000000..3f56ebcd2a --- /dev/null +++ b/shopinvader_restapi/security/shopinvader_security.xml @@ -0,0 +1,26 @@ + + + + + Shopinvader + 30 + + + + Shopinvader partner binding + + + + Manager + + + + + + diff --git a/shopinvader_restapi/services/__init__.py b/shopinvader_restapi/services/__init__.py new file mode 100644 index 0000000000..49dd896dc6 --- /dev/null +++ b/shopinvader_restapi/services/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import service +from . import abstract_download +from . import abstract_mail +from . import abstract_sale +from . import cart +from . import sale +from . import partner_mixin +from . import address +from . import customer +from . import invoice +from . import settings diff --git a/shopinvader_restapi/services/abstract_download.py b/shopinvader_restapi/services/abstract_download.py new file mode 100644 index 0000000000..bd4c3a6ebc --- /dev/null +++ b/shopinvader_restapi/services/abstract_download.py @@ -0,0 +1,118 @@ +# Copyright 2020 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import mimetypes + +from odoo import _ +from odoo.exceptions import MissingError +from odoo.http import content_disposition, request +from odoo.tools.safe_eval import safe_eval, time + +from odoo.addons.base_rest import restapi +from odoo.addons.component.core import AbstractComponent + + +class AbstractDownload(AbstractComponent): + """ + Class used to define behaviour to generate and download a document. + You only have to inherit this AbstractComponent and implement the function + _get_report_action(...). + + Example for invoice service: + Class InvoiceService(Component): + _inherit = [ + "base.shopinvader.service", + "abstract.shopinvader.download", + ] + _name = "shopinvader.invoice.service" + + def _get_report_action(self, target, params): + return target.invoice_print() + """ + + _name = "abstract.shopinvader.download" + + # This function should be overwritten + def _get_report_action(self, target, params=None): + """ + Get the action/dict to generate the report + :param target: recordset + :param params: dict + :return: dict/action + """ + raise NotImplementedError() + + @restapi.method( + routes=[(["//download"], "GET")], + output_param=restapi.BinaryData(required=True), + ) + def download(self, _id, **params): + """ + Get target file. This method is also callable by HTTP GET + """ + params = params or {} + target = self._get(_id) + headers, content = self._get_binary_content(target, params=params) + if not content: + raise MissingError(_("No content found for %s") % _id) + response = request.make_response(content, headers) + response.status_code = 200 + return response + + def _get_binary_content(self, target, params=None): + """ + Generate the report for the given target + :param target: + :param params: dict + :returns: (headers, content) + """ + # Ensure the report is generated + target_report_def = self._get_report_action(target, params=params) + report_name = target_report_def.get("report_name") + report_type = target_report_def.get("report_type") + report = self._get_report(report_name, report_type) + content, extension = self.env["ir.actions.report"]._render( + report_name, target.ids, data={"report_type": report_type} + ) + filename = self._get_binary_content_filename( + target, report, extension, params=params + ) + mimetype = mimetypes.guess_type(filename) + if mimetype: + mimetype = mimetype[0] + headers = [ + ("Content-Type", mimetype), + ("X-Content-Type-Options", "nosniff"), + ("Content-Disposition", content_disposition(filename)), + ("Content-Length", len(content)), + ] + return headers, content + + def _get_report(self, report_name, report_type, params=None): + """ + Load the report recordset + :param report_name: str + :param report_type: str + :param params: dict + :return: ir.actions.report recordset + """ + domain = [ + ("report_type", "=", report_type), + ("report_name", "=", report_name), + ] + return self.env["ir.actions.report"].search(domain) + + def _get_binary_content_filename(self, target, report, extension, params=None): + """ + Build the filename + :param target: recordset + :param report: ir.actions.report.xml recordset + :param format: str + :param params: dict + :return: str + """ + if report.print_report_name and not len(target) > 1: + report_name = safe_eval( + report.print_report_name, {"object": target, "time": time} + ) + return "{}.{}".format(report_name, extension) + return "{}.{}".format(report.name, extension) diff --git a/shopinvader_restapi/services/abstract_mail.py b/shopinvader_restapi/services/abstract_mail.py new file mode 100644 index 0000000000..c971b4b975 --- /dev/null +++ b/shopinvader_restapi/services/abstract_mail.py @@ -0,0 +1,100 @@ +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import AbstractComponent + + +class AbstractMailService(AbstractComponent): + _inherit = "base.shopinvader.service" + _name = "shopinvader.abstract.mail.service" + + def ask_email(self, _id): + """ + Ask to receive the record ID by email + :param _id: int + :return: + """ + self._ask_email(_id) + return {} + + def _validator_ask_email(self): + return {} + + def _get_email_notification_type(self, record): + """ + Based on the given record, get the notification type. + By default, it's build like this: + <_usage>_send_email + :param record: target record + :return: str + """ + if hasattr(self, "_usage") and self._usage: + return "%s_send_email" % self._usage + return "" + + def _load_target_email(self, record_id): + """ + Load the record (based on expose model) + :param record_id: int + :return: record or None + """ + if not getattr(self, "_expose_model", False): + return None + return self.env[self._expose_model].browse(record_id) + + def _get_email_recipient(self, record, notif_type): + """ + Get the default recipient of the given record (partner_id by default). + :param record: target record + :param notif_type: str + :return: res.partner recordset + """ + partner = self.env["res.partner"].browse() + if record and hasattr(record, "partner_id") and record.partner_id: + partner = record.partner_id + return partner + + def _allow_email_notification(self, partner, record, notif_type): + """ + Based on given record, partner and notif_type, check if the partner + is allowed to launch this kind of notification + :param partner: res.partner + :param record: target record + :param notif_type: str + :return: bool + """ + if not notif_type: + return False + recipient_partner = self._get_email_recipient(record, notif_type) + return partner == recipient_partner + + def _ask_email(self, _id): + """ + Send the notification about the email on the backend + :param _id: int + :return: dict + """ + # Can not ask an email if not logged + if not self._is_logged_in(): + return {} + target = self._load_target_email(_id) + if not target: + return {} + notif_type = self._get_email_notification_type(target) + allow = self._allow_email_notification(self.partner, target, notif_type) + if notif_type and allow: + self._launch_notification(target, notif_type) + return {} + + def _launch_notification(self, targets, notif_type): + """ + Action to launch the notification (on the current backend) for the + given record + :param targets: recordset + :param notif_type: str + :return: bool + """ + if not targets: + return True + for target in targets: + self.shopinvader_backend._send_notification(notif_type, target) + return True diff --git a/shopinvader_restapi/services/abstract_sale.py b/shopinvader_restapi/services/abstract_sale.py new file mode 100644 index 0000000000..bccff8e9a5 --- /dev/null +++ b/shopinvader_restapi/services/abstract_sale.py @@ -0,0 +1,108 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo.addons.component.core import AbstractComponent + +_logger = logging.getLogger(__name__) + + +class AbstractSaleService(AbstractComponent): + _inherit = "shopinvader.abstract.mail.service" + _name = "shopinvader.abstract.sale.service" + + def _convert_one_sale(self, sale): + sale.ensure_one() + state_label = self._get_selection_label(sale, "shopinvader_state") + return { + "id": sale.id, + "state": sale.shopinvader_state, + "state_label": state_label, + "name": sale.name, + "date": sale.date_order, + "date_delivery": sale.commitment_date, + "client_order_ref": sale.client_order_ref, + "step": self._convert_step(sale), + "lines": self._convert_lines(sale), + "amount": self._convert_amount(sale), + "shipping": self._convert_shipping(sale), + "invoicing": self._convert_invoicing(sale), + "note": sale.note, + } + + def _convert_step(self, sale): + return { + "current": sale.current_step_id.code, + "done": sale.done_step_ids.mapped("code"), + } + + def _is_item(self, line): + return True + + def _get_product_information(self, line): + return {"id": line.product_id.id} + + def _convert_one_line(self, line): + return { + "id": line.id, + "name": line.name, + "product": self._get_product_information(line), + "amount": { + "price": line.price_unit, + "untaxed": line.price_subtotal, + "tax": line.price_tax, + "total": line.price_total, + "total_without_discount": line.price_total_no_discount, + }, + "qty": line.product_uom_qty, + "discount": {"rate": line.discount, "value": line.discount_total}, + } + + def _convert_lines(self, sale): + items = [] + for line in sale.order_line: + if self._is_item(line): + items.append(self._convert_one_line(line)) + return { + "items": items, + # TODO: does this make any sense? Sum up qty of different products? + "count": sum([item["qty"] for item in items]), + "amount": { + "tax": sum([item["amount"]["tax"] for item in items]), + "untaxed": sum([item["amount"]["untaxed"] for item in items]), + "total": sum([item["amount"]["total"] for item in items]), + }, + } + + def _convert_shipping(self, sale): + if sale.partner_shipping_id == self.shopinvader_backend.anonymous_partner_id: + return {"address": {}} + else: + address_service = self.component(usage="addresses") + return {"address": address_service._to_json(sale.partner_shipping_id)[0]} + + def _convert_invoicing(self, sale): + if sale.partner_invoice_id == self.shopinvader_backend.anonymous_partner_id: + return {"address": {}} + else: + address_service = self.component(usage="addresses") + return {"address": address_service._to_json(sale.partner_invoice_id)[0]} + + def _convert_amount(self, sale): + return { + "tax": sale.amount_tax, + "untaxed": sale.amount_untaxed, + "total": sale.amount_total, + "discount_total": sale.discount_total, + "total_without_discount": sale.price_total_no_discount, + } + + def _to_json(self, sales, **kw): + res = [] + for sale in sales: + res.append(self._convert_one_sale(sale)) + return res diff --git a/shopinvader_restapi/services/address.py b/shopinvader_restapi/services/address.py new file mode 100644 index 0000000000..2a9d6c39b8 --- /dev/null +++ b/shopinvader_restapi/services/address.py @@ -0,0 +1,247 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# pylint: disable=method-required-super, consider-merging-classes-inherited + +from odoo import _ +from odoo.exceptions import AccessError + +from odoo.addons.base_rest.components.service import to_bool, to_int +from odoo.addons.component.core import Component + +from .. import shopinvader_response + + +class AddressService(Component): + """Shopinvader service to create and edit customers' addresses.""" + + _inherit = [ + "base.shopinvader.service", + "shopinvader.partner.service.mixin", + ] + _name = "shopinvader.address.service" + _usage = "addresses" + _expose_model = "res.partner" + _description = __doc__ + + # The following method are 'public' and can be called from the controller. + + def get(self, _id): + return self._to_address_info(_id) + + def search(self, **params): + if not self.partner: + return {"data": []} + else: + return self._paginate_search(**params) + + # pylint: disable=W8106 + def create(self, **params): + params["parent_id"] = self.partner.id + partner = self.env["res.partner"].create(self._prepare_params(params)) + self._post_create(partner) + return self.search() + + def update(self, _id, **params): + address = self._get(_id) + address.write(self._prepare_params(params, mode="update")) + res = self.search() + if self._store_cache_needed(address): + res["store_cache"] = {"customer": self._to_json(address)[0]} + customer = self.component(usage="customer") + response = shopinvader_response.get() + customer_data = customer._to_customer_info(address) + response.set_store_cache("customer", customer_data) + + self._post_update(address) + return res + + def delete(self, _id): + address = self._get(_id) + if self.partner == address: + raise AccessError(_("Can not delete the partner account")) + address.active = False + return self.search() + + # The following method are 'private' and should be never never NEVER call + # from the controller. + # All params are trusted as they have been checked before + def _to_address_info(self, _id): + return self._to_json(self._get(_id)) + + def _store_cache_needed(self, partner): + # TODO remove this kind of checks in v15. + # The frontend can now use `customer/update` to update the main partner. + return partner.address_type == "profile" + + # Validator + def _get_allowed_type(self): + return ["contact", "invoice", "delivery", "other", "private"] + + def _validator_search(self): + validator = self._default_validator_search() + validator.pop("domain", {}) + return validator + + def _validator_create(self): + res = { + "name": {"type": "string", "required": True}, + "type": { + "type": "string", + "allowed": self._get_allowed_type(), + "default": "contact", + }, + "street": {"type": "string", "required": True, "empty": False}, + "street2": {"type": "string", "nullable": True}, + "zip": {"type": "string", "required": True, "empty": False}, + "city": {"type": "string", "required": True, "empty": False}, + "phone": {"type": "string", "nullable": True, "empty": False}, + "email": {"type": "string", "required": False, "nullable": True}, + "state": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "nullable": True, + "type": "integer", + } + }, + }, + "country": { + "required": True, + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "nullable": False, + "type": "integer", + "excludes": ["code"], + }, + "code": { + "required": True, + "nullable": False, + "type": "string", + "excludes": ["id"], + }, + }, + }, + "title": { + "required": False, + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + } + }, + }, + "industry_id": { + "required": False, + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + } + }, + }, + "is_company": {"coerce": to_bool, "type": "boolean"}, + "opt_in": {"coerce": to_bool, "type": "boolean"}, + "opt_out": {"coerce": to_bool, "type": "boolean"}, + "lang": {"type": "string", "required": False}, + "vat": {"type": "string", "required": False, "nullable": True}, + "company_name": {"type": "string", "required": False, "nullable": True}, + "function": {"type": "string", "required": False, "nullable": True}, + } + return res + + def _validator_update(self): + res = self._validator_create() + for key in res: + if "required" in res[key]: + del res[key]["required"] + if "default" in res[key]: + del res[key]["default"] + return res + + def _validator_delete(self): + return {} + + def _get_base_search_domain(self): + return self._default_domain_for_partner_records( + # NOTE: here we must use `child_of` as default operator + partner_field="id", + operator="child_of", + with_backend=False, + ) + + def _json_parser(self): + res = [ + "id", + ("parent_id", lambda rec, fname: rec.parent_id.id), + "type", + "display_name", + "name", + "ref", + "street", + "street2", + "zip", + "city", + "phone", + "email", + "function", + "opt_in", + "is_blacklisted:opt_out", + "vat", + ("state_id:state", ["id", "name", "code"]), + ("country_id:country", ["id", "name", "code"]), + "address_type", + "is_company", + "lang", + ("title", ["id", "name"]), + "is_shopinvader_active:enabled", + ("industry_id", ["id", "name"]), + ] + return res + + def _to_json(self, address, **kw): + data = address.jsonify(self._json_parser()) + for item in data: + # access info on the current record partner record + item["access"] = self.access_info.for_address(item["id"]) + return data + + def _prepare_params(self, params, mode="create"): + if "country" in params: + val = params.pop("country") + if "id" in val: + params["country_id"] = val["id"] + elif "code" in val: + country = self.env["res.country"].search( + [("code", "=", val["code"])], + limit=1, + ) + params["country_id"] = country.id + if "state" in params: + val = params.pop("state") + if "id" in val: + params["state_id"] = val["id"] + + # TODO: every field like m2o should be handled in the same way. + # `country` and `state` are exceptions as they should match `_id` + # naming already on client side as it has been done for industry. + # Moreover, is weird that we send a dictionary containing and ID + # instead of sending the ID straight. + if params.get("industry_id"): + params["industry_id"] = params.get("industry_id")["id"] + if params.get("title"): + params["title"] = params.get("title")["id"] + + return params diff --git a/shopinvader_restapi/services/cart.py b/shopinvader_restapi/services/cart.py new file mode 100644 index 0000000000..299a5565ca --- /dev/null +++ b/shopinvader_restapi/services/cart.py @@ -0,0 +1,533 @@ +# Copyright 2016 Akretion (http://www.akretion.com) +# Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# pylint: disable=consider-merging-classes-inherited +import logging + +from cerberus import Validator +from werkzeug.exceptions import NotFound + +from odoo.exceptions import UserError +from odoo.tools.translate import _ + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class CartService(Component): + """Shopinvader service to provide e-commerce cart features.""" + + _inherit = "shopinvader.abstract.sale.service" + _name = "shopinvader.cart.service" + _usage = "cart" + _description = __doc__ + + @property + def cart_id(self): + return self.shopinvader_session.get("cart_id", 0) + + # The following method are 'public' and can be called from the controller. + + def search(self): + """Retrieve cart from session or existing cart for current customer.""" + if not self.cart_id: + return {} + return self._to_json(self._get(create_if_not_found=False)) + + def update(self, **params): + cart = self._get() + response = self._update(cart, params) + if response.get("redirect_to"): + return response + else: + return self._to_json(cart) + + def add_item(self, **params): + cart = self._get(create_if_not_found=False) + if not cart: + cart_params = self._add_item_create_cart_params(**params) + cart = self._create_empty_cart(**cart_params) + + self._add_item(cart, params) + return self._to_json(cart) + + def update_item(self, **params): + cart = self._get() + self._update_item(cart, params) + return self._to_json(cart) + + def delete_item(self, **params): + cart = self._get() + self._delete_item(cart, params) + return self._to_json(cart) + + def clear(self): + """ + Clear the current cart (by $session) + :return: dict/json + """ + cart = self._get() + cart = self._clear_cart(cart) + return self._to_json(cart) + + def _add_item_create_cart_params(self, **params): + # Hook here to customize cart creation + return {} + + def _get_validator_cart_by_id_domain(self, value): + """ + Get cart by id domain. Limiting to session partner. + :param value: + :return: + """ + return [("partner_id", "=", self.partner.id), ("id", "=", value)] + + def _get_line_copy_vals(self, line): + """ + Prepare copy values to be passed to _add_item + :param line: sale.order.line + :return: dict + """ + return { + "product_id": line.product_id.id, + "item_qty": line.product_uom_qty, + } + + def _get_lines_to_copy(self, cart): + return cart.order_line + + # pylint: disable=W8102,W8106 + def copy(self, **params): + """ + This service allows + :param params: + :return: + """ + return self._copy(**params) + + def _copy(self, **params): + """ + Copy the cart given by id without the lines + They will be re-added + :return: dict/json + """ + cart = self.env["sale.order"].search( + self._get_validator_cart_by_id_domain(params.get("id")) + ) + # Copy the existing cart + # Delete all lines and re-add them with 'shopinvader' flavour + new_cart = cart.copy({"order_line": False, "typology": "cart"}) + for line in self._get_lines_to_copy(cart): + vals = self._get_line_copy_vals(line) + self._add_item(new_cart, vals) + + return self._to_json(new_cart) + + # Validator + def _cart_validator_exists(self, field, value, error): + """ + Implements 'check_with' validation + :param field: + :param value: + :param error: + :return: + """ + cart = self.env["sale.order"].search( + self._get_validator_cart_by_id_domain(value) + ) + if len(cart) != 1: + error(field, _("The cart does not exists or does not belong to you!")) + + def _validator_copy(self): + return { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + "check_with": self._cart_validator_exists, + } + } + + def _validator_return_copy(self): + return Validator({}, allow_unknown=True) + + def _validator_search(self): + return {} + + def _validator_clear(self): + return {} + + def _subvalidator_shipping(self): + return { + "type": "dict", + "schema": { + "address": { + "type": "dict", + "schema": {"id": {"coerce": to_int}}, + } + }, + } + + def _subvalidator_invoicing(self): + return { + "type": "dict", + "schema": { + "address": { + "type": "dict", + "schema": {"id": {"coerce": to_int}}, + } + }, + } + + def _subvalidator_step(self): + return { + "type": "dict", + "schema": { + "current": {"type": "string"}, + "next": {"type": "string"}, + }, + } + + def _validator_update(self): + return { + "client_order_ref": { + "type": "string", + "required": False, + "nullable": True, + }, + "step": self._subvalidator_step(), + "shipping": self._subvalidator_shipping(), + "invoicing": self._subvalidator_invoicing(), + "note": {"type": "string"}, + } + + def _validator_add_item(self): + return { + "product_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "item_qty": {"coerce": float, "required": True, "type": "float"}, + } + + def _validator_update_item(self): + return { + "item_id": {"coerce": to_int, "required": True, "type": "integer"}, + "item_qty": {"coerce": float, "required": True, "type": "float"}, + } + + def _validator_delete_item(self): + return {"item_id": {"coerce": to_int, "required": True, "type": "integer"}} + + # The following method are 'private' and should be never never NEVER call + # from the controller. + # All params are trusted as they have been checked before + + def _upgrade_cart_item_quantity_vals(self, item, params, action="replace"): + assert action in ("sum", "replace") + if action == "replace": + qty = params["item_qty"] + else: + qty = item.product_uom_qty + params["item_qty"] + return {"product_uom_qty": qty} + + def _upgrade_cart_item_quantity(self, cart, item, params, action="replace"): + vals = self._upgrade_cart_item_quantity_vals(item, params, action=action) + with self.env.norecompute(): + new_values = item.play_onchanges(vals, vals.keys()) + # clear cache after play onchange + real_line_ids = [line.id for line in cart.order_line if line.id] + cart._cache["order_line"] = tuple(real_line_ids) + vals.update(new_values) + item.order_id.write({"order_line": [(1, item.id, vals)]}) + cart.flush_recordset() + + def _do_clear_cart_cancel(self, cart): + """ + Cancel the existing cart. + Don't need to create a new one because it'll done automatically + when the customer will add a new item. + :param cart: sale.order recordset + :return: sale.order recordset + """ + cart.action_cancel() + return cart.browse() + + def _do_clear_cart_delete(self, cart): + """ + Delete/unlink the given cart + :param cart: sale.order recordset + :return: sale.order recordset + """ + cart.unlink() + return cart.browse() + + def _do_clear_cart_clear(self, cart): + """ + Remove items from given cart. + :param cart: sale.order recordset + :return: sale.order recordset + """ + cart.write({"order_line": [(5, False, False)]}) + return cart + + def _clear_cart(self, cart): + """ + Action to clear the cart, depending on the backend configuration. + :param cart: sale.order recordset + :return: sale.order recordset + """ + clear_option = self.shopinvader_backend.clear_cart_options + do_clear = "_do_clear_cart_%s" % clear_option + if hasattr(self, do_clear): + cart = getattr(self, do_clear)(cart) + else: + _logger.error("The %s function doesn't exists.", do_clear) + raise NotImplementedError(_("Missing feature to clear the cart!")) + return cart + + def _add_item(self, cart, params): + item = self._check_existing_cart_item(cart, params) + if item: + self._upgrade_cart_item_quantity(cart, item, params, action="sum") + else: + with self.env.norecompute(): + item = self._create_cart_line(cart, params) + cart.flush_recordset() + return item + + def _create_cart_line(self, cart, params): + vals = self._prepare_cart_item(params, cart) + new_values = self.env["sale.order.line"].play_onchanges(vals, vals.keys()) + vals.update(new_values) + # As the frontend could be in several languages but we have only + # one anonymous parnter with his language set, we need to ensure + # that description on the line is in the right language + partner = cart.partner_id + ctx_lang = self.env.context.get("lang", partner.lang) + if partner.lang != ctx_lang: + vals["name"] = self._get_sale_order_line_name(vals) + existing_lines = cart.order_line + # A write on the cart itself to trigger changes + cart.write({"order_line": [(0, False, vals)]}) + return cart.order_line - existing_lines + + def _get_sale_order_line_name(self, vals): + product = self.env["product.product"].browse(vals["product_id"]) + name = product.name_get()[0][1] + if product.description_sale: + name += "\n" + product.description_sale + return name + + def _update_item(self, cart, params, item=False): + if not item: + item = self._get_cart_item(cart, params, raise_if_not_found=False) + if item: + self._upgrade_cart_item_quantity(cart, item, params) + return + # The item id is maybe the one from a previous cart. + line_id = params["item_id"] + line = self.env["sale.order.line"].search( + [ + ("id", "=", line_id), + ("order_id.partner_id", "=", cart.partner_id.id), + ] + ) + if line: + # silently create a new line on the new cart from the previous + # line. This case could occurs if the customer click on the add + # button from within an old session still open in its browser + add_item_params = self._prepare_add_item_params_from_line(line) + add_item_params["item_qty"] = params["item_qty"] + self._add_item(cart, add_item_params) + return params["item_qty"] + raise NotFound("No cart item found with id %s" % params["item_id"]) + + def _delete_item(self, cart, params): + item = self._get_cart_item(cart, params, raise_if_not_found=False) + if item: + item.order_id.write({"order_line": [(2, item.id, False)]}) + + def _prepare_add_item_params_from_line(self, sale_order_line): + return {"product_id": sale_order_line.product_id.id, "item_qty": 1} + + def _prepare_shipping(self, shipping, params): + if "address" in shipping: + address = shipping["address"] + # By default we always set the invoice address with the + # shipping address, if you want a different invoice address + # just pass it + params["partner_shipping_id"] = address["id"] + if ( + self.shopinvader_backend.cart_checkout_address_policy + == "invoice_defaults_to_shipping" + ): + params["partner_invoice_id"] = params["partner_shipping_id"] + + def _prepare_invoicing(self, invoicing, params): + if "address" in invoicing: + params["partner_invoice_id"] = invoicing["address"]["id"] + + def _prepare_step(self, step, params): + if "next" in step: + params["current_step_id"] = self._get_step_from_code(step["next"]).id + if "current" in step: + params["done_step_ids"] = [ + (4, self._get_step_from_code(step["current"]).id, 0) + ] + + def _prepare_update(self, cart, params): + if "shipping" in params: + self._prepare_shipping(params.pop("shipping"), params) + if "invoicing" in params: + self._prepare_invoicing(params.pop("invoicing"), params) + if "step" in params: + self._prepare_step(params.pop("step"), params) + return params + + def _update(self, cart, params): + params = self._prepare_update(cart, params) + if params: + cart.write_with_onchange(params) + return {} + + def _get_step_from_code(self, code): + step = self.env["shopinvader.cart.step"].search([("code", "=", code)]) + if not step: + raise UserError(_("Invalid step code %s") % code) + else: + return step + + def _to_json(self, cart, **kw): + if not cart: + return { + "data": {}, + "store_cache": {"cart": {}}, + "set_session": {"cart_id": 0}, + } + res = super(CartService, self)._to_json(cart)[0] + + return { + "data": res, + "set_session": {"cart_id": res["id"]}, + "store_cache": {"cart": res}, + } + + def _get(self, create_if_not_found=True): + """Get the session's cart + + Here we take advantage of the cache. If the cart has been already loaded, + no SQL query will be issued. + + :return: sale.order recordset (cart) + """ + domain = [ + ("shopinvader_backend_id", "=", self.shopinvader_backend.id), + ("typology", "=", "cart"), + ("state", "=", "draft"), + ] + cart = ( + self.env["sale.order"].browse(self.cart_id).exists().filtered_domain(domain) + ) + if not cart and create_if_not_found: + cart = self._create_empty_cart() + return cart + + def _create_empty_cart(self, **cart_params): + vals = self._prepare_cart(**cart_params) + return self.env["sale.order"].create(vals) + + def _prepare_cart(self, **cart_params): + partner = self.partner or self.shopinvader_backend.anonymous_partner_id + vals = cart_params.copy() + # Ensure our mandatory values have precendence + vals.update( + { + "typology": "cart", + "partner_id": partner.id, + "shopinvader_backend_id": self.shopinvader_backend.id, + } + ) + # Play onchanges + vals.update(self.env["sale.order"].play_onchanges(vals, vals.keys())) + # Set optional default values from backend configuration + backend = self.shopinvader_backend + if "analytic_account_id" not in cart_params and backend.account_analytic_id: + vals["analytic_account_id"] = backend.account_analytic_id.id + if "pricelist_id" not in cart_params: + pricelist = self._get_pricelist(partner) + if pricelist: + vals["pricelist_id"] = pricelist.id + if "name" not in cart_params and backend.sequence_id: + vals["name"] = backend.sequence_id._next() + return vals + + def _get_pricelist(self, partner): + return self.shopinvader_backend._get_cart_pricelist(partner) + + def _get_onchange_trigger_fields(self): + return ["partner_id", "partner_shipping_id", "partner_invoice_id"] + + def _check_call_onchange(self, params): + onchange_fields = self._get_onchange_trigger_fields() + for changed_field in params.keys(): + if changed_field in onchange_fields: + return True + return False + + def _get_cart_item(self, cart, params, raise_if_not_found=True): + # We search the line based on the item id and the cart id + # indeed the item_id information is given by the + # end user (untrusted data) and the cart id by the + # locomotive server (trusted data) + item = cart.mapped("order_line").filtered( + lambda l, id_=params["item_id"]: l.id == id_ + ) + if not item and raise_if_not_found: + raise NotFound("No cart item found with id %s" % params["item_id"]) + return item + + def _check_existing_cart_item(self, cart, params): + product_id = params["product_id"] + order_lines = cart.order_line + return order_lines.filtered( + lambda l, p=product_id: l.product_id.id == product_id + ) + + def _prepare_cart_item(self, params, cart): + valid_fields = self.env["sale.order.line"]._fields + _params = {k: v for k, v in params.items() if k in valid_fields} + _params.update( + { + "product_uom_qty": params["item_qty"], + "order_id": cart.id, + "sequence": max(cart.order_line.mapped("sequence"), default=0) + 1, + "currency_id": cart.currency_id.id, + } + ) + return _params + + def _load_target_email(self, record_id): + """ + As this service doesn't have a _expose_model, we have to do it manually + :param record_id: int + :return: record or None + """ + return self.env["sale.order"].browse(record_id) + + def _get_openapi_default_parameters(self): + defaults = super(CartService, self)._get_openapi_default_parameters() + defaults.append( + { + "name": "SESS-CART-ID", + "in": "header", + "description": "Session Cart Identifier", + "required": False, + "schema": {"type": "integer"}, + "style": "simple", + } + ) + return defaults diff --git a/shopinvader_restapi/services/customer.py b/shopinvader_restapi/services/customer.py new file mode 100644 index 0000000000..263242e69e --- /dev/null +++ b/shopinvader_restapi/services/customer.py @@ -0,0 +1,195 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# Copyright 2019 Camptocamp (http://www.camptocamp.com) +# Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# pylint: disable=consider-merging-classes-inherited,method-required-super +from odoo import _ +from odoo.exceptions import UserError + +from odoo.addons.component.core import Component + + +class CustomerService(Component): + """Shopinvader service to create and edit customers.""" + + _inherit = [ + "base.shopinvader.service", + "shopinvader.partner.service.mixin", + ] + _name = "shopinvader.customer.service" + _usage = "customer" + _expose_model = "res.partner" + _description = __doc__ + + # The following method are 'public' and can be called from the controller. + def get(self): + if self.partner: + customer = self._to_customer_info(self.partner) + return {"data": customer, "store_cache": {"customer": customer}} + else: + return {"data": {}} + + # pylint: disable=W8106 + def create(self, **params): + vals = self._prepare_params(params) + binding = self.env["shopinvader.partner"].create(vals) + self._load_partner_work_context(binding, True) + self._post_create(self.work.partner) + return self._prepare_create_response(binding) + + def update(self, _id, **params): + # We get the binding from the partner + if not self._is_logged_in(): + raise UserError(_("You must provide a partner")) + + partner = self._get(_id) + binding = partner._get_invader_partner(self.shopinvader_backend) + if not binding: + raise UserError(_("Partner %s is not a customer") % partner.id) + + vals = self._prepare_params(params, mode="update") + binding.write(vals) + + self._post_update(self.work.partner) + + return self.get() + + def sign_in(self, **params): + return self._assign_cart_and_get_store_cache() + + # The following method are 'private' and should be never never NEVER call + # from the controller. + # All params are trusted as they have been checked before + + def _validator_sign_in(self): + return {} + + def _validator_create(self): + address = self.component(usage="addresses") + schema = address._validator_create() + schema.update( + { + # Email is mandatory anyway + "email": {"type": "string", "required": True, "nullable": False}, + "lang": {"type": "string", "required": False, "nullable": True}, + } + ) + schema.update(self._validator_external_ref()) + for key in self._validator_create_exclude_keys(): + schema.pop(key, None) + for key in self._validator_create_non_required_address_keys(): + if key in schema: + schema[key]["required"] = False + schema[key]["nullable"] = True + return schema + + def _validator_update(self): + address = self.component(usage="addresses") + schema = address._validator_update() + schema.update(self._validator_external_ref()) + for key in self._validator_update_exclude_keys(): + schema.pop(key, None) + for key in self._validator_update_non_required_address_keys(): + if key in schema: + schema[key]["required"] = False + schema[key]["nullable"] = True + return schema + + def _validator_create_exclude_keys(self): + return ["type"] + + def _validator_update_exclude_keys(self): + return self._validator_create_exclude_keys() + + def _validator_create_non_required_address_keys(self): + # fmt: off + return [ + "external_id", + "vat", + "company_name", + "function", + "street", + "street2", + "zip", + "city", + "phone", + "country", + ] + # fmt: on + + def _validator_update_non_required_address_keys(self): + return self._validator_create_non_required_address_keys() + ["email", "lang"] + + def _validator_external_ref(self): + return { + "external_id": {"type": "string", "required": False, "nullable": True}, + } + + def _get_base_search_domain(self): + return self._default_domain_for_partner_records( + # NOTE: here we must use `child_of` as default operator + partner_field="id", + operator="child_of", + with_backend=False, + ) + + def _prepare_params(self, params, mode="create"): + address = self.component(usage="addresses") + params = address._prepare_params(params, mode=mode) + pricelist = self.shopinvader_backend._get_customer_default_pricelist() + # fmt: off + params.update( + { + "backend_id": self.shopinvader_backend.id, + "property_product_pricelist": pricelist.id if pricelist else None, + } + ) + # fmt: on + if mode == "create": + if params.get("is_company"): + params["is_company"] = True + return params + + def _get_and_assign_cart(self): + cart_service = self.component(usage="cart") + cart = cart_service._get(create_if_not_found=False) + if cart: + if self.partner and cart.partner_id != self.partner: + # we need to affect the cart to the partner + cart.write_with_onchange( + { + "partner_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "partner_invoice_id": self.partner.id, + } + ) + return cart_service._to_json(cart)["data"] + else: + return {} + + def _assign_cart_and_get_store_cache(self): + cart = self._get_and_assign_cart() + customer = self._to_customer_info(self.partner) + result = {"store_cache": {"cart": cart, "customer": customer}} + if cart: + result["set_session"] = {"cart_id": cart["id"]} + return result + + def _to_customer_info(self, partner): + address = self.component(usage="addresses") + info = address._to_json(partner)[0] + # access info on the current record partner record + info["access"] = self.access_info.for_profile(partner.id) + # global permission for current partner user + info["permissions"] = self.access_info.permissions() + return info + + def _prepare_create_response(self, binding): + response = self._assign_cart_and_get_store_cache() + response["data"] = { + "id": binding.record_id.id, + "name": binding.name, + "role": binding.role, + } + return response diff --git a/shopinvader_restapi/services/invoice.py b/shopinvader_restapi/services/invoice.py new file mode 100644 index 0000000000..91a7397592 --- /dev/null +++ b/shopinvader_restapi/services/invoice.py @@ -0,0 +1,106 @@ +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo.osv import expression + +from odoo.addons.component.core import Component + + +class InvoiceService(Component): + """Shopinvader service to expose invoices.""" + + _inherit = [ + "shopinvader.abstract.mail.service", + "abstract.shopinvader.download", + ] + _name = "shopinvader.invoice.service" + _usage = "invoices" + _expose_model = "account.move" + _description = __doc__ + + # The following method are 'public' and can be called from the controller. + # All params are untrusted so please check it ! + + # Private implementation + + def _get_allowed_invoice_states(self): + """Get invoice states. + + :return: list of str + """ + return ["posted"] + + def _get_allowed_payment_states(self): + """Get invoice payment states. + + :return: list of str + """ + states = ["paid", "reversed"] + if self.shopinvader_backend.invoice_access_open: + states += ["not_paid", "in_payment", "partial"] + return states + + def _get_base_search_domain(self): + """Domain used to retrieve requested invoices. + + This domain MUST TAKE CARE of restricting the access to the invoices + visible for the current customer + :return: Odoo domain + """ + # The partner must be set and not be the anonymous one + if not self._is_logged_in(): + return expression.FALSE_DOMAIN + invoices = self._get_available_invoices() + domain_invoice_ids = [("id", "in", invoices.ids)] + return expression.normalize_domain( + expression.AND([domain_invoice_ids, self._get_domain_state()]) + ) + + def _get_domain_state(self): + domain_state = [("state", "in", self._get_allowed_invoice_states())] + domain_payment_state = [ + ("payment_state", "in", self._get_allowed_payment_states()) + ] + return expression.AND([domain_state, domain_payment_state]) + + def _get_available_invoices(self): + """Retrieve invoices for current customer.""" + # here we only allow access to invoices linked to a sale order of the + # current customer + if self.shopinvader_backend.invoice_linked_to_sale_only: + so_domain = self._get_sale_order_domain() + # invoice_ids on sale.order is a computed field... + # to avoid to duplicate the logic, we search for the sale orders + # and check if the invoice_id is into the list of sale.invoice_ids + sales = self.env["sale.order"].search(so_domain) + invoices = sales.mapped("invoice_ids") + else: + invoices = self.env["account.move"].search( + [ + ("partner_id", "=", self.partner.id), + ("move_type", "in", ["out_invoice", "out_refund"]), + ] + ) + return invoices + + def _get_sale_order_domain(self): + return self.component(usage="sales")._get_base_search_domain() + + def _get_report_action(self, target, params=None): + """Get the action/dict to generate the report. + + :param target: recordset + :return: dict/action + """ + report = self.shopinvader_backend.invoice_report_id + if not report: + report = self.env.ref("account.account_invoices") + return report.report_action(target, config=False) + + +class DeprecatedInvoiceService(Component): + """Deprecated Service use 'invoices' instead""" + + _inherit = "shopinvader.invoice.service" + _name = "shopinvader.deprecated.invoice.service" + _usage = "invoice" + _description = __doc__ diff --git a/shopinvader_restapi/services/partner_mixin.py b/shopinvader_restapi/services/partner_mixin.py new file mode 100644 index 0000000000..d7dc09133d --- /dev/null +++ b/shopinvader_restapi/services/partner_mixin.py @@ -0,0 +1,145 @@ +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# pylint: disable=method-required-super, consider-merging-classes-inherited + +from odoo import _ + +from odoo.addons.component.core import AbstractComponent + +# TODO: refactor out to a component event the handling of notifications + + +class PartnerServiceMixin(AbstractComponent): + _name = "shopinvader.partner.service.mixin" + + @property + def access_info(self): + with self.shopinvader_backend.work_on( + "res.partner", + partner=self.partner, + partner_user=self.partner_user, + invader_partner=self.invader_partner, + invader_partner_user=self.invader_partner_user, + service_work=self.work, + ) as work: + return work.component(usage="access.info") + + def _post_create(self, partner): + self._notify_partner(partner, "create") + self._notify_salesman(partner, "create") + + def _post_update(self, partner): + self._notify_partner(partner, "update") + self._notify_salesman(partner, "update") + + def _notify_salesman(self, partner, mode): + needed = False + backend_policy = self.shopinvader_backend["salesman_notify_" + mode] + needed = self._notify_salesman_needed(backend_policy, partner, mode) + if needed: + self.env["mail.activity"].sudo().create( + self._notify_salesman_values(partner, mode) + ) + + def _notify_salesman_values(self, partner, mode): + # TODO: mode is not translated + msg = _("{addr_type} {mode} '{name}' needs review").format( + addr_type=partner.addr_type_display(), name=partner.name, mode=mode + ) + return { + "res_model_id": self.env.ref("base.model_res_partner").id, + "res_id": self._notify_salesman_recipient(partner, mode).id, + "user_id": self._get_salesman(partner).id, + "activity_type_id": self.env.ref( + "shopinvader_restapi.mail_activity_review_customer" + ).id, + "summary": msg, + } + + def _get_salesman(self, partner): + """Retrieve salesman for the partner up to its hierarchy.""" + user = partner.user_id + while not user and partner.parent_id: + partner = partner.parent_id + user = partner.user_id + return user or self.env.user + + def _notify_partner(self, partner, mode): + notif_type = self._notify_partner_type(partner, mode) + recipient = self._notify_partner_recipient(partner, mode) + if notif_type and recipient: + self.shopinvader_backend._send_notification(notif_type, recipient) + + # HACK: these methods were supposed to be overriden in specific services + # BUT the `customer` service has no `update` endpoint, + # it relies on `addresses` endpoint for updates, hence + # we are forced to discriminate on address type all in the same place. + + def _notify_partner_recipient(self, partner, mode): + handler = getattr( + self, "_notify_partner_recipient_" + partner.address_type, None + ) + if handler: + return handler(partner, mode) + return partner + + def _notify_partner_recipient_address(self, partner, mode): + # notify on the owner of the address + # Safe default to given partner in case we are updating the profile + # which is done w/ the addresses endpoint anyway. + return partner.parent_id if partner.parent_id else partner + + def _notify_partner_type(self, partner, mode): + handler = getattr(self, "_notify_partner_type_" + partner.address_type, None) + if handler: + return handler(partner, mode) + return partner + + def _notify_partner_type_profile(self, partner, mode): + notif = None + if mode == "create": + notif = "new_customer_welcome" + elif mode == "update": + notif = "customer_updated" + return notif + + def _notify_partner_type_address(self, partner, mode): + notif = None + if mode == "create": + notif = "address_created" + elif mode == "update": + notif = "address_updated" + return notif + + def _notify_salesman_recipient(self, partner, mode): + handler = getattr( + self, "_notify_salesman_recipient_" + partner.address_type, None + ) + if handler: + return handler(partner, mode) + return partner + + def _notify_salesman_recipient_address(self, partner, mode): + # notify on the owner of the address + # Safe default to given partner in case we are updating the profile + # which is done w/ the addresses endpoint anyway. + return partner.parent_id if partner.parent_id else partner + + def _notify_salesman_needed(self, backend_policy, partner, mode): + handler = getattr(self, "_notify_salesman_needed_" + partner.address_type, None) + if handler: + return handler(backend_policy, partner, mode) + return partner + + def _notify_salesman_needed_address(self, backend_policy, partner, mode): + return backend_policy in ("all", "address") + + def _notify_salesman_needed_profile(self, backend_policy, partner, mode): + if backend_policy in ("all", "company_and_user"): + return True + elif backend_policy == "company" and partner.is_company: + return True + elif backend_policy == "user" and not partner.is_company: + return True + return False diff --git a/shopinvader_restapi/services/sale.py b/shopinvader_restapi/services/sale.py new file mode 100644 index 0000000000..3ff515d2de --- /dev/null +++ b/shopinvader_restapi/services/sale.py @@ -0,0 +1,159 @@ +# 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.exceptions import UserError +from odoo.osv import expression +from odoo.tools.float_utils import float_is_zero +from odoo.tools.translate import _ + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class SaleService(Component): + """Shopinvader service to expose sale orders records.""" + + _inherit = [ + "shopinvader.abstract.sale.service", + "abstract.shopinvader.download", + ] + _name = "shopinvader.sale.service" + _usage = "sales" + _expose_model = "sale.order" + _description = __doc__ + + # The following method are 'public' and can be called from the controller. + # All params are untrusted so please check it ! + + def get(self, _id): + order = self._get(_id) + return self._to_json(order)[0] + + def search(self, **params): + return self._paginate_search(**params) + + def _get_report_action(self, target, params=None): + """ + Get the action/dict to generate the report + :param target: recordset + :param params: dict + :return: dict/action + """ + return self.env.ref("sale.action_report_saleorder").report_action( + target, config=False + ) + + def ask_email_invoice(self, _id): + """ + Ask to receive invoices related to sale ID by email + :param _id: int + :return: + """ + self._ask_email_invoice = True + return self.ask_email(_id) + + def cancel(self, _id): + order = self._get(_id) + self._cancel(order) + return self._to_json(order)[0] + + def reset_to_cart(self, _id): + order = self._get(_id) + self._cancel(order, reset_to_cart=True) + res = self._to_json(order)[0] + return { + "data": res, + "set_session": {"cart_id": res["id"]}, + "store_cache": {"cart": res}, + } + + # Validator + def _validator_search(self): + default_search_validator = self._default_validator_search() + default_search_validator.pop("domain", {}) + default_search_validator.update({"id": {"coerce": to_int, "type": "integer"}}) + return default_search_validator + + def _validator_ask_email_invoice(self): + return self._validator_ask_email() + + def _validator_cancel(self): + return {} + + def _validator_reset_to_cart(self): + return {} + + # The following method are 'private' and should be never never NEVER call + # from the controller. + # All params are trusted as they have been checked before + + def _get_base_search_domain(self): + return expression.normalize_domain( + self._default_domain_for_partner_records() + [("typology", "=", "sale")] + ) + + def _get_email_notification_type(self, record): + """ + Inherit to add the notification type for invoices related to this SO + :param record: target record + :return: str + """ + result = super(SaleService, self)._get_email_notification_type(record) + if getattr(self, "_ask_email_invoice", False): + result = "invoice_send_email" + return result + + def _launch_notification(self, target, notif_type): + """ + Action to launch the notification (on the current backend) for the + given record + :param target: record + :param notif_type: str + :return: bool + """ + if notif_type == "invoice_send_email": + target = target.invoice_ids + return super(SaleService, self)._launch_notification(target, notif_type) + + def _convert_one_sale(self, sale): + res = super(SaleService, self)._convert_one_sale(sale) + res["invoices"] = self._convert_invoices(self._get_invoices(sale)) + return res + + def _get_invoices(self, sale): + invoices = sale.sudo().invoice_ids + invoice_service = self.component(usage="invoice") + domain_state = invoice_service._get_domain_state() + return invoices.filtered_domain(domain_state) + + def _is_cancel_allowed(self, sale): + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + for line in sale.order_line: + if not float_is_zero( + line.qty_delivered, precision_digits=precision + ) or not float_is_zero(line.qty_invoiced, precision_digits=precision): + raise UserError( + _("Orders that have been delivered or invoiced cannot be edited.") + ) + return True + + def _cancel(self, sale, reset_to_cart=False): + if not self._is_cancel_allowed(sale): + raise UserError(_("This order cannot be cancelled")) + sale.with_context(disable_cancel_warning=True).action_cancel() + if reset_to_cart: + sale.action_draft() + sale.typology = "cart" + return sale + + def _convert_invoices(self, invoices): + return [self._convert_one_invoice(invoice) for invoice in invoices] + + def _convert_one_invoice(self, invoice): + return { + "id": invoice.id, + "name": invoice.name, + "date": invoice.invoice_date or None, + } diff --git a/shopinvader_restapi/services/service.py b/shopinvader_restapi/services/service.py new file mode 100644 index 0000000000..c98eaf729a --- /dev/null +++ b/shopinvader_restapi/services/service.py @@ -0,0 +1,263 @@ +# 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 + +from odoo import _ +from odoo.exceptions import MissingError, UserError +from odoo.osv import expression +from odoo.tools import frozendict + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import AbstractComponent + +from .. import shopinvader_response, utils + +_logger = logging.getLogger(__name__) + + +class BaseShopinvaderService(AbstractComponent): + _inherit = "base.rest.service" + _name = "base.shopinvader.service" + _collection = "shopinvader.backend" + _expose_model = None + + def _load_partner_work_context(self, invader_partner, force=False): + utils.load_partner_work_ctx(self, invader_partner, force=force) + + def _reset_partner_work_context(self): + # Basically like logging out a user + utils.reset_partner_work_ctx(self) + + @property + def env(self): + env = self.work.env + env.context = frozendict(dict(env.context, shopinvader_request=True)) + return env + + @property + def partner(self): + # partner that matches the real profile on client side + # or its main contact which in any case is used for all + # account information. + return self.work.partner + + @property + def invader_partner(self): + return self.work.invader_partner + + @property + def partner_user(self): + # partner that matches the real user on client side. + # The standard `self.partner` will match `partner_user` + # only when the main customer account is logged in. + # In this way we can support multiple actors for the same profile. + # TODO: check if there are place wher it's better to use + # `partner_user` instead of `partner`. + return getattr(self.work, "partner_user", self.partner) + + @property + def invader_partner_user(self): + return self.work.invader_partner_user + + @property + def shopinvader_session(self): + return self.work.shopinvader_session + + @property + def shopinvader_backend(self): + return self.work.shopinvader_backend + + @property + def client_header(self): + return self.work.client_header + + _scope_to_domain_operators = { + "gt": ">", + "gte": ">=", + "lt": "<", + "lte": "<=", + "ne": "!=", + "like": "like", + "ilike": "ilike", + } + + def _scope_to_domain(self, scope): + # Convert the frontend scope syntax to the odoo domain + try: + domain = [] + for key, value in scope.items(): + if "." in key: + key, op = key.split(".") + op = self._scope_to_domain_operators[op] + else: + op = "=" + domain.append((key, op, value)) + return domain + except Exception as e: + raise UserError( + _("Invalid scope {scope}, error: {error}").format( + scope=str(scope), error=str(e) + ) + ) from e + + # Validator + def _default_validator_search(self): + """ + Get a default validator for search service. + This search include every parameters used for pagination, scope and domain. + This directly used a _validator_search in case of an existing service + doesn't allow all of these parameters (backward compatibility). + :return: dict + """ + return { + "page": {"coerce": to_int, "nullable": True, "type": "integer"}, + "per_page": { + "coerce": to_int, + "nullable": True, + "type": "integer", + }, + "domain": {"type": "list", "nullable": True}, + "scope": {"type": "dict", "nullable": True}, + "order": {"type": "string", "nullable": True}, + } + + @property + def _exposed_model(self): + return self.env.get(self._expose_model) + + def _paginate_search( + self, default_page=1, default_per_page=5, order=None, **params + ): + """ + Build a domain and search on it. + As we use expression (from Odoo), manuals domains get from "scope" and + "domain" keys are normalized to avoid issues. + :param default_page: int + :param default_per_page: int + :param params: dict + :return: dict + """ + domain = self._get_base_search_domain() + if params.get("scope"): + scope_domain = self._scope_to_domain(params.pop("scope")) + scope_domain = expression.normalize_domain(scope_domain) + domain = expression.AND([domain, scope_domain]) + if params.get("domain"): + custom_domain = expression.normalize_domain(params.pop("domain")) + domain = expression.AND([domain, custom_domain]) + total_count = self._exposed_model.search_count(domain) + page = params.pop("page", default_page) + per_page = params.pop("per_page", default_per_page) + records = self._exposed_model.search( + domain, + limit=per_page, + offset=per_page * (page - 1), + order=self._get_search_order(order, **params), + ) + return {"size": total_count, "data": self._to_json(records, **params)} + + def _get_search_order(self, order, **params): + """Customize search results order. + + By default, simply pass the *internal odoo field* you want to sort on + (eg: 'date_order desc'). + + Override to define special sorting policies. + """ + return order + + def _get(self, _id): + domain = expression.normalize_domain(self._get_base_search_domain()) + domain = expression.AND([domain, [("id", "=", _id)]]) + record = self._exposed_model.search(domain) + if not record: + raise MissingError( + _("The record {model} {id} does not exist").format( + model=self._expose_model, id=_id + ) + ) + else: + return record + + def _get_base_search_domain(self): + return [] + + def _default_domain_for_partner_records( + self, partner_field="partner_id", operator="=", with_backend=True, **kw + ): + """Domain to filter records bound to current partner and backend.""" + domain = [(partner_field, operator, self.partner.id)] + if with_backend: + domain.append(("shopinvader_backend_id", "=", self.shopinvader_backend.id)) + return domain + + def _get_selection_label(self, record, field): + """ + Get the translated label of the record selection field + :param record: recordset + :param field: str + :return: str + """ + if field not in record._fields: + return "" + # convert_to_export(...) give the label of the selection (translated). + return record._fields.get(field).convert_to_export(record[field], record) + + def _get_openapi_default_parameters(self): + defaults = super(BaseShopinvaderService, self)._get_openapi_default_parameters() + defaults.append( + { + "name": "WEBSITE-UNIQUE-KEY", + "in": "header", + "description": "The unique key to identify the originating " + "website into Odoo. If this information is not provided," + "we expect to receive this information from the authentication system.", + "required": False, + "schema": {"type": "string"}, + "style": "simple", + } + ) + return defaults + + def _is_logged_in(self): + """ + Check if the current partner is a real partner (not the anonymous one + and not empty) + :return: bool + """ + logged = False + if ( + self.partner + and self.partner != self.shopinvader_backend.anonymous_partner_id + ): + logged = True + return logged + + def _is_logged(self): + _logger.warning("DEPRECATED: You should use `self._is_logged_in()`") + return self._is_logged_in() + + @property + def shopinvader_response(self): + """ + An instance of + ``odoo.addons.shopinvader.shopinvader_response.ShopinvaderResponse``. + """ + return shopinvader_response.get() + + def dispatch(self, method_name, *args, params=None): + res = super().dispatch(method_name, *args, params=params) + store_cache = self.shopinvader_response.store_cache + if store_cache: + values = res.get("store_cache", {}) + values.update(store_cache) + res["store_cache"] = values + session = self.shopinvader_response.session + if session: + values = res.get("set_session", {}) + values.update(session) + res["set_session"] = values + return res diff --git a/shopinvader_restapi/services/settings.py b/shopinvader_restapi/services/settings.py new file mode 100644 index 0000000000..b519c712ad --- /dev/null +++ b/shopinvader_restapi/services/settings.py @@ -0,0 +1,267 @@ +# Copyright 2021 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.base_rest import restapi +from odoo.addons.component.core import Component + + +class ExportSettingsService(Component): + """Shopinvader service to expose allowed settings""" + + _inherit = [ + "base.shopinvader.service", + ] + _name = "shopinvader.settings.service" + _usage = "settings" + _description = __doc__ + + def _get_all_schema(self): + return { + "countries": { + "type": "list", + "required": True, + "nullable": False, + "schema": { + "type": "dict", + "schema": self._get_countries_schema(), + }, + }, + "titles": { + "type": "list", + "required": True, + "nullable": False, + "schema": { + "type": "dict", + "schema": self._get_titles_schema(), + }, + }, + "industries": { + "type": "list", + "required": True, + "nullable": False, + "schema": { + "type": "dict", + "schema": self._get_industries_schema(), + }, + }, + "currencies": { + "type": "list", + "required": True, + "nullable": False, + "schema": { + "type": "dict", + "schema": self._get_currencies_schema(), + }, + }, + "languages": { + "type": "list", + "required": True, + "nullable": False, + "schema": { + "type": "dict", + "schema": self._get_languages_schema(), + }, + }, + } + + @restapi.method( + [(["/", "/all"], "GET")], + output_param=restapi.CerberusValidator("_get_all_schema"), + auth="public_or_default", + ) + def get_all(self): + return self._get_all() + + def _get_all(self): + return { + "countries": self._get_countries(), + "titles": self._get_titles(), + "industries": self._get_industries(), + "currencies": self._get_currencies(), + "languages": self._get_languages(), + } + + def _jsonify_fields_country(self): + return [ + "name", + "code", + "id", + ] + + def _get_countries(self): + return self.shopinvader_backend.allowed_country_ids.jsonify( + self._jsonify_fields_country() + ) + + def _get_countries_schema(self): + return { + "name": { + "type": "string", + "required": True, + "nullable": False, + }, + "code": { + "type": "string", + "required": True, + "nullable": False, + }, + "id": { + "type": "integer", + "required": True, + "nullable": False, + }, + } + + @restapi.method( + [(["/countries"], "GET")], + output_param=restapi.CerberusListValidator( + "_get_countries_schema", unique_items=True + ), + auth="public_or_default", + ) + def countries(self): + return self._get_countries() + + def _jsonify_fields_title(self): + return [ + "id", + "name", + ] + + def _get_titles(self): + return self.shopinvader_backend.partner_title_ids.jsonify( + self._jsonify_fields_title() + ) + + def _get_titles_schema(self): + return { + "name": { + "type": "string", + "required": True, + "nullable": False, + }, + "id": { + "type": "integer", + "required": True, + "nullable": False, + }, + } + + @restapi.method( + [(["/titles"], "GET")], + output_param=restapi.CerberusListValidator( + "_get_titles_schema", unique_items=True + ), + auth="public_or_default", + ) + def titles(self): + return self._get_titles() + + def _jsonify_fields_industry(self): + return [ + "id", + "name", + ] + + def _get_industries(self): + return self.shopinvader_backend.partner_industry_ids.jsonify( + self._jsonify_fields_industry() + ) + + def _get_industries_schema(self): + return { + "name": { + "type": "string", + "required": True, + "nullable": False, + }, + "id": { + "type": "integer", + "required": True, + "nullable": False, + }, + } + + @restapi.method( + [(["/industries"], "GET")], + output_param=restapi.CerberusListValidator( + "_get_industries_schema", unique_items=True + ), + auth="public_or_default", + ) + def industries(self): + return self._get_industries() + + def _jsonify_fields_currency(self): + return [ + "id", + "name", + ] + + def _get_currencies(self): + return self.shopinvader_backend.currency_ids.jsonify( + self._jsonify_fields_currency() + ) + + def _get_currencies_schema(self): + return { + "name": { + "type": "string", + "required": True, + "nullable": False, + }, + "id": { + "type": "integer", + "required": True, + "nullable": False, + }, + } + + @restapi.method( + [(["/currencies"], "GET")], + output_param=restapi.CerberusListValidator( + "_get_currencies_schema", unique_items=True + ), + auth="public_or_default", + ) + def currencies(self): + return self._get_currencies() + + def _jsonify_fields_lang(self): + return [ + "id", + "name", + "iso_code", + ] + + def _get_languages(self): + return self.shopinvader_backend.lang_ids.jsonify(self._jsonify_fields_lang()) + + def _get_languages_schema(self): + return { + "name": { + "type": "string", + "required": True, + "nullable": False, + }, + "id": { + "type": "integer", + "required": True, + "nullable": False, + }, + "iso_code": { + "type": "string", + "required": True, + "nullable": False, + }, + } + + @restapi.method( + [(["/languages"], "GET")], + output_param=restapi.CerberusListValidator( + "_get_languages_schema", unique_items=True + ), + auth="public_or_default", + ) + def languages(self): + return self._get_languages() diff --git a/shopinvader_restapi/shopinvader_response.py b/shopinvader_restapi/shopinvader_response.py new file mode 100644 index 0000000000..eff38daf8d --- /dev/null +++ b/shopinvader_restapi/shopinvader_response.py @@ -0,0 +1,78 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import threading + +from odoo.http import request + +_test_mode = False + +threadLocal = threading.local() + + +class ShopinvaderResponse(object): + """ + A response object used to enrich the response returned by a shopinvader + service with cache and session informations + """ + + def __init__(self): + self._store_cache = {} + self._session = {} + + def set_store_cache(self, key, value): + self._store_cache[key] = value + + def set_session(self, key, value): + self._session[key] = value + + @property + def store_cache(self): + """ + Read only store cache values + :return: dict + """ + return self._store_cache.copy() + + @property + def session(self): + """ + Read only session values + :return: dict + """ + return self._session.copy() + + def reset(self): + """ + Reset the content off the response + :return: + """ + self._session = {} + self._store_cache = {} + + +def set_testmode(mode): + global _test_mode + _test_mode = mode + if _test_mode: + get().reset() + + +def get(raise_if_not_found=True): + """ + Returns an instance of `ShopinvaderResponse`` + """ + current_local = request + if _test_mode: + # in test mode (unittest) request is not available as container to hold + # our context local data. (in a process/thread safe way). Since we are + # in test mode we can rely on the threadLocal context + current_local = threadLocal + try: + if not hasattr(current_local, "_shopinvader_response"): + current_local._shopinvader_response = ShopinvaderResponse() + except RuntimeError: + if raise_if_not_found: + raise + else: + return None + return current_local._shopinvader_response diff --git a/shopinvader_restapi/static/description/icon.png b/shopinvader_restapi/static/description/icon.png new file mode 100644 index 0000000000..25b31d573f Binary files /dev/null and b/shopinvader_restapi/static/description/icon.png differ diff --git a/shopinvader_restapi/static/description/icon.svg b/shopinvader_restapi/static/description/icon.svg new file mode 100644 index 0000000000..d82fe1abed --- /dev/null +++ b/shopinvader_restapi/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shopinvader_restapi/static/description/index.html b/shopinvader_restapi/static/description/index.html new file mode 100644 index 0000000000..51ac5ee393 --- /dev/null +++ b/shopinvader_restapi/static/description/index.html @@ -0,0 +1,479 @@ + + + + + + +Shopinvader + + + +
+

Shopinvader

+ + +

Beta License: AGPL-3 shopinvader/odoo-shopinvader

+

This is shopinvader the odoo module for the new generation of e-commerce.

+

ShopInvader is an ecommerce software to create and manage easily your online store with Odoo.

+

This is the Odoo side of the Shopinvader E-commerce Solution.

+

Table of contents

+ +
+

Known issues / Roadmap

+
    +
  • Customer validation limitation
  • +
+

Customer validation is global: enable/disable affects all websites, if you have more than one.

+
+

Technical

+
    +
  • Create methods should be rewritten to support multi
  • +
  • The logic to bind / unbind products and categories should be implemented as +component in place of wizard. +Previously it was possible to work with in-memory record of the wizard to +call the same logic from within odoo. In Odoo 13 it’s no more the case. +That means that to rebind thousand of records we must create thousand of +rows into the database to reuse the logic provided by the wizard.
  • +
  • On product.category the name is no more translatable in V13. +This functionality has been restored into shopinvader. +This should be moved into a dedicated addon
  • +
+
+
+
+

Changelog

+
+

10.0.1.0.0 (2017-04-11)

+
    +
  • First real version : [REF] rename project to the real name : shoptor is dead long live to shopinvader”, 2017-04-11)
  • +
+
+
+

12.0.1.0.0 (2019-05-10)

+
    +
  • [12.0][MIG] shopinvader
  • +
+
+
+
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Akretion
  • +
  • Adaptoo
  • +
  • Encresdubuit
  • +
  • Abilis
  • +
  • Camptocamp
  • +
  • Cosanum
  • +
+
+
+

Maintainers

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shopinvader_restapi/tests/__init__.py b/shopinvader_restapi/tests/__init__.py new file mode 100644 index 0000000000..412718eee4 --- /dev/null +++ b/shopinvader_restapi/tests/__init__.py @@ -0,0 +1,18 @@ +from . import test_backend +from . import test_cart +from . import test_cart_copy +from . import test_cart_item +from . import test_address +from . import test_partner_access_info +from . import test_sale +from . import test_sale_cancel +from . import test_customer +from . import test_shopinvader_partner +from . import test_res_partner +from . import test_invoice +from . import test_shopinvader_partner_binding +from . import test_notification +from . import test_salesman_notification +from . import test_search +from . import test_utils +from . import test_settings diff --git a/shopinvader_restapi/tests/common.py b/shopinvader_restapi/tests/common.py new file mode 100644 index 0000000000..78c8852dc4 --- /dev/null +++ b/shopinvader_restapi/tests/common.py @@ -0,0 +1,298 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# pylint: disable=method-required-super + +from contextlib import contextmanager +from unittest import mock + +from odoo import fields +from odoo.exceptions import MissingError +from odoo.tests import TransactionCase + +from odoo.addons.base_rest.controllers.main import RestController +from odoo.addons.base_rest.core import _rest_controllers_per_module +from odoo.addons.base_rest.tests.common import BaseRestCase, RegistryMixin +from odoo.addons.component.tests.common import ComponentMixin +from odoo.addons.queue_job.job import Job +from odoo.addons.shopinvader_restapi.models.track_external_mixin import ( + TrackExternalMixin, +) + +from .. import shopinvader_response, utils +from ..services import abstract_download + + +def _install_lang_odoo(env, lang_xml_id, full_install=False): + lang = env.ref(lang_xml_id) + + # Full install can be very expensive as it reloads EVERY translation + # for EVERY installed module. 99.999% you don't need it for tests. + if full_install: + wizard = env["base.language.install"].create({"lang": lang.code}) + wizard.lang_install() + else: + lang.active = True + return lang + + +class UtilsMixin(object): + def _install_lang(self, lang_xml_id): + return _install_lang_odoo(self.env, lang_xml_id) + + @staticmethod + def _create_invader_partner(env, **kw): + values = { + "backend_id": env.ref("shopinvader_restapi.backend_1").id, + } + values.update(kw) + return env["shopinvader.partner"].create(values) + + +class CommonMixin(RegistryMixin, ComponentMixin, UtilsMixin): + @staticmethod + def _setup_backend(cls): + cls.env = cls.env(context={"lang": "en_US", "shopinvader_test": True}) + cls.backend = cls.env.ref("shopinvader_restapi.backend_1") + cls.shopinvader_session = {} + cls.existing_jobs = cls.env["queue.job"].browse() + + @contextmanager + def work_on_services(self, **params): + params = self._work_on_services_default_params(self, **params) + with utils.work_on_service(self.env, **params) as work: + yield work + + @staticmethod + def _work_on_services_default_params(class_or_instance, **params): + if "shopinvader_backend" not in params: + params["shopinvader_backend"] = class_or_instance.backend + if "shopinvader_session" not in params: + params["shopinvader_session"] = {} + utils.partner_work_context_defaults( + class_or_instance.env, class_or_instance.backend, params + ) + return params + + def _update_work_ctx(self, service, **params): + params = self._work_on_services_default_params(self, **params) + utils.update_work_ctx(service, params) + + def _init_job_counter(self): + self.existing_jobs = self.env["queue.job"].search([]) + + @property + def created_jobs(self): + return self.env["queue.job"].search([]) - self.existing_jobs + + def _check_nbr_job_created(self, nbr): + self.assertEqual(len(self.created_jobs), nbr) + + def _perform_job(self, job): + Job.load(self.env, job.uuid).perform() + + def _perform_created_job(self): + for job in self.created_jobs: + self._perform_job(job) + + +class CommonCase(TransactionCase, CommonMixin): + + # by default disable tracking suite-wise, it's a time saver :) + tracking_disable = True + + @classmethod + def setUpClass(cls): + super(CommonCase, cls).setUpClass() + cls.env = cls.env( + context=dict(cls.env.context, tracking_disable=cls.tracking_disable) + ) + + class ControllerTest(RestController): + _root_path = "/test_shopinvader/" + _collection_name = "shopinvader.backend" + _default_auth = "public" + + # Force service registration by the creation of a fake controller + cls._ShopinvaderControllerTest = ControllerTest + CommonMixin._setup_backend(cls) + # TODO FIXME + # It seem that setUpComponent / setUpRegistry loose stuff from + # the cache so we do an explicit flush here to avoid losing data + cls.env["base"].flush_model() + cls.setUpComponent() + cls.setUpRegistry() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + _rest_controllers_per_module["shopinvader_restapi"] = [] + + def setUp(self): + TransactionCase.setUp(self) + CommonMixin.setUp(self) + + shopinvader_response.set_testmode(True) + + @self.addCleanup + def cleanupShopinvaderResponseTestMode(): + shopinvader_response.set_testmode(False) + + def _get_selection_label(self, record, field): + """ + Get the translated label of the record selection field + :param record: recordset + :param field: str + :return: str + """ + return record._fields.get(field).convert_to_export(record[field], record) + + def _get_last_external_update_date(self, record): + if isinstance(record, TrackExternalMixin): + return record.last_external_update_date + return False + + def _check_last_external_update_date(self, record, previous_date): + if isinstance(record, TrackExternalMixin): + self.assertTrue(record.last_external_update_date) + if previous_date: + self.assertTrue(record.last_external_update_date > previous_date) + + +class ShopinvaderRestCase(BaseRestCase): + def setUp(self, *args, **kwargs): + super(ShopinvaderRestCase, self).setUp(*args, **kwargs) + self.backend = self.env.ref("shopinvader_restapi.backend_1") + # To ensure multi-backend works correctly, we just have to create + # a new one on the same company. + self.backend_copy = self.env.ref("shopinvader_restapi.backend_2") + + +class CommonTestDownload(object): + """ + Dedicated class to test the download service. + Into your test class, just inherit this one (python mode) and call + correct function. + """ + + def _test_download_not_allowed(self, service, target): + """ + Data + * A target into an invalid/not allowed state + Case: + * Try to download the document + Expected result: + * MissingError should be raised + :param service: shopinvader service + :param target: recordset + :return: + """ + with self.assertRaises(MissingError): + service.download(target.id) + + def _test_download_allowed(self, service, target): + """ + Data + * A target with a valid state + Case: + * Try to download the document + Expected result: + * An http response with the file to download + :param service: shopinvader service + :param target: recordset + :return: + """ + with mock.patch( + "odoo.addons.shopinvader_restapi.services." + "abstract_download.content_disposition" + ) as mocked_cd: + request = mock.MagicMock() + abstract_download.request = request + mocked_cd.return_value = "attachment; filename=test" + # mocked_request.make_response = make_response + service.download(target.id) + self.assertEqual(1, request.make_response.call_count) + content, headers = request.make_response.call_args[0] + self.assertTrue(content) + self.assertIn(("Content-Disposition", "attachment; filename=test"), headers) + + def _test_download_not_owner(self, service, target): + """ + Data + * A target into a valid state but doesn't belong to the connected + user (from the service). + Case: + * Try to download the document + Expected result: + * MissingError should be raised + :param service: shopinvader service + :param target: recordset + :return: + """ + self._test_download_not_allowed(service, target) + + # FIXME: this seems duplicated in some common test cases + + def _ensure_posted(self, invoice): + if invoice.state != "posted": + invoice._post() + + def _make_payment(self, invoice): + """ + Make the invoice payment + :param invoice: account.invoice recordset + :return: bool + """ + self._ensure_posted(invoice) + ctx = {"active_ids": invoice.ids, "active_model": "account.move"} + wizard_obj = self.register_payments_obj.with_context(**ctx) + register_payments = wizard_obj.create( + { + "payment_date": fields.Date.today(), + "journal_id": self.bank_journal_euro.id, + "payment_method_line_id": self.payment_method_line_manual_in.id, + } + ) + register_payments._create_payments() + + +class NotificationCaseMixin(object): + def _check_notification(self, notif_type, record): + notif = self.env["shopinvader.notification"].search( + [ + ("backend_id", "=", self.backend.id), + ("notification_type", "=", notif_type), + ] + ) + vals = notif.template_id.generate_email( + record.id, + [ + "subject", + "body_html", + "email_from", + "email_to", + "partner_to", + "email_cc", + "reply_to", + "scheduled_date", + ], + ) + message = self.env["mail.message"].search( + [ + ("subject", "=", vals["subject"]), + ("model", "=", record._name), + ("res_id", "=", record.id), + ] + ) + self.assertEqual(len(message), 1) + + def _find_notification_job(self, **kw): + leafs = dict( + channel_method_name=".send", + model_name="shopinvader.notification", + ) + leafs.update(kw) + domain = [] + for k, v in leafs.items(): + domain.append((k, "=", v)) + return self.env["queue.job"].search(domain, limit=1) diff --git a/shopinvader_restapi/tests/test_address.py b/shopinvader_restapi/tests/test_address.py new file mode 100644 index 0000000000..1ba4153a1d --- /dev/null +++ b/shopinvader_restapi/tests/test_address.py @@ -0,0 +1,165 @@ +# 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.exceptions import AccessError, UserError + +from .common import CommonCase + + +def _check_partner_data(self, partner, data, skip_keys=None): + compare_data = {} + skip_keys = skip_keys or ("external_id", "partner_email") + for key, value in data.items(): + odoo_key = key + if key in skip_keys: + continue + elif key in ("country", "state", "title", "industry_id"): + value = value["id"] + if key in ("country", "state"): + odoo_key = key + "_id" + compare_data[odoo_key] = value + self.assertRecordValues(partner, [compare_data]) + + +class CommonAddressCase(CommonCase): + @classmethod + def setUpClass(cls): + super(CommonAddressCase, cls).setUpClass() + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.address = cls.env.ref("shopinvader_restapi.partner_1_address_1") + cls.address_2 = cls.env.ref("shopinvader_restapi.partner_1_address_2") + cls.address_params = { + "name": "Purple", + "street": "Rue du jardin", + "zip": "43110", + "city": "Aurec sur Loire", + "phone": "0485485454", + "country": {"id": cls.env.ref("base.fr").id}, + } + + def setUp(self, *args, **kwargs): + super(CommonAddressCase, self).setUp(*args, **kwargs) + with self.work_on_services(partner=self.partner) as work: + self.address_service = work.component(usage="addresses") + + def check_data(self, address, data): + _check_partner_data(self, address, data) + + def _create_address(self, params): + existing_res = self.address_service.search(per_page=100)["data"] + existing_ids = {address["id"] for address in existing_res} + self.address_service.dispatch("create", params=params)["data"] + after_res = self.address_service.search(per_page=100)["data"] + after_ids = {address["id"] for address in after_res} + created_ids = after_ids - existing_ids + return self.env["res.partner"].browse(created_ids) + + def _test_create_address(self, params, expected): + address = self._create_address(params) + self.check_data(address, expected) + return address + + def _test_update_address(self, address_id, params, expected): + self.address_service.dispatch("update", address_id, params=params) + address = self.env["res.partner"].browse(address_id) + self.check_data(address, expected) + return address + + def _test_search_address(self, params, expected): + """Test search address with params and asserts results + + :param params: search parameters + :param expected: res.partner recordset. + """ + res = self.address_service.dispatch("search", params=params)["data"] + res_ids = {x["id"] for x in res} + records = self.env["res.partner"].browse(res_ids) + self.assertEqual(records, expected) + return records + + +class AddressTestCase(object): + def test_create_address(self): + # no email, verify defaults + params = dict(self.address_params, type="other") + self._test_create_address(params, params) + # pass email and type + params = dict(self.address_params, email="purple@test.oca", type="invoice") + self._test_create_address(params, params) + # pass country as code + params = dict(self.address_params, country={"code": "FR"}, type="other") + self._test_create_address(params, dict(self.address_params)) + # can't pass both country id and country code + params = dict(params, country={"id": 1, "code": "FR"}, type="other") + with self.assertRaisesRegex(UserError, r"'id' must not be present with 'code'"): + self._test_create_address(params, dict(self.address_params)) + + def test_add_address_invoice(self): + # Create an invoice address with wrong type + # Check raise + params = dict(self.address_params, type="wrong") + with self.assertRaises(UserError): + self.address_service.dispatch("create", params=params) + # Create an invoice address with invoice type + params = dict(self.address_params, type="invoice") + self._test_create_address(params, params) + + def test_update_address(self): + # Case 1: Simply update fields (type is "other") + params = dict(self.address_params) + self._test_update_address(self.address.id, params, params) + # Case 2: "contact" type get the address from the parent + params = dict(self.address_params, email="foo@baz.test", type="contact") + expected = dict(params, street=self.partner.street) + self._test_update_address(self.address.id, params, expected) + + def test_read_address_profile(self): + params = {"scope": {"address_type": "profile"}} + expected = self.partner + self._test_search_address(params, expected) + + def test_read_address_address(self): + params = {"scope": {"address_type": "address"}} + expected = self.address + self.address_2 + self._test_search_address(params, expected) + + def test_read_address_invoice(self): + # Create an invoice address + params = dict(self.address_params, type="invoice") + address = self._create_address(params) + # Search it + params = {"scope": {"type": "invoice"}} + self._test_search_address(params, address) + + def test_read_address_all(self): + params = {} + expected = self.partner + self.address + self.address_2 + self._test_search_address(params, expected) + + def test_search_per_page(self): + # Ensure the 'per_page' is working into search. + res = self.address_service.dispatch("search", params={"per_page": 2})["data"] + self.assertEqual(len(res), 2) + # Ensure the 'page' is working. As there is 3 address for logged user, we + # should have only 1 remaining result on the second page. + res = self.address_service.dispatch( + "search", params={"per_page": 2, "page": 2} + )["data"] + self.assertEqual(len(res), 1) + + def test_delete_address(self): + address_id = self.address.id + self.address_service.delete(address_id) + address = self.env["res.partner"].search([("id", "=", address_id)]) + self.assertEqual(len(address), 0) + partner = self.env["res.partner"].search([("id", "=", self.partner.id)]) + self.assertEqual(len(partner), 1) + + def test_delete_main_address(self): + with self.assertRaises(AccessError): + self.address_service.delete(self.partner.id) + + +class AddressCase(CommonAddressCase, AddressTestCase): + """Test address""" diff --git a/shopinvader_restapi/tests/test_backend.py b/shopinvader_restapi/tests/test_backend.py new file mode 100644 index 0000000000..513f4a45c7 --- /dev/null +++ b/shopinvader_restapi/tests/test_backend.py @@ -0,0 +1,28 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from .common import CommonCase + + +class BackendCase(CommonCase): + @classmethod + def setUpClass(cls): + super(BackendCase, cls).setUpClass() + cls.lang_fr = cls._install_lang(cls, "base.lang_fr") + cls.env = cls.env(context=dict(cls.env.context, test_queue_job_no_delay=True)) + cls.backend = cls.backend.with_context(test_queue_job_no_delay=True) + + def test_lookup_by_website_unique_key(self): + website_unique_key = self.backend.website_unique_key + self.assertTrue(website_unique_key) + backend = self.env["shopinvader.backend"]._get_from_website_unique_key( + website_unique_key + ) + self.assertEqual(self.backend, backend) + self.backend.website_unique_key = "new_key" + backend = self.env["shopinvader.backend"]._get_from_website_unique_key( + "new_key" + ) + self.assertEqual(self.backend, backend) diff --git a/shopinvader_restapi/tests/test_cart.py b/shopinvader_restapi/tests/test_cart.py new file mode 100644 index 0000000000..a647c5a122 --- /dev/null +++ b/shopinvader_restapi/tests/test_cart.py @@ -0,0 +1,520 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import timedelta + +from odoo import fields +from odoo.tools import mute_logger + +from .common import CommonCase + + +class CartCase(CommonCase): + """ + Common class for cart tests + DON'T override it with tests + """ + + def setUp(self): + super(CartCase, self).setUp() + self.registry.enter_test_mode(self.env.cr) + self.address = self.env.ref("shopinvader_restapi.partner_1_address_1") + self.fposition = self.env.ref("shopinvader_restapi.fiscal_position_2") + self.default_fposition = self.env.ref("shopinvader_restapi.fiscal_position_0") + self.product_1 = self.env.ref("product.product_product_4b") + templates = self.env["product.template"].search([]) + templates.write( + {"taxes_id": [(6, 0, [self.env.ref("shopinvader_restapi.tax_1").id])]} + ) + + def _create_notification_config(self): + template = self.env.ref("account.email_template_edi_invoice") + values = { + "model_id": self.env.ref("sale.model_sale_order").id, + "notification_type": "cart_send_email", + "template_id": template.id, + } + self.service.shopinvader_backend.write({"notification_ids": [(0, 0, values)]}) + + def tearDown(self): + self.registry.leave_test_mode() + super(CartCase, self).tearDown() + + +class CartClearTest(object): + """ + Test class who implements checks to ensure the clear cart service + is working correctly. + """ + + def _check_clear_cart_result(self, cart): + """ + Check the cart clear. + :param cart: sale.order recordset + :return: bool + """ + cart_id = cart.id + existing_carts = cart.search([]) + self.service.shopinvader_session.update({"cart_id": cart.id}) + result = self.service.dispatch("clear") + session = result.get("set_session") + new_carts = cart.search([("id", "not in", existing_carts.ids)]) + clear_option = self.backend.clear_cart_options + if clear_option == "clear": + self.assertFalse(new_carts) + self.assertEqual(cart_id, cart.exists().id) + self.assertIsInstance(session, dict) + self.assertEqual(session.get("cart_id"), cart_id) + self.assertFalse(cart.order_line) + elif clear_option == "delete": + self.assertFalse(cart.exists()) + self.assertIsInstance(session, dict) + self.assertEqual(session.get("cart_id"), 0) + elif clear_option == "cancel": + # We only check that the previous cart is cancelled. + # The new cart will be created if the customer add a new item. + # Test the creation of new cart is not the goal of this test. + self.assertEqual(len(new_carts), 0) + self.assertIsInstance(session, dict) + self.assertFalse(session.get("cart_id")) + # The previous should exists + self.assertEqual(cart_id, cart.exists().id) + self.assertEqual(cart.state, "cancel") + return True + + @mute_logger("odoo.models.unlink") + def test_cart_clear(self): + self.backend.write({"clear_cart_options": "clear"}) + self._check_clear_cart_result(self.cart) + + @mute_logger("odoo.models.unlink") + def test_cart_delete(self): + self.backend.write({"clear_cart_options": "delete"}) + self._check_clear_cart_result(self.cart) + + def test_cart_cancel(self): + self.backend.write({"clear_cart_options": "cancel"}) + self._check_clear_cart_result(self.cart) + + +class AnonymousCartCase(CartCase, CartClearTest): + def setUp(self, *args, **kwargs): + super(AnonymousCartCase, self).setUp(*args, **kwargs) + self.cart = self.env.ref("shopinvader_restapi.sale_order_1") + self.shopinvader_session = {"cart_id": self.cart.id} + self.partner = self.backend.anonymous_partner_id + self.product_1 = self.env.ref("product.product_product_4b") + self.sale_obj = self.env["sale.order"] + with self.work_on_services( + partner=None, shopinvader_session=self.shopinvader_session + ) as work: + self.service = work.component(usage="cart") + + def _sign_with(self, invader_partner): + self.service._load_partner_work_context(invader_partner, force=True) + service_sign = self.service.component("customer") + service_sign.sign_in() + + def test_anonymous_cart_then_sign(self): + cart = self.cart + invader_partner = self.env.ref("shopinvader_restapi.shopinvader_partner_1") + partner = invader_partner.record_id + last_external_update_date = self._get_last_external_update_date(cart) + self._sign_with(invader_partner) + self._check_last_external_update_date(cart, last_external_update_date) + self.assertEqual(cart.partner_id, partner) + self.assertEqual(cart.partner_shipping_id, partner) + self.assertEqual(cart.partner_invoice_id, partner) + self.assertEqual(cart.pricelist_id, cart.shopinvader_backend_id.pricelist_id) + + def test_ask_email(self): + """ + Test the ask_email when not logged. + As the user is not logged, no email should be created + :return: + """ + self._create_notification_config() + now = fields.Date.today() + self.service.dispatch("ask_email", self.cart.id) + notif = "cart_send_email" + description = "Notify {} for {},{}".format(notif, self.cart._name, self.cart.id) + domain = [("name", "=", description), ("date_created", ">=", now)] + # It should not create any queue job because the user is not logged + self.assertEqual(self.env["queue.job"].search_count(domain), 0) + + def test_cart_pricelist_apply(self): + """ + Ensure the pricelist set on the backend is correctly used and applied. + 1) Create a SO manually (using same pricelist as backend) and save the + amount. + 2) Create a Cart/SO using shopinvader. The pricelist used should be + the one defined and the price should match with the SO created manually + just before. + :return: + """ + # User must be in this group to fill discount field on SO lines. + self.env.ref("product.group_discount_per_so_line").write( + {"users": [(4, self.env.user.id, False)]} + ) + # Create 2 pricelists + pricelist_values = { + "name": "Custom pricelist 1", + "discount_policy": "without_discount", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "1_product", + "product_tmpl_id": self.product_1.product_tmpl_id.id, + "compute_price": "fixed", + "fixed_price": 650, + }, + ) + ], + } + first_pricelist = self.env["product.pricelist"].create(pricelist_values) + pricelist_values = { + "name": "Custom pricelist 2", + "discount_policy": "without_discount", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "1_product", + "product_tmpl_id": self.product_1.product_tmpl_id.id, + "compute_price": "formula", + "base": "pricelist", + "price_surcharge": -100, + "base_pricelist_id": first_pricelist.id, + "date_start": fields.Date.today(), + "date_end": fields.Date.today() + timedelta(days=1), + }, + ) + ], + } + second_pricelist = self.env["product.pricelist"].create(pricelist_values) + # First, create the SO manually + sale = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "partner_invoice_id": self.partner.id, + "pricelist_id": second_pricelist.id, + "typology": "cart", + "shopinvader_backend_id": self.backend.id, + "date_order": fields.Datetime.now(), + "analytic_account_id": self.backend.account_analytic_id.id, + } + ) + so_line_obj = self.env["sale.order.line"] + line_values = { + "order_id": sale.id, + "product_id": self.product_1.id, + "product_uom_qty": 1, + } + new_line_values = so_line_obj.play_onchanges(line_values, line_values.keys()) + new_line_values.update(line_values) + line = so_line_obj.create(new_line_values) + expected_price = line.price_total + # Then create a new SO/Cart by shopinvader services + # Force to use this pricelist for the backend + self.backend.write({"pricelist_id": second_pricelist.id}) + params = {"product_id": self.product_1.id, "item_qty": 1} + self.service.shopinvader_session.clear() + response = self.service.dispatch("add_item", params=params) + data = response.get("data") + sale_id = response.get("set_session", {}).get("cart_id") + sale_order = self.sale_obj.browse(sale_id) + so_line = fields.first( + sale_order.order_line.filtered( + lambda l, p=self.product_1: l.product_id == p + ) + ) + self.assertEqual(sale_order.pricelist_id, second_pricelist) + self.assertAlmostEqual(so_line.price_total, expected_price) + self.assertAlmostEqual(sale_order.amount_total, expected_price) + self.assertAlmostEqual( + data.get("lines").get("items")[0].get("amount").get("total"), + expected_price, + ) + + def test_cart_robustness(self): + """ + The cart used by the service must always be with typology='cart' + and state='draft' for the current backend + If for some reason, these conditions are no more met, the service + silently create a new cart to replace the one into the session + """ + cart = self.service._get() + cart_bis = self.service._get() + self.assertEqual(cart, cart_bis) + cart.write({"state": "sale"}) + cart_bis = self.service._get() + self.assertNotEqual(cart, cart_bis) + self.assertEqual(cart_bis.typology, "cart") + self.assertEqual(cart_bis.state, "draft") + cart = cart_bis + cart.write({"typology": "sale"}) + cart_bis = self.service._get() + self.assertNotEqual(cart, cart_bis) + + def test_cart_search_no_create(self): + """ + - if search is called with a cart_id in session, cart must be returned + - if search is called without any cart_id in session, + result must be empty + """ + self.assertTrue(self.service.shopinvader_session.get("cart_id")) + search_result = self.service.search() + self.assertEqual(search_result["store_cache"]["cart"]["name"], self.cart.name) + # reset cart_id parameter + self.service.shopinvader_session.update({"cart_id": False}) + search_result = self.service.search() + self.assertEqual(search_result, {}) + self.service.shopinvader_session.update({"cart_id": self.cart.id}) + # if cart is no longer a cart, it shouldn't create one neither + # and it should clear the session's cart id + self.cart.typology = "sale" + search_result = self.service.search() + self.assertEqual(search_result["store_cache"]["cart"], {}) + self.assertEqual(search_result["set_session"]["cart_id"], 0) + + +class CommonConnectedCartCase(CartCase): + """ + Common class for connected cart tests + DON'T override it with tests + """ + + def setUp(self, *args, **kwargs): + super(CommonConnectedCartCase, self).setUp(*args, **kwargs) + self.cart = self.env.ref("shopinvader_restapi.sale_order_2") + self.shopinvader_session = {"cart_id": self.cart.id} + self.partner = self.env.ref("shopinvader_restapi.partner_1") + self.address = self.env.ref("shopinvader_restapi.partner_1_address_1") + with self.work_on_services( + partner=self.partner, shopinvader_session=self.shopinvader_session + ) as work: + self.service = work.component(usage="cart") + + +class ConnectedCartCase(CommonConnectedCartCase, CartClearTest): + @mute_logger("odoo.models.unlink") + def test_cart_create(self): + self.cart.unlink() + cart = self.service._get() + self.assertRecordValues( + cart, + [ + { + "partner_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "partner_invoice_id": self.partner.id, + "pricelist_id": self.backend.pricelist_id.id, + } + ], + ) + + def test_set_shipping_address_no_default_invocing(self): + cart = self.cart + self.backend.cart_checkout_address_policy = "no_defaults" + invoice_addr = self.env.ref("shopinvader_restapi.partner_1_address_2") + cart.partner_invoice_id = invoice_addr + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.address.id}}} + ) + self.assertEqual(cart.partner_id, self.partner) + # invoice address is preserved + self.assertEqual(cart.partner_shipping_id, self.address) + self.assertEqual(cart.partner_invoice_id, invoice_addr) + + def test_set_shipping_address_default_invoicing(self): + cart = self.cart + last_external_update_date = self._get_last_external_update_date(cart) + invoice_addr = self.env.ref("shopinvader_restapi.partner_1_address_2") + cart.partner_invoice_id = invoice_addr + self.backend.cart_checkout_address_policy = "invoice_defaults_to_shipping" + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.address.id}}} + ) + self._check_last_external_update_date(cart, last_external_update_date) + self.assertEqual(cart.partner_id, self.partner) + # invoice address is replaced + self.assertEqual(cart.partner_shipping_id, self.address) + self.assertEqual(cart.partner_invoice_id, self.address) + + def test_set_invoice_address(self): + cart = self.cart + last_external_update_date = self._get_last_external_update_date(cart) + self.service.dispatch( + "update", + params={"invoicing": {"address": {"id": self.address.id}}}, + ) + self._check_last_external_update_date(cart, last_external_update_date) + + self.assertEqual(cart.partner_id, self.partner) + self.assertEqual(cart.partner_shipping_id, self.partner) + self.assertEqual(cart.partner_invoice_id, self.address) + self.assertEqual(cart.pricelist_id, cart.shopinvader_backend_id.pricelist_id) + + def test_confirm_cart_maually(self): + self.assertEqual(self.cart.typology, "cart") + self.cart.action_confirm() + self.assertEqual(self.cart.typology, "sale") + + def test_ask_email1(self): + """ + Test the ask_email when a user is logged + As the user logged (and owner of this cart for this case), a new + queue job should be created to send an email + :return: + """ + self._create_notification_config() + now = fields.Datetime.now() + self.service.dispatch("ask_email", self.cart.id) + notif = "cart_send_email" + description = "Notify {} for {},{}".format(notif, self.cart._name, self.cart.id) + domain = [("name", "=", description), ("date_created", ">=", now)] + self.assertEqual(self.env["queue.job"].search_count(domain), 1) + + def test_ask_email2(self): + """ + Test the ask_email when a user is logged + As the user logged (and owner of this cart for this case), a new + queue job should be created to send an email. + But for this case we don't add the notification ("event") so nothing + should happens + :return: + """ + now = fields.Datetime.now() + self.service.dispatch("ask_email", self.cart.id) + notif = "cart_send_email" + description = "Notify {} for {},{}".format(notif, self.cart._name, self.cart.id) + domain = [("name", "=", description), ("date_created", ">=", now)] + self.assertEqual(self.env["queue.job"].search_count(domain), 0) + + def test_ask_email3(self): + """ + Test the ask_email when a user is logged + As the user logged (and NOT owner of this cart for this case), any + new queue job should be created. + :return: + """ + self._create_notification_config() + now = fields.Datetime.now() + self.cart.write({"partner_id": self.partner.copy({}).id}) + self.service.dispatch("ask_email", self.cart.id) + notif = "cart_send_email" + description = "Notify {} for {},{}".format(notif, self.cart._name, self.cart.id) + domain = [("name", "=", description), ("date_created", ">=", now)] + self.assertEqual(self.env["queue.job"].search_count(domain), 0) + + def test_cart_robustness(self): + """ + The cart used by the service must always be with typology='cart' + and state='draft' for the current backend + If for some reason, these conditions are no more met, the service + silently create a new cart to replace the one into the session + """ + cart = self.service._get() + cart_bis = self.service._get() + self.assertEqual(cart, cart_bis) + cart.write({"state": "sale"}) + cart_bis = self.service._get(create_if_not_found=False) + self.assertFalse(cart_bis) + cart_bis = self.service._get() + self.assertNotEqual(cart, cart_bis) + self.assertEqual(cart_bis.typology, "cart") + self.assertEqual(cart_bis.state, "draft") + self.assertEqual(cart_bis.partner_id, self.partner) + self.assertEqual(self.backend.account_analytic_id, cart_bis.analytic_account_id) + + @mute_logger("odoo.models.unlink") + def test_cart_delete_robustness(self): + """ + If for some reason, the cart does not exist anymore but + in session, these conditions are no more met, the service + silently create a new cart to replace the one into the session + """ + cart = self.service._get() + cart_bis = self.service._get() + self.assertEqual(cart, cart_bis) + cart.unlink() + cart_bis = self.service._get() + self.assertNotEqual(cart, cart_bis) + self.assertEqual(cart_bis.typology, "cart") + self.assertEqual(cart_bis.state, "draft") + self.assertEqual(cart_bis.partner_id, self.partner) + + def test_cart_misc_data_update(self): + self.service.dispatch( + "update", params={"client_order_ref": "#SpecialPurchaseDude!"} + ) + + cart = self.cart + self.assertEqual(cart.client_order_ref, "#SpecialPurchaseDude!") + + def test_writing_note(self): + res = self.service.dispatch("update", params={"note": "FOO"}) + self.assertIn("note", res["data"]) + self.assertEqual("

FOO

", res["data"]["note"]) + + +class ConnectedCartNoTaxCase(CartCase): + def setUp(self, *args, **kwargs): + super(ConnectedCartNoTaxCase, self).setUp(*args, **kwargs) + self.cart = self.env.ref("shopinvader_restapi.sale_order_3") + self.shopinvader_session = {"cart_id": self.cart.id} + self.partner = self.env.ref("shopinvader_restapi.partner_2") + self.address = self.env.ref("shopinvader_restapi.partner_2_address_1") + with self.work_on_services( + partner=self.partner, shopinvader_session=self.shopinvader_session + ) as work: + self.service = work.component(usage="cart") + + def test_set_shipping_address_with_tax(self): + cart = self.cart + # Remove taxes by setting an address without tax + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.partner.id}}} + ) + self.assertEqual(cart.amount_total, cart.amount_untaxed) + # Set an address that should have taxes + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.address.id}}} + ) + self.assertEqual(cart.partner_id, self.partner) + self.assertEqual(cart.partner_shipping_id, self.address) + self.assertEqual(cart.fiscal_position_id, self.default_fposition) + self.assertNotEqual(cart.amount_total, cart.amount_untaxed) + + def test_set_shipping_address_without_tax(self): + cart = self.cart + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.partner.id}}} + ) + self.assertEqual(cart.partner_id, self.partner) + self.assertEqual(cart.partner_shipping_id, self.partner) + self.assertEqual(cart.fiscal_position_id, self.fposition) + self.assertEqual(cart.amount_total, cart.amount_untaxed) + + def test_edit_shipping_address_without_tax(self): + cart = self.cart + # Make an double call to reset the fiscal position with the right value + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.partner.id}}} + ) + self.service.dispatch( + "update", params={"shipping": {"address": {"id": self.address.id}}} + ) + self.assertEqual(cart.partner_id, self.partner) + self.assertEqual(cart.partner_shipping_id, self.address) + self.assertEqual(cart.fiscal_position_id, self.default_fposition) + self.assertNotEqual(cart.amount_total, cart.amount_untaxed) + + self.address.write({"country_id": self.env.ref("base.us").id}) + self.assertEqual(cart.partner_id, self.partner) + self.assertEqual(cart.fiscal_position_id, self.fposition) + self.assertEqual(cart.amount_total, cart.amount_untaxed) diff --git a/shopinvader_restapi/tests/test_cart_copy.py b/shopinvader_restapi/tests/test_cart_copy.py new file mode 100644 index 0000000000..6c65437a7e --- /dev/null +++ b/shopinvader_restapi/tests/test_cart_copy.py @@ -0,0 +1,37 @@ +# Copyright 2020 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +from .test_cart import CommonConnectedCartCase + + +class TestCartCopy(CommonConnectedCartCase): + @classmethod + def setUpClass(cls): + super(TestCartCopy, cls).setUpClass() + cls.product_copy = cls.env.ref("product.product_product_24") + cls.product_copy.list_price = 500.0 + + @mute_logger("odoo.models.unlink") + def test_cart_copy(self): + # Copy existing cart + # Check if we return a new one with new values + result = self.service.dispatch("copy", params={"id": self.cart.id}) + cart_data = result.get("data") + new_id = cart_data.get("id") + self.assertNotEqual(new_id, self.cart.id) + copy_cart = self.env["sale.order"].browse(new_id) + self.assertEqual("cart", copy_cart.typology) + line = copy_cart.order_line.filtered( + lambda l: l.product_id == self.product_copy + ) + self.assertEqual(500.0, line.price_unit) + + @mute_logger("odoo.models.unlink") + def test_cart_copy_does_not_exist(self): + cart_id = self.cart.id + self.cart.unlink() + # Check validator + with self.assertRaises(UserError): + self.service.dispatch("copy", params={"id": cart_id}) diff --git a/shopinvader_restapi/tests/test_cart_item.py b/shopinvader_restapi/tests/test_cart_item.py new file mode 100644 index 0000000000..83001c0e21 --- /dev/null +++ b/shopinvader_restapi/tests/test_cart_item.py @@ -0,0 +1,233 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import time + +from odoo.tools import mute_logger + +from .common import CommonCase + + +class ItemCaseMixin(object): + @classmethod + def _setup_products(cls): + cls.product_1 = cls.env.ref("product.product_product_4b") + cls.product_2 = cls.env.ref("product.product_product_13") + cls.product_3 = cls.env.ref("product.product_product_11") + cls.pricelist = cls.env.ref("product.list0") + + def extract_cart(self, response): + self.shopinvader_session["cart_id"] = response["set_session"]["cart_id"] + self.assertEqual(response["store_cache"], {"cart": response["data"]}) + return response["data"] + + def add_item(self, product_id, qty, **kw): + params = {"product_id": product_id, "item_qty": qty} + params.update(kw) + return self.extract_cart(self.service.dispatch("add_item", params=params)) + + def update_item(self, item_id, qty, **kw): + params = {"item_id": item_id, "item_qty": qty} + params.update(kw) + return self.extract_cart(self.service.dispatch("update_item", params=params)) + + def delete_item(self, item_id): + return self.extract_cart( + self.service.dispatch("delete_item", params={"item_id": item_id}) + ) + + def check_product_and_qty(self, line, product_id, qty): + self.assertEqual(line["product"]["id"], product_id) + self.assertEqual(line["qty"], qty) + + @mute_logger("odoo.models.unlink") + def remove_cart(self): + self.cart.unlink() + self.shopinvader_session.pop("cart_id") + + +class AbstractItemCase(ItemCaseMixin): + @classmethod + def setUpClass(cls): + super(AbstractItemCase, cls).setUpClass() + cls._setup_products() + + def test_add_item_without_cart(self): + self.remove_cart() + last_order = self.env["sale.order"].search([], limit=1, order="id desc") + cart = self.add_item(self.product_1.id, 2) + self.assertGreater(cart["id"], last_order.id) + self.assertEqual(len(cart["lines"]["items"]), 1) + self.assertEqual(cart["lines"]["count"], 2) + self.check_product_and_qty(cart["lines"]["items"][0], self.product_1.id, 2) + self.check_partner(cart) + + def test_add_item_with_an_existing_cart(self): + cart = self.service.search()["data"] + nbr_line = len(cart["lines"]["items"]) + + cart = self.add_item(self.product_1.id, 2) + self.assertEqual(cart["id"], self.cart.id) + self.assertEqual(len(cart["lines"]["items"]), nbr_line + 1) + self.check_product_and_qty(cart["lines"]["items"][-1], self.product_1.id, 2) + self.check_partner(cart) + + def test_add_item_with_custom_sequence(self): + """If a commercial have changed the sequence we need to ensure that + the last add item is always added at the end""" + self.cart.order_line[-1].sequence = 20 + cart = self.add_item(self.product_1.id, 2) + self.assertEqual(cart["lines"]["items"][-1]["product"]["id"], self.product_1.id) + + def test_update_item(self): + line_id = self.cart.order_line[0].id + product_id = self.cart.order_line[0].product_id.id + cart = self.update_item(line_id, 5) + self.check_product_and_qty(cart["lines"]["items"][0], product_id, 5) + self.assertEqual(self.cart.id, cart["id"]) + + def test_update_item_robustness(self): + """ + In this case we update an item on confirmed cart... + As result, a new item will be added on a new cart with the expectd qty + """ + # by changing the typology, the cart is no more available on + # the cart service + self.cart.typology = "sale" + line_id = self.cart.order_line[0].id + product_id = self.cart.order_line[0].product_id.id + cart = self.update_item(line_id, 5) + self.check_product_and_qty(cart["lines"]["items"][0], product_id, 5) + # A new line has been created on a new cart... + self.assertNotEqual(self.cart.id, cart["id"]) + + @mute_logger("odoo.models.unlink") + def test_delete_item(self): + cart = self.service.search()["data"] + cart_id = cart["id"] + items = cart["lines"]["items"] + nbr_line = len(items) + cart = self.delete_item(items[0]["id"]) + self.assertEqual(len(cart["lines"]["items"]), nbr_line - 1) + self.assertEqual(cart_id, cart["id"]) + + def test_delete_item_robustness(self): + """ + In this case we remove an item of a confirmed cart... + The deletion must be ignored.. and a new empty cart returned + """ + # by changing the typology, the cart is no more available on + # the cart service + self.cart.typology = "sale" + line_id = self.cart.order_line[0].id + cart = self.delete_item(line_id) + self.assertEqual(len(cart["lines"]["items"]), 0) + self.assertNotEqual(self.cart.id, cart["id"]) + + def test_add_item_with_same_product_without_cart(self): + self.remove_cart() + cart = self.add_item(self.product_1.id, 1) + self.assertEqual(len(cart["lines"]["items"]), 1) + self.check_product_and_qty(cart["lines"]["items"][0], self.product_1.id, 1) + cart = self.add_item(self.product_1.id, 1) + self.assertEqual(len(cart["lines"]["items"]), 1) + self.check_product_and_qty(cart["lines"]["items"][0], self.product_1.id, 2) + + def _test_pricelist_product(self): + self.remove_cart() + # be sure that discount group is active for user + self.env.user.write( + {"groups_id": [(4, self.ref("product.group_discount_per_so_line"), 0)]} + ) + # we create a new pricelist for the product with a discount of 10% + self.env["product.pricelist.item"].create( + { + "base": "list_price", + "percent_price": 10, + "name": "Product discount Ipod", + "pricelist_id": self.pricelist.id, + "compute_price": "percentage", + "applied_on": "0_product_variant", + "product_id": self.product_3.id, + } + ) + cart_data = self.add_item(self.product_3.id, 1) + cart = self.env["sale.order"].browse(cart_data["id"]) + self.assertEqual(cart.pricelist_id, self.pricelist) + return cart_data["lines"]["items"][0]["amount"] + + def test_pricelist_product_price_unit_without_discount(self): + self.pricelist.discount_policy = "without_discount" + amount = self._test_pricelist_product() + # into the cart, the price must be the price without discount + self.assertEqual(amount["price"], 33) + # but the total for the line into the cart info must be the price with + # discount + self.assertEqual(amount["total"], 29.7) + + def test_pricelist_product_price_unit_with_discount(self): + self.pricelist.discount_policy = "with_discount" + amount = self._test_pricelist_product() + # into the cart, the price must be the price with discount + self.assertEqual(amount["price"], 29.7) + # same for the total + self.assertEqual(amount["total"], 29.7) + + def test_upgrade_last_update_date(self): + last_external_update_date = self._get_last_external_update_date(self.cart) + self.add_item(self.product_1.id, 2) + self._check_last_external_update_date(self.cart, last_external_update_date) + + time.sleep(1) + + last_external_update_date = self._get_last_external_update_date(self.cart) + line_id = self.cart.order_line[0].id + self.update_item(line_id, 5) + self._check_last_external_update_date(self.cart, last_external_update_date) + + time.sleep(1) + + last_external_update_date = self._get_last_external_update_date(self.cart) + self.delete_item(line_id) + self._check_last_external_update_date(self.cart, last_external_update_date) + + +class AnonymousItemCase(AbstractItemCase, CommonCase): + @classmethod + def setUpClass(cls): + super(AnonymousItemCase, cls).setUpClass() + cls.partner = cls.backend.anonymous_partner_id + cls.cart = cls.env.ref("shopinvader_restapi.sale_order_1") + + def setUp(self, *args, **kwargs): + super(AnonymousItemCase, self).setUp(*args, **kwargs) + self.shopinvader_session = {"cart_id": self.cart.id} + with self.work_on_services( + partner=None, shopinvader_session=self.shopinvader_session + ) as work: + self.service = work.component(usage="cart") + + def check_partner(self, cart): + self.assertEqual(cart["shipping"]["address"], {}) + self.assertEqual(cart["invoicing"]["address"], {}) + + +class ConnectedItemCase(AbstractItemCase, CommonCase): + @classmethod + def setUpClass(cls): + super(ConnectedItemCase, cls).setUpClass() + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.cart = cls.env.ref("shopinvader_restapi.sale_order_2") + + def setUp(self, *args, **kwargs): + super(ConnectedItemCase, self).setUp(*args, **kwargs) + self.shopinvader_session = {"cart_id": self.cart.id} + with self.work_on_services( + partner=self.partner, shopinvader_session=self.shopinvader_session + ) as work: + self.service = work.component(usage="cart") + + def check_partner(self, cart): + self.assertEqual(cart["shipping"]["address"]["id"], self.partner.id) + self.assertEqual(cart["invoicing"]["address"]["id"], self.partner.id) diff --git a/shopinvader_restapi/tests/test_customer.py b/shopinvader_restapi/tests/test_customer.py new file mode 100644 index 0000000000..a595590761 --- /dev/null +++ b/shopinvader_restapi/tests/test_customer.py @@ -0,0 +1,150 @@ +# 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 + +from odoo.exceptions import MissingError, UserError +from odoo.tools import mute_logger + +from .common import CommonCase +from .test_address import _check_partner_data + +_logger = logging.getLogger(__name__) + + +class TestCustomerCommon(CommonCase): + @classmethod + def setUpClass(cls): + super(TestCustomerCommon, cls).setUpClass() + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.partner_binding = cls.env.ref("shopinvader_restapi.shopinvader_partner_1") + cls.address = cls.env.ref("shopinvader_restapi.partner_1_address_1") + cls.partner_2 = cls.env.ref("shopinvader_restapi.partner_2") + + def setUp(self, *args, **kwargs): + super(TestCustomerCommon, self).setUp(*args, **kwargs) + self.data = { + "email": "new@customer.example.com", + "name": "Purple", + "street": "Rue du jardin", + "zip": "43110", + "city": "Aurec sur Loire", + "phone": "0485485454", + "country": {"id": self.env.ref("base.fr").id}, + "is_company": False, + } + with self.work_on_services( + partner=None, shopinvader_session=self.shopinvader_session + ) as work: + self.service = work.component(usage="customer") + self.address_service = work.component(usage="addresses") + + with self.work_on_services( + partner=self.partner, shopinvader_session=self.shopinvader_session + ) as work: + self.service_with_partner = work.component(usage="customer") + + def _test_partner_data(self, partner, data): + _check_partner_data(self, partner, data) + + +class TestCustomer(TestCustomerCommon): + def test_create_customer(self): + self.data["external_id"] = "D5CdkqOEL" + res = self.service.dispatch("create", params=self.data)["data"] + partner = self.env["res.partner"].browse(res["id"]) + self._test_partner_data(partner, self.data) + + def test_create_customer_business_vat_only(self): + self.data["external_id"] = "D5CdkqOEL" + # no `is_company` flag + self.data["vat"] = "BE0477472701" + res = self.service.dispatch("create", params=self.data)["data"] + partner = self.env["res.partner"].browse(res["id"]) + # no flag, no company, no party :) + self.assertEqual(partner.is_company, False) + + def test_create_customer_business(self): + self.data["external_id"] = "D5CdkqOEL" + self.data["is_company"] = True + self.data["vat"] = "BE0477472701" + res = self.service.dispatch("create", params=self.data)["data"] + partner = self.env["res.partner"].browse(res["id"]) + self.assertEqual(partner.is_company, True) + + def test_address_type(self): + partner = self.env.ref("shopinvader_restapi.partner_1") + self.assertEqual(partner.address_type, "profile") + address = self.env.ref("shopinvader_restapi.partner_1_address_1") + self.assertEqual(address.address_type, "address") + + def test_update_address_type(self): + data = {"email": "address@customer.example.com", "name": "Address"} + partner = self.env["res.partner"].create(data) + self.assertEqual(partner.address_type, "profile") + data = {"email": "parent@customer.example.com", "name": "Parent"} + parent = self.env["res.partner"].create(data) + partner.parent_id = parent.id + self.assertEqual(partner.address_type, "address") + + def test_create_no_create_cart(self): + """ + Create a customer should not create an empty cart + """ + self.data["external_id"] = "D5CdkqOEL" + res = self.service.dispatch("create", params=self.data)["data"] + partner = self.env["res.partner"].browse(res["id"]) + sale_domain = [("partner_id", "=", partner.id)] + SaleOrder = self.env["sale.order"] + self.assertFalse(SaleOrder.search(sale_domain)) + + @mute_logger("odoo.models.unlink") + def test_sign_in_no_create_cart(self): + """ + Customer sign-in should not create an empty cart + """ + partner = self.env.ref("shopinvader_restapi.partner_1") + sale_domain = [("partner_id", "=", partner.id)] + SaleOrder = self.env["sale.order"] + SaleOrder.search(sale_domain).unlink() + + invader_partner = partner._get_invader_partner(self.backend) + self.service._load_partner_work_context(invader_partner) + self.service.sign_in() + self.assertFalse(SaleOrder.search(sale_domain)) + + def test_update_customer(self): + params = {"street": "New Street"} + res = self.service_with_partner.dispatch( + "update", self.partner.id, params=params + ) + self.assertEqual(res["data"]["id"], self.partner.id) + self.assertEqual(self.partner.street, "New Street") + self.assertEqual(self.partner_binding.street, "New Street") + + def test_update_customer_no_partner(self): + params = {"street": "New Street"} + with self.assertRaises(UserError): + self.service.dispatch("update", self.partner.id, params=params) + + def test_update_customer_binding(self): + params = {"external_id": "D5CdkqOEL"} + res = self.service_with_partner.dispatch( + "update", self.partner.id, params=params + ) + self.assertEqual(res["data"]["id"], self.partner.id) + self.assertEqual(self.partner_binding.external_id, "D5CdkqOEL") + + def test_update_customer_not_allowed(self): + params = {"street": "New Street"} + + with self.assertRaises(MissingError): + self.service_with_partner.dispatch( + "update", self.partner_2.id, params=params + ) + + def test_update_customer_child_not_allowed(self): + params = {"street": "New Street"} + with self.assertRaises(UserError): + self.service_with_partner.dispatch("update", self.address.id, params=params) diff --git a/shopinvader_restapi/tests/test_invoice.py b/shopinvader_restapi/tests/test_invoice.py new file mode 100644 index 0000000000..7eae930582 --- /dev/null +++ b/shopinvader_restapi/tests/test_invoice.py @@ -0,0 +1,192 @@ +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .common import CommonCase, CommonTestDownload + + +class TestInvoice(CommonCase, CommonTestDownload): + @classmethod + def setUpClass(cls): + super(TestInvoice, cls).setUpClass() + cls.register_payments_obj = cls.env["account.payment.register"] + cls.journal_obj = cls.env["account.journal"] + cls.sale = cls.env.ref("shopinvader_restapi.sale_order_2") + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.payment_method_manual_in = cls.env.ref( + "account.account_payment_method_manual_in" + ) + cls.payment_method_line_manual_in = cls.env[ + "account.payment.method.line" + ].search([("payment_method_id", "=", cls.payment_method_manual_in.id)], limit=1) + cls.bank_journal_euro = cls.journal_obj.create( + {"name": "Bank", "type": "bank", "code": "BNK627"} + ) + cls.invoice_obj = cls.env["account.move"] + cls.invoice = cls._confirm_and_invoice_sale(cls, cls.sale) + cls.non_sale_invoice = cls.invoice.copy() + # set the layout on the company to be sure that the print action + # will not display the document layout configurator + cls.env.company.external_report_layout_id = cls.env.ref( + "web.external_layout_standard" + ).id + + def setUp(self, *args, **kwargs): + super(TestInvoice, self).setUp(*args, **kwargs) + with self.work_on_services(partner=self.partner) as work: + self.sale_service = work.component(usage="sales") + self.invoice_service = work.component(usage="invoices") + + def _confirm_and_invoice_sale(self, sale): + sale.action_confirm() + for line in sale.order_line: + line.write({"qty_delivered": line.product_uom_qty}) + return sale._create_invoices() + + def _create_invoice(self, partner, **kw): + product = self.env.ref("product.product_product_4") + account = self.env["account.account"].search( + [ + ( + "user_type_id", + "=", + self.env.ref("account.data_account_type_receivable").id, + ) + ], + limit=1, + ) + values = { + "partner_id": self.partner.id, + "type": "out_invoice", + "line_ids": [ + ( + 0, + 0, + { + "account_id": account.id, + "product_id": product.product_variant_ids[:1].id, + "name": "Product 1", + "quantity": 4.0, + "price_unit": 123.00, + }, + ) + ], + } + values.update(kw) + return self.env["account.move"].create(values) + + def test_01(self): + """ + Data + * A confirmed sale order with an invoice not yet paid + Case: + * Try to download the PDF + Expected result: + * MissingError should be raised + """ + self._test_download_not_allowed(self.invoice_service, self.invoice) + + def test_02(self): + """ + Data + * A confirmed sale order with a paid invoice + Case: + * Try to download the PDF + Expected result: + * An http response with the file to download + """ + self._make_payment(self.invoice) + self._test_download_allowed(self.invoice_service, self.invoice) + + def test_03(self): + """ + Data + * A confirmed sale order with a paid invoice but not for the + current customer + Case: + * Try to download the PDF + Expected result: + * MissingError should be raised + """ + sale = self.env.ref("sale.sale_order_1") + sale.shopinvader_backend_id = self.backend + self.assertNotEqual(sale.partner_id, self.partner) + invoice = self._confirm_and_invoice_sale(sale) + self._make_payment(invoice) + self._test_download_not_owner(self.invoice_service, self.invoice) + + def test_domain_01(self): + # By default include only invoices related to sales + self.assertTrue(self.backend.invoice_linked_to_sale_only) + # and only paid invoice are accessible + self.assertFalse(self.backend.invoice_access_open) + # Invoices are open, none of them is included + self._ensure_posted(self.invoice) + self._ensure_posted(self.non_sale_invoice) + domain = self.invoice_service._get_base_search_domain() + self.assertNotIn(self.non_sale_invoice, self.invoice_obj.search(domain)) + self.assertNotIn(self.invoice, self.invoice_obj.search(domain)) + # pay both invoices + self._make_payment(self.invoice) + self._make_payment(self.non_sale_invoice) + domain = self.invoice_service._get_base_search_domain() + # Extra invoice still not found + self.assertNotIn(self.non_sale_invoice, self.invoice_obj.search(domain)) + self.assertIn(self.invoice, self.invoice_obj.search(domain)) + + def test_domain_02(self): + # Include extra invoices + self.backend.invoice_linked_to_sale_only = False + # and only paid invoice are accessible + self.assertFalse(self.backend.invoice_access_open) + # Invoices are open, none of them is included + self._ensure_posted(self.invoice) + self._ensure_posted(self.non_sale_invoice) + domain = self.invoice_service._get_base_search_domain() + self.assertNotIn(self.non_sale_invoice, self.invoice_obj.search(domain)) + self.assertNotIn(self.invoice, self.invoice_obj.search(domain)) + # pay both invoices + self._make_payment(self.invoice) + self._make_payment(self.non_sale_invoice) + domain = self.invoice_service._get_base_search_domain() + # Extra invoice available now as well + self.assertIn(self.non_sale_invoice, self.invoice_obj.search(domain)) + self.assertIn(self.invoice, self.invoice_obj.search(domain)) + + def test_domain_03(self): + # Include extra invoices + self.backend.invoice_linked_to_sale_only = False + # and open invoices enabled as well + self.backend.invoice_access_open = True + self._ensure_posted(self.invoice) + self._ensure_posted(self.non_sale_invoice) + domain = self.invoice_service._get_base_search_domain() + self.assertIn(self.non_sale_invoice, self.invoice_obj.search(domain)) + self.assertIn(self.invoice, self.invoice_obj.search(domain)) + # pay both invoices + self._make_payment(self.invoice) + self._make_payment(self.non_sale_invoice) + domain = self.invoice_service._get_base_search_domain() + # Still both available + self.assertIn(self.non_sale_invoice, self.invoice_obj.search(domain)) + self.assertIn(self.invoice, self.invoice_obj.search(domain)) + + def test_report_get(self): + default_report = self.env.ref("account.account_invoices") + self.assertEqual( + self.invoice_service._get_report_action(self.invoice), + default_report.report_action(self.invoice, config=False), + ) + # set a custom report + custom = default_report.copy({"name": "My custom report"}) + self.backend.invoice_report_id = custom + self.assertEqual( + self.invoice_service._get_report_action(self.invoice)["name"], + "My custom report", + ) + + +class DeprecatedTestInvoice(TestInvoice): + def setUp(self, *args, **kwargs): + super(DeprecatedTestInvoice, self).setUp(*args, **kwargs) + with self.work_on_services(partner=self.partner) as work: + self.invoice_service = work.component(usage="invoice") diff --git a/shopinvader_restapi/tests/test_notification.py b/shopinvader_restapi/tests/test_notification.py new file mode 100644 index 0000000000..2f3dc60757 --- /dev/null +++ b/shopinvader_restapi/tests/test_notification.py @@ -0,0 +1,101 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import CommonCase, NotificationCaseMixin +from .test_address import CommonAddressCase + + +class NotificationCartCase(CommonCase, NotificationCaseMixin): + @classmethod + def setUpClass(cls): + super(NotificationCartCase, cls).setUpClass() + cls.cart = cls.env.ref("shopinvader_restapi.sale_order_2") + + def test_cart_notification(self): + self._init_job_counter() + self.cart.action_confirm_cart() + self._check_nbr_job_created(1) + self._perform_created_job() + self._check_notification("cart_confirmation", self.cart) + + def test_sale_notification(self): + self.cart.action_confirm_cart() + self._init_job_counter() + self.cart.action_confirm() + self._check_nbr_job_created(1) + self._perform_created_job() + self._check_notification("sale_confirmation", self.cart) + + def test_invoice_notification(self): + self.cart.action_confirm_cart() + self.cart.action_confirm() + for line in self.cart.order_line: + line.qty_delivered = line.product_uom_qty + self.cart._create_invoices() + self._init_job_counter() + self.cart.invoice_ids._post() + self._check_nbr_job_created(1) + self._perform_created_job() + self._check_notification("invoice_open", self.cart.invoice_ids[0]) + + +class NotificationCustomerCase(CommonAddressCase, NotificationCaseMixin): + def setUp(self, *args, **kwargs): + super(NotificationCustomerCase, self).setUp(*args, **kwargs) + with self.work_on_services( + partner=None, shopinvader_session=self.shopinvader_session + ) as work: + self.customer_service = work.component(usage="customer") + + def _create_customer(self, **kw): + data = { + "email": "new@customer.example.com", + "external_id": "D5CdkqOEL", + "name": "Purple", + "street": "Rue du jardin", + "zip": "43110", + "city": "Aurec sur Loire", + "phone": "0485485454", + "country": {"id": self.env.ref("base.fr").id}, + } + data.update(kw) + res = self.customer_service.dispatch("create", params=data)["data"] + return self.env["res.partner"].browse(res["id"]) + + def test_new_customer_welcome(self): + partner = self._create_customer() + job = self._find_notification_job( + name="Notify new_customer_welcome for res.partner,%d" % partner.id + ) + self.assertTrue(job) + self._perform_job(job) + self._check_notification("new_customer_welcome", partner) + + def test_address_created(self): + params = dict(self.address_params, name="John Doe") + self.address_service.dispatch("create", params=params) + address = self.env["res.partner"].search([("name", "=", "John Doe")]) + self.assertEqual(address.parent_id, self.partner) + # notification goes to the owner of the address + partner = self.partner + job = self._find_notification_job( + name="Notify address_created for res.partner,%d" % partner.id + ) + self.assertTrue(job) + self._perform_job(job) + self._check_notification("address_created", partner) + + def test_address_updated(self): + params = dict(email="joe@foo.com") + self.address_service.dispatch("update", self.address.id, params=params) + # notification goes to the owner of the address + partner = self.address.parent_id + job = self._find_notification_job( + name="Notify address_updated for res.partner,%d" % partner.id + ) + self.assertTrue(job) + self._perform_job(job) + self._check_notification("address_updated", partner) diff --git a/shopinvader_restapi/tests/test_partner_access_info.py b/shopinvader_restapi/tests/test_partner_access_info.py new file mode 100644 index 0000000000..647fc819d2 --- /dev/null +++ b/shopinvader_restapi/tests/test_partner_access_info.py @@ -0,0 +1,66 @@ +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from .common import CommonCase + + +class TestPartnerAccessInfo(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.invader_partner = cls.partner._get_invader_partner(cls.backend) + cls.invader_contact = cls._create_invader_partner( + cls.env, + name="Just A User", + parent_id=cls.partner.id, + email="just@auser.com", + ) + cls.contact = cls.invader_contact.record_id + + def test_access_info_owner1(self): + with self.backend.work_on( + "res.partner", + partner=self.partner, + partner_user=self.partner, + invader_partner=self.invader_partner, + invader_partner_user=self.invader_partner, + ) as work: + info = work.component(usage="access.info") + + self.assertTrue(info.is_owner(self.partner.id)) + + # on my partner I can RU + self.assertEqual( + info.for_profile(self.partner.id), + {"read": True, "update": True, "delete": False}, + ) + # on my addresses I can RUD + self.assertEqual( + info.for_address(self.contact.id), + {"read": True, "update": True, "delete": True}, + ) + + def test_access_info_non_owner1(self): + with self.backend.work_on( + "res.partner", + partner=self.partner, + invader_partner=self.invader_partner, + partner_user=self.contact, + invader_partner_user=self.invader_contact, + ) as work: + info = work.component(usage="access.info") + + self.assertFalse(info.is_owner(self.partner.id)) + self.assertTrue(info.is_owner(self.contact.id)) + + # on my partner I can R only + self.assertEqual( + info.for_profile(self.partner.id), + {"read": True, "update": False, "delete": False}, + ) + # on my addresses I can RU only + self.assertEqual( + info.for_address(self.contact.id), + {"read": True, "update": True, "delete": False}, + ) diff --git a/shopinvader_restapi/tests/test_res_partner.py b/shopinvader_restapi/tests/test_res_partner.py new file mode 100644 index 0000000000..ff88c3780f --- /dev/null +++ b/shopinvader_restapi/tests/test_res_partner.py @@ -0,0 +1,78 @@ +# Copyright 2018 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from odoo.exceptions import ValidationError + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestResPartner(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super(TestResPartner, cls).setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.unique_email = datetime.now().isoformat() + "@test.com" + cls.backend1 = cls.env.ref("shopinvader_restapi.backend_1") + cls.backend2 = cls.env.ref("shopinvader_restapi.backend_2") + + def test_unique_email_partner(self): + self.assertFalse(self.env["res.partner"]._is_partner_duplicate_prevented()) + partner_1 = self.env["res.partner"].create( + { + "email": self.unique_email, + "name": "test partner", + "shopinvader_bind_ids": [(0, False, {"backend_id": self.backend1.id})], + } + ) + # by default we can create partner with shopinvader user with same email + # as long as they are related to different backends + partner_2 = self.env["res.partner"].create( + { + "email": self.unique_email, + "name": "test partner 2", + "shopinvader_bind_ids": [(0, False, {"backend_id": self.backend2.id})], + } + ) + + # unlink partner_2 to validate the constrain after, to avoid having + # to create a new backend for the test + partner_2.shopinvader_bind_ids.unlink() + partner_2.unlink() + + # activate no partner duplicate parameter + self.env["ir.config_parameter"].create( + {"key": "shopinvader.no_partner_duplicate", "value": "True"} + ) + + # once you've changed the config to disable duplicate partner + # it's no more possible to create a partner with shopinvader user + self.assertTrue(self.env["res.partner"]._is_partner_duplicate_prevented()) + # with the same email + with self.assertRaises(ValidationError), self.cr.savepoint(): + self.env["res.partner"].create( + { + "email": self.unique_email, + "name": "test partner 2", + "shopinvader_bind_ids": [ + (0, False, {"backend_id": self.backend2.id}) + ], + } + ) + + # unicity constrains is only applicable on partners with + # shopinvader user + self.env["res.partner"].create( + {"email": self.unique_email, "name": "test partner 2"} + ) + + # unicity constrains is also only applicable on active records + partner_1.write({"active": False}) + self.env["res.partner"].create( + { + "email": self.unique_email, + "name": "test partner 3", + "shopinvader_bind_ids": [(0, False, {"backend_id": self.backend2.id})], + } + ) diff --git a/shopinvader_restapi/tests/test_sale.py b/shopinvader_restapi/tests/test_sale.py new file mode 100644 index 0000000000..3fc402c13d --- /dev/null +++ b/shopinvader_restapi/tests/test_sale.py @@ -0,0 +1,208 @@ +# 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 fields +from odoo.exceptions import MissingError +from odoo.tools import mute_logger + +from .common import CommonCase, CommonTestDownload + + +class CommonSaleCase(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.sale = cls.env.ref("shopinvader_restapi.sale_order_2") + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.register_payments_obj = cls.env["account.payment.register"] + cls.journal_obj = cls.env["account.journal"] + cls.payment_method_manual_in = cls.env.ref( + "account.account_payment_method_manual_in" + ) + cls.payment_method_line_manual_in = cls.env[ + "account.payment.method.line" + ].search([("payment_method_id", "=", cls.payment_method_manual_in.id)], limit=1) + cls.bank_journal_euro = cls.journal_obj.create( + {"name": "Bank", "type": "bank", "code": "BNK6278"} + ) + + def setUp(self, *args, **kwargs): + super().setUp(*args, **kwargs) + with self.work_on_services(partner=self.partner) as work: + self.service = work.component(usage="sales") + + +class SaleCase(CommonSaleCase, CommonTestDownload): + def _confirm_and_invoice_sale(self): + self.sale.action_confirm() + for line in self.sale.order_line: + line.write({"qty_delivered": line.product_uom_qty}) + self.invoice = self.sale._create_invoices() + + def test_read_sale(self): + self.sale.action_confirm_cart() + res = self.service.get(self.sale.id) + self.assertEqual(res["id"], self.sale.id) + self.assertEqual(res["name"], self.sale.name) + self.assertEqual(res["state"], self.sale.shopinvader_state) + self.assertEqual( + res["state_label"], + self._get_selection_label(self.sale, "shopinvader_state"), + ) + + def test_cart_are_not_readable_as_sale(self): + with self.assertRaises(MissingError): + self.service.get(self.sale.id) + + def test_list_sale(self): + self.sale.action_confirm_cart() + res = self.service.search() + self.assertEqual(len(res["data"]), 1) + sale = res["data"][0] + self.assertEqual(sale["id"], self.sale.id) + self.assertEqual(sale["name"], self.sale.name) + self.assertEqual(sale["state"], self.sale.shopinvader_state) + state_label = self._get_selection_label(self.sale, "shopinvader_state") + self.assertEqual(sale["state_label"], state_label) + self.assertEqual(sale["client_order_ref"], "DEMO_ORDER_2") + + def test_hack_read_other_customer_sale(self): + sale = self.env.ref("sale.sale_order_1") + sale.shopinvader_backend_id = self.backend + # We raise a not found error because in a point of view of the hacker + # and his right the record does not exist + with self.assertRaises(MissingError): + self.service.get(sale.id) + + def _create_notification_config(self): + template = self.env.ref("account.email_template_edi_invoice") + values = { + "model_id": self.env.ref("account.model_account_move").id, + "notification_type": "invoice_send_email", + "template_id": template.id, + } + self.service.shopinvader_backend.notification_ids.unlink() + self.service.shopinvader_backend.write({"notification_ids": [(0, 0, values)]}) + + @mute_logger("odoo.models.unlink") + def test_ask_email_invoice(self): + """ + Test the ask_email when not logged. + As the user is not logged, no email should be created + :return: + """ + self._create_notification_config() + now = fields.Date.today() + notif = "invoice_send_email" + self.sale.action_confirm() + for line in self.sale.order_line: + line.write({"qty_delivered": line.product_uom_qty}) + invoice = self.sale._create_invoices() + description = "Notify {} for {},{}".format(notif, invoice._name, invoice.id) + domain = [("name", "=", description), ("date_created", ">=", now)] + self.service.dispatch("ask_email_invoice", self.sale.id) + self.assertEqual(self.env["queue.job"].search_count(domain), 1) + + def test_invoice_01(self): + """ + Data + * A confirmed sale order with an invoice not yet paid + Case: + * Load data + Expected result: + * No invoice information returned + """ + self._confirm_and_invoice_sale() + self.assertNotEqual(self.invoice.payment_state, "paid") + res = self.service.get(self.sale.id) + self.assertFalse(res["invoices"]) + + def test_invoice_02(self): + """ + Data + * A confirmed sale order with a paid invoice + Case: + * Load data + Expected result: + * Invoice information must be filled + """ + self._confirm_and_invoice_sale() + self._make_payment(self.invoice) + self.assertEqual(self.invoice.payment_state, "paid") + res = self.service.get(self.sale.id) + self.assertTrue(res) + self.assertEqual( + res["invoices"], + [ + { + "id": self.invoice.id, + "name": self.invoice.name, + "date": self.invoice.invoice_date, + } + ], + ) + + def test_download01(self): + """ + Data + * A draft sale order + Case: + * Try to download the document + Expected result: + * MissingError should be raised + """ + self._test_download_not_allowed(self.service, self.sale) + + def test_download02(self): + """ + Data + * A confirmed sale order + Case: + * Try to download the document + Expected result: + * An http response with the file to download + """ + self.sale.action_confirm_cart() + self._test_download_allowed(self.service, self.sale) + + def test_download03(self): + """ + Data + * A confirmed sale order but not for the current customer + Case: + * Try to download the document + Expected result: + * MissingError should be raised + """ + sale = self.env.ref("sale.sale_order_1") + sale.action_confirm_cart() + sale.shopinvader_backend_id = self.backend + self.assertNotEqual(sale.partner_id, self.service.partner) + self._test_download_not_owner(self.service, sale) + + # TODO (long-term): this test is not specifically for sale.order. + # This as many other tests should be moved to a generic test case + # to ensure core feature a working on independently. + def test_sale_search_order(self): + order1 = self.sale + order1.date_order = "2020-08-18" + order1.action_confirm_cart() + order2 = order1.copy({"date_order": "2020-08-31"}) + order2.action_confirm_cart() + res = self.service.search() + self.assertEqual(len(res["data"]), 2) + # by default order is `_order = 'date_order desc, id desc'` + self.assertEqual(res["data"][0]["id"], order2.id) + self.assertEqual(res["data"][1]["id"], order1.id) + # change ordering + res = self.service.dispatch("search", params={"order": "date_order asc"}) + self.assertEqual(len(res["data"]), 2) + self.assertEqual(res["data"][0]["id"], order1.id) + self.assertEqual(res["data"][1]["id"], order2.id) + order1.name = "O1" + order2.name = "O2" + res = self.service.dispatch("search", params={"order": "name desc"}) + self.assertEqual(len(res["data"]), 2) + self.assertEqual(res["data"][0]["id"], order2.id) + self.assertEqual(res["data"][1]["id"], order1.id) diff --git a/shopinvader_restapi/tests/test_sale_cancel.py b/shopinvader_restapi/tests/test_sale_cancel.py new file mode 100644 index 0000000000..02eaa34657 --- /dev/null +++ b/shopinvader_restapi/tests/test_sale_cancel.py @@ -0,0 +1,41 @@ +# Copyright 2021 Akretion (https://www.akretion.com). +# @author Pierrick Brun +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError + +from .test_sale import CommonSaleCase + + +class TestSaleCancel(CommonSaleCase): + def test_sale_cancel(self): + self.sale.action_confirm() + self.assertEqual("sale", self.sale.typology) + self.assertEqual("sale", self.sale.state) + + self.service.dispatch("cancel", self.sale.id) + + self.assertEqual("cancel", self.sale.state) + + def test_sale_cancel_fail_if_delivered(self): + self.sale.action_confirm() + # deliver only 1 line + self.sale.order_line[0].write({"qty_delivered": 1}) + self.sale.order_line.flush_recordset() + with self.assertRaises(UserError): + self.service.dispatch("cancel", self.sale.id) + self.assertEqual("sale", self.sale.typology) + self.assertEqual("sale", self.sale.state) + + def test_sale_cancel_to_cart(self): + self.sale.action_confirm() + self.assertEqual("sale", self.sale.typology) + self.assertEqual("sale", self.sale.state) + + result = self.service.dispatch("reset_to_cart", self.sale.id) + + session = result.get("set_session") + self.assertEqual("draft", self.sale.state) + self.assertEqual("cart", self.sale.typology) + self.assertIsInstance(session, dict) + self.assertEqual(session.get("cart_id"), self.sale.id) diff --git a/shopinvader_restapi/tests/test_salesman_notification.py b/shopinvader_restapi/tests/test_salesman_notification.py new file mode 100644 index 0000000000..2290e66bf7 --- /dev/null +++ b/shopinvader_restapi/tests/test_salesman_notification.py @@ -0,0 +1,220 @@ +# Copyright 2019 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import CommonCase + + +class TestCustomer(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.base_data = { + "email": "acme@ltd.com", + "name": "Purple", + "street": "Rue du jardin", + "zip": "43110", + "city": "Aurec sur Loire", + "phone": "0485485454", + "country": {"id": cls.env.ref("base.fr").id}, + "is_company": False, + "external_id": "12345678", + } + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + + def setUp(self): + super().setUp() + with self.work_on_services(partner=None) as work: + self.customer_service = work.component(usage="customer") + + def address_service(self, **kw): + with self.work_on_services(**kw) as work: + return work.component(usage="addresses") + + def _find_activity(self, record): + domain = [ + ("res_model_id", "=", self.env.ref("base.model_res_partner").id), + ("res_id", "=", record.id), + ( + "activity_type_id", + "=", + self.env.ref("shopinvader_restapi.mail_activity_review_customer").id, + ), + ] + return self.env["mail.activity"].search_count(domain) + + def _create_customer(self, **kw): + data = dict(self.base_data) + data.update(kw) + self.customer_service._reset_partner_work_context() + self.customer_service.dispatch("create", params=data)["data"] + return self.customer_service.partner + + def _create_address(self, partner=None, **kw): + data = dict(self.base_data) + data.update(kw) + res = self.address_service(partner=partner or self.partner).dispatch( + "create", params=data + )["data"] + new_address = [x for x in res if x["name"] == kw["name"]][0] + return self.env["res.partner"].browse(new_address["id"]) + + def _update_partner(self, partner, rec_id, **kw): + self.address_service(partner=partner).dispatch("update", rec_id, params=kw) + + def test_notify_none(self): + # set none + self.backend.salesman_notify_create = "" + self.backend.salesman_notify_update = "" + # normal customer create -> none + partner = self._create_customer() + self.assertFalse(self._find_activity(partner)) + # normal customer update -> none + self._update_partner(partner, partner.id, name="Pippo") + self.assertFalse(self._find_activity(partner)) + # company create -> none + partner = self._create_customer( + external_id="12345678X", + is_company=True, + vat="BE0477472701", + email="acme@foo.com", + ) + self.assertFalse(self._find_activity(partner)) + # company update -> none + self._update_partner(partner, partner.id, name="C2C") + self.assertFalse(self._find_activity(partner)) + # address create -> none + partner = self._create_address(name="John Doe") + self.assertFalse(self._find_activity(partner.parent_id)) + # address update -> none + self._update_partner(partner.parent_id, partner.id, name="Somewhere else") + self.assertFalse(self._find_activity(partner.parent_id)) + + def test_notify_company(self): + self.backend.salesman_notify_create = "company" + self.backend.salesman_notify_update = "" + # normal customer create -> none + partner = self._create_customer() + self.assertFalse(self._find_activity(partner)) + # normal customer update -> none + self._update_partner(partner, partner.id, name="Pippo") + self.assertFalse(self._find_activity(partner)) + # company create -> yes + partner = self._create_customer( + external_id="12345678X", + is_company=True, + vat="BE0477472701", + email="acme@foo.com", + ) + self.assertEqual(self._find_activity(partner), 1) + # company update -> none + self._update_partner(partner, partner.id, name="C2C") + self.assertEqual(self._find_activity(partner), 1) + # enable for company update + self.backend.salesman_notify_update = "company" + # company update -> one more + self._update_partner(partner, partner.id, name="C2C again") + self.assertEqual(self._find_activity(partner), 2) + # address create -> none + partner = self._create_address(name="John Doe") + self.assertFalse(self._find_activity(partner.parent_id)) + # address update -> none + self._update_partner(partner.parent_id, partner.id, name="Somewhere else") + self.assertFalse(self._find_activity(partner.parent_id)) + + def test_notify_user(self): + # set none + self.backend.salesman_notify_create = "user" + self.backend.salesman_notify_update = "" + # normal customer create -> yes + partner = self._create_customer() + self.assertTrue(self._find_activity(partner)) + # normal customer update -> none + self._update_partner(partner, partner.id, name="Pippo") + self.assertEqual(self._find_activity(partner), 1) + # enable for user update + self.backend.salesman_notify_update = "user" + # normal customer update -> yes + self._update_partner(partner, partner.id, name="Pippo") + self.assertEqual(self._find_activity(partner), 2) + # company + partner = self._create_customer( + external_id="12345678X", + is_company=True, + vat="BE0477472701", + email="acme@foo.com", + ) + self.assertFalse(self._find_activity(partner)) + # company update -> none + self._update_partner(partner, partner.id, name="C2C") + self.assertFalse(self._find_activity(partner)) + # address create -> none + partner = self._create_address(name="John Doe") + self.assertFalse(self._find_activity(partner.parent_id)) + # address update -> none + self._update_partner(partner.parent_id, partner.id, name="Somewhere else") + self.assertFalse(self._find_activity(partner.parent_id)) + + def test_notify_company_and_user(self): + # set none + self.backend.salesman_notify_create = "company_and_user" + self.backend.salesman_notify_update = "" + # normal customer create -> yes + partner = self._create_customer() + self.assertTrue(self._find_activity(partner)) + # normal customer update -> none + self._update_partner(partner, partner.id, name="Pippo") + self.assertEqual(self._find_activity(partner), 1) + # enable for user update + self.backend.salesman_notify_update = "company_and_user" + # company create -> yes + partner = self._create_customer( + external_id="12345678X", + is_company=True, + vat="BE0477472701", + email="acme@foo.com", + ) + self.assertEqual(self._find_activity(partner), 1) + # company update -> none + self.backend.salesman_notify_update = "" + self._update_partner(partner, partner.id, name="C2C") + self.assertEqual(self._find_activity(partner), 1) + # enable for company update + self.backend.salesman_notify_update = "company_and_user" + # company update -> one more + self._update_partner(partner, partner.id, name="C2C again") + self.assertEqual(self._find_activity(partner), 2) + # address create -> none + partner = self._create_address(name="John Doe") + self.assertFalse(self._find_activity(partner.parent_id)) + # address update -> none + self._update_partner(partner.parent_id, partner.id, name="Somewhere else") + self.assertFalse(self._find_activity(partner.parent_id)) + + def test_notify_address(self): + # set none + self.backend.salesman_notify_create = "address" + self.backend.salesman_notify_update = "address" + # normal customer create -> none + partner = self._create_customer() + self.assertFalse(self._find_activity(partner)) + # normal customer update -> none + self._update_partner(partner, partner.id, name="Pippo") + self.assertFalse(self._find_activity(partner)) + # company create -> none + partner = self._create_customer( + external_id="12345678X", + is_company=True, + vat="BE0477472701", + email="acme@foo.com", + ) + self.assertFalse(self._find_activity(partner)) + # company update -> none + self._update_partner(partner, partner.id, name="C2C") + self.assertFalse(self._find_activity(partner)) + # address create -> yes + partner = self._create_address(name="John Doe") + self.assertEqual(self._find_activity(partner.parent_id), 1) + # address update -> yes + self._update_partner(partner.parent_id, partner.id, name="Pippo") + self.assertEqual(self._find_activity(partner.parent_id), 2) diff --git a/shopinvader_restapi/tests/test_search.py b/shopinvader_restapi/tests/test_search.py new file mode 100644 index 0000000000..8c4dfd5bdb --- /dev/null +++ b/shopinvader_restapi/tests/test_search.py @@ -0,0 +1,71 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError + +from .common import CommonCase + + +class CommonSearchCase(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env.ref("shopinvader_restapi.partner_1") + cls.address = cls.env.ref("shopinvader_restapi.partner_1_address_1") + cls.address_2 = cls.env.ref("shopinvader_restapi.partner_1_address_2") + + def setUp(self): + super().setUp() + with self.work_on_services(partner=self.partner) as work: + self.address_service = work.component(usage="addresses") + + +class SearchCase(CommonSearchCase): + def test_domain_conversion(self): + service = self.address_service + scope = { + "foo.gt": 1, + "bar.gte": 2, + "baz.lt": 3, + "boo.lte": 4, + "yoo.like": "Me%", + "woo.ilike": "%you%", + } + expected = [ + ("foo", ">", 1), + ("bar", ">=", 2), + ("baz", "<", 3), + ("boo", "<=", 4), + ("yoo", "like", "Me%"), + ("woo", "ilike", "%you%"), + ] + domain = service._scope_to_domain(scope) + self.assertEqual(sorted(domain), sorted(expected)) + + def test_search_by_text(self): + self.address.name = "TEST ADDR 1" + self.address_2.name = "TEST ADDR 2 WHATEVER" + scope = {"name.like": "TEST%"} + res = self.address_service.dispatch("search", params={"scope": scope})["data"] + found = [(x["id"], x["name"]) for x in res] + expected = [(x["id"], x["name"]) for x in self.address + self.address_2] + self.assertEqual(found, expected) + + scope = {"name.ilike": "test%"} + res = self.address_service.dispatch("search", params={"scope": scope})["data"] + found = [(x["id"], x["name"]) for x in res] + expected = [(x["id"], x["name"]) for x in self.address + self.address_2] + self.assertEqual(found, expected) + + scope = {"name.ilike": "%whatever"} + res = self.address_service.dispatch("search", params={"scope": scope})["data"] + found = [(x["id"], x["name"]) for x in res] + expected = [(x["id"], x["name"]) for x in self.address_2] + self.assertEqual(found, expected) + + def test_search_bad_scope(self): + scope = {"name.noway": "TEST%"} + msg = "Invalid scope" + with self.assertRaisesRegex(UserError, msg): + self.address_service.dispatch("search", params={"scope": scope}) diff --git a/shopinvader_restapi/tests/test_settings.py b/shopinvader_restapi/tests/test_settings.py new file mode 100644 index 0000000000..5e215a28b0 --- /dev/null +++ b/shopinvader_restapi/tests/test_settings.py @@ -0,0 +1,86 @@ +# Copyright 2021 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from .common import CommonCase + +EXPECTED_GET_COUNTRY = ( + "Belgium", + "France", + "Italy", + "Luxembourg", + "Spain", +) +EXPECTED_GET_TITLE = ( + "Doctor", + "Madam", + "Miss", + "Mister", + "Professor", +) +EXPECTED_GET_INDUSTRY = ( + "Administrative/Utilities", + "Agriculture", + "Construction", + "Education", + "Energy supply", + "Entertainment", + "Extraterritorial", + "Finance/Insurance", + "Food/Hospitality", + "Health/Social", + "Households", + "IT/Communication", + "Manufacturing", + "Mining", + "Other Services", + "Public Administration", + "Real Estate", + "Scientific", + "Transportation/Logistics", + "Water supply", + "Wholesale/Retail", +) +EXPECTED_GET_CURRENCY = ["EUR", "USD"] +EXPECTED_GET_LANG = ["English (US)"] + + +class SettingsTestCase(CommonCase): + def setUp(self, *args, **kwargs): + super().setUp(*args, **kwargs) + with self.work_on_services( + partner=self.env.ref("shopinvader_restapi.partner_1") + ) as work: + self.settings_service = work.component(usage="settings") + + def _check_names_identical(self, to_check, expected_vals): + actual_vals = {el["name"] for el in to_check} + self.assertSetEqual(set(expected_vals), actual_vals) + + def test_country(self): + res = self.settings_service.dispatch("countries") + self._check_names_identical(res, EXPECTED_GET_COUNTRY) + + def test_title(self): + res = self.settings_service.dispatch("titles") + self._check_names_identical(res, EXPECTED_GET_TITLE) + + def test_industry(self): + res = self.settings_service.dispatch("industries") + self._check_names_identical(res, EXPECTED_GET_INDUSTRY) + + def test_currency(self): + res = self.settings_service.dispatch("currencies") + self._check_names_identical(res, EXPECTED_GET_CURRENCY) + + def test_lang(self): + res = self.settings_service.dispatch("languages") + self._check_names_identical(res, EXPECTED_GET_LANG) + + def test_all(self): + res = self.settings_service.dispatch("get_all") + self._check_names_identical(res["countries"], EXPECTED_GET_COUNTRY) + self._check_names_identical(res["titles"], EXPECTED_GET_TITLE) + self._check_names_identical(res["industries"], EXPECTED_GET_INDUSTRY) + self._check_names_identical(res["currencies"], EXPECTED_GET_CURRENCY) + self._check_names_identical(res["languages"], EXPECTED_GET_LANG) diff --git a/shopinvader_restapi/tests/test_shopinvader_partner.py b/shopinvader_restapi/tests/test_shopinvader_partner.py new file mode 100644 index 0000000000..8c1ff9c2fa --- /dev/null +++ b/shopinvader_restapi/tests/test_shopinvader_partner.py @@ -0,0 +1,152 @@ +# Copyright 2018 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from psycopg2 import IntegrityError + +from odoo.tools import mute_logger + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestShopinvaderPartner(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super(TestShopinvaderPartner, cls).setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.backend = cls.env.ref("shopinvader_restapi.backend_1") + cls.unique_email = datetime.now().isoformat() + "@test.com" + + @mute_logger("odoo.sql_db") + def test_unique_binding(self): + self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test partner", + "backend_id": self.backend.id, + } + ) + with self.assertRaises(IntegrityError): + self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test partner", + "backend_id": self.backend.id, + } + ) + + @mute_logger("odoo.models.unlink") + def test_partner_duplicate(self): + """ + Test that the partner are duplicated if we create 2 binding with the + email (after having removed the first binding) + :return: + """ + self.assertFalse(self.env["res.partner"]._is_partner_duplicate_prevented()) + # we create a first binding + binding = self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test partner", + "backend_id": self.backend.id, + } + ) + # a partner has been created for this binding + res = self.env["res.partner"].search([("email", "=", self.unique_email)]) + self.assertEqual(binding.record_id, res) + # if we remove the partner and create a new binding with the same email + # a new partner will be created + binding.unlink() + self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test partner 2", + "backend_id": self.backend.id, + } + ) + res = self.env["res.partner"].search([("email", "=", self.unique_email)]) + self.assertEqual(len(res), 2) + + def test_partner_no_duplicate(self): + """ + Test that if a partner already exists with the same email, the binding + will not create a new partner + """ + # IMPORTANT: never call `execute` on settings in tests + # otherwise is going to reset the env and screw computed fields. + # We could call `set_values` instead but it loads a lot of things + # that we don't need. Plus, is not going to show you what you can do to + # change the behavior via config param. + self.env["ir.config_parameter"].create( + {"key": "shopinvader.no_partner_duplicate", "value": "True"} + ) + self.assertTrue(self.env["res.partner"]._is_partner_duplicate_prevented()) + vals = {"email": self.unique_email, "name": "test partner", "customer_rank": 1} + # create a partner... + partner = self.env["res.partner"].create(vals) + # create a binding + binding = self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test partner", + "backend_id": self.backend.id, + } + ) + # the binding must be linked to the partner + self.assertEqual(partner, binding.record_id) + # no child should exists since all the values are the same on the + # partner + self.assertFalse(partner.child_ids) + + def test_partner_no_duplicate_child(self): + """ + In this test we check that if a partner already exists for the binding + but with different values, the binding is linked to the partner and a + new child partner or partner is created to keep the information + provided when creating the binding + """ + self.env["ir.config_parameter"].create( + {"key": "shopinvader.no_partner_duplicate", "value": "True"} + ) + self.assertTrue(self.env["res.partner"]._is_partner_duplicate_prevented()) + vals = {"email": self.unique_email, "name": "test partner"} + # create a partner... + partner = self.env["res.partner"].create(vals) + self.assertFalse(partner.child_ids) + # create a binding with the same email but an other name + binding = self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test other partner", + "street": "my street", + "backend_id": self.backend.id, + } + ) + self.assertEqual(partner, binding.record_id) + partner.invalidate_recordset() + self.assertTrue(partner.child_ids) + self.assertEqual(1, len(partner.child_ids)) + child = partner.child_ids + self.assertEqual(child.name, "test other partner") + self.assertEqual(child.street, "my street") + + # only one partner should exists with this email + res = self.env["res.partner"].search([("email", "=", self.unique_email)]) + self.assertEqual(len(res), 1) + + @mute_logger("odoo.models.unlink") + def test_partner_is_customer(self): + """ + Ensure the new shopinvader partner is marked as customer + :return: + """ + self.assertFalse(self.env["res.partner"]._is_partner_duplicate_prevented()) + binding = self.env["shopinvader.partner"].create( + { + "email": self.unique_email, + "name": "test partner customer", + "backend_id": self.backend.id, + } + ) + self.assertTrue(binding.customer_rank) diff --git a/shopinvader_restapi/tests/test_shopinvader_partner_binding.py b/shopinvader_restapi/tests/test_shopinvader_partner_binding.py new file mode 100644 index 0000000000..9f21a497c5 --- /dev/null +++ b/shopinvader_restapi/tests/test_shopinvader_partner_binding.py @@ -0,0 +1,52 @@ +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import exceptions + +from .common import CommonCase + + +class TestShopinvaderPartnerBinding(CommonCase): + """ + Tests for shopinvader.partner.binding + """ + + def setUp(self): + super(TestShopinvaderPartnerBinding, self).setUp() + self.binding_wiz_obj = self.env["shopinvader.partner.binding"] + self.partner = self.env.ref("base.res_partner_2") + + def test_binding1(self): + """ + Test the binding by using the shopinvader.partner.binding wizard. + :return: + """ + shopinv_partner = self.partner._get_invader_partner(self.backend) + # This partner shouldn't be already binded + self.assertFalse(shopinv_partner) + context = self.env.context.copy() + context.update( + { + "active_id": self.partner.id, + "active_ids": self.partner.ids, + "active_model": self.partner._name, + } + ) + wizard_obj = self.binding_wiz_obj.with_context(**context) + fields_list = wizard_obj.fields_get().keys() + values = wizard_obj.default_get(fields_list) + values.update({"shopinvader_backend_id": self.backend.id}) + wizard = wizard_obj.create(values) + wizard._onchange_shopinvader_backend_id() + wizard.binding_lines.write({"bind": False}) + with self.assertRaises(exceptions.UserError) as e: + wizard.action_apply() + self.assertIn("unbind is not implemented", e.exception.args[0]) + shopinv_partner = self.partner._get_invader_partner(self.backend) + # As we set bind = False, we check if the binding is not executed. + self.assertFalse(shopinv_partner) + wizard.binding_lines.write({"bind": True}) + wizard.action_apply() + shopinv_partner = self.partner._get_invader_partner(self.backend) + # But now we set bind = True so we check if it's done. + self.assertTrue(shopinv_partner) + return diff --git a/shopinvader_restapi/tests/test_utils.py b/shopinvader_restapi/tests/test_utils.py new file mode 100644 index 0000000000..2ba77ea796 --- /dev/null +++ b/shopinvader_restapi/tests/test_utils.py @@ -0,0 +1,88 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest import mock + +from odoo.addons.shopinvader_restapi import utils # pylint: disable=W7950 + +from .common import CommonCase + + +class TestUtils(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.invader_partner = cls.env.ref("shopinvader_restapi.shopinvader_partner_1") + + def test_partner_work_ctx(self): + ctx = utils.get_partner_work_context(self.invader_partner) + expected = { + "partner": self.invader_partner.record_id, + "partner_user": self.invader_partner.record_id, + "invader_partner": self.invader_partner, + "invader_partner_user": self.invader_partner, + } + self.assertEqual(ctx, expected) + + def test_partner_work_ctx_custom(self): + new_invader_partner = self._create_invader_partner( + self.env, + name="Just A User", + email="just@auser.com", + parent_id=self.invader_partner.record_id.id, + ) + # Simulate `get_shop_partner` give us another partner eg: the parent + with mock.patch.object( + type(new_invader_partner.record_id), "get_shop_partner" + ) as mocked: + mocked.return_value = new_invader_partner.record_id.parent_id + ctx = utils.get_partner_work_context(new_invader_partner) + expected = { + "partner": self.invader_partner.record_id, + "partner_user": new_invader_partner.record_id, + "invader_partner": self.invader_partner, + "invader_partner_user": new_invader_partner, + } + self.assertEqual(ctx, expected) + + def test_load_partner_work_ctx(self): + with utils.work_on_service(self.env, shopinvader_backend=self.backend) as work: + service = work.component(usage="customer") + service._load_partner_work_context(self.invader_partner) + expected = { + "partner": self.invader_partner.record_id, + "partner_user": self.invader_partner.record_id, + "invader_partner": self.invader_partner, + "invader_partner_user": self.invader_partner, + } + for k, v in expected.items(): + self.assertEqual(getattr(service, k), v) + + def test_reset_partner_work_ctx(self): + with utils.work_on_service(self.env, shopinvader_backend=self.backend) as work: + service = work.component(usage="customer") + service._load_partner_work_context(self.invader_partner) + service.work.whatever_shall_be_kept = "something" + service._reset_partner_work_context() + expected = { + "partner": self.env["res.partner"].browse(), + "partner_user": self.env["res.partner"].browse(), + "invader_partner": self.env["shopinvader.partner"].browse(), + "invader_partner_user": self.env["shopinvader.partner"].browse(), + } + for k, v in expected.items(): + self.assertEqual(getattr(service, k), v) + + def test_work_on_service_with_partner(self): + with utils.work_on_service_with_partner(self.env, self.invader_partner) as work: + service = work.component(usage="customer") + service._load_partner_work_context(self.invader_partner) + expected = { + "partner": self.invader_partner.record_id, + "partner_user": self.invader_partner.record_id, + "invader_partner": self.invader_partner, + "invader_partner_user": self.invader_partner, + } + for k, v in expected.items(): + self.assertEqual(getattr(service, k), v) diff --git a/shopinvader_restapi/utils.py b/shopinvader_restapi/utils.py new file mode 100644 index 0000000000..d66ace81b1 --- /dev/null +++ b/shopinvader_restapi/utils.py @@ -0,0 +1,81 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from contextlib import contextmanager + +from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.component.core import WorkContext + + +def get_partner_work_context(shopinvader_partner): + """Retrieve service work context for given shopinvader.partner""" + ctx = {} + # TODO: `invader_partner` and `invader_partner_user` could be abandoned as soon as + # all `shopinvader` modules stop relying on `self.invader_partner`. + ctx["invader_partner"] = shopinvader_partner + ctx["invader_partner_user"] = shopinvader_partner + + partner_user = shopinvader_partner.record_id + ctx["partner_user"] = partner_user + # The partner user for the main account or for sale order may differ. + partner_shop = partner_user.get_shop_partner(shopinvader_partner.backend_id) + ctx["partner"] = partner_shop + if partner_shop != partner_user: + # Invader partner must represent the same partner as the shop + invader_partner_shop = partner_shop._get_invader_partner( + shopinvader_partner.backend_id + ) + if invader_partner_shop: + ctx["invader_partner"] = invader_partner_shop + return ctx + + +def load_partner_work_ctx(service, invader_partner, force=False): + """Update work context for given service loading given invader partner ctx.""" + params = get_partner_work_context(invader_partner) + update_work_ctx(service, params, force=force) + + +def reset_partner_work_ctx(service): + """Update work context flushing all partner keys.""" + defaults = {} + partner_work_context_defaults(service.env, service.shopinvader_backend, defaults) + update_work_ctx(service, defaults, force=True) + + +def update_work_ctx(service, params, force=False): + """Update work context for given service.""" + for k, v in params.items(): + # The attribute on the service could be None or empty recordset. + if force or not getattr(service.work, k, None): + setattr(service.work, k, v) + + +def partner_work_context_defaults(env, backend, params): + """Inject defaults as these keys are mandatory for work ctx.""" + if params.get("partner") and not params.get("partner_user"): + params["partner_user"] = params["partner"] + for k in ("partner", "partner_user"): + if not params.get(k): + params[k] = env["res.partner"].browse() + if not params.get("invader_" + k): + params["invader_" + k] = params[k]._get_invader_partner(backend) + + +@contextmanager +def work_on_service(env, **params): + """Work on a shopinvader service.""" + collection = _PseudoCollection("shopinvader.backend", env) + yield WorkContext( + model_name="rest.service.registration", collection=collection, **params + ) + + +@contextmanager +def work_on_service_with_partner(env, invader_partner, **kw): + """Work on a shopinvader service using given shopinvader.partner.""" + params = get_partner_work_context(invader_partner) + params["shopinvader_backend"] = invader_partner.backend_id + params.update(kw) + with work_on_service(env, **params) as work: + yield work diff --git a/shopinvader_restapi/views/partner_view.xml b/shopinvader_restapi/views/partner_view.xml new file mode 100644 index 0000000000..b7a08b7d43 --- /dev/null +++ b/shopinvader_restapi/views/partner_view.xml @@ -0,0 +1,53 @@ + + + + + res.partner + + + + + + + + + + + + + + + + + + + shopinvader.backend + + + + + + + + + + + Shopinvader Website + shopinvader.backend + tree,form + + + + + + diff --git a/shopinvader_restapi/views/shopinvader_cart_step_view.xml b/shopinvader_restapi/views/shopinvader_cart_step_view.xml new file mode 100644 index 0000000000..b0f5849cba --- /dev/null +++ b/shopinvader_restapi/views/shopinvader_cart_step_view.xml @@ -0,0 +1,52 @@ + + + + + shopinvader.cart.step + + + + + + + + + + shopinvader.cart.step + + + + + + + + + + Shopinvader Cart Step + ir.actions.act_window + shopinvader.cart.step + tree + + [] + {} + + + + + + tree + + + + + + diff --git a/shopinvader_restapi/views/shopinvader_menu.xml b/shopinvader_restapi/views/shopinvader_menu.xml new file mode 100644 index 0000000000..aec3a467a7 --- /dev/null +++ b/shopinvader_restapi/views/shopinvader_menu.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + diff --git a/shopinvader_restapi/views/shopinvader_partner_view.xml b/shopinvader_restapi/views/shopinvader_partner_view.xml new file mode 100644 index 0000000000..64f4d1cbfe --- /dev/null +++ b/shopinvader_restapi/views/shopinvader_partner_view.xml @@ -0,0 +1,128 @@ + + + + + shopinvader.partner + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + shopinvader.partner + + + + + + + + + + + + + + + shopinvader.partner + + + + + + + + + + + + + + + + + Shopinvader Partner + shopinvader.partner + + [] + {'shopinvader_partner_main_view': True} + + + + + + tree + + + + + + + form + + + + + +
diff --git a/shopinvader_restapi/views/shopinvader_sale_view.xml b/shopinvader_restapi/views/shopinvader_sale_view.xml new file mode 100644 index 0000000000..07bc467639 --- /dev/null +++ b/shopinvader_restapi/views/shopinvader_sale_view.xml @@ -0,0 +1,98 @@ + + + + + + sale.order + primary + + + + false + + + hide + + + hide + + + show + + + show + + + + + + sale.order + primary + + + + false + + + hide + + + hide + + + show + + + show + + + + + + Carts + ir.actions.act_window + sale.order + tree,kanban,form,calendar,pivot,graph,activity + + + {"default_typology": "cart"} + + [("typology", "=", "cart"), ("shopinvader_backend_id", "!=", False)] + + + + + Orders + ir.actions.act_window + sale.order + tree,kanban,form,calendar,pivot,graph,activity + + + {"default_typology": "sale"} + + [("typology", "=", "sale"), ("shopinvader_backend_id", "!=", False)] + + + + + + + + diff --git a/shopinvader_restapi/wizards/__init__.py b/shopinvader_restapi/wizards/__init__.py new file mode 100644 index 0000000000..09be1500e0 --- /dev/null +++ b/shopinvader_restapi/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import shopinvader_partner_binding +from . import shopinvader_partner_binding_line diff --git a/shopinvader_restapi/wizards/shopinvader_partner_binding.py b/shopinvader_restapi/wizards/shopinvader_partner_binding.py new file mode 100644 index 0000000000..9c092fd9d1 --- /dev/null +++ b/shopinvader_restapi/wizards/shopinvader_partner_binding.py @@ -0,0 +1,68 @@ +# 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 ShopinvaderPartnerBinding(models.TransientModel): + """ + Wizard used to bind manually some partners on shopinvader backends + """ + + _name = "shopinvader.partner.binding" + _description = "Shopinvader partner binding" + + shopinvader_backend_id = fields.Many2one( + comodel_name="shopinvader.backend", + string="Shopinvader backend", + required=True, + ondelete="cascade", + ) + binding_lines = fields.One2many( + comodel_name="shopinvader.partner.binding.line", + inverse_name="shopinvader_partner_binding_id", + string="Lines", + ) + + @api.model + def default_get(self, fields_list): + """ + Inherit the default_get to auto-fill the backend if only 1 is found + :param fields_list: list of str + :return: dict + """ + result = super(ShopinvaderPartnerBinding, self).default_get(fields_list) + # Auto fill the backend if we have only 1 backend found + backend = self.shopinvader_backend_id.search([], limit=2) + if len(backend) == 1: + result.update({"shopinvader_backend_id": backend.id}) + return result + + @api.onchange("shopinvader_backend_id") + def _onchange_shopinvader_backend_id(self): + """ + Onchange for the shopinvader_backend_id field. + Auto fill some info based on active_ids and selected backend. + :return: + """ + if self.env.context.get("active_model") == "res.partner": + partner_ids = self.env.context.get("active_ids", []) + lines = [(6, False, [])] + for partner in self.env["res.partner"].browse(partner_ids): + shopinv_partner = partner.shopinvader_bind_ids.filtered( + lambda x, b=self.shopinvader_backend_id: x.backend_id == b + ) + if shopinv_partner: + # If the user is already binded, ignore it + continue + values = {"partner_id": partner.id, "bind": False} + lines.append((0, False, values)) + self.binding_lines = lines + + def action_apply(self): + """ + Apply binding + :return: + """ + self.ensure_one() + self.binding_lines.action_apply() + return {"type": "ir.actions.act_window_close"} diff --git a/shopinvader_restapi/wizards/shopinvader_partner_binding.xml b/shopinvader_restapi/wizards/shopinvader_partner_binding.xml new file mode 100644 index 0000000000..dfcd953003 --- /dev/null +++ b/shopinvader_restapi/wizards/shopinvader_partner_binding.xml @@ -0,0 +1,57 @@ + + + + + shopinvader.partner.binding.form (in shopinvader) + shopinvader.partner.binding + +
+ + + + + + +
+ Select which partners should belong to the Shopinvader backend in the list below. + The email address of each selected contact must be valid and unique. + Partners already binded are ignored. +
+ + + + + + + + + +
+
+ +
+
+ + + Shopinvader Partner Binding Wizard + shopinvader.partner.binding + form + {} + new + + + + + +
diff --git a/shopinvader_restapi/wizards/shopinvader_partner_binding_line.py b/shopinvader_restapi/wizards/shopinvader_partner_binding_line.py new file mode 100644 index 0000000000..f26bb7b633 --- /dev/null +++ b/shopinvader_restapi/wizards/shopinvader_partner_binding_line.py @@ -0,0 +1,55 @@ +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, exceptions, fields, models + + +class ShopinvaderPartnerBindingLine(models.TransientModel): + """ + Wizard lines used to bind manually some partners on shopinvader backends + """ + + _name = "shopinvader.partner.binding.line" + _description = "Shopinvader partner binding line" + + shopinvader_partner_binding_id = fields.Many2one( + comodel_name="shopinvader.partner.binding", + string="Wizard", + required=True, + ondelete="cascade", + ) + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Partner", + required=True, + ondelete="cascade", + readonly=True, + ) + email = fields.Char(related="partner_id.email", required=True, readonly=True) + bind = fields.Boolean( + help="Tick to bind the partner to the backend. Untick to unbind it." + ) + + def action_apply(self): + """ + Bind selected partners + :return: dict + """ + if self.filtered(lambda r: not r.bind): + message = _( + "The unbind is not implemented.\n" + "If you want to continue, please delete lines where " + "the bind field is not ticked." + ) + raise exceptions.UserError(message) + for record in self.filtered(lambda r: r.bind): + backend = record.shopinvader_partner_binding_id.shopinvader_backend_id + # Ensure the binding doesn't exist yet + partner_binding = record.partner_id.shopinvader_bind_ids.filtered( + lambda x, b=backend: x.backend_id == b + ) + if not partner_binding: + bind_values = {"backend_id": backend.id} + record.partner_id.write( + {"shopinvader_bind_ids": [(0, False, bind_values)]} + ) + return {}