details:   https://code.tryton.org/tryton/commit/9b5bd944259b
branch:    default
user:      Cédric Krier <[email protected]>
date:      Wed Jul 29 15:48:59 2026 +0200
description:
        Add scheduled task to cancel abandoned draft sale from web shop

        Closes #14974
diffstat:

 modules/web_shop/CHANGELOG            |   1 +
 modules/web_shop/ir.py                |  16 ++++++++
 modules/web_shop/sale.py              |  19 ++++++++++
 modules/web_shop/sale.xml             |   8 ++++
 modules/web_shop/tests/test_module.py |  64 +++++++++++++++++++++++++++++++++-
 modules/web_shop/tryton.cfg           |   1 +
 modules/web_shop/view/shop_form.xml   |   2 +
 modules/web_shop/web.py               |  11 ++++++
 8 files changed, 120 insertions(+), 2 deletions(-)

diffs (209 lines):

diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/CHANGELOG
--- a/modules/web_shop/CHANGELOG        Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/CHANGELOG        Wed Jul 29 15:48:59 2026 +0200
@@ -1,3 +1,4 @@
+* Add scheduled task to cancel abandoned draft sale
 
 Version 8.0.0 - 2026-04-20
 --------------------------
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/ir.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/web_shop/ir.py    Wed Jul 29 15:48:59 2026 +0200
@@ -0,0 +1,16 @@
+# This file is part of Tryton.  The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+from trytond.pool import PoolMeta
+
+
+class Cron(metaclass=PoolMeta):
+    __name__ = 'ir.cron'
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls.method.selection.extend([
+                ('sale.sale|web_cancel_draft_abandoned',
+                    "Cancel Abandoned Sales from Web Shop"),
+                ])
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/sale.py
--- a/modules/web_shop/sale.py  Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/sale.py  Wed Jul 29 15:48:59 2026 +0200
@@ -1,5 +1,7 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
+
+import datetime as dt
 from itertools import groupby
 from secrets import token_hex
 
@@ -111,6 +113,23 @@
         for web_shop, s_sales in groupby(sales, lambda s: s.web_shop):
             web_shop.update_sales(list(s_sales))
 
+    @classmethod
+    def web_cancel_draft_abandoned(cls):
+        pool = Pool()
+        WebShop = pool.get('web.shop')
+        now = dt.datetime.now()
+        sales = []
+        for web_shop in WebShop.search([
+                    ('sale_draft_abandon_delay', '!=', None),
+                    ]):
+            sales.extend(cls.search([
+                        ('state', '=', 'draft'),
+                        ('web_shop', '=', web_shop),
+                        ('last_modified_at', '<',
+                            now - web_shop.sale_draft_abandon_delay),
+                        ]))
+        cls.cancel(sales)
+
 
 class Line_GiftCard(metaclass=PoolMeta):
     __name__ = 'sale.line'
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/sale.xml
--- a/modules/web_shop/sale.xml Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/sale.xml Wed Jul 29 15:48:59 2026 +0200
@@ -25,4 +25,12 @@
             <field name="group" ref="sale.group_sale_admin"/>
         </record>
     </data>
+
+    <data noupdate="1">
+        <record model="ir.cron" id="cron_cancel_draft_abandoned">
+            <field name="method">sale.sale|web_cancel_draft_abandoned</field>
+            <field name="interval_number" eval="1"/>
+            <field name="interval_type">days</field>
+        </record>
+    </data>
 </tryton>
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/tests/test_module.py
--- a/modules/web_shop/tests/test_module.py     Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/tests/test_module.py     Wed Jul 29 15:48:59 2026 +0200
@@ -1,8 +1,12 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
 
-from trytond.modules.company.tests import CompanyTestMixin
-from trytond.tests.test_tryton import ModuleTestCase
+import datetime as dt
+
+from trytond.modules.company.tests import (
+    CompanyTestMixin, create_company, set_company)
+from trytond.pool import Pool
+from trytond.tests.test_tryton import ModuleTestCase, with_transaction
 
 
 class WebShopTestCase(CompanyTestMixin, ModuleTestCase):
