diff --git a/website_sale_cart_expire/README.rst b/website_sale_cart_expire/README.rst new file mode 100644 index 0000000000..81152c4962 --- /dev/null +++ b/website_sale_cart_expire/README.rst @@ -0,0 +1,95 @@ +======================== +Website Sale Cart Expire +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4c846ae9d2828ae45e3719902ce2b99d3d25f11762c3e8691cce86bf74e9ac08 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/17.0/website_sale_cart_expire + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-17-0/e-commerce-17-0-website_sale_cart_expire + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-commerce&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allows to automatically cancel carts without activity after a +configurable time. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to Website > Settings and set a delay for Expire Carts settings. + +A cart expiration timer can be displayed on the website by enabling the +Cart Expiration Timer setting in the Customize menu. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- `Camptocamp `__ + + - Iván Todorovich + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-ivantodorovich| image:: https://github.com/ivantodorovich.png?size=40px + :target: https://github.com/ivantodorovich + :alt: ivantodorovich + +Current `maintainer `__: + +|maintainer-ivantodorovich| + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_cart_expire/__init__.py b/website_sale_cart_expire/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/website_sale_cart_expire/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_sale_cart_expire/__manifest__.py b/website_sale_cart_expire/__manifest__.py new file mode 100644 index 0000000000..f4b893579f --- /dev/null +++ b/website_sale_cart_expire/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Website Sale Cart Expire", + "summary": "Cancel carts without activity after a configurable time", + "version": "17.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["ivantodorovich"], + "website": "https://github.com/OCA/e-commerce", + "license": "AGPL-3", + "category": "Website", + "depends": ["website_sale"], + "data": [ + "data/ir_cron.xml", + "views/res_config_settings.xml", + "views/templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_sale_cart_expire/static/src/js/website_sale_cart_expire.esm.js", + ], + }, +} diff --git a/website_sale_cart_expire/controllers/__init__.py b/website_sale_cart_expire/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/website_sale_cart_expire/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_sale_cart_expire/controllers/main.py b/website_sale_cart_expire/controllers/main.py new file mode 100644 index 0000000000..48c4d594d4 --- /dev/null +++ b/website_sale_cart_expire/controllers/main.py @@ -0,0 +1,22 @@ +# Copyright 2022 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import http +from odoo.http import request + +from odoo.addons.website_sale.controllers.main import WebsiteSale + + +class WebsiteSaleCartExpire(WebsiteSale): + @http.route( + ["/shop/cart/get_expire_date"], + type="json", + auth="public", + methods=["POST"], + website=True, + csrf=False, + ) + def get_expire_date(self, **kw): + order = request.website.sale_get_order() + return order.cart_expire_date diff --git a/website_sale_cart_expire/data/ir_cron.xml b/website_sale_cart_expire/data/ir_cron.xml new file mode 100644 index 0000000000..582b8f6338 --- /dev/null +++ b/website_sale_cart_expire/data/ir_cron.xml @@ -0,0 +1,17 @@ + + + + + Website: Expire Carts + + code + model._scheduler_website_expire_cart(autocommit=True) + 5 + minutes + -1 + + diff --git a/website_sale_cart_expire/i18n/es.po b/website_sale_cart_expire/i18n/es.po new file mode 100644 index 0000000000..ce3f8d07e8 --- /dev/null +++ b/website_sale_cart_expire/i18n/es.po @@ -0,0 +1,105 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_cart_expire +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-08-03 20:09+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "" +"Expire Carts\n" +" " +msgstr "" +"Carro expirado\n" +" " + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Automatically cancel carts without activity after a period of time" +msgstr "" +"Cancelar automáticamente carritos sin actividad después de un período de " +"tiempo" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,help:website_sale_cart_expire.field_res_config_settings__cart_expire_delay +#: model:ir.model.fields,help:website_sale_cart_expire.field_website__cart_expire_delay +msgid "" +"Automatically cancel website orders after the given time.\n" +"Set to 0 to disable this feature." +msgstr "" +"Cancelar automáticamente los pedidos del sitio web después del tiempo dado.\n" +"Establecer en 0 para desactivar esta función." + +#. module: website_sale_cart_expire +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_sale_order__cart_expire_date +msgid "Cart Expire Date" +msgstr "Fecha de caducidad del carro" + +#. module: website_sale_cart_expire +#. odoo-python +#: code:addons/website_sale_cart_expire/models/website.py:0 +#, python-format +msgid "Cart expired" +msgstr "Carro caducado" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Cart is cancelled after" +msgstr "El carro ha sido cancelado después" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Carts are cancelled after this delay." +msgstr "Los carros se cancelan después de este plazo." + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de Configuración" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_res_config_settings__cart_expire_delay +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_website__cart_expire_delay +msgid "Expire Delay" +msgstr "Expiración retardada" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_sale_order +msgid "Sales Order" +msgstr "Órdenes de venta" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,help:website_sale_cart_expire.field_sale_order__cart_expire_date +msgid "Technical field: The date this cart will automatically expire" +msgstr "Campo técnico: La fecha en que este carro caducará automáticamente" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_website +msgid "Website" +msgstr "Página Web" + +#. module: website_sale_cart_expire +#: model:ir.actions.server,name:website_sale_cart_expire.ir_cron_cart_expire_ir_actions_server +#: model:ir.cron,cron_name:website_sale_cart_expire.ir_cron_cart_expire +msgid "Website: Expire Carts" +msgstr "Página web: Carros caducados" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "hours." +msgstr "horas." diff --git a/website_sale_cart_expire/i18n/it.po b/website_sale_cart_expire/i18n/it.po new file mode 100644 index 0000000000..b3ed2637c8 --- /dev/null +++ b/website_sale_cart_expire/i18n/it.po @@ -0,0 +1,103 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_cart_expire +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-05-08 07:46+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "" +"Expire Carts\n" +" " +msgstr "" +"Carrelli in scadenza\n" +" " + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Automatically cancel carts without activity after a period of time" +msgstr "" +"Annulla automaticamente i carrelli senza attività dopo un periodo di tempo" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,help:website_sale_cart_expire.field_res_config_settings__cart_expire_delay +#: model:ir.model.fields,help:website_sale_cart_expire.field_website__cart_expire_delay +msgid "" +"Automatically cancel website orders after the given time.\n" +"Set to 0 to disable this feature." +msgstr "" +"Annulla automaticamente ordini sito web dopo un certo periodo di tempo.\n" +"Impostare a 0 per disabilitare questa opzione." + +#. module: website_sale_cart_expire +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_sale_order__cart_expire_date +msgid "Cart Expire Date" +msgstr "Data scadenza carrello" + +#. module: website_sale_cart_expire +#. odoo-python +#: code:addons/website_sale_cart_expire/models/website.py:0 +#, python-format +msgid "Cart expired" +msgstr "Carrello scaduto" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Cart is cancelled after" +msgstr "Il carrello viene annullato dopo" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Carts are cancelled after this delay." +msgstr "I carrelli vengono annullati dopo questo ritardo." + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_res_config_settings +msgid "Config Settings" +msgstr "Impostazioni configurazione" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_res_config_settings__cart_expire_delay +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_website__cart_expire_delay +msgid "Expire Delay" +msgstr "Ritardo scadenza" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_sale_order +msgid "Sales Order" +msgstr "Ordine di vendita" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,help:website_sale_cart_expire.field_sale_order__cart_expire_date +msgid "Technical field: The date this cart will automatically expire" +msgstr "Campo tecnico: la data in cui questo carrello scade automaticamente" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_website +msgid "Website" +msgstr "Sito web" + +#. module: website_sale_cart_expire +#: model:ir.actions.server,name:website_sale_cart_expire.ir_cron_cart_expire_ir_actions_server +#: model:ir.cron,cron_name:website_sale_cart_expire.ir_cron_cart_expire +msgid "Website: Expire Carts" +msgstr "Sito web: scadenza carrelli" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "hours." +msgstr "ore." diff --git a/website_sale_cart_expire/i18n/website_sale_cart_expire.pot b/website_sale_cart_expire/i18n/website_sale_cart_expire.pot new file mode 100644 index 0000000000..0eff313adb --- /dev/null +++ b/website_sale_cart_expire/i18n/website_sale_cart_expire.pot @@ -0,0 +1,93 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_cart_expire +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.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: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "" +"Expire Carts\n" +" " +msgstr "" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Automatically cancel carts without activity after a period of time" +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,help:website_sale_cart_expire.field_res_config_settings__cart_expire_delay +#: model:ir.model.fields,help:website_sale_cart_expire.field_website__cart_expire_delay +msgid "" +"Automatically cancel website orders after the given time.\n" +"Set to 0 to disable this feature." +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_sale_order__cart_expire_date +msgid "Cart Expire Date" +msgstr "" + +#. module: website_sale_cart_expire +#. odoo-python +#: code:addons/website_sale_cart_expire/models/website.py:0 +#, python-format +msgid "Cart expired" +msgstr "" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Cart is cancelled after" +msgstr "" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "Carts are cancelled after this delay." +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_res_config_settings__cart_expire_delay +#: model:ir.model.fields,field_description:website_sale_cart_expire.field_website__cart_expire_delay +msgid "Expire Delay" +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model.fields,help:website_sale_cart_expire.field_sale_order__cart_expire_date +msgid "Technical field: The date this cart will automatically expire" +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.model,name:website_sale_cart_expire.model_website +msgid "Website" +msgstr "" + +#. module: website_sale_cart_expire +#: model:ir.actions.server,name:website_sale_cart_expire.ir_cron_cart_expire_ir_actions_server +#: model:ir.cron,cron_name:website_sale_cart_expire.ir_cron_cart_expire +msgid "Website: Expire Carts" +msgstr "" + +#. module: website_sale_cart_expire +#: model_terms:ir.ui.view,arch_db:website_sale_cart_expire.res_config_settings_view_form +msgid "hours." +msgstr "" diff --git a/website_sale_cart_expire/migrations/15.0.1.1.0/post-migrate.py b/website_sale_cart_expire/migrations/15.0.1.1.0/post-migrate.py new file mode 100644 index 0000000000..cb04ec548c --- /dev/null +++ b/website_sale_cart_expire/migrations/15.0.1.1.0/post-migrate.py @@ -0,0 +1,17 @@ +# Copyright 2022 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import SUPERUSER_ID, api + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + cron = env.ref( + "website_sale_cart_expire.ir_cron_cart_expire", + raise_if_not_found=False, + ) + if cron: + cron.code = "model._scheduler_website_expire_cart(autocommit=True)" diff --git a/website_sale_cart_expire/models/__init__.py b/website_sale_cart_expire/models/__init__.py new file mode 100644 index 0000000000..0cf62fc1fa --- /dev/null +++ b/website_sale_cart_expire/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_config_settings +from . import sale_order +from . import website diff --git a/website_sale_cart_expire/models/res_config_settings.py b/website_sale_cart_expire/models/res_config_settings.py new file mode 100644 index 0000000000..72190d947a --- /dev/null +++ b/website_sale_cart_expire/models/res_config_settings.py @@ -0,0 +1,13 @@ +# 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" + + cart_expire_delay = fields.Float( + related="website_id.cart_expire_delay", readonly=False + ) diff --git a/website_sale_cart_expire/models/sale_order.py b/website_sale_cart_expire/models/sale_order.py new file mode 100644 index 0000000000..6b57c10b4c --- /dev/null +++ b/website_sale_cart_expire/models/sale_order.py @@ -0,0 +1,51 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + cart_expire_date = fields.Datetime( + compute="_compute_cart_expire_date", + help="Technical field: The date this cart will automatically expire", + ) + + def _should_bypass_cart_expiration(self): + """Hook method to prevent a cart from expiring""" + self.ensure_one() + # We don't want to cancel carts that are already in payment. + return any( + tx.state in ["pending", "authorized", "done"] for tx in self.transaction_ids + ) + + @api.depends( + "write_date", + "website_id.cart_expire_delay", + "transaction_ids.last_state_change", + ) + def _compute_cart_expire_date(self): + for rec in self: + if ( + rec.state == "draft" + and rec.website_id.cart_expire_delay + and not rec._should_bypass_cart_expiration() + ): + # In case of draft records, use current date + from_date = rec.write_date or fields.Datetime.now() + # In case or records with transactions, consider last tx date + if rec.transaction_ids: + last_tx_date = max( + rec.transaction_ids.mapped( + lambda x: x.last_state_change or x.write_date + ) + ) + from_date = max(from_date, last_tx_date) + expire_delta = timedelta(hours=rec.website_id.cart_expire_delay) + rec.cart_expire_date = from_date + expire_delta + elif rec.cart_expire_date: + rec.cart_expire_date = False diff --git a/website_sale_cart_expire/models/website.py b/website_sale_cart_expire/models/website.py new file mode 100644 index 0000000000..14e2fc1bf7 --- /dev/null +++ b/website_sale_cart_expire/models/website.py @@ -0,0 +1,61 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.osv import expression + +_logger = logging.getLogger(__name__) + + +class Website(models.Model): + _inherit = "website" + + cart_expire_delay = fields.Float( + string="Expire Delay", + default=0.0, + help="Automatically cancel website orders after the given time.\n" + "Set to 0 to disable this feature.", + ) + + def _get_cart_expire_delay_domain(self): + self.ensure_one() + expire_date = fields.Datetime.now() - timedelta(hours=self.cart_expire_delay) + return [ + ("website_id", "=", self.id), + ("state", "=", "draft"), + ("write_date", "<=", expire_date), + # We don't want to cancel carts that are already in payment. + "|", + ("transaction_ids", "=", False), + "!", + ("transaction_ids.state", "in", ["pending", "authorized", "done"]), + ] + + @api.model + def _scheduler_website_expire_cart(self, autocommit=False): + websites = self.search([("cart_expire_delay", ">", 0)]) + if not websites: + return True + carts = self.env["sale.order"].search( + expression.OR( + [website._get_cart_expire_delay_domain() for website in websites] + ) + ) + now = fields.Datetime.now() + for cart in carts: + if not cart.cart_expire_date or cart.cart_expire_date > now: + continue + try: + with self.env.cr.savepoint(): + cart.message_post(body=_("Cart expired")) + cart.action_cancel() + except Exception as e: + _logger.exception("Unable to cancel expired cart %s: %s", cart, e) + else: + if autocommit: + self.env.cr.commit() # pylint: disable=invalid-commit + return True diff --git a/website_sale_cart_expire/pyproject.toml b/website_sale_cart_expire/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_cart_expire/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_cart_expire/readme/CONFIGURE.md b/website_sale_cart_expire/readme/CONFIGURE.md new file mode 100644 index 0000000000..ce1127fd32 --- /dev/null +++ b/website_sale_cart_expire/readme/CONFIGURE.md @@ -0,0 +1,4 @@ +Go to Website \> Settings and set a delay for Expire Carts settings. + +A cart expiration timer can be displayed on the website by enabling the +Cart Expiration Timer setting in the Customize menu. diff --git a/website_sale_cart_expire/readme/CONTRIBUTORS.md b/website_sale_cart_expire/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..06927a8502 --- /dev/null +++ b/website_sale_cart_expire/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Camptocamp](https://www.camptocamp.com) + + > - Iván Todorovich \<\> diff --git a/website_sale_cart_expire/readme/DESCRIPTION.md b/website_sale_cart_expire/readme/DESCRIPTION.md new file mode 100644 index 0000000000..74ce4d23d2 --- /dev/null +++ b/website_sale_cart_expire/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +Allows to automatically cancel carts without activity after a +configurable time. diff --git a/website_sale_cart_expire/static/description/icon.png b/website_sale_cart_expire/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/website_sale_cart_expire/static/description/icon.png differ diff --git a/website_sale_cart_expire/static/description/index.html b/website_sale_cart_expire/static/description/index.html new file mode 100644 index 0000000000..42aa080a94 --- /dev/null +++ b/website_sale_cart_expire/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +Website Sale Cart Expire + + + +
+

