From 8cdc527e43cf12dd22b16e8c7177cbf9cc09ecd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrius=20Laukavi=C4=8Dius?= Date: Tue, 12 Dec 2023 15:03:07 +0200 Subject: [PATCH] [IMP] vuestorefront: various improvements, tests This is a forked module previously called ``graphql_vuestorefront``. It is backwards incompatible module, so it should not be installed alongside ``graphql_vuestorefront``. Custom changes different than in forked module: * ``get_product_list`` in ``schemas.product`` now uses offset/limit in standard search instead of searching for all possible products and then slicing (which has terrible performance). * ``website_slug``: (renamed to ``slug``) got rid of translate awareness (to simplify it), and not adding slashes in slug, because slug is not supposed to have that. (on category, removed validation as it was forcing to use slash. Added unique constraint, because category slug can be entered manually). * Split odoo model files into more appropriate, to make it more readable and maintainable. * Removed ``public_categ_slug_ids`` field as it was confusing and redundant field that was very slow to compute for large amount of products. * implemented ``products`` ``category_slug`` filter. * moved Product schema domain method to ``product.template`` to be extendable. Addresses --------- * Moved address domain methods on ``res.partner`` to be extendable. * Moved preparation of partner (address) creation/update on ``res.partner to be extendable. Sale Order ---------- * Added UpdateOrder mutation to be able to update some data on sale order. Users ----- * Can pass extra data when signing up (only ``vat`` for now). --- vuestorefront/README.rst | 209 +++++++----- vuestorefront/controllers/main.py | 10 +- vuestorefront/models/__init__.py | 21 +- vuestorefront/models/invalidate_cache.py | 31 +- vuestorefront/models/product.py | 321 ------------------ vuestorefront/models/product_product.py | 58 ++++ .../models/product_public_category.py | 78 +++++ vuestorefront/models/product_template.py | 216 ++++++++++++ vuestorefront/models/res_partner.py | 52 +++ vuestorefront/models/res_users.py | 13 +- vuestorefront/models/sale_order.py | 13 + vuestorefront/schemas/address.py | 103 ++---- vuestorefront/schemas/category.py | 23 +- vuestorefront/schemas/country.py | 8 +- vuestorefront/schemas/invoice.py | 8 +- vuestorefront/schemas/mailing_list.py | 15 +- vuestorefront/schemas/objects.py | 5 +- vuestorefront/schemas/order.py | 31 +- vuestorefront/schemas/product.py | 121 +------ vuestorefront/schemas/sign.py | 22 +- vuestorefront/tests/__init__.py | 10 + vuestorefront/tests/common.py | 104 ++++++ vuestorefront/tests/test_mutate_shop.py | 59 ++++ vuestorefront/tests/test_mutate_user.py | 36 ++ vuestorefront/tests/test_product.py | 20 ++ vuestorefront/tests/test_product_query.py | 105 ++++++ vuestorefront/tests/test_public_category.py | 33 ++ vuestorefront/tests/test_query_category.py | 20 ++ .../tests/test_query_mutate_address.py | 120 +++++++ .../tests/test_query_mutate_order.py | 63 ++++ vuestorefront/utils.py | 12 + vuestorefront/views/product_views.xml | 4 +- 32 files changed, 1272 insertions(+), 672 deletions(-) delete mode 100644 vuestorefront/models/product.py create mode 100644 vuestorefront/models/product_product.py create mode 100644 vuestorefront/models/product_public_category.py create mode 100644 vuestorefront/models/product_template.py create mode 100644 vuestorefront/models/res_partner.py create mode 100644 vuestorefront/models/sale_order.py create mode 100644 vuestorefront/tests/__init__.py create mode 100644 vuestorefront/tests/common.py create mode 100644 vuestorefront/tests/test_mutate_shop.py create mode 100644 vuestorefront/tests/test_mutate_user.py create mode 100644 vuestorefront/tests/test_product.py create mode 100644 vuestorefront/tests/test_product_query.py create mode 100644 vuestorefront/tests/test_public_category.py create mode 100644 vuestorefront/tests/test_query_category.py create mode 100644 vuestorefront/tests/test_query_mutate_address.py create mode 100644 vuestorefront/tests/test_query_mutate_order.py create mode 100644 vuestorefront/utils.py diff --git a/vuestorefront/README.rst b/vuestorefront/README.rst index 103ac5f..7ac3aec 100644 --- a/vuestorefront/README.rst +++ b/vuestorefront/README.rst @@ -1,6 +1,43 @@ -====================== -Graphql Vue Storefront -====================== +============== +Vue Storefront +============== + +This is a forked module previously called ``graphql_vuestorefront``. It +is backwards incompatible module, so it should not be installed +alongside ``graphql_vuestorefront``. Custom changes different than in +forked module: + +* ``get_product_list`` in ``schemas.product`` now uses offset/limit in standard + search instead of searching for all possible products and then slicing ( + which has terrible performance). +* ``website_slug``: (renamed to ``slug``) got rid of translate awareness ( + to simplify it), and not adding + slashes in slug, because slug is not supposed to have that. (on category, removed + validation as it was forcing to use slash. Added unique constraint, because + category slug can be entered manually). +* Split odoo model files into more appropriate, to make it more readable and + maintainable. +* Removed ``public_categ_slug_ids`` field as it was confusing and redundant + field that was very slow to compute for large amount of products. +* implemented ``products`` ``category_slug`` filter. +* moved Product schema domain method to ``product.template`` to be extendable. + +Addresses +--------- + +* Moved address domain methods on ``res.partner`` to be extendable. +* Moved preparation of partner (address) creation/update on ``res.partner to + be extendable. + +Sale Order +---------- + +* Added UpdateOrder mutation to be able to update some data on sale order. + +Users +----- + +* Can pass extra data when signing up (only ``vat`` for now). Login ===== @@ -8,109 +45,127 @@ Login To authenticate, use the default /web/session/authenticate endpoint. Example using axios: -axios.post('/web/session/authenticate', { - "jsonrpc": "2.0", - "method": "call", - "params": { - "db": , - "login": , - "password": -}}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/web/session/authenticate', { + "jsonrpc": "2.0", + "method": "call", + "params": { + "db": , + "login": , + "password": + }}, { + "withCredentials": true + }) Logout ====== -axios.post('/web/session/destroy', { - "jsonrpc": "2.0", - "method": "call" -}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/web/session/destroy', { + "jsonrpc": "2.0", + "method": "call" + }, { + "withCredentials": true + }) Add to Cart =========== -axios.post('/shop/cart/update_json', { - "jsonrpc": "2.0", - "method": "call", - "params": { - "product_id": , - "add_qty": -}}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/cart/update_json', { + "jsonrpc": "2.0", + "method": "call", + "params": { + "product_id": , + "add_qty": + }}, { + "withCredentials": true + }) Add to wishlist =============== -axios.post('/shop/wishlist/add', { - "jsonrpc": "2.0", - "method": "call", - "params": { - "product_id": , -}}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/wishlist/add', { + "jsonrpc": "2.0", + "method": "call", + "params": { + "product_id": , + }}, { + "withCredentials": true + }) Remove from wishlist ==================== -axios.post('/shop/wishlist/remove/', { - "jsonrpc": "2.0", - "method": "call" -}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/wishlist/remove/', { + "jsonrpc": "2.0", + "method": "call" + }, { + "withCredentials": true + }) Get the rate for a shipping method ================================== -axios.post('/shop/carrier_rate_shipment', { - "jsonrpc": "2.0", - "method": "call" - "params": { - "carrier_id": , -}}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/carrier_rate_shipment', { + "jsonrpc": "2.0", + "method": "call" + "params": { + "carrier_id": , + }}, { + "withCredentials": true + }) Get all product template attributes for product template page ============================================================= -axios.post('/shop/get_combinations/', { - "jsonrpc": "2.0", - "method": "call" -}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/get_combinations/', { + "jsonrpc": "2.0", + "method": "call" + }, { + "withCredentials": true + }) Get product id and price after selecting the combination on the product template page ===================================================================================== -axios.post('/shop/get_combination_info/', { - "jsonrpc": "2.0", - "method": "call" - "params": { - "combination_ids": [1, 2], - add_qty=1 -}}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/get_combination_info/', { + "jsonrpc": "2.0", + "method": "call" + "params": { + "combination_ids": [1, 2], + add_qty=1 + }}, { + "withCredentials": true + }) Get products for shop with search, category, sort, count, pagination and attributes filtering ============================================================================================= -axios.post('/shop/products', { - "jsonrpc": "2.0", - "method": "call" - "params": { - "search": "", - "category_id": 1, - "offset": 0, - "ppg": 20, - "attrib_list": [] -}}, { - "withCredentials": true -}) +.. code-block:: + + axios.post('/shop/products', { + "jsonrpc": "2.0", + "method": "call" + "params": { + "search": "", + "category_id": 1, + "offset": 0, + "ppg": 20, + "attrib_list": [] + }}, { + "withCredentials": true + }) diff --git a/vuestorefront/controllers/main.py b/vuestorefront/controllers/main.py index cd7a474..865203d 100644 --- a/vuestorefront/controllers/main.py +++ b/vuestorefront/controllers/main.py @@ -201,13 +201,13 @@ def vsf_categories(self): if website.default_lang_id: lang_code = website.default_lang_id.code - domain = [("website_slug", "!=", False)] + domain = [("slug", "!=", False)] for category in ( request.env["product.public.category"].sudo().search(domain) ): category = category.with_context(lang=lang_code) - categories.append(category.website_slug) + categories.append(category.slug) return Response( json.dumps(categories), @@ -223,14 +223,14 @@ def vsf_products(self): if website.default_lang_id: lang_code = website.default_lang_id.code - domain = [("website_published", "=", True), ("website_slug", "!=", False)] + domain = [("website_published", "=", True), ("slug", "!=", False)] for product in request.env["product.template"].sudo().search(domain): product = product.with_context(lang=lang_code) - url_parsed = urlparse(product.website_slug) + url_parsed = urlparse(product.slug) name = os.path.basename(url_parsed.path) - path = product.website_slug.replace(name, "") + path = product.slug.replace(name, "") products.append( { diff --git a/vuestorefront/models/__init__.py b/vuestorefront/models/__init__.py index fca3668..f5556c0 100644 --- a/vuestorefront/models/__init__.py +++ b/vuestorefront/models/__init__.py @@ -1,10 +1,15 @@ # Copyright 2023 ODOOGAP/PROMPTEQUATION LDA # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). - -from . import invalidate_cache -from . import ir_http -from . import website -from . import product -from . import res_config_settings -from . import res_users -from . import payment_transaction +from . import ( + invalidate_cache, + ir_http, + website, + product_public_category, + product_template, + product_product, + res_config_settings, + res_users, + payment_transaction, + res_partner, + sale_order, +) diff --git a/vuestorefront/models/invalidate_cache.py b/vuestorefront/models/invalidate_cache.py index ce94fc2..6e47c9f 100644 --- a/vuestorefront/models/invalidate_cache.py +++ b/vuestorefront/models/invalidate_cache.py @@ -125,26 +125,37 @@ def request_vsf_cache_invalidation(self): # FIXME: this probably should not be done. self.env.cr.commit() # pylint: disable=invalid-commit + # TODO: these methods don't make sense. They have same code. Need + # to redesign this, when its clear what they actually should return. def _get_product_tags(self, product_ids): tags = ",".join(f"P{product_id}" for product_id in product_ids) - category_ids = ( + categories = ( self.env["product.template"] .search([("id", "in", product_ids)]) - .mapped("public_categ_slug_ids") - .ids + .mapped("public_categ_ids") ) - if category_ids: - tags += "," + ",".join(f"C{category_id}" for category_id in category_ids) + ancestor_categs = self.env["product.public.category"] + for parent in categories.get_ancestors(): + ancestor_categs |= parent + categories |= ancestor_categs + if categories: + tags += "," + ",".join(f"C{category_id}" for category_id in categories.ids) return tags + # TODO: this method most likely is incorrect. Should not expect product_ids as + # it looks like when its called, it actually calls with category_ids + # (product.public.category). def _get_category_tags(self, product_ids): tags = ",".join(f"P{product_id}" for product_id in product_ids) - category_ids = ( + categories = ( self.env["product.template"] .search([("id", "in", product_ids)]) - .mapped("public_categ_slug_ids") - .ids + .mapped("public_categ_ids") ) - if category_ids: - tags += "," + ",".join(f"C{category_id}" for category_id in category_ids) + ancestor_categs = self.env["product.public.category"] + for parent in categories.get_ancestors(): + ancestor_categs |= parent + categories |= ancestor_categs + if categories: + tags += "," + ",".join(f"C{category_id}" for category_id in categories.ids) return tags diff --git a/vuestorefront/models/product.py b/vuestorefront/models/product.py deleted file mode 100644 index a80523f..0000000 --- a/vuestorefront/models/product.py +++ /dev/null @@ -1,321 +0,0 @@ -# Copyright 2023 ODOOGAP/PROMPTEQUATION LDA -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). - -import json - -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - -from odoo.addons.http_routing.models.ir_http import slug, slugify - - -class ProductTemplate(models.Model): - _inherit = "product.template" - - def _get_public_categ_slug(self, category_ids, category): - category_ids.append(category.id) - - if category.parent_id: - category_ids = self._get_public_categ_slug(category_ids, category.parent_id) - - return category_ids - - @api.depends("public_categ_ids") - def _compute_public_categ_slug_ids(self): - """Compute to allow search of website_slug on parent categories.""" - cr = self.env.cr - - for product in self: - category_ids = [] - - for category in product.public_categ_ids: - category_ids = product._get_public_categ_slug(category_ids, category) - - cr.execute( - """ - DELETE FROM product_template_product_public_category_slug_rel - WHERE product_template_id=%s; - """, - (product.id,), - ) - - for category_id in list(dict.fromkeys(category_ids)): - cr.execute( - """ - INSERT INTO - product_template_product_public_category_slug_rel( - product_template_id, product_public_category_id - ) - VALUES(%s, %s); - """, - ( - product.id, - category_id, - ), - ) - - @api.depends("name") - def _compute_website_slug(self): - langs = self.env["res.lang"].search([]) - - for product in self: - for lang in langs: - product = product.with_context(lang=lang.code) - - if not product.id: - product.website_slug = None - else: - prefix = "/product" - slug_name = slugify(product.name or "").strip().strip("-") - product.website_slug = f"{prefix}/{slug_name}-{product.id}" - - @api.depends("product_variant_ids") - def _compute_variant_attribute_value_ids(self): - """Compute attribute values. - - This method computes a list of attribute values from variants of - published products. - This will ensure that the available attribute values on the website - filtering will return results. - By default, Odoo only shows attributes that will return results but - doesn't consider that a particular - attribute value may not have a variant. - """ - for product in self: - variants = product.product_variant_ids - attribute_values = variants.mapped( - "product_template_attribute_value_ids" - ).mapped("product_attribute_value_id") - product.variant_attribute_value_ids = [(6, 0, attribute_values.ids)] - - variant_attribute_value_ids = fields.Many2many( - "product.attribute.value", - "product_template_variant_product_attribute_value_rel", - compute="_compute_variant_attribute_value_ids", - store=True, - readonly=True, - ) - website_slug = fields.Char( - compute="_compute_website_slug", - store=True, - readonly=True, - translate=True, - ) - public_categ_slug_ids = fields.Many2many( - "product.public.category", - "product_template_product_public_category_slug_rel", - compute="_compute_public_categ_slug_ids", - store=True, - readonly=True, - ) - json_ld = fields.Char("JSON-LD") - - def write(self, vals): - res = super().write(vals) - self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) - return res - - def unlink(self): - self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) - return super().unlink() - - def _get_combination_info( - self, - combination=False, - product_id=False, - add_qty=1, - pricelist=False, - parent_combination=False, - only_template=False, - ): - """Add discount value and percentage based.""" - combination_info = super()._get_combination_info( - combination=combination, - product_id=product_id, - add_qty=add_qty, - pricelist=pricelist, - parent_combination=parent_combination, - only_template=only_template, - ) - - discount = 0.00 - discount_perc = 0 - if combination_info["has_discounted_price"] and product_id: - discount = combination_info["list_price"] - combination_info["price"] - discount_perc = ( - combination_info["list_price"] - and (discount * 100 / combination_info["list_price"]) - or 0 - ) - - if discount_perc: - discount_perc = int(round(discount_perc, 0)) - if not discount_perc: - discount_perc = 1 - - combination_info.update( - { - "discount": round(discount, 2), - "discount_perc": discount_perc, - } - ) - - return combination_info - - def get_json_ld(self): - self.ensure_one() - if self.json_ld: - return self.json_ld - - env = self.env - website = env["website"].get_current_website() - base_url = env["ir.config_parameter"].sudo().get_param("web.base.url", "") - if base_url and base_url[-1:] == "/": - base_url = base_url[:-1] - - # Get list of images - images = list() - if self.image_1920: - images.append("%s/web/image/product.product/%s/image" % (base_url, self.id)) - - json_ld = { - "@context": "https://schema.org/", - "@type": "Product", - "name": self.display_name, - "image": images, - "offers": { - "@type": "Offer", - "url": "%s/product/%s" % (website.domain or "", slug(self)), - "priceCurrency": self.currency_id.name, - "price": self.list_price, - "itemCondition": "https://schema.org/NewCondition", - "availability": "https://schema.org/InStock", - "seller": { - "@type": "Organization", - "name": website - and website.display_name - or self.env.user.company_id.display_name, - }, - }, - } - - if self.description_sale: - json_ld.update({"description": self.description_sale}) - - if self.default_code: - json_ld.update({"sku": self.default_code}) - - return json.dumps(json_ld) - - -class ProductProduct(models.Model): - _inherit = "product.product" - - def get_json_ld(self): - self.ensure_one() - if self.json_ld: - return self.json_ld - - env = self.env - website = env["website"].get_current_website() - base_url = env["ir.config_parameter"].sudo().get_param("web.base.url", "") - if base_url and base_url[-1:] == "/": - base_url = base_url[:-1] - - # Get list of images - images = list() - if self.image_1920: - images.append("%s/web/image/product.product/%s/image" % (base_url, self.id)) - - json_ld = { - "@context": "https://schema.org/", - "@type": "Product", - "name": self.display_name, - "image": images, - "offers": { - "@type": "Offer", - "url": "%s/product/%s" % (website.domain or "", slug(self)), - "priceCurrency": self.currency_id.name, - "price": self.list_price, - "itemCondition": "https://schema.org/NewCondition", - "availability": "https://schema.org/InStock", - "seller": { - "@type": "Organization", - "name": website - and website.display_name - or self.env.user.company_id.display_name, - }, - }, - } - - if self.description_sale: - json_ld.update({"description": self.description_sale}) - - if self.default_code: - json_ld.update({"sku": self.default_code}) - - return json.dumps(json_ld) - - -class ProductPublicCategory(models.Model): - _inherit = "product.public.category" - - def _validate_website_slug(self): - for category in self.filtered(lambda c: c.website_slug): - if category.website_slug[0] != "/": - raise ValidationError(_("Slug should start with /")) - - if self.search( - [ - ("website_slug", "=", category.website_slug), - ("id", "!=", category.id), - ], - limit=1, - ): - raise ValidationError( - _(f"Slug is already in use: {category.website_slug}") - ) - - website_slug = fields.Char(translate=True, copy=False) - json_ld = fields.Char("JSON-LD") - - @api.model - def create(self, vals): - rec = super().create(vals) - - if rec.website_slug: - rec._validate_website_slug() - else: - rec.website_slug = f"/category/{rec.id}" - - return rec - - def write(self, vals): - res = super().write(vals) - if vals.get("website_slug", False): - self._validate_website_slug() - self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) - return res - - def unlink(self): - self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) - return super().unlink() - - def get_json_ld(self): - self.ensure_one() - if self.json_ld: - return self.json_ld - - website = self.env["website"].get_current_website() - base_url = website.domain or "" - if base_url and base_url[-1] == "/": - base_url = base_url[:-1] - - json_ld = { - "@context": "https://schema.org", - "@type": "CollectionPage", - "url": "{}{}".format(base_url, self.website_slug or ""), - "name": self.display_name, - } - - return json.dumps(json_ld) diff --git a/vuestorefront/models/product_product.py b/vuestorefront/models/product_product.py new file mode 100644 index 0000000..912d61e --- /dev/null +++ b/vuestorefront/models/product_product.py @@ -0,0 +1,58 @@ +# Copyright 2023 ODOOGAP/PROMPTEQUATION LDA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json + +from odoo import models + +from odoo.addons.http_routing.models.ir_http import slug + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def get_json_ld(self): + self.ensure_one() + if self.json_ld: + return self.json_ld + + env = self.env + website = env["website"].get_current_website() + # TODO: reduce boilerplate + base_url = env["ir.config_parameter"].sudo().get_param("web.base.url", "") + if base_url and base_url[-1:] == "/": + base_url = base_url[:-1] + + # Get list of images + images = list() + if self.image_1920: + images.append("%s/web/image/product.product/%s/image" % (base_url, self.id)) + + json_ld = { + "@context": "https://schema.org/", + "@type": "Product", + "name": self.display_name, + "image": images, + "offers": { + "@type": "Offer", + "url": "%s/product/%s" % (website.domain or "", slug(self)), + "priceCurrency": self.currency_id.name, + "price": self.list_price, + "itemCondition": "https://schema.org/NewCondition", + "availability": "https://schema.org/InStock", + "seller": { + "@type": "Organization", + "name": website + and website.display_name + or self.env.user.company_id.display_name, + }, + }, + } + + if self.description_sale: + json_ld.update({"description": self.description_sale}) + + if self.default_code: + json_ld.update({"sku": self.default_code}) + + return json.dumps(json_ld) diff --git a/vuestorefront/models/product_public_category.py b/vuestorefront/models/product_public_category.py new file mode 100644 index 0000000..e00db00 --- /dev/null +++ b/vuestorefront/models/product_public_category.py @@ -0,0 +1,78 @@ +import json + +from odoo import api, fields, models + +from ..utils import slugify_with_sfx + + +class ProductPublicCategory(models.Model): + _inherit = "product.public.category" + + slug = fields.Char( + readonly=False, + index=True, + store=True, + compute="_compute_slug", + ) + json_ld = fields.Char("JSON-LD") + + _sql_constraints = [ + ( + "slug_uniq", + "unique (slug)", + "The Website Slug must be unique!", + ) + ] + + @api.depends("name") + def _compute_slug(self): + for rec in self: + if not isinstance(rec.id, models.NewId) and not rec.slug and rec.name: + rec.slug = slugify_with_sfx(rec.name, f"-{rec.id}") + + def write(self, vals): + res = super().write(vals) + self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) + return res + + def unlink(self): + self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) + return super().unlink() + + def get_json_ld(self): + self.ensure_one() + if self.json_ld: + return self.json_ld + + website = self.env["website"].get_current_website() + base_url = website.domain or "" + if base_url and base_url[-1] == "/": + base_url = base_url[:-1] + + json_ld = { + "@context": "https://schema.org", + "@type": "CollectionPage", + "url": "{}{}".format(base_url, self.slug or ""), + "name": self.display_name, + } + + return json.dumps(json_ld) + + def get_ancestors(self): + for categ in self: + parent = categ.parent_id + while parent: + yield parent + parent = parent.parent_id + + def get_category_by_slug(self, slug): + return self.search([("slug", "=", slug)]) + + @api.model + def _get_category_slug_leaf(self, category_slug): + # We need to search for category, because we want to include child + # categories. + categ = self.get_category_by_slug(category_slug) + if categ: + return ("public_categ_ids", "child_of", categ.id) + return None diff --git a/vuestorefront/models/product_template.py b/vuestorefront/models/product_template.py new file mode 100644 index 0000000..7317d01 --- /dev/null +++ b/vuestorefront/models/product_template.py @@ -0,0 +1,216 @@ +# Copyright 2023 ODOOGAP/PROMPTEQUATION LDA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json + +from odoo import api, fields, models +from odoo.osv import expression +from odoo.tools.sql import column_exists, create_column + +from odoo.addons.http_routing.models.ir_http import slug + +from ..utils import slugify_with_sfx + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def _auto_init(self): + """Create/set slug via SQL to save on memory usage.""" + cr = self.env.cr + if not column_exists(cr, "product_template", "slug"): + create_column(cr, "product_template", "slug", "varchar") + cr.execute("SELECT id, name FROM product_template WHERE name IS NOT NULL") + for res in cr.fetchall(): + id_, name = res + cr.execute( + "UPDATE product_template SET slug = %s WHERE id = %s", + (slugify_with_sfx(name, f"-{id_}"), id_), + ) + return super()._auto_init() + + @api.depends("name") + def _compute_slug(self): + for product in self: + # To make sure we don't assign ID before record is created. + if not isinstance(product.id, models.NewId): + product.slug = slugify_with_sfx(product.name, f"-{product.id}") + + slug = fields.Char( + compute="_compute_slug", + store=True, + readonly=True, + ) + json_ld = fields.Char("JSON-LD") + + @api.model + def prepare_vsf_domain(self, search, **kwargs): + # Only get sellable products. + domains = [self.env["website"].get_current_website().sale_product_domain()] + if self.is_vsf_published_only(**kwargs): + domains.append([("is_published", "=", True)]) + # Filter with ids + if kwargs.get("ids", False): + domains.append([("id", "in", kwargs["ids"])]) + # Filter with Category ID + if kwargs.get("category_id", False): + domains.append([("public_categ_ids", "child_of", kwargs["category_id"])]) + if kwargs.get("category_slug"): + category_slug_leaf = self.env[ + "product.public.category" + ]._get_category_slug_leaf(kwargs["category_slug"]) + if category_slug_leaf is not None: + domains.append([category_slug_leaf]) + # Filter With Barcode + if kwargs.get("barcode", False): + domains.append([("barcode", "ilike", kwargs["barcode"])]) + # Filter With Name + if kwargs.get("name", False): + name = kwargs["name"] + for n in name.split(" "): + domains.append([("name", "ilike", n)]) + if search: + for name in search.split(" "): + domains.append(self._get_product_search_name_domain(name)) + # Product Price Filter + if kwargs.get("min_price", False): + domains.append([("list_price", ">=", float(kwargs["min_price"]))]) + if kwargs.get("max_price", False): + domains.append([("list_price", "<=", float(kwargs["max_price"]))]) + # Deprecated: filter with Attribute Value + if kwargs.get("attribute_value_id", False): + domains.append( + [("attribute_line_ids.value_ids", "in", kwargs["attribute_value_id"])] + ) + # Filter with Attribute Value + if kwargs.get("attrib_values", False): + ids = [] + + for value in kwargs["attrib_values"]: + try: + value = value.split("-") + if len(value) != 2: + continue + + attribute_value_id = int(value[1]) + except ValueError: + continue + + ids.append(attribute_value_id) + if ids: + domains.append([("attribute_line_ids.value_ids", "in", ids)]) + return expression.AND(domains) + + def write(self, vals): + res = super().write(vals) + self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) + return res + + def unlink(self): + self.env["invalidate.cache"].create_invalidate_cache(self._name, self.ids) + return super().unlink() + + def _get_combination_info( + self, + combination=False, + product_id=False, + add_qty=1, + pricelist=False, + parent_combination=False, + only_template=False, + ): + """Add discount value and percentage based.""" + combination_info = super()._get_combination_info( + combination=combination, + product_id=product_id, + add_qty=add_qty, + pricelist=pricelist, + parent_combination=parent_combination, + only_template=only_template, + ) + + discount = 0.00 + discount_perc = 0 + if combination_info["has_discounted_price"] and product_id: + discount = combination_info["list_price"] - combination_info["price"] + discount_perc = ( + combination_info["list_price"] + and (discount * 100 / combination_info["list_price"]) + or 0 + ) + + if discount_perc: + discount_perc = int(round(discount_perc, 0)) + if not discount_perc: + discount_perc = 1 + + combination_info.update( + { + "discount": round(discount, 2), + "discount_perc": discount_perc, + } + ) + + return combination_info + + def get_json_ld(self): + self.ensure_one() + if self.json_ld: + return self.json_ld + + env = self.env + website = env["website"].get_current_website() + base_url = env["ir.config_parameter"].sudo().get_param("web.base.url", "") + if base_url and base_url[-1:] == "/": + base_url = base_url[:-1] + + # Get list of images + images = list() + if self.image_1920: + images.append("%s/web/image/product.product/%s/image" % (base_url, self.id)) + + json_ld = { + "@context": "https://schema.org/", + "@type": "Product", + "name": self.display_name, + "image": images, + "offers": { + "@type": "Offer", + "url": "%s/product/%s" % (website.domain or "", slug(self)), + "priceCurrency": self.currency_id.name, + "price": self.list_price, + "itemCondition": "https://schema.org/NewCondition", + "availability": "https://schema.org/InStock", + "seller": { + "@type": "Organization", + "name": website + and website.display_name + or self.env.user.company_id.display_name, + }, + }, + } + + if self.description_sale: + json_ld.update({"description": self.description_sale}) + + if self.default_code: + json_ld.update({"sku": self.default_code}) + + return json.dumps(json_ld) + + @api.model + def is_vsf_published_only(self, **kwargs): + """Check if product search is for published products only.""" + return True + + @api.model + def _get_product_search_name_domain(self, name): + return [ + "|", + "|", + "|", + ("name", "ilike", name), + ("description_sale", "like", name), + ("default_code", "like", name), + ("barcode", "ilike", name), + ] diff --git a/vuestorefront/models/res_partner.py b/vuestorefront/models/res_partner.py new file mode 100644 index 0000000..ef341d6 --- /dev/null +++ b/vuestorefront/models/res_partner.py @@ -0,0 +1,52 @@ +from odoo import api, models +from odoo.osv import expression + +VSF_ADDRESS_DIRECT_FIELDS = ["name", ""] + + +class ResPartner(models.Model): + _inherit = "res.partner" + + @api.model + def prepare_vsf_address_direct_fields(self): + return [ + "name", + "street", + "street2", + "phone", + "zip", + "city", + "state_id", + "country_id", + "email", + ] + + @api.model + def prepare_vsf_address_vals(self, address): + vals = {} + for fname in self.prepare_vsf_address_direct_fields(): + if fname in address: + vals[fname] = address[fname] + return vals + + def prepare_vsf_invoice_address_domain(self): + self.ensure_one() + return [("type", "=", "invoice")] + + def prepare_vsf_delivery_address_domain(self): + self.ensure_one() + return [("type", "=", "delivery")] + + def _prepare_vsf_base_address_domain(self): + self.ensure_one() + return [("id", "child_of", self.commercial_partner_id.ids)] + + def _prepare_vsf_address_domain(self, address_types): + self.ensure_one() + base_domain = self._prepare_vsf_base_address_domain() + types = [at.value for at in address_types] + type_domains = [] + for addr_type in types: + method_name = f"prepare_vsf_{addr_type}_address_domain" + type_domains.append(getattr(self, method_name)()) + return expression.AND([base_domain, expression.OR(type_domains)]) diff --git a/vuestorefront/models/res_users.py b/vuestorefront/models/res_users.py index a5b9861..15abde1 100644 --- a/vuestorefront/models/res_users.py +++ b/vuestorefront/models/res_users.py @@ -3,7 +3,7 @@ import logging -from odoo import _, models +from odoo import _, api, models from odoo.exceptions import UserError from odoo.http import request @@ -68,3 +68,14 @@ def api_action_reset_password(self): user.login, user.email, ) + + @api.model + def prepare_vsf_signup_vals(self, name, login, password, **kw): + vals = { + "name": name, + "login": login, + "password": password, + } + if "vat" in kw: + vals["vat"] = kw["vat"] + return vals diff --git a/vuestorefront/models/sale_order.py b/vuestorefront/models/sale_order.py new file mode 100644 index 0000000..6b865fd --- /dev/null +++ b/vuestorefront/models/sale_order.py @@ -0,0 +1,13 @@ +from odoo import models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def prepare_vsf_vals(self, data): + # Only used for current session user current order. + self.ensure_one() + vals = {} + if "client_order_ref" in data: + vals["client_order_ref"] = data["client_order_ref"] + return vals diff --git a/vuestorefront/schemas/address.py b/vuestorefront/schemas/address.py index 1ded337..d20ec3f 100644 --- a/vuestorefront/schemas/address.py +++ b/vuestorefront/schemas/address.py @@ -49,7 +49,7 @@ class AddressEnum(graphene.Enum): class AddressFilterInput(graphene.InputObjectType): - address_type = graphene.List(AddressEnum) + address_types = graphene.List(AddressEnum) class AddressQuery(graphene.ObjectType): @@ -65,10 +65,10 @@ def resolve_addresses(self, info, filter): website = env["website"].get_current_website() request.website = website order = website.sale_get_order() - if not order: raise GraphQLError(_("Shopping cart not found.")) + # TODO: getting partner ID should be on its own function/method. # Is public user if ( not order.partner_id.user_ids @@ -77,17 +77,10 @@ def resolve_addresses(self, info, filter): partner_id = order.partner_id.id else: partner_id = env.user.partner_id.commercial_partner_id.id - + partner = env["res.partner"].browse(partner_id) # Get all addresses of a specific addressType - delivery or/and shipping - if filter.get("address_type"): - types = [ - address_type.value for address_type in filter.get("address_type", []) - ] - - domain = [ - ("id", "child_of", partner_id), - ("type", "in", types), - ] + if filter.get("address_types"): + domain = partner._prepare_vsf_address_domain(filter["address_types"]) # Get all addresses with addressType delivery and invoice else: domain = [ @@ -96,7 +89,6 @@ def resolve_addresses(self, info, filter): ("type", "in", ["delivery", "invoice"]), ("id", "=", partner_id), ] - return ResPartner.search(domain, order="id desc") @@ -135,46 +127,29 @@ class UpdateAddressInput(graphene.InputObjectType): class AddAddress(graphene.Mutation): class Arguments: - type = AddressEnum(required=True) + address_type = AddressEnum(required=True) address = AddAddressInput() Output = Partner @staticmethod - def mutate(self, info, type, address): + def mutate(self, info, address_type, address): env = info.context["env"] ResPartner = env["res.partner"].sudo().with_context(tracking_disable=True) website = env["website"].get_current_website() request.website = website order = website.sale_get_order() - if not order: raise GraphQLError(_("Shopping cart not found.")) - - values = { - "name": address.get("name"), - "street": address.get("street"), - "street2": address.get("street2"), - "phone": address.get("phone"), - "zip": address.get("zip"), - "city": address.get("city"), - "state_id": address.get("state_id", False), - "country_id": address.get("country_id", False), - "email": address.get("email", False), - } + values = ResPartner.prepare_vsf_address_vals(address) partner_id = order.partner_id.id - # Check public user if partner_id == website.user_id.sudo().partner_id.id: # Create main contact - values["type"] = "contact" - partner_id = ResPartner.create(values).id + partner_id = ResPartner.create(dict(values, type="contact")).id order.partner_id = partner_id - - values["type"] = type.value - values["parent_id"] = partner_id - + values.update({"type": address_type.value, "parent_id": partner_id}) # Create the new shipping or invoice address partner = ResPartner.create(values) @@ -183,9 +158,9 @@ def mutate(self, info, type, address): order.partner_invoice_id = partner.id elif values["type"] == "delivery": order.partner_shipping_id = partner.id - - # Trigger the change of fiscal position when the shipping address is modified - order._compute_fiscal_position_id() + # Trigger the change of fiscal position when the shipping address + # is modified + order._compute_fiscal_position_id() return partner @@ -202,37 +177,13 @@ def mutate(self, info, address): website = env["website"].get_current_website() request.website = website order = website.sale_get_order() - partner = get_partner(env, address["id"], order, website) - - values = {} - if address.get("name"): - values.update({"name": address["name"]}) - if address.get("street"): - values.update({"street": address["street"]}) - if address.get("street2"): - values.update({"street2": address["street2"]}) - if address.get("phone"): - values.update({"phone": address["phone"]}) - if address.get("zip"): - values.update({"zip": address["zip"]}) - if address.get("city"): - values.update({"city": address["city"]}) - if address.get("state_id"): - values.update({"state_id": address["state_id"]}) - if address.get("country_id"): - values.update({"country_id": address["country_id"]}) - - # Trigger the change of fiscal position when the shipping address - # is modified - order._compute_fiscal_position_id() - - if address.get("email"): - values.update({"email": address["email"]}) - + values = env["res.partner"].prepare_vsf_address_vals(address) + # Trigger the change of fiscal position when the shipping address + # is modified + order._compute_fiscal_position_id() if values: partner.write(values) - return partner @@ -259,26 +210,22 @@ def mutate(self, info, address): if order.partner_shipping_id.id == partner.id: order.partner_shipping_id = partner.parent_id.id - + order._compute_fiscal_position_id() # Archive address, safer than delete since this address could be in use by # other object partner.active = False - - # Trigger the change of fiscal position when the shipping address is modified - order._compute_fiscal_position_id() - return DeleteAddress(result=True) class SelectAddress(graphene.Mutation): class Arguments: - type = AddressEnum(required=True) + address_type = AddressEnum(required=True) address = SelectAddressInput() Output = Partner @staticmethod - def mutate(self, info, type, address): + def mutate(self, info, address_type, address): env = info.context["env"] website = env["website"].get_current_website() request.website = website @@ -287,13 +234,13 @@ def mutate(self, info, type, address): partner = get_partner(env, address["id"], order, website) # Update order with the new shipping or invoice address - if type.value == "invoice": + if address_type.value == "invoice": order.partner_invoice_id = partner.id - elif type.value == "delivery": + elif address_type.value == "delivery": order.partner_shipping_id = partner.id - - # Trigger the change of fiscal position when the shipping address is modified - order._compute_fiscal_position_id() + # Trigger the change of fiscal position when the shipping address + # is modified + order._compute_fiscal_position_id() return partner diff --git a/vuestorefront/schemas/category.py b/vuestorefront/schemas/category.py index f86506c..7156d7d 100644 --- a/vuestorefront/schemas/category.py +++ b/vuestorefront/schemas/category.py @@ -3,6 +3,7 @@ import graphene +from ..utils import get_offset from .objects import Category, SortEnum @@ -20,7 +21,7 @@ def get_search_order(sort): class CategoryFilterInput(graphene.InputObjectType): - id = graphene.List(graphene.Int) + ids = graphene.List(graphene.Int) parent = graphene.Boolean() @@ -57,18 +58,15 @@ class CategoryQuery(graphene.ObjectType): def resolve_category(self, info, id=None, slug=None): env = info.context["env"] Category = env["product.public.category"] - domain = env["website"].get_current_website().website_domain() - if id: domain += [("id", "=", id)] category = Category.search(domain, limit=1) elif slug: - domain += [("website_slug", "=", slug)] + domain += [("slug", "=", slug)] category = Category.search(domain, limit=1) else: category = Category - return category @staticmethod @@ -76,24 +74,15 @@ def resolve_categories(self, info, filter, current_page, page_size, search, sort env = info.context["env"] order = get_search_order(sort) domain = env["website"].get_current_website().website_domain() - if search: for srch in search.split(" "): domain += [("name", "ilike", srch)] - - if filter.get("id"): - domain += [("id", "in", filter["id"])] - + if filter.get("ids"): + domain += [("id", "in", filter["ids"])] # Parent if is a Top Category if filter.get("parent"): domain += [("parent_id", "=", False)] - - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 - + offset = get_offset(current_page, page_size) ProductPublicCategory = env["product.public.category"] total_count = ProductPublicCategory.search_count(domain) categories = ProductPublicCategory.search( diff --git a/vuestorefront/schemas/country.py b/vuestorefront/schemas/country.py index 4694ed9..353710c 100644 --- a/vuestorefront/schemas/country.py +++ b/vuestorefront/schemas/country.py @@ -3,6 +3,7 @@ import graphene +from ..utils import get_offset from .objects import Country, SortEnum @@ -72,12 +73,7 @@ def resolve_countries(self, info, filter, current_page, page_size, search, sort) if filter.get("id"): domain += [("id", "=", filter["id"])] - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 - + offset = get_offset(current_page, page_size) Country = env["res.country"] total_count = Country.search_count(domain) countries = Country.search(domain, limit=page_size, offset=offset, order=order) diff --git a/vuestorefront/schemas/invoice.py b/vuestorefront/schemas/invoice.py index 38d155b..3b11241 100644 --- a/vuestorefront/schemas/invoice.py +++ b/vuestorefront/schemas/invoice.py @@ -7,6 +7,7 @@ from odoo import _ from odoo.http import request +from ..utils import get_offset from .objects import ( Invoice, SortEnum, @@ -82,12 +83,7 @@ def resolve_invoices(self, info, current_page, page_size, sort): ("message_partner_ids", "child_of", [partner.commercial_partner_id.id]) ] - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 - + offset = get_offset(current_page, page_size) AccountMove = env["account.move"] invoices = get_document_with_check_access( AccountMove, diff --git a/vuestorefront/schemas/mailing_list.py b/vuestorefront/schemas/mailing_list.py index 3f29471..a1534f3 100644 --- a/vuestorefront/schemas/mailing_list.py +++ b/vuestorefront/schemas/mailing_list.py @@ -9,6 +9,7 @@ from odoo.addons.website_mass_mailing.controllers.main import MassMailController +from ..utils import get_offset from .objects import MailingContact, MailingList, SortEnum @@ -81,12 +82,7 @@ def resolve_mailing_contacts( if filter.get("id", False): domain += [("id", "=", filter["id"])] - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 - + offset = get_offset(current_page, page_size) MailingContact = env["mailing.contact"].sudo() total_count = MailingContact.search_count(domain) mailing_contacts = MailingContact.search( @@ -158,12 +154,7 @@ def resolve_mailing_lists( if filter.get("id", False): domain += [("id", "=", filter["id"])] - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 - + offset = get_offset(current_page, page_size) MailingList = env["mailing.list"].sudo() total_count = MailingList.search_count(domain) mailing_lists = MailingList.search( diff --git a/vuestorefront/schemas/objects.py b/vuestorefront/schemas/objects.py index 42eb7a8..847cc61 100644 --- a/vuestorefront/schemas/objects.py +++ b/vuestorefront/schemas/objects.py @@ -240,6 +240,7 @@ class Partner(OdooObjectType): billing_address = graphene.Field(lambda: Partner) is_company = graphene.Boolean(required=True) company = graphene.Field(lambda: Partner) + company_name = graphene.String() contacts = graphene.List(graphene.NonNull(lambda: Partner)) signup_token = graphene.String() signup_valid = graphene.String() @@ -324,7 +325,7 @@ def resolve_childs(self, info): return self.child_id or None def resolve_slug(self, info): - return self.website_slug + return self.slug def resolve_products(self, info): return self.product_tmpl_ids or None @@ -558,7 +559,7 @@ def resolve_qty(self, info): return self.free_qty def resolve_slug(self, info): - return self.website_slug + return self.slug def resolve_alternative_products(self, info): return self.alternative_product_ids or None diff --git a/vuestorefront/schemas/order.py b/vuestorefront/schemas/order.py index 95c4a42..6fefa81 100644 --- a/vuestorefront/schemas/order.py +++ b/vuestorefront/schemas/order.py @@ -7,6 +7,7 @@ from odoo import _ from odoo.http import request +from ..utils import get_offset from .objects import ( InvoiceStatus, Order, @@ -34,6 +35,10 @@ def get_search_order(sort): return sorting +class UpdateOrderInput(graphene.InputObjectType): + client_order_ref = graphene.String() + + class OrderFilterInput(graphene.InputObjectType): stages = graphene.List(OrderStage) invoice_status = graphene.List(InvoiceStatus) @@ -106,12 +111,7 @@ def resolve_orders(self, info, filter, current_page, page_size, sort): ] domain += [("invoice_status", "in", invoice_status)] - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 - + offset = get_offset(current_page, page_size) SaleOrder = env["sale.order"] orders = get_document_with_check_access( SaleOrder, @@ -136,6 +136,24 @@ def resolve_delivery_methods(self, info): return order._get_delivery_methods() +class UpdateOrder(graphene.Mutation): + class Arguments: + data = UpdateOrderInput(required=True) + + Output = Order + + @staticmethod + def mutate(self, info, data): + env = info.context["env"] + website = env["website"].get_current_website() + request.website = website + order = website.sale_get_order(force_create=True) + vals = order.prepare_vsf_vals(data) + if vals: + order.write(vals) + return order + + class ApplyCoupon(graphene.Mutation): class Arguments: promo = graphene.String() @@ -191,5 +209,6 @@ def mutate(self, info, promo): class OrderMutation(graphene.ObjectType): + update_order = UpdateOrder.Field(description="Update Order") apply_coupon = ApplyCoupon.Field(description="Apply Coupon") apply_gift_card = ApplyGiftCard.Field(description="Apply Gift Card") diff --git a/vuestorefront/schemas/product.py b/vuestorefront/schemas/product.py index 4ef56ae..a839c1f 100644 --- a/vuestorefront/schemas/product.py +++ b/vuestorefront/schemas/product.py @@ -6,8 +6,8 @@ from odoo import _ from odoo.http import request -from odoo.osv import expression +from ..utils import get_offset from .objects import Attribute, AttributeValue, Product, SortEnum @@ -30,115 +30,20 @@ def get_search_order(sort): return sorting -def get_search_domain(env, search, **kwargs): - # Only get published products - domains = [env["website"].get_current_website().sale_product_domain()] - - # Filter with ids - if kwargs.get("ids", False): - domains.append([("id", "in", kwargs["ids"])]) - - # Filter with Category ID - if kwargs.get("category_id", False): - domains.append([("public_categ_ids", "child_of", kwargs["category_id"])]) - - # Filter with Category Slug - if kwargs.get("category_slug", False): - domains.append( - [("public_categ_slug_ids.website_slug", "=", kwargs["category_slug"])] - ) - - # Filter With Name - if kwargs.get("name", False): - name = kwargs["name"] - for n in name.split(" "): - domains.append([("name", "ilike", n)]) - - if search: - for srch in search.split(" "): - domains.append( - [ - "|", - "|", - ("name", "ilike", srch), - ("description_sale", "like", srch), - ("default_code", "like", srch), - ] - ) - - partial_domain = domains.copy() - - # Product Price Filter - if kwargs.get("min_price", False): - domains.append([("list_price", ">=", float(kwargs["min_price"]))]) - if kwargs.get("max_price", False): - domains.append([("list_price", "<=", float(kwargs["max_price"]))]) - - # Deprecated: filter with Attribute Value - if kwargs.get("attribute_value_id", False): - domains.append( - [("attribute_line_ids.value_ids", "in", kwargs["attribute_value_id"])] - ) - - # Filter with Attribute Value - if kwargs.get("attrib_values", False): - attributes = {} - attributes_domain = [] - - for value in kwargs["attrib_values"]: - try: - value = value.split("-") - if len(value) != 2: - continue - - attribute_id = int(value[0]) - attribute_value_id = int(value[1]) - except ValueError: - continue - - if attribute_id not in attributes: - attributes[attribute_id] = [] - - attributes[attribute_id].append(attribute_value_id) - - for value in attributes.values(): - attributes_domain.append([("attribute_line_ids.value_ids", "in", value)]) - - attributes_domain = expression.AND(attributes_domain) - domains.append(attributes_domain) - - return expression.AND(domains), expression.AND(partial_domain) - - def get_product_list(env, current_page, page_size, search, sort, **kwargs): Product = env["product.template"].sudo() - domain, partial_domain = get_search_domain(env, search, **kwargs) - - # First offset is 0 but first page is 1 - if current_page > 1: - offset = (current_page - 1) * page_size - else: - offset = 0 + domain = Product.prepare_vsf_domain(search, **kwargs) + offset = get_offset(current_page, page_size) order = get_search_order(sort) - products = Product.search(domain, order=order) - - # If attribute values are selected, we need to get the full list of - # attribute values and prices - if domain == partial_domain: - attribute_values = products.mapped("variant_attribute_value_ids") - prices = products.mapped("list_price") - else: - without_attributes_products = Product.search(partial_domain) - attribute_values = without_attributes_products.mapped( - "variant_attribute_value_ids" - ) - prices = without_attributes_products.mapped("list_price") - - total_count = len(products) - products = products[offset : offset + page_size] - if prices: - return products, total_count, attribute_values, min(prices), max(prices) - return products, total_count, attribute_values, 0.0, 0.0 + products = Product.search(domain, order=order, offset=offset, limit=page_size) + # Total count from the whole query is needed for creating proper pagination for it + total_count = Product.search_count(domain) + ProductAttributeValue = env["product.attribute.value"] + # TODO: either return standard attribute value from products or + # redesign to not return anything instead of empty recordset ( + # ProductAttributeValue). + # TODO: return some product prices instead of `0.0`. + return products, total_count, ProductAttributeValue, 0.0, 0.0 class Products(graphene.Interface): @@ -223,7 +128,7 @@ def resolve_product(self, info, id=None, slug=None, barcode=None): if id: product = Product.search([("id", "=", id)], limit=1) elif slug: - product = Product.search([("website_slug", "=", slug)], limit=1) + product = Product.search([("slug", "=", slug)], limit=1) elif barcode: product = Product.search([("barcode", "=", barcode)], limit=1) else: diff --git a/vuestorefront/schemas/sign.py b/vuestorefront/schemas/sign.py index 2425672..08bd110 100644 --- a/vuestorefront/schemas/sign.py +++ b/vuestorefront/schemas/sign.py @@ -15,6 +15,10 @@ from .objects import User +class UserRegisterExtraInput(graphene.InputObjectType): + vat = graphene.String() + + class Login(graphene.Mutation): class Arguments: email = graphene.String(required=True) @@ -63,37 +67,29 @@ class Arguments: email = graphene.String(required=True) password = graphene.String(required=True) subscribe_newsletter = graphene.Boolean(default_value=False) + extra = UserRegisterExtraInput(default_value={}) Output = User @staticmethod - def mutate(self, info, name, email, password, subscribe_newsletter): + def mutate(self, info, name, email, password, subscribe_newsletter, extra): env = info.context["env"] + ResUsers = env["res.users"] website = env["website"].get_current_website() request.website = website - # Set email in lowercase email = email.lower() - - data = { - "name": name, - "login": email, - "password": password, - } - - if env["res.users"].sudo().search([("login", "=", data["login"])], limit=1): + data = ResUsers.prepare_vsf_signup_vals(name, email, password, **extra) + if ResUsers.sudo().search_count([("login", "=", data["login"])], limit=1): raise GraphQLError( _("Another user is already registered using this email address.") ) - env["res.users"].sudo().signup(data) - # Subscribe Newsletter if website and website.vsf_mailing_list_id and subscribe_newsletter: MassMailController().subscribe( website.vsf_mailing_list_id.id, email, "email" ) - return env["res.users"].sudo().search([("login", "=", data["login"])], limit=1) diff --git a/vuestorefront/tests/__init__.py b/vuestorefront/tests/__init__.py new file mode 100644 index 0000000..5de4c01 --- /dev/null +++ b/vuestorefront/tests/__init__.py @@ -0,0 +1,10 @@ +from . import ( + test_public_category, + test_product, + test_product_query, + test_mutate_user, + test_mutate_shop, + test_query_category, + test_query_mutate_address, + test_query_mutate_order, +) diff --git a/vuestorefront/tests/common.py b/vuestorefront/tests/common.py new file mode 100644 index 0000000..2b8f30c --- /dev/null +++ b/vuestorefront/tests/common.py @@ -0,0 +1,104 @@ +from graphene.test import Client + +from odoo.tests.common import TransactionCase + +from ..schema import schema + + +class TestVuestorefrontCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Website = cls.env["website"] + cls.ProductProduct = cls.env["product.product"] + cls.ProductPublicCategory = cls.env["product.public.category"] + cls.ResUsers = cls.env["res.users"] + cls.ResPartner = cls.env["res.partner"] + cls.PaymentTransaction = cls.env["payment.transaction"] + cls.SaleOrder = cls.env["sale.order"] + cls.SaleOrderLine = cls.env["sale.order.line"] + cls.graphene_client = Client(schema) + cls.InvalidateCache = cls.env["invalidate.cache"] + cls.user_portal = cls.env.ref("base.demo_user0") + cls.partner_portal = cls.user_portal.partner_id + cls.public_category_desks = cls.env.ref("website_sale.public_category_desks") + cls.public_category_components = cls.env.ref( + "website_sale.public_category_desks_components" + ) + cls.public_category_bins = cls.env.ref("website_sale.public_category_bins") + cls.product_bin = cls.env.ref("product.product_product_9") + cls.product_tmpl_bin = cls.product_bin.product_tmpl_id + cls.country_lt = cls.env.ref("base.lt") + cls.website_1 = cls.env.ref("website.default_website") + cls.payment_acquirer_transfer = cls.env.ref("payment.payment_provider_transfer") + cls.product_tmpl_bin.is_published = True + + def execute(self, query, **kw): + res = self.graphene_client.execute(query, context={"env": self.env}, **kw) + if not res: + raise RuntimeError("GraphQL query returned no data") + if res.get("errors"): + raise RuntimeError( + "GraphQL query returned error: {}".format(repr(res["errors"])) + ) + return res.get("data") + + +class TestVuestorefrontSaleCommon(TestVuestorefrontCommon): + """Common class to have patched active sale order for portal user.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + ( + cls.partner_invoice_1, + cls.partner_delivery_1, + cls.partner_other_1, + ) = cls.ResPartner.create( + [ + { + "name": "Partner Invoice Address", + "street": "invoice 1", + "type": "invoice", + "parent_id": cls.partner_portal.id, + }, + { + "name": "Partner Delivery Address", + "street": "delivery 1", + "type": "delivery", + "parent_id": cls.partner_portal.id, + }, + { + "name": "Partner Other Address", + "street": "other 1", + "type": "other", + "parent_id": cls.partner_portal.id, + }, + ] + ) + + cls.sale_1 = cls.SaleOrder.create( + { + "partner_id": cls.partner_portal.id, + "partner_invoice_id": cls.partner_invoice_1.id, + "partner_shipping_id": cls.partner_delivery_1.id, + } + ) + cls.sale_1_line_1 = cls.SaleOrderLine.create( + { + "product_id": cls.product_bin.id, + "product_uom_qty": 10, + "order_id": cls.sale_1.id, + } + ) + cls.Website._patch_method( + "sale_get_order", + lambda *args, **kw: cls.sale_1.with_user(cls.user_portal).sudo(), + ) + # To give access to portal user. + cls.sale_1.message_subscribe(partner_ids=[cls.partner_portal.id]) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.Website._revert_method("sale_get_order") diff --git a/vuestorefront/tests/test_mutate_shop.py b/vuestorefront/tests/test_mutate_shop.py new file mode 100644 index 0000000..582d9af --- /dev/null +++ b/vuestorefront/tests/test_mutate_shop.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock, patch + +from ..schemas import shop +from .common import TestVuestorefrontSaleCommon + + +class TestMutateShop(TestVuestorefrontSaleCommon): + @patch.object(shop, "request", MagicMock()) + def test_01_mutate_add_to_cart(self): + # GIVEN + self.sale_1.order_line.unlink() + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + mutation CartAddItem ($productId: Int!) { + cartAddItem( + productId: $productId + quantity: 10 + ) { + order { + id + } + } + } + """, + variables={"productId": self.product_bin.id}, + ) + # THEN + self.assertEqual(res["cartAddItem"]["order"]["id"], self.sale_1.id) + self.assertEqual(self.sale_1.state, "draft") + sale_line_1 = self.sale_1.order_line + self.assertEqual(len(sale_line_1), 1) + self.assertEqual(sale_line_1.product_id, self.product_bin) + self.assertEqual(sale_line_1.product_uom_qty, 10) + # GIVEN + # Check if we can still query product on cart if it was + # unpublished after it was already in a cart. + self.product_bin.is_published = False + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + query getProductFromCart { + cart { + order { + orderLines { + product { + name + } + } + } + } + } + """, + ) + self.assertEqual( + res["cart"]["order"]["orderLines"], [{"product": {"name": "Pedal Bin"}}] + ) diff --git a/vuestorefront/tests/test_mutate_user.py b/vuestorefront/tests/test_mutate_user.py new file mode 100644 index 0000000..7c5874f --- /dev/null +++ b/vuestorefront/tests/test_mutate_user.py @@ -0,0 +1,36 @@ +from unittest.mock import MagicMock, patch + +from ..schemas import sign +from . import common + + +class TestMutateUser(common.TestVuestorefrontCommon): + @patch.object(sign, "request", MagicMock()) + def test_01_register_new_user(self): + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + mutation Register( + $name: String!, $email: String!, $password: String! + ) { + register ( + name: $name + email: $email + password: $password + extra: {vat: "XX123"} + ) { + id + } + } + """, + variables={ + "name": "my-name-1", + "email": "my-login-1", + "password": "my-password", # pragma: allowlist secret + }, + ) + # THEN + user = self.ResUsers.browse(res["register"]["id"]) + self.assertEqual(user.login, "my-login-1") + self.assertEqual(user.partner_id.vat, "XX123") diff --git a/vuestorefront/tests/test_product.py b/vuestorefront/tests/test_product.py new file mode 100644 index 0000000..a55e83d --- /dev/null +++ b/vuestorefront/tests/test_product.py @@ -0,0 +1,20 @@ +from ..schemas.product import get_product_list +from . import common + + +class TestProduct(common.TestVuestorefrontCommon): + def test_01_get_product_list_by_name(self): + # WHEN + res = get_product_list( + self.env, 1, 100, "", {}, name=self.product_tmpl_bin.name + ) + # THEN + self.assertEqual(res[0], self.product_tmpl_bin) + + def test_02_product_slug(self): + # GIVEN + pt = self.product_tmpl_bin + # WHEN + pt.name = "My product" + # THEN + self.assertEqual(pt.slug, f"my-product-{pt.id}") diff --git a/vuestorefront/tests/test_product_query.py b/vuestorefront/tests/test_product_query.py new file mode 100644 index 0000000..c6cb4da --- /dev/null +++ b/vuestorefront/tests/test_product_query.py @@ -0,0 +1,105 @@ +from . import common + + +class TestQueryProduct(common.TestVuestorefrontCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.public_category_bins.slug = "bins" + # Parent + cls.public_category_desks.slug = "desks" + # Child + cls.public_category_components.slug = "components" + + def test_01_query_product_archived(self): + # GIVEN + self.product_tmpl_bin.active = False + # WHEN + res = self.execute( + """ + query getProductTemplate($id: Int) { + product(id: $id) { + name + } + } + """, + variables={"id": self.product_tmpl_bin.id}, + ) + # THEN + self.assertEqual(res["product"], {"name": None}) + + def test_02_query_products_with_category_slug(self): + # WHEN + res = self.execute( + """ + query getProductTemplates($ids: [Int], $categorySlug: String) { + products(filter: {ids: $ids, categorySlug: $categorySlug}) { + products { + name + } + } + } + """, + variables={"ids": self.product_tmpl_bin.ids, "categorySlug": "bins"}, + ) + # THEN + self.assertEqual(res["products"]["products"], [{"name": "Pedal Bin"}]) + + def test_03_query_products_unpublished_with_category_slug(self): + # GIVEN + self.product_tmpl_bin.is_published = False + # WHEN + res = self.execute( + """ + query getProductTemplates($ids: [Int], $categorySlug: String) { + products(filter: {ids: $ids, categorySlug: $categorySlug}) { + products { + name + } + } + } + """, + variables={"ids": self.product_tmpl_bin.ids, "categorySlug": "bins"}, + ) + # THEN + self.assertEqual(res["products"]["products"], []) + + def test_04_query_products_with_category_slug_is_child(self): + # GIVEN + self.product_tmpl_bin.public_categ_ids |= self.public_category_components + # WHEN + res = self.execute( + """ + query getProductTemplates($ids: [Int], $categorySlug: String) { + products(filter: {ids: $ids, categorySlug: $categorySlug}) { + products { + name + } + } + } + """, + # Related with child, calling from parent. + variables={"ids": self.product_tmpl_bin.ids, "categorySlug": "desks"}, + ) + # THEN + self.assertEqual(res["products"]["products"], [{"name": "Pedal Bin"}]) + + def test_05_query_products_with_category_slug_is_parent(self): + # GIVEN + self.product_tmpl_bin.public_categ_ids |= self.public_category_desks + # WHEN + res = self.execute( + """ + query getProductTemplates($ids: [Int], $categorySlug: String) { + products(filter: {ids: $ids, categorySlug: $categorySlug}) { + products { + name + } + } + } + """, + # Related with parent, calling from child. + variables={"ids": self.product_tmpl_bin.ids, "categorySlug": "components"}, + ) + # THEN + self.assertEqual(res["products"]["products"], []) diff --git a/vuestorefront/tests/test_public_category.py b/vuestorefront/tests/test_public_category.py new file mode 100644 index 0000000..2ea4b1e --- /dev/null +++ b/vuestorefront/tests/test_public_category.py @@ -0,0 +1,33 @@ +from . import common + + +class TestPublicCategory(common.TestVuestorefrontCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_tmpl_bin.public_categ_ids |= cls.public_category_components + + def test_01_get_product_tags(self): + # WHEN + res = self.InvalidateCache._get_product_tags(self.product_tmpl_bin.ids) + # THEN + res = res.split(",") + self.assertEqual(len(res), 4) + self.assertIn(f"P{self.product_tmpl_bin.id}", res) + self.assertIn(f"C{self.public_category_bins.id}", res) + self.assertIn(f"C{self.public_category_components.id}", res) + self.assertIn(f"C{self.public_category_desks.id}", res) + + def test_02_compute_category_slug(self): + # WHEN + categ = self.ProductPublicCategory.create({"name": "my_categ"}) + # THEN + self.assertEqual(categ.slug, f"my-categ-{categ.id}") + + def test_03_set_category_slug_manually(self): + # WHEN + categ = self.ProductPublicCategory.create( + {"name": "my_categ", "slug": "custom-slug"} + ) + # THEN + self.assertEqual(categ.slug, "custom-slug") diff --git a/vuestorefront/tests/test_query_category.py b/vuestorefront/tests/test_query_category.py new file mode 100644 index 0000000..3dd03a7 --- /dev/null +++ b/vuestorefront/tests/test_query_category.py @@ -0,0 +1,20 @@ +from . import common + + +class TestQueryCategory(common.TestVuestorefrontCommon): + def test_01_query_categories(self): + # WHEN + res = self.execute( + """ + query getCategory($ids: [Int]) { + categories(filter: {ids: $ids}) { + categories { + name + } + } + } + """, + variables={"ids": self.public_category_bins.ids}, + ) + # THEN + self.assertEqual(res["categories"]["categories"], [{"name": "Bins"}]) diff --git a/vuestorefront/tests/test_query_mutate_address.py b/vuestorefront/tests/test_query_mutate_address.py new file mode 100644 index 0000000..23b9fa5 --- /dev/null +++ b/vuestorefront/tests/test_query_mutate_address.py @@ -0,0 +1,120 @@ +from unittest.mock import MagicMock, patch + +from ..schemas import address +from . import common + + +@patch.object(address, "request", MagicMock()) +class TestQueryMutateAddress(common.TestVuestorefrontSaleCommon): + def test_01_query_addresses_delivery(self): + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + query getAddresses($addressTypes: [AddressEnum]) { + addresses(filter: {addressTypes: $addressTypes}) { + name + street + } + } + """, + variables={"addressTypes": ["Shipping"]}, + ) + # THEN + self.assertEqual( + sorted(res["addresses"], key=lambda x: x["name"]), + sorted( + [ + { + "name": "Partner Delivery Address", + "street": "delivery 1", + }, + ], + key=lambda x: x["name"], + ), + ) + + def test_02_query_addresses_invoice_n_delivery(self): + # GIVEN + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + query getAddresses($addressTypes: [AddressEnum]) { + addresses(filter: {addressTypes: $addressTypes}) { + name + street + } + } + """, + variables={"addressTypes": ["Billing", "Shipping"]}, + ) + # THEN + self.assertEqual( + sorted(res["addresses"], key=lambda x: x["name"]), + sorted( + [ + { + "name": "Partner Invoice Address", + "street": "invoice 1", + }, + { + "name": "Partner Delivery Address", + "street": "delivery 1", + }, + ], + key=lambda x: x["name"], + ), + ) + + def test_03_mutate_create_delivery_address(self): + # GIVEN + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + mutation AddAddress($addressType: AddressEnum!, $countryId: Int!) { + addAddress ( + addressType: $addressType, + address: { + name: "Partner Delivery Address 2" + street: "delivery 2" + zip: "zip 2" + countryId: $countryId + phone: "123456" + } + ) { + id + } + } + """, + variables={"addressType": "Shipping", "countryId": self.country_lt.id}, + ) + # THEN + partner = self.ResPartner.browse(res["addAddress"]["id"]) + self.assertEqual(partner.street, "delivery 2") + self.assertEqual(self.sale_1.partner_shipping_id, partner) + + def test_04_mutate_update_delivery_address(self): + # GIVEN + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + mutation UpdateAddress($id: Int!) { + updateAddress ( + address: { + id: $id + street: "delivery 1 updated" + } + ) { + id + } + } + """, + variables={"id": self.partner_delivery_1.id}, + ) + # THEN + partner = self.ResPartner.browse(res["updateAddress"]["id"]) + self.assertEqual(partner, self.partner_delivery_1) + self.assertEqual(partner.street, "delivery 1 updated") diff --git a/vuestorefront/tests/test_query_mutate_order.py b/vuestorefront/tests/test_query_mutate_order.py new file mode 100644 index 0000000..2a2bd4e --- /dev/null +++ b/vuestorefront/tests/test_query_mutate_order.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock, patch + +from ..schemas import order +from . import common + + +class TestQueryMutateOrder(common.TestVuestorefrontSaleCommon): + def test_01_query_order(self): + # GIVEN + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + query getOrder($id: Int) { + order(id: $id) { + name + clientOrderRef + orderLines { + product { + name + } + } + } + } + """, + variables={"id": self.sale_1.id}, + ) + # THEN + order_lines = res["order"].pop("orderLines") + self.assertEqual( + res["order"], + { + "name": self.sale_1.name, + "clientOrderRef": None, + }, + ) + self.assertEqual(order_lines[0]["product"]["name"], "Pedal Bin") + + @patch.object(order, "request", MagicMock()) + def test_02_mutate_order(self): + # GIVEN + # WHEN + with self.with_user("portal"): + res = self.execute( + """ + mutation { + updateOrder( + data: { + clientOrderRef: "abc123" + } + ) { + id + clientOrderRef + } + } + """, + ) + # THEN + self.assertEqual( + res["updateOrder"], + {"id": self.sale_1.id, "clientOrderRef": "abc123"}, + ) + self.assertEqual(self.sale_1.client_order_ref, "abc123") diff --git a/vuestorefront/utils.py b/vuestorefront/utils.py new file mode 100644 index 0000000..b61dc7c --- /dev/null +++ b/vuestorefront/utils.py @@ -0,0 +1,12 @@ +from odoo.addons.http_routing.models.ir_http import slugify + + +def slugify_with_sfx(value, sfx): + return f"{slugify(value)}{sfx}" + + +def get_offset(current_page, page_size): + # First offset is 0 but first page is 1 + if current_page > 1: + return (current_page - 1) * page_size + return 0 diff --git a/vuestorefront/views/product_views.xml b/vuestorefront/views/product_views.xml index e70d69d..f4b692e 100644 --- a/vuestorefront/views/product_views.xml +++ b/vuestorefront/views/product_views.xml @@ -11,7 +11,7 @@ - + @@ -22,7 +22,7 @@ - +