[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:
@@ -6,11 +6,11 @@
|
|||||||
<field name="name">Calendar: Event Reminder</field>
|
<field name="name">Calendar: Event Reminder</field>
|
||||||
<field name="model_id" ref="model_calendar_alarm_manager"/>
|
<field name="model_id" ref="model_calendar_alarm_manager"/>
|
||||||
<field name="state">code</field>
|
<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 eval="True" name="active" />
|
||||||
<field name="user_id" ref="base.user_root" />
|
<field name="user_id" ref="base.user_root" />
|
||||||
<field name="interval_number">30</field>
|
<field name="interval_number">1</field>
|
||||||
<field name="interval_type">minutes</field>
|
<field name="interval_type">days</field>
|
||||||
<field name="numbercall">-1</field>
|
<field name="numbercall">-1</field>
|
||||||
<field eval="False" name="doall" />
|
<field eval="False" name="doall" />
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -116,55 +116,55 @@ class AlarmManager(models.AbstractModel):
|
|||||||
"""
|
"""
|
||||||
result = []
|
result = []
|
||||||
# TODO: remove event_maxdelta and if using it
|
# 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
|
past = one_date - timedelta(minutes=(missing * event_maxdelta))
|
||||||
for alarm in event.alarm_ids:
|
future = fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds)
|
||||||
if alarm.alarm_type == alarm_type and \
|
if future <= past:
|
||||||
one_date - timedelta(minutes=(missing * alarm.duration_minutes)) < fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \
|
return result
|
||||||
(not after or one_date - timedelta(minutes=alarm.duration_minutes) > fields.Datetime.from_string(after)):
|
for alarm in event.alarm_ids:
|
||||||
alert = {
|
if alarm.alarm_type != alarm_type:
|
||||||
'alarm_id': alarm.id,
|
continue
|
||||||
'event_id': event.id,
|
past = one_date - timedelta(minutes=(missing * alarm.duration_minutes))
|
||||||
'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
|
if future <= past:
|
||||||
}
|
continue
|
||||||
result.append(alert)
|
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
|
return result
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def get_next_mail(self):
|
def _send_reminder_email(self, partners=None):
|
||||||
return self._get_partner_next_mail(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
|
domain = [
|
||||||
def _get_partner_next_mail(self, partners=None):
|
("event_id", "in", self.env.cr.fetchall()),
|
||||||
self = self.with_context(mail_notify_force_send=True)
|
("state", "!=", "declined"),
|
||||||
last_notif_mail = fields.Datetime.to_string(self.env.context.get('lastcall') or fields.Datetime.now())
|
]
|
||||||
|
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)
|
attendees = self.env["calendar.attendee"].search(domain)
|
||||||
if not cron:
|
attendees._send_mail_to_attendees(
|
||||||
_logger.error("Cron for " + self._name + " can not be identified !")
|
'calendar.calendar_template_meeting_reminder',
|
||||||
return False
|
force_send=True,
|
||||||
|
ignore_recurrence=True,
|
||||||
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)
|
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def get_next_notif(self):
|
def get_next_notif(self):
|
||||||
@@ -186,15 +186,6 @@ class AlarmManager(models.AbstractModel):
|
|||||||
all_notif.append(self.do_notif_reminder(alert))
|
all_notif.append(self.do_notif_reminder(alert))
|
||||||
return all_notif
|
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):
|
def do_notif_reminder(self, alert):
|
||||||
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
||||||
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ class Meeting(models.Model):
|
|||||||
|
|
||||||
@api.depends('stop', 'start')
|
@api.depends('stop', 'start')
|
||||||
def _compute_duration(self):
|
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)
|
event.duration = self._get_duration(event.start, event.stop)
|
||||||
|
|
||||||
@api.depends('start', 'duration')
|
@api.depends('start', 'duration')
|
||||||
@@ -675,11 +675,7 @@ class Meeting(models.Model):
|
|||||||
(detached_events & self).active = False
|
(detached_events & self).active = False
|
||||||
(detached_events - self).with_context(archive_on_error=True).unlink()
|
(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
|
self._setup_alarms()
|
||||||
# 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)
|
|
||||||
|
|
||||||
current_attendees = self.filtered('active').attendee_ids
|
current_attendees = self.filtered('active').attendee_ids
|
||||||
if 'partner_ids' in values:
|
if 'partner_ids' in values:
|
||||||
@@ -692,6 +688,15 @@ class Meeting(models.Model):
|
|||||||
|
|
||||||
return True
|
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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
vals_list = [ # Else bug with quick_create when we are filter on an other user
|
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.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() })
|
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
|
events._setup_alarms()
|
||||||
# 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)
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def read(self, fields=None, load='_classic_read'):
|
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
|
: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
|
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):
|
def _rrule_serialize(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ class TestEventNotifications(TransactionCase, MailCase):
|
|||||||
self.assertEqual(notif, bus_message)
|
self.assertEqual(notif, bus_message)
|
||||||
|
|
||||||
def test_email_alarm(self):
|
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({
|
alarm = self.env['calendar.alarm'].create({
|
||||||
'name': 'Alarm',
|
'name': 'Alarm',
|
||||||
'alarm_type': 'email',
|
'alarm_type': 'email',
|
||||||
@@ -152,12 +155,18 @@ class TestEventNotifications(TransactionCase, MailCase):
|
|||||||
self.event.write({
|
self.event.write({
|
||||||
'start': now + relativedelta(minutes=15),
|
'start': now + relativedelta(minutes=15),
|
||||||
'stop': now + relativedelta(minutes=18),
|
'stop': now + relativedelta(minutes=18),
|
||||||
'partner_ids': [(4, self.partner.id)],
|
'partner_ids': [fields.Command.link(self.partner.id)],
|
||||||
'alarm_ids': [(4, alarm.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 patch.object(fields.Datetime, 'now', lambda: now):
|
||||||
with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], {
|
with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], {
|
||||||
'message_type': 'user_notification',
|
'message_type': 'user_notification',
|
||||||
'subtype': 'mail.mt_note',
|
'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)
|
||||||
|
|||||||
Reference in New Issue
Block a user