diff --git a/mail_activity_reminder/README.rst b/mail_activity_reminder/README.rst new file mode 100644 index 000000000..e3346cffe --- /dev/null +++ b/mail_activity_reminder/README.rst @@ -0,0 +1,84 @@ +====================== +Mail Activity Reminder +====================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/12.0/mail_activity_reminder + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-12-0/social-12-0-mail_activity_reminder + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/205/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows setting reminders for various Activity Types. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure reminders for specific Activity Type: + +#. Go to *Settings > Technical > Activity Types* +#. Open a specific activity type +#. Fill *Reminders* field with a non-digit-separated list of offsets (in days) + when reminders should be fired: e.g. 0 means "on the deadline day" while + 5 means "5 calendar days before the deadline". + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Brainbean Apps + +Contributors +~~~~~~~~~~~~ + +* Alexey Pelykh + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_activity_reminder/__init__.py b/mail_activity_reminder/__init__.py new file mode 100644 index 000000000..4b76c7b2d --- /dev/null +++ b/mail_activity_reminder/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/mail_activity_reminder/__manifest__.py b/mail_activity_reminder/__manifest__.py new file mode 100644 index 000000000..68f7097b7 --- /dev/null +++ b/mail_activity_reminder/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2020 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + 'name': 'Mail Activity Reminder', + 'version': '12.0.1.0.0', + 'category': 'Discuss', + 'website': 'https://github.com/OCA/social', + 'author': + 'Brainbean Apps, ' + 'Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'installable': True, + 'application': False, + 'summary': 'Reminder notifications about planned activities', + 'depends': [ + 'mail', + ], + 'data': [ + 'data/mail_activity_reminder_cron.xml', + 'views/mail_activity_type.xml', + ], +} diff --git a/mail_activity_reminder/data/mail_activity_reminder_cron.xml b/mail_activity_reminder/data/mail_activity_reminder_cron.xml new file mode 100644 index 000000000..bcdddc602 --- /dev/null +++ b/mail_activity_reminder/data/mail_activity_reminder_cron.xml @@ -0,0 +1,20 @@ + + + + + + Mail Activity: Reminders + + code + model._process_reminders() + 1 + hours + 2020-01-01 00:01:00 + -1 + + + + diff --git a/mail_activity_reminder/models/__init__.py b/mail_activity_reminder/models/__init__.py new file mode 100644 index 000000000..965eb883c --- /dev/null +++ b/mail_activity_reminder/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import mail_activity_type +from . import mail_activity diff --git a/mail_activity_reminder/models/mail_activity.py b/mail_activity_reminder/models/mail_activity.py new file mode 100644 index 000000000..ee53e8b24 --- /dev/null +++ b/mail_activity_reminder/models/mail_activity.py @@ -0,0 +1,141 @@ +# Copyright 2020 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime, time +from dateutil.relativedelta import relativedelta +from pytz import timezone, UTC + +from odoo import _, api, fields, models + + +class MailActivity(models.Model): + _inherit = 'mail.activity' + + next_reminder = fields.Datetime( + string='Next reminder', + compute='_compute_next_reminder', + compute_sudo=True, + store=True, + ) + last_reminder_local = fields.Datetime( + string='Last reminder (local)', + ) + deadline = fields.Datetime( + string='Deadline', + compute='_compute_deadline', + compute_sudo=True, + store=True, + ) + + @api.model + def _get_activities_to_remind_domain(self): + """Hook for extensions""" + return [ + ('next_reminder', '<=', fields.Datetime.now()), + ('deadline', '>=', fields.Datetime.now()), + ] + + @api.model + def _get_activities_to_remind(self): + return self \ + .search(self._get_activities_to_remind_domain()) + + @api.model + def _process_reminders(self): + activities = self._get_activities_to_remind() + activities.action_remind() + return activities + + @api.multi + @api.depends( + 'user_id.tz', + 'activity_type_id.reminders', + 'deadline', + 'last_reminder_local', + ) + def _compute_next_reminder(self): + now = fields.Datetime.now() + for activity in self: + if activity.deadline < now: + activity.next_reminder = None + continue + reminders = activity.activity_type_id._get_reminder_offsets() + if not reminders: + activity.next_reminder = None + continue + reminders.sort(reverse=True) + tz = timezone(activity.user_id.sudo().tz or 'UTC') + last_reminder_local = tz.localize( + activity.last_reminder_local + ) if activity.last_reminder_local else None + local_deadline = tz.localize(datetime.combine( + activity.date_deadline, + time.min # Schedule reminder based of beginning of day + )) + for reminder in reminders: + next_reminder_local = local_deadline - relativedelta( + days=reminder, + ) + if not last_reminder_local \ + or next_reminder_local > last_reminder_local: + break + if last_reminder_local \ + and next_reminder_local <= last_reminder_local: + activity.next_reminder = None + continue + activity.next_reminder = next_reminder_local \ + .astimezone(UTC) \ + .replace(tzinfo=None) + + @api.multi + @api.depends('user_id.tz', 'date_deadline') + def _compute_deadline(self): + for activity in self: + tz = timezone(activity.user_id.sudo().tz or 'UTC') + activity.deadline = tz.localize( + datetime.combine(activity.date_deadline, time.max) + ).astimezone(UTC).replace(tzinfo=None) + + @api.multi + def action_notify(self): + super().action_notify() + utc_now = fields.Datetime.now().replace(tzinfo=UTC) + for activity in self: + if activity.last_reminder_local: + continue + tz = timezone(activity.user_id.sudo().tz or 'UTC') + activity.last_reminder_local = utc_now \ + .astimezone(tz) \ + .replace(tzinfo=None) + + @api.multi + def action_remind(self): + IrModel = self.env['ir.model'] + MailThread = self.env['mail.thread'] + message_activity_assigned = self.env.ref( + 'mail.message_activity_assigned' + ) + utc_now = fields.Datetime.now().replace(tzinfo=UTC) + for activity in self: + tz = timezone(activity.user_id.sudo().tz or 'UTC') + local_now = utc_now.astimezone(tz) + model_description = IrModel._get(activity.res_model).display_name + subject = _('%s: %s assigned to you, %d day(s) remaining') % ( + activity.res_name, + activity.summary or activity.activity_type_id.name, + (activity.date_deadline - local_now.date()).days + ) + body = message_activity_assigned.render( + dict(activity=activity, model_description=model_description), + engine='ir.qweb', + minimal_qcontext=True, + ) + MailThread.message_notify( + partner_ids=activity.user_id.partner_id.ids, + body=body, + subject=subject, + record_name=activity.res_name, + model_description=model_description, + notif_layout='mail.mail_notification_light', + ) + activity.last_reminder_local = local_now.replace(tzinfo=None) diff --git a/mail_activity_reminder/models/mail_activity_type.py b/mail_activity_reminder/models/mail_activity_type.py new file mode 100644 index 000000000..9feb4c1d2 --- /dev/null +++ b/mail_activity_reminder/models/mail_activity_type.py @@ -0,0 +1,27 @@ +# Copyright 2020 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from re import split + +from odoo import api, fields, models + + +class MailActivityType(models.Model): + _inherit = 'mail.activity.type' + + reminders = fields.Char( + string='Reminders', + help=( + 'A non-digit-separated list of offsets (in days) when reminders' + ' should be fired: e.g. 0 means "on the deadline day" while' + ' 5 means "5 calendar days before the deadline".' + ), + ) + + @api.multi + def _get_reminder_offsets(self): + """Hook for extensions""" + self.ensure_one() + if not self.reminders: + return [] + return [int(x) for x in split(r'\D+', self.reminders) if x] diff --git a/mail_activity_reminder/readme/CONFIGURE.rst b/mail_activity_reminder/readme/CONFIGURE.rst new file mode 100644 index 000000000..d8e331c64 --- /dev/null +++ b/mail_activity_reminder/readme/CONFIGURE.rst @@ -0,0 +1,7 @@ +To configure reminders for specific Activity Type: + +#. Go to *Settings > Technical > Activity Types* +#. Open a specific activity type +#. Fill *Reminders* field with a non-digit-separated list of offsets (in days) + when reminders should be fired: e.g. 0 means "on the deadline day" while + 5 means "5 calendar days before the deadline". diff --git a/mail_activity_reminder/readme/CONTRIBUTORS.rst b/mail_activity_reminder/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..1c6a35a1e --- /dev/null +++ b/mail_activity_reminder/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexey Pelykh diff --git a/mail_activity_reminder/readme/DESCRIPTION.rst b/mail_activity_reminder/readme/DESCRIPTION.rst new file mode 100644 index 000000000..7800db6f8 --- /dev/null +++ b/mail_activity_reminder/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module allows setting reminders for various Activity Types. diff --git a/mail_activity_reminder/readme/ROADMAP.rst b/mail_activity_reminder/readme/ROADMAP.rst new file mode 100644 index 000000000..adc78bcf4 --- /dev/null +++ b/mail_activity_reminder/readme/ROADMAP.rst @@ -0,0 +1,2 @@ + * Maybe, group reminders by receiver and send multiple scheduled remiders + in one message. diff --git a/mail_activity_reminder/static/description/index.html b/mail_activity_reminder/static/description/index.html new file mode 100644 index 000000000..e3b7b46f2 --- /dev/null +++ b/mail_activity_reminder/static/description/index.html @@ -0,0 +1,431 @@ + + + + + + +Mail Activity Reminder + + + +
+

