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 @@ +