From 6a4c1aa5e333d5a17bf43e803c8c8cc2678382ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Tue, 14 Apr 2026 18:29:42 +0200 Subject: [PATCH] Parse equality to a date as two clauses when handling datetime fields Closes #14778 (grafted from 6952308a8b6b65ff583a2b04d9cd558b44c4e4a9) --HG-- branch : 8.0 --- sao/src/common.js | 26 ++++++++++++++ sao/tests/sao.js | 34 +++++++++++++++++++ tryton/tryton/common/domain_parser.py | 20 +++++++++++ .../tryton/tests/test_common_domain_parser.py | 33 ++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/sao/src/common.js b/sao/src/common.js index 3ab39bfc4d..25fec178fb 100644 --- a/sao/src/common.js +++ b/sao/src/common.js @@ -1951,6 +1951,32 @@ ]); return; } + if ((typeof value == 'string') && + ['datetime', 'timestamp'].includes(field.type) && + (operator == '=')) { + let ctx, format_, parsed_date; + if (this.context && Object.keys(this.context).length) { + ctx = this.context; + } else { + ctx = {}; + } + format_ = Sao.common.date_format(ctx.date_format); + parsed_date = Sao.common.parse_date(format_, value); + if (parsed_date && + (Sao.common.format_date(format_, parsed_date) == value)) { + let date = Sao.DateTime.combine(parsed_date, Sao.Time()); + let next_day = Sao.DateTime( + date.year(), date.month(), date.date(), + date.hour(), date.minute(), date.second(), + date.millisecond()) + next_day.add(1, 'day'); + result.push(this._clausify([ + [field_name, '>=', date], + [field_name, '<', next_day] + ])); + return; + } + } } if (['many2one', 'one2many', 'many2many', 'one2one', 'many2many', 'one2one'].includes(field.type) && value) { diff --git a/sao/tests/sao.js b/sao/tests/sao.js index f143d0d17b..84e32c0217 100644 --- a/sao/tests/sao.js +++ b/sao/tests/sao.js @@ -1933,6 +1933,12 @@ 'name': 'integer', 'type': 'integer' }, + 'timestamp': { + 'name': 'timestamp', + 'string': 'Timestamp', + 'type': 'timestamp', + 'format': '"%H:%M:%S"', + }, 'selection': { 'string': 'Selection', 'name': 'selection', @@ -2033,6 +2039,7 @@ c(['integer', '>=', 3]), c(['integer', '<=', 5]) ]]], + [[c(['Timestamp', null, null])], [c(['timestamp', '=', null])]], [[c(['Reference', null, 'foo'])], [c(['reference', 'ilike', '%foo%'])]], [[c(['Reference', null, 'Spam'])], @@ -2073,6 +2080,33 @@ QUnit.assert.deepEqual(parser.parse_clause(value), result, 'parse_clause(' + JSON.stringify(value) + ')'); }); + + let clause = parser.parse_clause([c( + ['Timestamp', '=', Sao.common.format_date('%x', Sao.Date(2002, 12, 4))])]); + QUnit.assert.strictEqual(clause[0].length, 2); + let [ , operator, value] = clause[0][0]; + QUnit.assert.strictEqual(operator, '>='); + QUnit.assert.ok(value.isSame(Sao.Date(2002, 12, 4))); + [ , operator, value] = clause[0][1]; + QUnit.assert.strictEqual(operator, '<'); + QUnit.assert.ok(value.isSame(Sao.Date(2002, 12, 5))); + + clause = parser.parse_clause([c( + ['Timestamp', '=', + Sao.common.format_datetime('%x %X', Sao.DateTime(2002, 12, 4, 12, 30))])]); + [ , operator, value] = clause[0]; + QUnit.assert.strictEqual(operator, '='); + QUnit.assert.ok(value.isSame(Sao.DateTime(2002, 12, 4, 12, 30))); + + clause = parser.parse_clause([c( + ['Timestamp', null, [ + `${Sao.common.format_date('%x', Sao.Date(2002, 12, 4))}`, + `${Sao.common.format_date('%x', Sao.Date(2002, 12, 5))}`, + ]])]); + [ , operator, value] = clause[0]; + QUnit.assert.strictEqual(operator, 'in'); + QUnit.assert.ok(value[0].isSame(Sao.DateTime(2002, 12, 4))); + QUnit.assert.ok(value[1].isSame(Sao.DateTime(2002, 12, 5))); }); QUnit.test('DomainParser.format_value', function() { diff --git a/tryton/tryton/common/domain_parser.py b/tryton/tryton/common/domain_parser.py index b8bacb54ec..c0099123c1 100644 --- a/tryton/tryton/common/domain_parser.py +++ b/tryton/tryton/common/domain_parser.py @@ -885,6 +885,26 @@ class DomainParser(object): (field_name, '<=', rvalue), ]) continue + if (isinstance(value, str) + and field['type'] in {'datetime', 'timestamp'} + and operator == '='): + ctx = self.context if self.context else {} + format_ = date_format(ctx.get('date_format')) + try: + dt = datetime.datetime.strptime( + value, format_).date() + except (ValueError, TypeError): + dt = None + if dt: + date = untimezoned_date( + datetime.datetime.combine( + dt, datetime.time())) + next_date = date + datetime.timedelta(days=1) + yield iter([ + (field_name, '>=', date), + (field_name, '<', next_date), + ]) + continue if field['type'] in { 'many2one', 'one2many', 'many2many', 'one2one', } and value: diff --git a/tryton/tryton/tests/test_common_domain_parser.py b/tryton/tryton/tests/test_common_domain_parser.py index 5f946d06c6..efbb754afc 100644 --- a/tryton/tryton/tests/test_common_domain_parser.py +++ b/tryton/tryton/tests/test_common_domain_parser.py @@ -943,6 +943,12 @@ class DomainParserTestCase(TestCase): 'name': 'integer', 'type': 'integer', }, + 'timestamp': { + 'name': 'timestamp', + 'string': 'Timestamp', + 'type': 'timestamp', + 'format': '"%H:%M:%S"', + }, 'selection': { 'string': 'Selection', 'name': 'selection', @@ -1161,6 +1167,33 @@ class DomainParserTestCase(TestCase): self.assertEqual( rlist(dom.parse_clause(iter([['AND']]))), [('rec_name', 'ilike', "%AND%")]) + for search_text, domain in [ + ([('Timestamp', None, None)], [('timestamp', '=', None)]), + ( + [('Timestamp', '=', dt.date(2002, 12, 4).strftime('%x'))], + [[ + ('timestamp', '>=', + untimezoned_date(dt.datetime(2002, 12, 4))), + ('timestamp', '<', + untimezoned_date(dt.datetime(2002, 12, 5)))]]), + ( + [('Timestamp', '=', + dt.datetime(2002, 12, 4, 12, 30).strftime('%x %X')) + ], + [('timestamp', '=', untimezoned_date( + dt.datetime(2002, 12, 4, 12, 30)))]), + ( + [('Timestamp', None, [ + f"{dt.date(2002, 12, 4).strftime('%x')}", + f"{dt.date(2002, 12, 5).strftime('%x')}", + ])], + [('timestamp', 'in', [ + untimezoned_date(dt.datetime(2002, 12, 4)), + untimezoned_date(dt.datetime(2002, 12, 5)), + ])]), + ]: + with self.subTest(f'parse_clause({search_text})'): + self.assertEqual(rlist(dom.parse_clause(search_text)), domain) def test_completion_char(self): "Test completion char"