From 69d23262f847a173bde8d2c2efd95c3f8c429a43 Mon Sep 17 00:00:00 2001 From: "Gabriel de Paula Felix (gdpf)" Date: Tue, 5 Sep 2023 23:12:51 -0300 Subject: [PATCH] [IMP] microsoft_calendar: Credentials module and don't sync old events Before this commit, when the first synchronization with Outlook Calendar was done or when the synchronization was restarted, all events previously created in Odoo (before synchronizing) were being synced with Outlook, triggering lots of emails and notifications for attendees. Additionaly, all microsoft credentials information were concentrated within the res_users file, difficulting updates in this file. After this commit, only events created or updated after the synchronization starts (or restarts) are synced with Outlook Calendar, with 5 minutes of time acceptance for avoiding network delay issues. Moreover, a Microsoft Credentials module was added for gathering microsoft credentials in one place. closes odoo/odoo#128150 Task-id: 3410651 Related: odoo/upgrade#5047 Signed-off-by: Arnaud Joset (arj) --- addons/microsoft_calendar/data/neutralize.sql | 8 +- addons/microsoft_calendar/models/__init__.py | 1 + addons/microsoft_calendar/models/calendar.py | 12 ++- .../models/microsoft_credentials.py | 16 ++++ .../models/microsoft_sync.py | 5 ++ addons/microsoft_calendar/models/res_users.py | 38 +++++++-- .../security/ir.model.access.csv | 2 + .../security/microsoft_calendar_security.xml | 30 +++++++ .../tests/test_create_events.py | 82 ++++++++++++++++++- .../views/res_users_views.xml | 1 + .../wizard/reset_account.py | 5 +- 11 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 addons/microsoft_calendar/models/microsoft_credentials.py create mode 100644 addons/microsoft_calendar/security/microsoft_calendar_security.xml diff --git a/addons/microsoft_calendar/data/neutralize.sql b/addons/microsoft_calendar/data/neutralize.sql index e05b7ebfac1..ab2ca0faedd 100644 --- a/addons/microsoft_calendar/data/neutralize.sql +++ b/addons/microsoft_calendar/data/neutralize.sql @@ -1,5 +1,9 @@ -- neutralization of Microsoft calendar UPDATE res_users SET microsoft_calendar_token = NULL, - microsoft_calendar_rtoken = NULL, - microsoft_synchronization_stopped = True; + microsoft_calendar_rtoken = NULL; + +UPDATE microsoft_calendar_credentials + SET calendar_sync_token = NULL, + synchronization_stopped = True, + last_sync_date = NULL; diff --git a/addons/microsoft_calendar/models/__init__.py b/addons/microsoft_calendar/models/__init__.py index a87bf35b73d..74bf6a5c4b0 100644 --- a/addons/microsoft_calendar/models/__init__.py +++ b/addons/microsoft_calendar/models/__init__.py @@ -7,3 +7,4 @@ from . import calendar from . import calendar_recurrence_rule from . import res_users from . import calendar_attendee +from . import microsoft_credentials diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py index 2ac3a9a1bf3..de9d36e1aa0 100644 --- a/addons/microsoft_calendar/models/calendar.py +++ b/addons/microsoft_calendar/models/calendar.py @@ -4,7 +4,7 @@ import logging import pytz import re -from datetime import datetime +from datetime import datetime, timedelta from dateutil.parser import parse from dateutil.relativedelta import relativedelta @@ -12,6 +12,7 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError from odoo.tools import is_html_empty, email_normalize from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids +from odoo.osv import expression ATTENDEE_CONVERTER_O2M = { 'needsAction': 'notresponded', @@ -53,7 +54,14 @@ class Meeting(models.Model): @api.model def _restart_microsoft_sync(self): - self.env['calendar.event'].search(self._get_microsoft_sync_domain()).write({ + domain = self._get_microsoft_sync_domain() + + # Sync only events created/updated after last sync date (with 5 min of time acceptance). + if self.env.user.microsoft_last_sync_date: + time_offset = timedelta(minutes=5) + domain = expression.AND([domain, [('write_date', '>=', self.env.user.microsoft_last_sync_date - time_offset)]]) + + self.env['calendar.event'].search(domain).write({ 'need_sync_m': True, }) diff --git a/addons/microsoft_calendar/models/microsoft_credentials.py b/addons/microsoft_calendar/models/microsoft_credentials.py new file mode 100644 index 00000000000..a5d3a649c4b --- /dev/null +++ b/addons/microsoft_calendar/models/microsoft_credentials.py @@ -0,0 +1,16 @@ +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + +class MicrosoftCredentials(models.Model): + """"Microsoft Account of res_users""" + + _name = 'microsoft.calendar.credentials' + _description = 'Microsoft Calendar Account Data' + + user_ids = fields.One2many('res.users', 'microsoft_calendar_account_id', required=True) + calendar_sync_token = fields.Char('Microsoft Next Sync Token', copy=False) + synchronization_stopped = fields.Boolean('Outlook Synchronization stopped', copy=False) + last_sync_date = fields.Datetime('Last Sync Date', copy=False, help='Last synchronization date with Outlook Calendar') diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py index de479e96de3..8673c0b73a5 100644 --- a/addons/microsoft_calendar/models/microsoft_sync.py +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -6,6 +6,7 @@ from contextlib import contextmanager from functools import wraps import pytz from dateutil.parser import parse +from datetime import timedelta from odoo import api, fields, models, registry from odoo.exceptions import UserError @@ -519,4 +520,8 @@ class MicrosoftSync(models.AbstractModel): '&', ('ms_universal_event_id', '=', False), is_active_clause, ('need_sync_m', '=', True), ]]) + # Sync only events created/updated after last sync date (with 5 min of time acceptance). + if self.env.user.microsoft_last_sync_date: + time_offset = timedelta(minutes=5) + domain = expression.AND([domain, [('write_date', '>=', self.env.user.microsoft_last_sync_date - time_offset)]]) return domain diff --git a/addons/microsoft_calendar/models/res_users.py b/addons/microsoft_calendar/models/res_users.py index a13e126f52c..cf75c2268b8 100644 --- a/addons/microsoft_calendar/models/res_users.py +++ b/addons/microsoft_calendar/models/res_users.py @@ -6,7 +6,7 @@ import requests from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token from datetime import timedelta -from odoo import api, fields, models, _ +from odoo import api, fields, models, _, Command from odoo.exceptions import UserError from odoo.loglevels import exception_to_unicode from odoo.addons.microsoft_account.models.microsoft_service import DEFAULT_MICROSOFT_TOKEN_ENDPOINT @@ -19,16 +19,22 @@ _logger = logging.getLogger(__name__) class User(models.Model): _inherit = 'res.users' - microsoft_calendar_sync_token = fields.Char('Microsoft Next Sync Token', copy=False) - microsoft_synchronization_stopped = fields.Boolean('Outlook Synchronization stopped', copy=False) + microsoft_calendar_account_id = fields.Many2one('microsoft.calendar.credentials') + microsoft_calendar_sync_token = fields.Char(related='microsoft_calendar_account_id.calendar_sync_token') + microsoft_synchronization_stopped = fields.Boolean(related='microsoft_calendar_account_id.synchronization_stopped', readonly=False) + microsoft_last_sync_date = fields.Datetime(related='microsoft_calendar_account_id.last_sync_date', readonly=False) + + _sql_constraints = [ + ('microsoft_token_uniq', 'unique (microsoft_calendar_account_id)', "The user has already a microsoft account"), + ] @property def SELF_READABLE_FIELDS(self): - return super().SELF_READABLE_FIELDS + ['microsoft_synchronization_stopped'] + return super().SELF_READABLE_FIELDS + ['microsoft_synchronization_stopped', 'microsoft_calendar_account_id', 'microsoft_last_sync_date'] @property def SELF_WRITEABLE_FIELDS(self): - return super().SELF_WRITEABLE_FIELDS + ['microsoft_synchronization_stopped'] + return super().SELF_WRITEABLE_FIELDS + ['microsoft_synchronization_stopped', 'microsoft_calendar_account_id', 'microsoft_last_sync_date'] def _microsoft_calendar_authenticated(self): return bool(self.sudo().microsoft_calendar_rtoken) @@ -79,7 +85,9 @@ class User(models.Model): 'microsoft_calendar_rtoken': False, 'microsoft_calendar_token': False, 'microsoft_calendar_token_validity': False, - 'microsoft_calendar_sync_token': False, + }) + self.microsoft_calendar_account_id.write({ + 'calendar_sync_token': False }) self.env.cr.commit() error_key = error.response.json().get("error", "nc") @@ -100,6 +108,10 @@ class User(models.Model): def _sync_microsoft_calendar(self): self.ensure_one() + # Create the Calendar Credentials if it is not defined yet and define the last sync date after creation. + if not self.microsoft_calendar_account_id and self._check_microsoft_calendar_credentials(): + self.microsoft_last_sync_date = fields.datetime.now() + if self._get_microsoft_sync_status() != "sync_active": return False calendar_service = self.env["calendar.event"]._get_microsoft_service() @@ -110,7 +122,7 @@ class User(models.Model): except InvalidSyncToken: events, next_sync_token = calendar_service.get_events(token=token) full_sync = True - self.microsoft_calendar_sync_token = next_sync_token + self.microsoft_calendar_account_id.calendar_sync_token = next_sync_token # Microsoft -> Odoo synced_events, synced_recurrences = self.env['calendar.event']._sync_microsoft2odoo(events) if events else (self.env['calendar.event'], self.env['calendar.recurrence']) @@ -123,6 +135,7 @@ class User(models.Model): events = self.env['calendar.event']._get_microsoft_records_to_sync(full_sync=full_sync) (events - synced_events)._sync_odoo2microsoft() + self.microsoft_last_sync_date = fields.datetime.now() return bool(events | synced_events) or bool(recurrences | synced_recurrences) @@ -139,12 +152,23 @@ class User(models.Model): _logger.exception("[%s] Calendar Synchro - Exception : %s!", user, exception_to_unicode(e)) self.env.cr.rollback() + def _check_microsoft_calendar_credentials(self): + # Create the Calendar Credentials for the current user if not already created. + self.ensure_one() + if not self.microsoft_calendar_account_id: + self.microsoft_calendar_account_id = self.env['microsoft.calendar.credentials'].sudo().create([{'user_ids': [Command.set(self.ids)]}]) + return True + def stop_microsoft_synchronization(self): self.ensure_one() + self._check_microsoft_calendar_credentials() self.microsoft_synchronization_stopped = True + self.microsoft_last_sync_date = None def restart_microsoft_synchronization(self): self.ensure_one() + self._check_microsoft_calendar_credentials() + self.microsoft_last_sync_date = fields.datetime.now() self.microsoft_synchronization_stopped = False self.env['calendar.recurrence']._restart_microsoft_sync() self.env['calendar.event']._restart_microsoft_sync() diff --git a/addons/microsoft_calendar/security/ir.model.access.csv b/addons/microsoft_calendar/security/ir.model.access.csv index 478501e7e4a..7518fba6e35 100644 --- a/addons/microsoft_calendar/security/ir.model.access.csv +++ b/addons/microsoft_calendar/security/ir.model.access.csv @@ -1,2 +1,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink microsoft_calendar_account_reset,microsoft_calendar_account_reset_access_right,model_microsoft_calendar_account_reset,base.group_system,1,1,1,0 +microsoft_calendar.access_microsoft_calendar_credentials,access_microsoft_calendar_credentials,microsoft_calendar.model_microsoft_calendar_credentials,base.group_user,1,1,0,0 +microsoft_calendar_manager,access_microsoft_calendar_credentials_manager,microsoft_calendar.model_microsoft_calendar_credentials,base.group_system,1,1,0,0 diff --git a/addons/microsoft_calendar/security/microsoft_calendar_security.xml b/addons/microsoft_calendar/security/microsoft_calendar_security.xml new file mode 100644 index 00000000000..518e9d5d509 --- /dev/null +++ b/addons/microsoft_calendar/security/microsoft_calendar_security.xml @@ -0,0 +1,30 @@ + + + + Microsoft Calendar NOT own token access rule + + [('user_ids', 'not in', user.id)] + + + + + + + + Microsoft Calendar own token access rule + + [('user_ids', 'in', user.id)] + + + + + + + + Microsoft Calendar manage all tokens + + [(1,'=',1)] + + + + diff --git a/addons/microsoft_calendar/tests/test_create_events.py b/addons/microsoft_calendar/tests/test_create_events.py index 40aaa3e1a78..f64c82317d0 100644 --- a/addons/microsoft_calendar/tests/test_create_events.py +++ b/addons/microsoft_calendar/tests/test_create_events.py @@ -1,5 +1,5 @@ -from datetime import datetime, timedelta from unittest.mock import patch +from datetime import timedelta, datetime from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent @@ -299,3 +299,83 @@ class TestCreateEvents(TestCommon): self.assertEqual(self.organizer_user._get_microsoft_sync_status(), "sync_paused") self.assertTrue(record.need_sync_m, "Sync variable must be true for updating event when sync re-activates") mock_insert.assert_not_called() + + @patch.object(MicrosoftCalendarService, 'get_events') + @patch.object(MicrosoftCalendarService, 'insert') + def test_sync_create_update_single_event(self, mock_insert, mock_get_events): + """ + If the synchronization with Outlook is stopped, then records (events and recurrences) created or updated + should not be synced. They must be synced only when created or updated having the synchronization active. + In this test, the synchronization is stopped and an event is created locally. After this, the synchronization + is restarted and the event is updated (this way, syncing it with Outlook Calendar). + """ + # Stop the synchronization for clearing the last_sync_date. + self.organizer_user.with_user(self.organizer_user).sudo().stop_microsoft_synchronization() + self.assertEqual(self.organizer_user.microsoft_last_sync_date, False, + "Variable last_sync_date must be False due to sync stop.") + + # Create a not synced event (local). + simple_event_values_updated = self.simple_event_values + for date_field in ['start', 'stop']: + simple_event_values_updated[date_field] = simple_event_values_updated[date_field].replace(year=datetime.now().year) + event = self.env["calendar.event"].with_user(self.organizer_user).create(simple_event_values_updated) + + # Assert that insert was not called and prepare mock for the synchronization restart. + mock_insert.assert_not_called() + mock_get_events.return_value = ([], None) + + # Restart the synchronization with Outlook Calendar. + self.organizer_user.with_user(self.organizer_user).sudo().restart_microsoft_synchronization() + # Last_sync_date is manually updated here due to simulate real use in the test environment. + self.organizer_user.microsoft_calendar_account_id.last_sync_date = event.write_date + timedelta(minutes=10) + # Sync microsoft calendar, considering that ten minutes were passed after the event creation. + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + self.call_post_commit_hooks() + event.invalidate_recordset() + + # Assert that insert function was not called and check last_sync_date variable value. + mock_insert.assert_not_called() + + self.assertNotEqual(self.organizer_user.microsoft_last_sync_date, False, + "Variable last_sync_date must not be empty after sync.") + self.assertLessEqual(event.write_date, self.organizer_user.microsoft_last_sync_date, + "Event creation must happen before last_sync_date") + + # Assert that the local event did not get synced after synchronization restart. + self.assertEqual(event.ms_organizer_event_id, False, + "Event should not be synchronized while sync is paused.") + self.assertEqual(event.ms_universal_event_id, False, + "Event should not be synchronized while sync is paused.") + + # Synchronize after updating last_sync_date and assert that insert was not called. + # Last_sync_date is manually updated here due to simulate real use in the test environment. + self.organizer_user.microsoft_calendar_account_id.last_sync_date = event.write_date + timedelta(minutes=10) + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + self.call_post_commit_hooks() + mock_insert.assert_not_called() + + # Update local event information. + event.write({ + "name": "New event name" + }) + self.call_post_commit_hooks() + + # Prepare mock for new synchronization. + event_id = "123" + event_iCalUId = "456" + mock_insert.return_value = (event_id, event_iCalUId) + mock_get_events.return_value = ([], None) + + # Synchronize local event with Outlook after updating it locally. + # Last_sync_date is manually updated here due to simulate real use in the test environment. + self.organizer_user.microsoft_calendar_account_id.last_sync_date = event.write_date - timedelta(minutes=10) + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + self.call_post_commit_hooks() + event.invalidate_recordset() + + # Assert that the event got synchronized with Microsoft (through mock). + self.assertEqual(event.ms_organizer_event_id, "123") + self.assertEqual(event.ms_universal_event_id, "456") + + # Assert that the Microsoft Insert was called once. + mock_insert.assert_called_once() diff --git a/addons/microsoft_calendar/views/res_users_views.xml b/addons/microsoft_calendar/views/res_users_views.xml index d41a385884d..0e7066f8e9c 100644 --- a/addons/microsoft_calendar/views/res_users_views.xml +++ b/addons/microsoft_calendar/views/res_users_views.xml @@ -16,6 +16,7 @@ +