[REF] Lunch: Use dynamic daily crons for auto emails

The Lunch module can automatically send an email to a supplier with the
daily orders at a configured time. It can also remind the users via chat
so they don't forget to order their sanddwish.

Before, two high frequency cron jobs were responsible to check every 20
and 5 minutes if we reached the moment when to send the email to the
suppliers or the notification to the users. Together the two crons were
executed 360 times a day.

The new model uses a dedicated daily cron per supplier record and per
alert record, the dedicated cron is created on the fly and its moment of
execution automatically updated to reflect the supplier/alert record.

See also #41858 for prior work.

closes odoo/odoo#63749

Task: 2416741
Related: odoo/upgrade#2044
Signed-off-by: Julien Castiaux <Julien00859@users.noreply.github.com>
This commit is contained in:
Julien Castiaux
2021-03-17 02:10:41 +00:00
parent 758f09890a
commit e5cf8cc8b7
8 changed files with 342 additions and 99 deletions
-1
View File
@@ -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', ],
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_lunch" model="ir.cron">
<field name="name">Lunch: automatic email send</field>
<field name="model_id" ref="model_lunch_supplier"/>
<field name="state">code</field>
<field name="code">model._auto_email_send()</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">20</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
<record id="ir_cron_lunch_alerts" model="ir.cron">
<field name="name">Lunch: alert chat notification</field>
<field name="model_id" ref="model_lunch_alert"/>
<field name="state">code</field>
<field name="code">model._notify_chat()</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
</data>
</odoo>
+94 -27
View File
@@ -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)
+98 -36
View File
@@ -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',
+1
View File
@@ -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
+38 -1
View File
@@ -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')
+56
View File
@@ -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))
+55 -5
View File
@@ -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))