Website Sale Cart Expire

+ + +

Beta License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

Allows to automatically cancel carts without activity after a +configurable time.

+

Table of contents

+ +
+

Configuration

+

Go to Website > Settings and set a delay for Expire Carts settings.

+

A cart expiration timer can be displayed on the website by enabling the +Cart Expiration Timer setting in the Customize menu.

+
+
+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

ivantodorovich

+

This module is part of the OCA/e-commerce project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/website_sale_cart_expire/static/src/js/website_sale_cart_expire.esm.js b/website_sale_cart_expire/static/src/js/website_sale_cart_expire.esm.js new file mode 100644 index 0000000000..e5d782ed58 --- /dev/null +++ b/website_sale_cart_expire/static/src/js/website_sale_cart_expire.esm.js @@ -0,0 +1,118 @@ +/** @odoo-module **/ + +import {deserializeDateTime} from "@web/core/l10n/dates"; +import publicWidget from "@web/legacy/js/public/public_widget"; + +/** + * Cart Expire Timer widget. + * + * Displays a countdown timer for the cart expiration date. + */ + +publicWidget.registry.WebsiteSaleCartExpireTimer = publicWidget.Widget.extend({ + selector: ".my_cart_expiration", + + /** + * @override + */ + start: async function () { + await this._super.apply(this, arguments); + this._setExpirationDate(this.$el.data("order-expire-date")); + const remainingMs = this._getRemainingMs(); + if (remainingMs > 0) { + this._renderTimer(remainingMs); + this._startTimer(); + } + // Attempts to hook into the cart quantity widget to update the expiration date + // whenever it changes. + this.$el.siblings(".my_cart_quantity").on( + "DOMSubtreeModified", + _.debounce(() => this._refreshExpirationDate(), 250) + ); + }, + /** + * @override + */ + destroy: function () { + this.$el.remove(); + this._stopTimer(); + return this._super.apply(this, arguments); + }, + /** + * Sets the timer target date. + * + * @param {String|Date} expireDate + */ + _setExpirationDate: function (expireDate) { + if (typeof expireDate === "string") { + expireDate = deserializeDateTime(expireDate); + } + this.expireDate = expireDate ? moment(expireDate) : false; + }, + /** + * @returns {Number} + */ + _getRemainingMs: function () { + return this.expireDate ? this.expireDate.diff(moment()) : 0; + }, + /** + * Starts the timer. + */ + _startTimer: function () { + this._stopTimer(); + this.timer = setInterval(this._refreshTimer.bind(this), 1000); + }, + /** + * Stops the timer. + */ + _stopTimer: function () { + if (this.timer) { + clearInterval(this.timer); + } + }, + /** + * Refreshes the countdown timer. + * It destroys itself if the countdown reaches 0. + */ + _refreshTimer: function () { + const remainingMs = this._getRemainingMs(); + this._renderTimer(remainingMs); + if (remainingMs <= 0) { + this._stopTimer(); + this._refreshExpirationDate(); + } + }, + /** + * Updates the remaining time on the dom + */ + _renderTimer: function (remainingMs) { + const remainingMsRounded = Math.ceil(remainingMs / 1000) * 1000; + // Don't show the timer if remaining time is less than 1 hour + if (remainingMsRounded >= 3600000) { + return this.$el.hide(); + } + this.$el.show(); + // Format the countdown timer + const remainingStr = moment.utc(remainingMsRounded).format("mm:ss"); + if (remainingStr !== this.$el.text()) { + this.$el.text(remainingStr); + } + }, + /** + * Updates the expiration date by reading from backend + */ + _refreshExpirationDate: async function () { + const expireDate = await this._rpc({route: "/shop/cart/get_expire_date"}); + this._setExpirationDate(expireDate); + const remainingMs = this._getRemainingMs(); + if (remainingMs > 0) { + this._renderTimer(remainingMs); + this._startTimer(); + this.$el.show(); + } else { + this._stopTimer(); + this.$el.hide(); + } + return this.expireDate; + }, +}); diff --git a/website_sale_cart_expire/tests/__init__.py b/website_sale_cart_expire/tests/__init__.py new file mode 100644 index 0000000000..7043f0665e --- /dev/null +++ b/website_sale_cart_expire/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_cart_expire diff --git a/website_sale_cart_expire/tests/test_website_sale_cart_expire.py b/website_sale_cart_expire/tests/test_website_sale_cart_expire.py new file mode 100644 index 0000000000..7088542f4b --- /dev/null +++ b/website_sale_cart_expire/tests/test_website_sale_cart_expire.py @@ -0,0 +1,112 @@ +# Copyright 2021 Camptocamp (http://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from freezegun import freeze_time + +from odoo import fields +from odoo.tests import TransactionCase + + +class TestWebsiteSaleCartExpire(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tx_counter = 0 + # Websites + cls.website_1 = cls.env.ref("website.default_website") + cls.website_2 = cls.env.ref("website.website2") + cls.website_1.cart_expire_delay = 0.00 # hours (= disabled) + cls.website_2.cart_expire_delay = 2.00 # hours + # Orders + cls.order_1 = cls.env.ref("website_sale.website_sale_order_1") + cls.order_2 = cls.env.ref("website_sale.website_sale_order_2") + cls.order_3 = cls.env.ref("website_sale.website_sale_order_3") + cls.order_4 = cls.env.ref("website_sale.website_sale_order_4") + cls.orders = cls.order_1 + cls.order_2 + cls.order_3 + cls.order_4 + # Set to draft and assign all to website_2 + # (this also updates write_date to now()) + cls.orders.write({"state": "draft", "website_id": cls.website_2.id}) + + def _create_payment_transaction(self, order): + self.tx_counter += 1 + provider = self.env.ref("payment.payment_provider_demo") + return self.env["payment.transaction"].create( + { + "provider_id": provider.id, + "payment_method_id": self.env.ref("payment.payment_method_7eleven").id, + "reference": f"{order.name}-{self.tx_counter}", + "amount": order.amount_total, + "currency_id": order.currency_id.id, + "partner_id": order.partner_id.id, + "operation": "online_direct", + "sale_order_ids": [fields.Command.set([order.id])], + } + ) + + def test_expire_dates(self): + # Expire Date is set in the future + self.assertTrue(self.order_1.cart_expire_date) + # Changing to a website without expire delay should remove it + self.order_1.website_id = self.website_1 + self.assertFalse(self.order_1.cart_expire_date) + + def test_expire_scheduler(self): + # Case 1: We haven't reached the expire date yet + self.env["website"]._scheduler_website_expire_cart() + for order in self.orders: + self.assertEqual(order.state, "draft") + # Case 2: We have reached website 2 expire date + with freeze_time(datetime.now() + timedelta(hours=3)): + self.env["website"]._scheduler_website_expire_cart() + for order in self.orders: + self.assertEqual(order.state, "cancel") + + def test_expire_scheduler_multi_website(self): + # For this test, we split the orders among the 2 websites + (self.order_1 + self.order_2).write({"website_id": self.website_1.id}) + with freeze_time(datetime.now() + timedelta(hours=3)): + self.env["website"]._scheduler_website_expire_cart() + self.assertEqual(self.order_1.state, "draft", "No expire delay on website 1") + self.assertEqual(self.order_2.state, "draft", "No expire delay on website 1") + self.assertEqual(self.order_3.state, "cancel", "Should've been cancelled") + self.assertEqual(self.order_4.state, "cancel", "Should've been cancelled") + + @freeze_time(datetime.now() + timedelta(hours=3)) + def test_expire_scheduler_ignore_sent_quotation(self): + """Test that sent quotations aren't cancelled + + Quotations can be sent manually or automatically when using + wire transfer as payment. They shouldn't be cancelled. + """ + self.order_1.action_quotation_sent() + self.env["website"]._scheduler_website_expire_cart() + self.assertNotEqual(self.order_1.state, "cancel") + + @freeze_time(datetime.now() + timedelta(hours=3)) + def test_expire_scheduler_ignore_in_payment(self): + """Carts with a payment transaction in progress shouldn't expire""" + self._create_payment_transaction(self.order_1) + tx_2 = self._create_payment_transaction(self.order_2) + tx_2._set_pending() + tx_3 = self._create_payment_transaction(self.order_3) + tx_3._set_canceled() + tx_4 = self._create_payment_transaction(self.order_4) + tx_4._set_error("Something went wrong") + # Carts with transactions in progress are not canceled + # Even those with 'draft' or 'error' transactions, because + # the timer is reseted whenever a tx state changes. + self.env["website"]._scheduler_website_expire_cart() + self.assertNotEqual(self.order_1.state, "cancel") + self.assertNotEqual(self.order_2.state, "cancel") + self.assertNotEqual(self.order_3.state, "cancel") + self.assertNotEqual(self.order_4.state, "cancel") + # In case of error, another transaction can be initialized + # However for order_1, no more activity was detected, so it's canceled + with freeze_time(datetime.now() + timedelta(hours=3)): + self._create_payment_transaction(self.order_4) + self.env["website"]._scheduler_website_expire_cart() + self.assertEqual(self.order_1.state, "cancel") + self.assertNotEqual(self.order_4.state, "cancel") diff --git a/website_sale_cart_expire/views/res_config_settings.xml b/website_sale_cart_expire/views/res_config_settings.xml new file mode 100644 index 0000000000..4d77bfca90 --- /dev/null +++ b/website_sale_cart_expire/views/res_config_settings.xml @@ -0,0 +1,47 @@ + + + + + res.config.settings + + + + + + + + + diff --git a/website_sale_cart_expire/views/templates.xml b/website_sale_cart_expire/views/templates.xml new file mode 100644 index 0000000000..8ec4d59d73 --- /dev/null +++ b/website_sale_cart_expire/views/templates.xml @@ -0,0 +1,25 @@ + + + + + + +