diff --git a/addons/lunch/__manifest__.py b/addons/lunch/__manifest__.py index 75dad95cc52..d56aedf3a89 100644 --- a/addons/lunch/__manifest__.py +++ b/addons/lunch/__manifest__.py @@ -36,7 +36,6 @@ If you want to save your employees' time and avoid them to always have coins in 'views/lunch_views.xml', 'data/mail_template_data.xml', 'data/lunch_data.xml', - 'data/ir_cron_data.xml' ], 'demo': ['data/lunch_demo.xml'], 'qweb': ['static/src/xml/lunch_templates.xml', ], diff --git a/addons/lunch/data/ir_cron_data.xml b/addons/lunch/data/ir_cron_data.xml deleted file mode 100644 index 5c78a5b7509..00000000000 --- a/addons/lunch/data/ir_cron_data.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - Lunch: automatic email send - - code - model._auto_email_send() - - - 20 - minutes - -1 - - - - Lunch: alert chat notification - - code - model._notify_chat() - - - 5 - minutes - -1 - - - - diff --git a/addons/lunch/models/lunch_alert.py b/addons/lunch/models/lunch_alert.py index 9110f792356..41cbcab8e5c 100644 --- a/addons/lunch/models/lunch_alert.py +++ b/addons/lunch/models/lunch_alert.py @@ -1,16 +1,20 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz +import logging from odoo import api, fields, models from odoo.osv import expression from .lunch_supplier import float_to_time from datetime import datetime, timedelta +from textwrap import dedent from odoo.addons.base.models.res_partner import _tz_get +_logger = logging.getLogger(__name__) WEEKDAY_TO_NAME = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] +CRON_DEPENDS = {'name', 'active', 'mode', 'until', 'notification_time', 'notification_moment', 'tz'} + class LunchAlert(models.Model): """ Alerts to display during a lunch order. An alert can be specific to a @@ -35,6 +39,7 @@ class LunchAlert(models.Model): ('am', 'AM'), ('pm', 'PM')], default='am', required=True) tz = fields.Selection(_tz_get, string='Timezone', required=True, default=lambda self: self.env.user.tz or 'UTC') + cron_id = fields.Many2one('ir.cron', ondelete='cascade', required=True, readonly=True) until = fields.Date('Show Until') recurrency_monday = fields.Boolean('Monday', default=True) @@ -84,36 +89,98 @@ class LunchAlert(models.Model): ]) ]) + def _sync_cron(self): + """ Synchronise the related cron fields to reflect this alert """ + for alert in self: + alert = alert.with_context(tz=alert.tz) + + cron_required = ( + alert.active + and alert.mode == 'chat' + and (not alert.until or fields.Date.context_today(alert) <= alert.until) + ) + + sendat_tz = pytz.timezone(alert.tz).localize(datetime.combine( + fields.Date.context_today(alert, fields.Datetime.now()), + float_to_time(alert.notification_time, alert.notification_moment))) + lc = alert.cron_id.lastcall + if (( + lc and sendat_tz.date() <= fields.Datetime.context_timestamp(alert, lc).date() + ) or ( + not lc and sendat_tz <= fields.Datetime.context_timestamp(alert, fields.Datetime.now()) + )): + sendat_tz += timedelta(days=1) + sendat_utc = sendat_tz.astimezone(pytz.UTC).replace(tzinfo=None) + + alert.cron_id.name = f"Lunch: alert chat notification ({alert.name})" + alert.cron_id.active = cron_required + alert.cron_id.nextcall = sendat_utc + alert.cron_id.code = dedent(f"""\ + # This cron is dynamically controlled by {self._description}. + # Do NOT modify this cron, modify the related record instead. + env['{self._name}'].browse([{alert.id}])._notify_chat()""") + + @api.model_create_multi + def create(self, vals_list): + crons = self.env['ir.cron'].sudo().create([ + { + 'user_id': self.env.ref('base.user_root').id, + 'active': False, + 'interval_type': 'days', + 'interval_number': 1, + 'numbercall': -1, + 'doall': False, + 'name': "Lunch: alert chat notification", + 'model_id': self.env['ir.model']._get_id(self._name), + 'state': 'code', + 'code': "", + } + for _ in range(len(vals_list)) + ]) + for vals, cron in zip(vals_list, crons): + vals['cron_id'] = cron.id + + alerts = super().create(vals_list) + alerts._sync_cron() + return alerts + + def write(self, values): + super().write(values) + if not CRON_DEPENDS.isdisjoint(values): + self._sync_cron() + + def unlink(self): + crons = self.cron_id + super().unlink() + crons.unlink() + def _notify_chat(self): - records = self.search([('mode', '=', 'chat'), ('active', '=', True)]) + # Called daily by cron + self.ensure_one() - today = fields.Date.today() - now = fields.Datetime.now() + if not self.available_today: + _logger.warning("cancelled, not available today") + if self.cron_id and self.until and fields.Date.context_today(self) > self.until: + self.cron_id.unlink() + self.cron_id = False + return - for alert in records: - notification_to = now.astimezone(pytz.timezone(alert.tz)).replace(second=0, microsecond=0, tzinfo=None) - notification_from = notification_to - timedelta(minutes=5) - send_at = datetime.combine(fields.Date.today(), - float_to_time(alert.notification_time, alert.notification_moment)) + if not self.active or self.mode != 'chat': + raise ValueError("Cannot send a chat notification in the current state") - if alert.available_today and send_at > notification_from and send_at <= notification_to: - order_domain = [('state', '!=', 'cancelled')] + order_domain = [('state', '!=', 'cancelled')] - if alert.location_ids.ids: - order_domain = expression.AND([order_domain, [('user_id.last_lunch_location_id', 'in', alert.location_ids.ids)]]) + if self.location_ids.ids: + order_domain = expression.AND([order_domain, [('user_id.last_lunch_location_id', 'in', self.location_ids.ids)]]) - if alert.recipients != 'everyone': - weeks = 1 + if self.recipients != 'everyone': + weeksago = fields.Date.today() - timedelta(weeks=( + 1 if self.recipients == 'last_week' else + 4 if self.recipients == 'last_month' else + 52 # if self.recipients == 'last_year' + )) + order_domain = expression.AND([order_domain, [('date', '>=', weeksago)]]) - if alert.recipients == 'last_month': - weeks = 4 - else: # last_year - weeks = 52 - - delta = timedelta(weeks=weeks) - order_domain = expression.AND([order_domain, [('date', '>=', today - delta)]]) - - orders = self.env['lunch.order'].search(order_domain).mapped('user_id') - partner_ids = [user.partner_id.id for user in orders] - if partner_ids: - self.env['mail.thread'].message_notify(body=alert.message, partner_ids=partner_ids) + partners = self.env['lunch.order'].search(order_domain).user_id.partner_id + if partners: + self.env['mail.thread'].message_notify(body=self.message, partner_ids=partners.ids) diff --git a/addons/lunch/models/lunch_supplier.py b/addons/lunch/models/lunch_supplier.py index e1d9068366f..28e923de70a 100644 --- a/addons/lunch/models/lunch_supplier.py +++ b/addons/lunch/models/lunch_supplier.py @@ -1,10 +1,10 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import math import pytz -from datetime import datetime, time +from datetime import datetime, time, timedelta +from textwrap import dedent from odoo import api, fields, models from odoo.osv import expression @@ -14,6 +14,7 @@ from odoo.addons.base.models.res_partner import _tz_get WEEKDAY_TO_NAME = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] +CRON_DEPENDS = {'name', 'active', 'send_by', 'automatic_email_time', 'moment', 'tz'} def float_to_time(hours, moment='am', tz=None): """ Convert a number of hours into a time object. """ @@ -59,6 +60,7 @@ class LunchSupplier(models.Model): ('mail', 'Email'), ], 'Send Order By', default='phone') automatic_email_time = fields.Float('Order Time', default=12.0, required=True) + cron_id = fields.Many2one('ir.cron', ondelete='cascade', required=True, readonly=True) recurrency_monday = fields.Boolean('Monday', default=True) recurrency_tuesday = fields.Boolean('Tuesday', default=True) @@ -103,46 +105,106 @@ class LunchSupplier(models.Model): res.append((supplier.id, supplier.name)) return res - @api.model - def _auto_email_send(self): - """ - This method is called every 20 minutes via a cron. - Its job is simply to get all the orders made for each supplier and send an email - automatically to the supplier if the supplier is configured for it and we are ready - to send it (usually at 11am or so) - """ - records = self.search([('send_by', '=', 'mail')]) + def _sync_cron(self): + for supplier in self: + supplier = supplier.with_context(tz=supplier.tz) - for supplier in records: - send_at = datetime.combine(fields.Date.today(), - float_to_time(supplier.automatic_email_time, supplier.moment, supplier.tz)).astimezone(pytz.UTC).replace(tzinfo=None) - if supplier.available_today and fields.Datetime.now() > send_at: - lines = self.env['lunch.order'].search([('supplier_id', '=', supplier.id), - ('state', '=', 'ordered'), ('date', '=', fields.Date.today())]) + sendat_tz = pytz.timezone(supplier.tz).localize(datetime.combine( + fields.Date.context_today(supplier), + float_to_time(supplier.automatic_email_time, supplier.moment))) + lc = supplier.cron_id.lastcall + if (( + lc and sendat_tz.date() <= fields.Datetime.context_timestamp(supplier, lc).date() + ) or ( + not lc and sendat_tz <= fields.Datetime.context_timestamp(supplier, fields.Datetime.now()) + )): + sendat_tz += timedelta(days=1) + sendat_utc = sendat_tz.astimezone(pytz.UTC).replace(tzinfo=None) - if lines: - order = { - 'company_name': lines[0].company_id.name, - 'currency_id': lines[0].currency_id.id, - 'supplier_id': supplier.partner_id.id, - 'supplier_name': supplier.name, - 'email_from': supplier.responsible_id.email_formatted, - } + supplier.cron_id.active = supplier.active and supplier.send_by == 'mail' + supplier.cron_id.name = f"Lunch: send automatic email to {supplier.name}" + supplier.cron_id.nextcall = sendat_utc + supplier.cron_id.code = dedent(f"""\ + # This cron is dynamically controlled by {self._description}. + # Do NOT modify this cron, modify the related record instead. + env['{self._name}'].browse([{supplier.id}])._send_auto_email()""") - _lines = [{ - 'product': line.product_id.name, - 'note': line.note, - 'quantity': line.quantity, - 'price': line.price, - 'toppings': line.display_toppings, - 'username': line.user_id.name, - } for line in lines] + @api.model_create_multi + def create(self, vals_list): + crons = self.env['ir.cron'].sudo().create([ + { + 'user_id': self.env.ref('base.user_root').id, + 'active': False, + 'interval_type': 'days', + 'interval_number': 1, + 'numbercall': -1, + 'doall': False, + 'name': "Lunch: send automatic email", + 'model_id': self.env['ir.model']._get_id(self._name), + 'state': 'code', + 'code': "", + } + for _ in range(len(vals_list)) + ]) + for vals, cron in zip(vals_list, crons): + vals['cron_id'] = cron.id - order['amount_total'] = sum(line.price for line in lines) + suppliers = super().create(vals_list) + suppliers._sync_cron() + return suppliers - self.env.ref('lunch.lunch_order_mail_supplier').with_context(order=order, lines=_lines).send_mail(supplier.id) + def write(self, values): + super().write(values) + if not CRON_DEPENDS.isdisjoint(values): + self._sync_cron() - lines.action_confirm() + def unlink(self): + crons = self.cron_id + super().unlink() + crons.unlink() + + def _send_auto_email(self): + """ Send an email to the supplier with the order of the day """ + # Called daily by cron + self.ensure_one() + + if not self.available_today: + return + + if self.send_by != 'mail': + raise ValueError("Cannot send an email to this supplier") + + orders = self.env['lunch.order'].search([ + ('supplier_id', '=', self.id), + ('state', '=', 'ordered'), + ('date', '=', fields.Date.context_today(self.with_context(tz=self.tz))), + ]) + if not orders: + return + + order = { + 'company_name': orders[0].company_id.name, + 'currency_id': orders[0].currency_id.id, + 'supplier_id': self.partner_id.id, + 'supplier_name': self.name, + 'email_from': self.responsible_id.email_formatted, + 'amount_total': sum(order.price for order in orders), + } + + email_orders = [{ + 'product': order.product_id.name, + 'note': order.note, + 'quantity': order.quantity, + 'price': order.price, + 'toppings': order.display_toppings, + 'username': order.user_id.name, + } for order in orders] + + self.env.ref('lunch.lunch_order_mail_supplier').with_context( + order=order, lines=email_orders + ).send_mail(self.id) + + orders.action_confirm() @api.depends('recurrency_end_date', 'recurrency_monday', 'recurrency_tuesday', 'recurrency_wednesday', 'recurrency_thursday', 'recurrency_friday', diff --git a/addons/lunch/tests/__init__.py b/addons/lunch/tests/__init__.py index 368a677b5dc..ce3a01ead3a 100644 --- a/addons/lunch/tests/__init__.py +++ b/addons/lunch/tests/__init__.py @@ -2,5 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import common +from . import test_alert from . import test_product_report from . import test_supplier diff --git a/addons/lunch/tests/common.py b/addons/lunch/tests/common.py index 61e75d3dce5..1b3732a22a2 100644 --- a/addons/lunch/tests/common.py +++ b/addons/lunch/tests/common.py @@ -1,10 +1,18 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime +from freezegun import freeze_time from odoo.tests import common +fakenow = datetime(2021, 1, 29, 12, 20, 0) + +@freeze_time(fakenow) class TestsCommon(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.fakenow = fakenow def setUp(self): super(TestsCommon, self).setUp() @@ -30,6 +38,17 @@ class TestsCommon(common.TransactionCase): ], }) + self.partner_kothai = self.env['res.partner'].create({ + 'name': 'Kothai', + }) + + self.supplier_kothai = self.env['lunch.supplier'].create({ + 'partner_id': self.partner_kothai.id, + 'send_by': 'mail', + 'automatic_email_time': 10, + 'tz': 'America/New_York', + }) + self.partner_coin_gourmand = self.env['res.partner'].create({ 'name': 'Coin Gourmand', }) @@ -73,3 +92,21 @@ class TestsCommon(common.TransactionCase): self.env['lunch.cashmove'].create({ 'amount': 100, }) + + self.alert_ny = self.env['lunch.alert'].create({ + 'name': 'New York UTC-5', + 'mode': 'chat', + 'notification_time': 10, + 'notification_moment': 'am', + 'tz': 'America/New_York', + 'message': "", + }).with_context(tz='America/New_York') + + self.alert_tokyo = self.env['lunch.alert'].create({ + 'name': 'Tokyo UTC+9', + 'mode': 'chat', + 'notification_time': 8, + 'notification_moment': 'am', + 'tz': 'Asia/Tokyo', + 'message': "", + }).with_context(tz='Asia/Tokyo') diff --git a/addons/lunch/tests/test_alert.py b/addons/lunch/tests/test_alert.py new file mode 100644 index 00000000000..148a1757c27 --- /dev/null +++ b/addons/lunch/tests/test_alert.py @@ -0,0 +1,56 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, timedelta +from odoo import fields +from odoo.addons.lunch.tests.common import TestsCommon + + +class TestAlarm(TestsCommon): + def test_cron_sync_create(self): + cron_ny = self.alert_ny.cron_id + self.assertTrue(cron_ny.active) + self.assertEqual(cron_ny.name, "Lunch: alert chat notification (New York UTC-5)") + self.assertEqual( + [line for line in cron_ny.code.splitlines() if not line.lstrip().startswith("#")], + ["env['lunch.alert'].browse([%i])._notify_chat()" % self.alert_ny.id]) + self.assertEqual(cron_ny.nextcall, datetime(2021, 1, 29, 15, 0)) # New-york is UTC-5 + + tokyo_cron = self.alert_tokyo.cron_id + self.assertEqual(tokyo_cron.nextcall, datetime(2021, 1, 29, 23, 0)) # Tokyo is UTC+9 but the cron is posponed + + def test_cron_sync_active(self): + cron_ny = self.alert_ny.cron_id + + self.alert_ny.active = False + self.assertFalse(cron_ny.active) + self.alert_ny.active = True + self.assertTrue(cron_ny.active) + + self.alert_ny.mode = 'alert' + self.assertFalse(cron_ny.active) + self.alert_ny.mode = 'chat' + self.assertTrue(cron_ny.active) + + ctx_today = fields.Date.context_today(self.alert_ny, self.fakenow) + self.alert_ny.until = ctx_today - timedelta(days=1) + self.assertFalse(cron_ny.active) + self.alert_ny.until = ctx_today + timedelta(days=2) + self.assertTrue(cron_ny.active) + self.alert_ny.until = False + self.assertTrue(cron_ny.active) + + def test_cron_sync_nextcall(self): + cron_ny = self.alert_ny.cron_id + old_nextcall = cron_ny.nextcall + + self.alert_ny.notification_time -= 5 + self.assertEqual(cron_ny.nextcall, old_nextcall - timedelta(hours=5) + timedelta(days=1)) + + # Simulate cron execution + cron_ny.lastcall = old_nextcall - timedelta(hours=5) + cron_ny.nextcall += timedelta(days=1) + + self.alert_ny.notification_time += 7 + self.assertEqual(cron_ny.nextcall, old_nextcall + timedelta(days=1, hours=2)) + + self.alert_ny.notification_time -= 1 + self.assertEqual(cron_ny.nextcall, old_nextcall + timedelta(days=1, hours=1)) diff --git a/addons/lunch/tests/test_supplier.py b/addons/lunch/tests/test_supplier.py index 3048237d9d1..e24c0c74806 100644 --- a/addons/lunch/tests/test_supplier.py +++ b/addons/lunch/tests/test_supplier.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz -from datetime import datetime +from datetime import datetime, time, timedelta from unittest.mock import patch from odoo import fields @@ -25,6 +24,18 @@ class TestSupplier(TestsCommon): self.saturday_1pm = datetime(2018, 11, 3, 13, 0, 0) self.saturday_8pm = datetime(2018, 11, 3, 20, 0, 0) + def test_send_email_cron(self): + self.supplier_kothai.cron_id.ensure_one() + self.assertEqual(self.supplier_kothai.cron_id.nextcall.time(), time(15, 0)) + self.assertEqual(self.supplier_kothai.cron_id.code, f"""\ +# This cron is dynamically controlled by Lunch Supplier. +# Do NOT modify this cron, modify the related record instead. +env['lunch.supplier'].browse([{self.supplier_kothai.id}])._send_auto_email()""") + + cron_id = self.supplier_kothai.cron_id.id + self.supplier_kothai.unlink() + self.assertFalse(self.env['ir.cron'].search([('id', '=', cron_id)])) + def test_compute_available_today(self): tests = [(self.monday_1am, True), (self.monday_10am, True), (self.monday_1pm, True), (self.monday_8pm, True), @@ -76,7 +87,7 @@ class TestSupplier(TestsCommon): line.action_order() assert line.state == 'ordered' - self.supplier_pizza_inn._auto_email_send() + self.supplier_pizza_inn._send_auto_email() assert line.state == 'confirmed' @@ -94,7 +105,7 @@ class TestSupplier(TestsCommon): assert line.state == 'ordered' assert line2.state == 'ordered' - self.supplier_pizza_inn._auto_email_send() + self.supplier_pizza_inn._send_auto_email() assert line.state == 'confirmed' assert line2.state == 'ordered' @@ -121,4 +132,43 @@ class TestSupplier(TestsCommon): assert all(line.state == 'ordered' for line in [line_1, line_2, line_3]) - self.supplier_pizza_inn._auto_email_send() + self.supplier_pizza_inn._send_auto_email() + + def test_cron_sync_create(self): + cron_ny = self.supplier_kothai.cron_id # I am at New-York + self.assertTrue(cron_ny.active) + self.assertEqual(cron_ny.name, "Lunch: send automatic email to Kothai") + self.assertEqual( + [line for line in cron_ny.code.splitlines() if not line.lstrip().startswith("#")], + ["env['lunch.supplier'].browse([%i])._send_auto_email()" % self.supplier_kothai.id]) + self.assertEqual(cron_ny.nextcall, datetime(2021, 1, 29, 15, 0)) # New-york is UTC-5 + + def test_cron_sync_active(self): + cron_ny = self.supplier_kothai.cron_id + + self.supplier_kothai.active = False + self.assertFalse(cron_ny.active) + self.supplier_kothai.active = True + self.assertTrue(cron_ny.active) + + self.supplier_kothai.send_by = 'phone' + self.assertFalse(cron_ny.active) + self.supplier_kothai.send_by = 'mail' + self.assertTrue(cron_ny.active) + + def test_cron_sync_nextcall(self): + cron_ny = self.supplier_kothai.cron_id + old_nextcall = cron_ny.nextcall + + self.supplier_kothai.automatic_email_time -= 5 + self.assertEqual(cron_ny.nextcall, old_nextcall - timedelta(hours=5) + timedelta(days=1)) + + # Simulate cron execution + cron_ny.lastcall = old_nextcall - timedelta(hours=5) + cron_ny.nextcall += timedelta(days=1) + + self.supplier_kothai.automatic_email_time += 7 + self.assertEqual(cron_ny.nextcall, old_nextcall + timedelta(days=1, hours=2)) + + self.supplier_kothai.automatic_email_time -= 1 + self.assertEqual(cron_ny.nextcall, old_nextcall + timedelta(days=1, hours=1))