@@ -12,5 +16,61 @@
         'account_tax_rule_country', 'product_attribute', 'product_image',
         'sale_price_list']
 
+    @with_transaction()
+    def test_cancel_abandoned(self):
+        "Test cancel abandoned sale"
+        pool = Pool()
+        WebShop = pool.get('web.shop')
+        Party = pool.get('party.party')
+        Sale = pool.get('sale.sale')
+
+        guest = Party(name="Guest")
+        guest.save()
+        company = create_company()
+        with set_company(company):
+            web_shop = WebShop(
+                name="Test",
+                guest_party=guest,
+                sale_draft_abandon_delay=dt.timedelta(),
+                )
+            web_shop.save()
+
+            sale = web_shop.get_sale()
+            sale.save()
+            self.assertEqual(sale.state, 'draft')
+
+            Sale.web_cancel_draft_abandoned()
+
+            sale = Sale(sale.id)
+            self.assertEqual(sale.state, 'cancelled')
+
+    @with_transaction()
+    def test_not_cancel_abandoned_no_delay(self):
+        "Test not cancel abandoned sale without delay"
+        pool = Pool()
+        WebShop = pool.get('web.shop')
+        Party = pool.get('party.party')
+        Sale = pool.get('sale.sale')
+
+        guest = Party(name="Guest")
+        guest.save()
+        company = create_company()
+        with set_company(company):
+            web_shop = WebShop(
+                name="Test",
+                guest_party=guest,
+                sale_draft_abandon_delay=None,
+                )
+            web_shop.save()
+
+            sale = web_shop.get_sale()
+            sale.save()
+            self.assertEqual(sale.state, 'draft')
+
+            Sale.web_cancel_draft_abandoned()
+
+            sale = Sale(sale.id)
+            self.assertEqual(sale.state, 'draft')
+
 
 del ModuleTestCase
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/tryton.cfg
--- a/modules/web_shop/tryton.cfg       Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/tryton.cfg       Wed Jul 29 15:48:59 2026 +0200
@@ -25,6 +25,7 @@
 
 [register]
 model:
+    ir.Cron
     web.Shop
     web.Shop_Warehouse
     web.Shop_Country
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/view/shop_form.xml
--- a/modules/web_shop/view/shop_form.xml       Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/view/shop_form.xml       Wed Jul 29 15:48:59 2026 +0200
@@ -19,6 +19,8 @@
 
     <label name="guest_party"/>
     <field name="guest_party"/>
+    <label name="sale_draft_abandon_delay"/>
+    <field name="sale_draft_abandon_delay"/>
 
     <notebook colspan="4">
         <page name="warehouses">
diff -r bd2d0b0cc6fa -r 9b5bd944259b modules/web_shop/web.py
--- a/modules/web_shop/web.py   Thu Jul 23 14:52:10 2026 +0200
+++ b/modules/web_shop/web.py   Wed Jul 29 15:48:59 2026 +0200
@@ -1,5 +1,7 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
+
+import datetime as dt
 from collections import defaultdict
 
 from sql import Literal
@@ -70,6 +72,15 @@
             'company': Eval('company', -1),
             },
         depends={'company'})
+    sale_draft_abandon_delay = fields.TimeDelta(
+        "Sale Draft Abandon Delay",
+        domain=['OR',
+            ('sale_draft_abandon_delay', '>=', dt.timedelta()),
+            ('sale_draft_abandon_delay', '=', None),
+            ],
+        help="The time period after which a sale made via the web shop "
+        "without modification is cancelled.\n"
+        "Leave empty to never cancel any draft sales.")
     countries = fields.Many2Many(
         'web.shop-country.country', 'shop', 'country', "Countries")
 

Reply via email to