diff --git a/addons/mail/i18n/mail.pot b/addons/mail/i18n/mail.pot index 7917a703257..f7d091f1f35 100644 --- a/addons/mail/i18n/mail.pot +++ b/addons/mail/i18n/mail.pot @@ -7619,6 +7619,11 @@ msgstr "" msgid "Wrong operation name (%s)" msgstr "" +#. module: mail +#: model_terms:ir.ui.view,arch_db:mail.view_mail_form +msgid "YYYY-MM-DD HH:MM:SS" +msgstr "" + #. module: mail #. openerp-web #: code:addons/mail/static/src/models/tracking_value_item.js:0 diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 86d8c8a478d..3d04974adfd 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -9,8 +9,10 @@ import psycopg2 import smtplib import threading import re +import pytz from collections import defaultdict +from dateutil.parser import parse from odoo import _, api, fields, models from odoo import tools @@ -127,7 +129,12 @@ class MailMail(models.Model): for values in values_list: if 'is_notification' not in values and values.get('mail_message_id'): values['is_notification'] = True - + if values.get('scheduled_date'): + parsed_datetime = self._parse_scheduled_datetime(values['scheduled_date']) + if parsed_datetime: + values['scheduled_date'] = parsed_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + else: + values['scheduled_date'] = False new_mails = super(MailMail, self).create(values_list) new_mails_w_attach = self @@ -140,6 +147,12 @@ class MailMail(models.Model): return new_mails def write(self, vals): + if vals.get('scheduled_date'): + parsed_datetime = self._parse_scheduled_datetime(vals['scheduled_date']) + if parsed_datetime: + vals['scheduled_date'] = parsed_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + else: + vals['scheduled_date'] = False res = super(MailMail, self).write(vals) if vals.get('attachment_ids'): for mail in self: @@ -204,11 +217,13 @@ class MailMail(models.Model): messages to send (by default all 'outgoing' messages are sent). """ - filters = ['&', - ('state', '=', 'outgoing'), - '|', - ('scheduled_date', '<', datetime.datetime.now()), - ('scheduled_date', '=', False)] + filters = [ + '&', + ('state', '=', 'outgoing'), + '|', + ('scheduled_date', '=', False), + ('scheduled_date', '<=', datetime.datetime.utcnow()), + ] if 'filters' in self._context: filters.extend(self._context['filters']) # TODO: make limit configurable @@ -270,6 +285,42 @@ class MailMail(models.Model): return True + def _parse_scheduled_datetime(self, scheduled_datetime): + """ Taking an arbitrary datetime (either as a date, a datetime or a string) + try to parse it and return a datetime timezoned to UTC. + + If no specific timezone information is given, we consider it as being + given in UTC, as all datetime values given to the server. Trying to + guess its timezone based on user or flow would be strange as this is + not standard. When manually creating datetimes for mail.mail scheduled + date, business code should ensure either a timezone info is set, either + it is converted into UTC. + + Using yearfirst when parsing str datetimes eases parser's job when + dealing with the hard-to-parse trio (01/04/09 -> ?). In most use cases + year will be given first as this is the expected default formatting. + + :return datetime: parsed datetime (or False if parser failed) + """ + if isinstance(scheduled_datetime, datetime.datetime): + parsed_datetime = scheduled_datetime + elif isinstance(scheduled_datetime, datetime.date): + parsed_datetime = datetime.combine(scheduled_datetime, datetime.time.min) + else: + try: + parsed_datetime = parse(scheduled_datetime, yearfirst=True) + except (ValueError, TypeError): + parsed_datetime = False + if parsed_datetime: + if not parsed_datetime.tzinfo: + parsed_datetime = pytz.utc.localize(parsed_datetime) + else: + try: + parsed_datetime = parsed_datetime.astimezone(pytz.utc) + except Exception: + pass + return parsed_datetime + # ------------------------------------------------------ # mail_mail formatting, tools and send mechanism # ------------------------------------------------------ diff --git a/addons/mail/views/mail_mail_views.xml b/addons/mail/views/mail_mail_views.xml index 0f15d316147..16e9cc1b4c3 100644 --- a/addons/mail/views/mail_mail_views.xml +++ b/addons/mail/views/mail_mail_views.xml @@ -33,7 +33,7 @@ domain="[('type', '!=', 'private'), ('active', '=', True)]"/> - + diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index a23b0f22e74..a992e039f7b 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -2,18 +2,21 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import psycopg2 +import pytz import smtplib +from datetime import datetime, timedelta +from freezegun import freeze_time from OpenSSL.SSL import Error as SSLError from socket import gaierror, timeout from unittest.mock import call, patch -from odoo import api, Command +from odoo import api, Command, tools from odoo.addons.base.models.ir_mail_server import MailDeliveryException from odoo.addons.test_mail.tests.common import TestMailCommon from odoo.exceptions import AccessError from odoo.tests import common, tagged, users -from odoo.tools import mute_logger +from odoo.tools import mute_logger, DEFAULT_SERVER_DATETIME_FORMAT @tagged('mail_mail') @@ -169,6 +172,57 @@ class TestMailMail(TestMailCommon): mail.send() self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain)) + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_mail_mail_schedule(self): + """Test that a mail scheduled in the past/future are sent or not""" + now = datetime(2022, 6, 28, 14, 0, 0) + scheduled_datetimes = [ + # falsy values + False, '', 'This is not a date format', + # datetimes (UTC/GMT +10 hours for Australia/Brisbane) + now, pytz.timezone('Australia/Brisbane').localize(now), + # string + (now - timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + (now + timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + (now + timedelta(days=1)).strftime("%H:%M:%S %d-%m-%Y"), + # tz: is actually 1 hour before now in UTC + (now + timedelta(hours=3)).strftime("%H:%M:%S %d-%m-%Y") + " +0400", + # tz: is actually 1 hour after now in UTC + (now + timedelta(hours=-3)).strftime("%H:%M:%S %d-%m-%Y") + " -0400", + ] + expected_datetimes = [ + False, '', False, + now, now - pytz.timezone('Australia/Brisbane').utcoffset(now), + now - timedelta(days=1), now + timedelta(days=1), now + timedelta(days=1), + now + timedelta(hours=-1), + now + timedelta(hours=1), + ] + expected_states = [ + # falsy values = send now + 'sent', 'sent', 'sent', + 'sent', 'sent', + 'sent', 'outgoing', 'outgoing', + 'sent', 'outgoing' + ] + + mails = self.env['mail.mail'].create([ + {'body_html': '

Test

', + 'email_to': 'test@example.com', + 'scheduled_date': scheduled_datetime, + } for scheduled_datetime in scheduled_datetimes + ]) + + for mail, expected_datetime, scheduled_datetime in zip(mails, expected_datetimes, scheduled_datetimes): + expected = expected_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) if expected_datetime else expected_datetime + self.assertEqual(mail.scheduled_date, expected, + 'Scheduled date: %s should be stored as %s, received %s' % (scheduled_datetime, expected, mail.scheduled_date)) + self.assertEqual(mail.state, 'outgoing') + + with freeze_time(now): + self.env['mail.mail'].process_email_queue() + for mail, expected_state in zip(mails, expected_states): + self.assertEqual(mail.state, expected_state) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_exceptions_origin(self): """ Test various use case with exceptions and errors and see how they are