[IMP] calendar: schedule reminders using triggers

The calendar reminders are emails or web notifications sent moments
before an event start. The mechanism to send web notifications is
controlled by the browser, it fetches all the future notifications and
uses `setTimeout()` to delay them. The mechanism to send emails was
controlled by a cron, every 30 minutes the cron would look for the
coming events and send the mail reminder.

The cron was running every 30 minutes even if there was no event. The
best precision was 30 minutes.

The new mechanism use cron triggers, every time one add email reminders,
triggers are created to call the cron at the precise moment the alarm is
set. The cron no longer send emails for events in the coming 30 minutes
as those will be triggered too.

closes odoo/odoo#64560

Task: 2416741
Signed-off-by: Julien Castiaux <Julien00859@users.noreply.github.com>
This commit is contained in:
Julien Castiaux
2021-01-18 15:57:41 +00:00
parent cbf00ae8ee
commit c8f031ad77
5 changed files with 73 additions and 72 deletions
+3 -3
View File
@@ -6,11 +6,11 @@
<field name="name">Calendar: Event Reminder</field>
<field name="model_id" ref="model_calendar_alarm_manager"/>
<field name="state">code</field>
<field name="code">model.get_next_mail()</field>
<field name="code">model._send_reminder_email()</field>
<field eval="True" name="active" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
</record>
@@ -116,55 +116,55 @@ class AlarmManager(models.AbstractModel):
"""
result = []
# TODO: remove event_maxdelta and if using it
if one_date - timedelta(minutes=(missing * event_maxdelta)) < fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds): # if an alarm is possible for this date
for alarm in event.alarm_ids:
if alarm.alarm_type == alarm_type and \
one_date - timedelta(minutes=(missing * alarm.duration_minutes)) < fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \
(not after or one_date - timedelta(minutes=alarm.duration_minutes) > fields.Datetime.from_string(after)):
alert = {
'alarm_id': alarm.id,
'event_id': event.id,
'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
}
result.append(alert)
past = one_date - timedelta(minutes=(missing * event_maxdelta))
future = fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds)
if future <= past:
return result
for alarm in event.alarm_ids:
if alarm.alarm_type != alarm_type:
continue
past = one_date - timedelta(minutes=(missing * alarm.duration_minutes))
if future <= past:
continue
if after and past <= fields.Datetime.from_string(after):
continue
result.append({
'alarm_id': alarm.id,
'event_id': event.id,
'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
})
return result
@api.model
def get_next_mail(self):
return self._get_partner_next_mail(partners=None)
def _send_reminder_email(self, partners=None):
# Executed via cron
self.env.cr.execute('''
SELECT "event"."id"
FROM "calendar_event" AS "event"
JOIN "calendar_alarm_calendar_event_rel" AS "event_alarm_rel"
ON "event"."id" = "event_alarm_rel"."calendar_event_id"
JOIN "calendar_alarm" AS "alarm"
ON "event_alarm_rel"."calendar_alarm_id" = "alarm"."id"
WHERE (
"alarm"."alarm_type" = 'email'
AND "event"."active"
AND "event"."start" - CAST("alarm"."duration" || ' ' || "alarm"."interval" AS Interval) >= %s
AND "event"."start" - CAST("alarm"."duration" || ' ' || "alarm"."interval" AS Interval) < now() at time zone 'utc'
)''', [self.env.context['lastcall']])
@api.model
def _get_partner_next_mail(self, partners=None):
self = self.with_context(mail_notify_force_send=True)
last_notif_mail = fields.Datetime.to_string(self.env.context.get('lastcall') or fields.Datetime.now())
domain = [
("event_id", "in", self.env.cr.fetchall()),
("state", "!=", "declined"),
]
if partners is not None:
domain.append(("partner_id", "in", partners.ids))
cron = self.env.ref('calendar.ir_cron_scheduler_alarm', raise_if_not_found=False)
if not cron:
_logger.error("Cron for " + self._name + " can not be identified !")
return False
interval_to_second = {
"weeks": 7 * 24 * 60 * 60,
"days": 24 * 60 * 60,
"hours": 60 * 60,
"minutes": 60,
"seconds": 1
}
if cron.interval_type not in interval_to_second:
_logger.error("Cron delay can not be computed !")
return False
cron_interval = cron.interval_number * interval_to_second[cron.interval_type]
all_meetings = self._get_next_potential_limit_alarm('email', seconds=cron_interval, partners=partners)
for meeting in self.env['calendar.event'].browse(all_meetings):
max_delta = all_meetings[meeting.id]['max_duration']
in_date_format = meeting.start
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, 0, 'email', after=last_notif_mail, missing=True)
for alert in last_found:
self.do_mail_reminder(alert)
attendees = self.env["calendar.attendee"].search(domain)
attendees._send_mail_to_attendees(
'calendar.calendar_template_meeting_reminder',
force_send=True,
ignore_recurrence=True,
)
@api.model
def get_next_notif(self):
@@ -186,15 +186,6 @@ class AlarmManager(models.AbstractModel):
all_notif.append(self.do_notif_reminder(alert))
return all_notif
def do_mail_reminder(self, alert):
meeting = self.env['calendar.event'].browse(alert['event_id'])
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
result = False
if alarm.alarm_type == 'email':
result = meeting.attendee_ids.filtered(lambda r: r.state != 'declined')._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True, ignore_recurrence=True)
return result
def do_notif_reminder(self, alert):
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
meeting = self.env['calendar.event'].browse(alert['event_id'])
+13 -12
View File
@@ -328,7 +328,7 @@ class Meeting(models.Model):
@api.depends('stop', 'start')
def _compute_duration(self):
for event in self.with_context(dont_notify=True):
for event in self:
event.duration = self._get_duration(event.start, event.stop)
@api.depends('start', 'duration')
@@ -675,11 +675,7 @@ class Meeting(models.Model):
(detached_events & self).active = False
(detached_events - self).with_context(archive_on_error=True).unlink()
# Notify attendees if there is an alarm on the modified event, or if there was an alarm
# that has just been removed, as it might have changed their next event notification
if not self._context.get('dont_notify'):
if self.alarm_ids or values.get('alarm_ids'):
self.env['calendar.alarm_manager']._notify_next_alarm(self.partner_ids.ids)
self._setup_alarms()
current_attendees = self.filtered('active').attendee_ids
if 'partner_ids' in values:
@@ -692,6 +688,15 @@ class Meeting(models.Model):
return True
def _setup_alarms(self):
""" Trigger the cron in the future for every email reminder """
cron = self.env.ref('calendar.ir_cron_scheduler_alarm')
for event in self:
for alarm in (alarm for alarm in event.alarm_ids if alarm.alarm_type == 'email'):
cron._trigger(at=event.start-timedelta(minutes=alarm.duration_minutes))
if any(alarm.alarm_type == 'notification' for alarm in event.alarm_ids):
self.env['calendar.alarm_manager']._notify_next_alarm(event.partner_ids.ids)
@api.model_create_multi
def create(self, vals_list):
vals_list = [ # Else bug with quick_create when we are filter on an other user
@@ -741,12 +746,8 @@ class Meeting(models.Model):
events.filtered(lambda event: event.start > fields.Datetime.now()).attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_invitation')
events._sync_activities(fields={f for vals in vals_list for f in vals.keys() })
# Notify attendees if there is an alarm on the created event, as it might have changed their
# next event notification
if not self._context.get('dont_notify'):
for event in events:
if len(event.alarm_ids) > 0:
self.env['calendar.alarm_manager']._notify_next_alarm(event.partner_ids.ids)
events._setup_alarms()
return events
def read(self, fields=None, load='_classic_read'):
@@ -284,7 +284,7 @@ class RecurrenceRule(models.Model):
:param dstart: if provided, only write events starting from this point in time
"""
events = self._get_events_from(dtstart) if dtstart else self.calendar_event_ids
return events.with_context(no_mail_to_attendees=True, dont_notify=True).write(dict(values, recurrence_update='self_only'))
return events.with_context(no_mail_to_attendees=True).write(dict(values, recurrence_update='self_only'))
def _rrule_serialize(self):
"""
@@ -142,6 +142,9 @@ class TestEventNotifications(TransactionCase, MailCase):
self.assertEqual(notif, bus_message)
def test_email_alarm(self):
cron_id = self.env.ref('calendar.ir_cron_scheduler_alarm').id
triggers_before = self.env['ir.cron.trigger'].search([('cron_id', '=', cron_id)])
alarm = self.env['calendar.alarm'].create({
'name': 'Alarm',
'alarm_type': 'email',
@@ -152,12 +155,18 @@ class TestEventNotifications(TransactionCase, MailCase):
self.event.write({
'start': now + relativedelta(minutes=15),
'stop': now + relativedelta(minutes=18),
'partner_ids': [(4, self.partner.id)],
'alarm_ids': [(4, alarm.id)],
'partner_ids': [fields.Command.link(self.partner.id)],
'alarm_ids': [fields.Command.link(alarm.id)],
})
triggers_after = self.env['ir.cron.trigger'].search([('cron_id', '=', cron_id)])
new_triggers = triggers_after - triggers_before
new_triggers.ensure_one()
self.assertEqual(new_triggers.call_at, now - relativedelta(minutes=5))
with patch.object(fields.Datetime, 'now', lambda: now):
with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], {
'message_type': 'user_notification',
'subtype': 'mail.mt_note',
}):
self.env['calendar.alarm_manager'].with_context(lastcall=now - relativedelta(minutes=15))._get_partner_next_mail(self.partner)
self.env['calendar.alarm_manager'].with_context(lastcall=now - relativedelta(minutes=15))._send_reminder_email(self.partner)