Mail Activity Reminder

+ + +

Beta License: AGPL-3 OCA/social Translate me on Weblate Try me on Runbot

+

This module allows setting reminders for various Activity Types.

+

Table of contents

+ +
+

Configuration

+

To configure reminders for specific Activity Type:

+
    +
  1. Go to Settings > Technical > Activity Types
  2. +
  3. Open a specific activity type
  4. +
  5. Fill Reminders field with a non-digit-separated list of offsets (in days) +when reminders should be fired: e.g. 0 means “on the deadline day” while +5 means “5 calendar days before the deadline”.
  6. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Brainbean Apps
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/social project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/mail_activity_reminder/tests/__init__.py b/mail_activity_reminder/tests/__init__.py new file mode 100644 index 000000000..8f1a33650 --- /dev/null +++ b/mail_activity_reminder/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_mail_activity_reminder diff --git a/mail_activity_reminder/tests/test_mail_activity_reminder.py b/mail_activity_reminder/tests/test_mail_activity_reminder.py new file mode 100644 index 000000000..7dc179601 --- /dev/null +++ b/mail_activity_reminder/tests/test_mail_activity_reminder.py @@ -0,0 +1,196 @@ +# Copyright 2020 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime +from dateutil.relativedelta import relativedelta +from freezegun import freeze_time + +from odoo.tests import common + + +class TestMailActivityReminder(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.env = cls.env(context=dict( + cls.env.context, + tracking_disable=True, + no_reset_password=True, + )) + cls.ResUsers = cls.env['res.users'] + cls.Company = cls.env['res.company'] + cls.MailActivityType = cls.env['mail.activity.type'] + cls.MailActivity = cls.env['mail.activity'] + cls.company_id = cls.Company._company_default_get() + cls.now = datetime(2020, 4, 19, 15, 00) + cls.today = cls.now.date() + cls.model_res_partner = cls.env['ir.model'].search( + [('model', '=', 'res.partner')], limit=1 + ) + cls.partner_DecoAddict = cls.env['res.partner'].search( + [('name', 'ilike', 'Deco Addict')], limit=1 + ) + + def test_none_reminders(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + }) + self.assertEqual(activity_type._get_reminder_offsets(), []) + + def test_empty_reminders(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': ' -./', + }) + self.assertEqual(activity_type._get_reminder_offsets(), []) + + def test_delimiters(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': '0 1_2/3.4t5', + }) + self.assertEqual(activity_type._get_reminder_offsets(), [ + 0, 1, 2, 3, 4, 5 + ]) + + def test_first_notice_is_reminder(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': '0', + }) + user = self.ResUsers.sudo().create({ + 'name': 'User', + 'login': 'user', + 'email': 'user@example.com', + 'company_id': self.company_id.id, + }) + activity = self.MailActivity.create({ + 'summary': 'Activity', + 'activity_type_id': activity_type.id, + 'res_model_id': self.model_res_partner.id, + 'res_id': self.partner_DecoAddict.id, + 'date_deadline': self.today, + 'user_id': user.id, + }) + + self.assertTrue(activity.last_reminder_local) + + def test_reminder_behaviour(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': '0/2', + }) + + with freeze_time(self.now): + activity = self.MailActivity.create({ + 'summary': 'Activity', + 'activity_type_id': activity_type.id, + 'res_model_id': self.model_res_partner.id, + 'res_id': self.partner_DecoAddict.id, + 'date_deadline': self.today + relativedelta(days=5), + }) + + with freeze_time(self.now): + activities = self.MailActivity._get_activities_to_remind() + self.assertFalse(activities) + + with freeze_time(self.now + relativedelta(days=2)): + activities = self.MailActivity._get_activities_to_remind() + self.assertFalse(activities) + + with freeze_time(self.now + relativedelta(days=3)): + activities = self.MailActivity._get_activities_to_remind() + self.assertEqual(activities, activity) + activities.action_remind() + + with freeze_time(self.now + relativedelta(days=4)): + activities = self.MailActivity._get_activities_to_remind() + self.assertFalse(activities) + + with freeze_time(self.now + relativedelta(days=5)): + activities = self.MailActivity._get_activities_to_remind() + self.assertEqual(activities, activity) + activities.action_remind() + + activity.active = False + with freeze_time(self.now + relativedelta(days=5)): + activities = self.MailActivity._get_activities_to_remind() + self.assertFalse(activities) + + def test_reminder_flow(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': '0/2', + }) + + with freeze_time(self.now): + activity = self.MailActivity.create({ + 'summary': 'Activity', + 'activity_type_id': activity_type.id, + 'res_model_id': self.model_res_partner.id, + 'res_id': self.partner_DecoAddict.id, + 'date_deadline': self.today + relativedelta(days=5), + }) + + with freeze_time(self.now): + activities = self.MailActivity._process_reminders() + self.assertFalse(activities) + + with freeze_time(self.now + relativedelta(days=2)): + activities = self.MailActivity._process_reminders() + self.assertFalse(activities) + + with freeze_time(self.now + relativedelta(days=3)): + activities = self.MailActivity._process_reminders() + self.assertEqual(activities, activity) + + with freeze_time(self.now + relativedelta(days=4)): + activities = self.MailActivity._process_reminders() + self.assertFalse(activities) + + with freeze_time(self.now + relativedelta(days=5)): + activities = self.MailActivity._process_reminders() + self.assertEqual(activities, activity) + + def test_repeated_reminder(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': '0', + }) + + with freeze_time(self.now): + activity = self.MailActivity.create({ + 'summary': 'Activity', + 'activity_type_id': activity_type.id, + 'res_model_id': self.model_res_partner.id, + 'res_id': self.partner_DecoAddict.id, + 'date_deadline': self.today + relativedelta(days=1), + }) + + with freeze_time(self.now + relativedelta(days=1)): + activities = self.MailActivity._process_reminders() + self.assertEqual(activities, activity) + + activities = self.MailActivity._process_reminders() + self.assertFalse(activities) + + def test_overdue_reminder(self): + activity_type = self.MailActivityType.create({ + 'name': 'Activity Type', + 'reminders': '0', + }) + + with freeze_time(self.now): + self.MailActivity.create({ + 'summary': 'Activity', + 'activity_type_id': activity_type.id, + 'res_model_id': self.model_res_partner.id, + 'res_id': self.partner_DecoAddict.id, + 'date_deadline': self.today + relativedelta(days=1), + }) + + with freeze_time(self.now + relativedelta(days=2)): + activities = self.MailActivity._get_activities_to_remind() + self.assertFalse(activities) diff --git a/mail_activity_reminder/views/mail_activity_type.xml b/mail_activity_reminder/views/mail_activity_type.xml new file mode 100644 index 000000000..c83f347da --- /dev/null +++ b/mail_activity_reminder/views/mail_activity_type.xml @@ -0,0 +1,19 @@ + + + + + + mail.activity.type.view.form + mail.activity.type + + + + + + + + +