From f31174e02157e612650e77ebba3ed1fe54b96776 Mon Sep 17 00:00:00 2001 From: "ahmad(alah)" Date: Thu, 21 Mar 2024 07:57:17 +0000 Subject: [PATCH] [FIX] google_calendar: updating recurrent events on google side Before this commit when accepting a recurrent event from google calendar with option "this event", it didn't reflect on odoo calendar. This happened due to the write_date check which applies google update only if their write_date is after odoo write_date, but multiple updates from google might change some events write_date to now, which causes other google updates to get discarded. This commit aims to fix this issue by keeping the write_date of the affected events before applying any google updates, and considering these dates instead of the live odoo write_date. closes odoo/odoo#160357 Task: 3731552 X-original-commit: 3bcf6c458a4b483af6467d1c027b60771d1ce08d Signed-off-by: Yannick Tivisse (yti) --- addons/google_calendar/models/google_sync.py | 5 +- addons/google_calendar/models/res_users.py | 13 +- .../google_calendar/tests/test_sync_common.py | 18 +- .../tests/test_sync_google2odoo.py | 240 +++++++++++++++++- 4 files changed, 270 insertions(+), 6 deletions(-) diff --git a/addons/google_calendar/models/google_sync.py b/addons/google_calendar/models/google_sync.py index c60c78f13e1..c0823c19b0d 100644 --- a/addons/google_calendar/models/google_sync.py +++ b/addons/google_calendar/models/google_sync.py @@ -154,6 +154,7 @@ class GoogleSync(models.AbstractModel): """ existing = google_events.exists(self.env) new = google_events - existing - google_events.cancelled() + write_dates = self._context.get('write_dates', {}) odoo_values = [ dict(self._odoo_values(e, default_reminders), need_sync=False) @@ -181,8 +182,10 @@ class GoogleSync(models.AbstractModel): # This could be dangerous if google server time and odoo server time are different updated = parse(gevent.updated) odoo_record = self.browse(gevent.odoo_id(self.env)) + # Use the record's write_date to apply Google updates only if they are newer than Odoo's write_date. + odoo_record_write_date = write_dates.get(odoo_record.id, odoo_record.write_date) # Migration from 13.4 does not fill write_date. Therefore, we force the update from Google. - if not odoo_record.write_date or updated >= pytz.utc.localize(odoo_record.write_date): + if not odoo_record_write_date or updated >= pytz.utc.localize(odoo_record_write_date): vals = dict(self._odoo_values(gevent, default_reminders), need_sync=False) odoo_record.with_context(dont_notify=True)._write_from_google(gevent, vals) synced_records |= odoo_record diff --git a/addons/google_calendar/models/res_users.py b/addons/google_calendar/models/res_users.py index 7bb114fdabb..d90c06c9ac6 100644 --- a/addons/google_calendar/models/res_users.py +++ b/addons/google_calendar/models/res_users.py @@ -61,8 +61,17 @@ class User(models.Model): send_updates = not full_sync events.clear_type_ambiguity(self.env) recurrences = events.filter(lambda e: e.is_recurrence()) - synced_recurrences = self.env['calendar.recurrence']._sync_google2odoo(recurrences) - synced_events = self.env['calendar.event']._sync_google2odoo(events - recurrences, default_reminders=default_reminders) + + # We apply Google updates only if their write date is later than the write date in Odoo. + # It's possible that multiple updates affect the same record, maybe not directly. + # To handle this, we preserve the write dates in Odoo before applying any updates, + # and use these dates instead of the current live dates. + odoo_events = self.env['calendar.event'].browse((events - recurrences).odoo_ids(self.env)) + odoo_recurrences = self.env['calendar.recurrence'].browse(recurrences.odoo_ids(self.env)) + recurrences_write_dates = {r.id: r.write_date for r in odoo_recurrences} + events_write_dates = {e.id: e.write_date for e in odoo_events} + synced_recurrences = self.env['calendar.recurrence'].with_context(write_dates=recurrences_write_dates)._sync_google2odoo(recurrences) + synced_events = self.env['calendar.event'].with_context(write_dates=events_write_dates)._sync_google2odoo(events - recurrences, default_reminders=default_reminders) # Odoo -> Google recurrences = self.env['calendar.recurrence']._get_records_to_sync(full_sync=full_sync) diff --git a/addons/google_calendar/tests/test_sync_common.py b/addons/google_calendar/tests/test_sync_common.py index 8ebf95309a9..c782e0d773f 100644 --- a/addons/google_calendar/tests/test_sync_common.py +++ b/addons/google_calendar/tests/test_sync_common.py @@ -7,7 +7,10 @@ from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarServ from odoo.addons.google_account.models.google_service import GoogleService from odoo.addons.google_calendar.models.res_users import User from odoo.addons.google_calendar.models.google_sync import GoogleSync -from odoo.tests.common import HttpCase +from odoo.tests.common import HttpCase, new_test_user +from freezegun import freeze_time +from contextlib import contextmanager + def patch_api(func): @patch.object(GoogleSync, '_google_insert', MagicMock(spec=GoogleSync._google_insert)) @@ -24,6 +27,19 @@ class TestSyncGoogle(HttpCase): super().setUp() self.google_service = GoogleCalendarService(self.env['google.service']) self.env.user.sudo().unpause_google_synchronization() + self.organizer_user = new_test_user(self.env, login="organizer_user") + self.attendee_user = new_test_user(self.env, login='attendee_user') + + @contextmanager + def mock_datetime_and_now(self, mock_dt): + """ + Used when synchronization date (using env.cr.now()) is important + in addition to standard datetime mocks. Used mainly to detect sync + issues. + """ + with freeze_time(mock_dt), \ + patch.object(self.env.cr, 'now', lambda: mock_dt): + yield def assertGoogleEventDeleted(self, google_id): GoogleSync._google_delete.assert_called() diff --git a/addons/google_calendar/tests/test_sync_google2odoo.py b/addons/google_calendar/tests/test_sync_google2odoo.py index 72adec9faed..7ecb5aeac0f 100644 --- a/addons/google_calendar/tests/test_sync_google2odoo.py +++ b/addons/google_calendar/tests/test_sync_google2odoo.py @@ -2,14 +2,15 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz -from datetime import datetime, date +from datetime import datetime, date, timedelta from dateutil.relativedelta import relativedelta from odoo.tests.common import new_test_user from odoo.exceptions import ValidationError from odoo.addons.google_calendar.tests.test_sync_common import TestSyncGoogle, patch_api -from odoo.addons.google_calendar.utils.google_calendar import GoogleEvent +from odoo.addons.google_calendar.utils.google_calendar import GoogleEvent, GoogleCalendarService from odoo import Command, tools +from unittest.mock import patch class TestSyncGoogle2Odoo(TestSyncGoogle): @@ -31,6 +32,79 @@ class TestSyncGoogle2Odoo(TestSyncGoogle): 'company_id': self.other_company.id, }) + def generate_recurring_event(self, mock_dt, **values): + """ Function Used to return a recurrence created at fake time of 'mock_dt'. """ + rrule = values.pop('rrule', None) + google_id = values.pop('google_id', None) + with self.mock_datetime_and_now(mock_dt): + base_event = self.env['calendar.event'].with_user(self.organizer_user).create({ + 'name': 'coucou', + 'need_sync': False, + **values + }) + recurrence = self.env['calendar.recurrence'].with_user(self.organizer_user).create({ + 'google_id': google_id, + 'rrule': rrule, + 'need_sync': False, + 'base_event_id': base_event.id, + }) + recurrence._apply_recurrence() + return recurrence + + def google_respond_to_recurrent_event_with_option_this_event(self, recurrence, event_index, response_status): + """ + Function returns google api response simulating 'self.attendee_user' responding to the event number 'event_index' (0-indexed) + in 'recurrence' with response of 'response_status' and option "This event" + """ + update_time = (recurrence.write_date + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + recurrence_google_values = recurrence._google_values() + recurrence_google_values['updated'] = update_time + updated_event_google_values = recurrence.calendar_event_ids.sorted('start')[event_index]._google_values() + updated_event_google_values['updated'] = update_time + updated_event_google_values['attendees'] = [ + {"email": self.organizer_user.partner_id.email, "responseStatus": "accepted"}, + {"email": self.attendee_user.partner_id.email, "responseStatus": response_status}, + ] + return [ + recurrence_google_values, + updated_event_google_values, + ] + + def google_respond_to_recurrent_event_with_option_all_events(self, recurrence, response_status): + """ + Function returns google api response simulating 'self.attendee_user' responding to 'recurrence' + with response of 'response_status' and option "All events" + """ + update_time = (recurrence.write_date + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + recurrence_google_values = recurrence._google_values() + recurrence_google_values['updated'] = update_time + recurrence_google_values['attendees'] = [ + {"email": self.organizer_user.partner_id.email, "responseStatus": "accepted"}, + {"email": self.attendee_user.partner_id.email, "responseStatus": response_status}, + ] + return [recurrence_google_values] + + def google_respond_to_recurrent_event_with_option_following_events(self, recurrence, event_index, response_status, rrule1, rrule2): + """ + Function returns google api response simulating 'self.attendee_user' responding to the event number 'event_index' (0-indexed) + in 'recurrence' with response of 'response_status' and option "This and following events". + """ + update_time = (recurrence.write_date + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + recurrence_google_values = recurrence._google_values() + recurrence_google_values['updated'] = update_time + updated_event_google_values = recurrence.calendar_event_ids.sorted('start')[event_index]._google_values() + updated_event_google_values['updated'] = update_time + updated_event_google_values['attendees'] = [ + {"email": self.organizer_user.partner_id.email, "responseStatus": "accepted"}, + {"email": self.attendee_user.partner_id.email, "responseStatus": response_status}, + ] + recurrence_google_values["recurrence"] = [rrule1] + updated_event_google_values["recurrence"] = [rrule2] + return [ + recurrence_google_values, + updated_event_google_values, + ] + @property def now(self): return pytz.utc.localize(datetime.now()).isoformat() @@ -1872,3 +1946,165 @@ class TestSyncGoogle2Odoo(TestSyncGoogle): self.assertEqual(len(events), 3, "it should have created a recurrence with 3 events") self.assertEqual(events[0].attendee_ids[0].state, 'accepted', 'after google sync, organizer should have accepted status still') self.assertGoogleAPINotCalled() + + @patch.object(GoogleCalendarService, 'get_events') + def test_accepting_recurrent_event_with_this_event_option_synced_by_attendee(self, mock_get_events): + """ + Test accepting a recurring event with the option "This event" on Google Calendar and syncing the attendee's calendar. + Ensure that event is accepeted by attendee in Odoo. + """ + recurrence_id = "abcd1" + recurrence = self.generate_recurring_event( + mock_dt="2024-04-20", + google_id=recurrence_id, + rrule="FREQ=DAILY;INTERVAL=1;COUNT=4", + start=datetime(2024, 3, 20, 9, 0), + stop=datetime(2024, 3, 20, 10, 0), + partner_ids=[Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])], + ) + google_events = self.google_respond_to_recurrent_event_with_option_this_event(recurrence, 2, 'accepted') + mock_get_events.return_value = ( + GoogleEvent(google_events), None, [{'method': 'popup', 'minutes': 30}] + ) + expected_states = ["needsAction", "needsAction", "accepted", "needsAction"] + with self.mock_datetime_and_now("2024-04-22"): + self.attendee_user.sudo()._sync_google_calendar(self.google_service) + for i, event in enumerate(recurrence.calendar_event_ids.sorted("start")): + self.assertEqual(event.attendee_ids[1].state, expected_states[i]) + + @patch.object(GoogleCalendarService, 'get_events') + def test_accepting_recurrent_event_with_this_event_option_synced_by_organizer(self, mock_get_events): + """ + Test accepting a recurring event with the option "This event" on Google Calendar and syncing the organizer's calendar. + Ensure that event is accepeted by attendee in Odoo. + """ + recurrence_id = "abcd2" + recurrence = self.generate_recurring_event( + mock_dt="2024-04-20", + google_id=recurrence_id, + rrule="FREQ=DAILY;INTERVAL=1;COUNT=4", + start=datetime(2024, 3, 20, 9, 0), + stop=datetime(2024, 3, 20, 10, 0), + partner_ids=[Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])], + ) + google_events = self.google_respond_to_recurrent_event_with_option_this_event(recurrence, 2, 'accepted') + mock_get_events.return_value = ( + GoogleEvent(google_events), None, [{'method': 'popup', 'minutes': 30}] + ) + expected_states = ["needsAction", "needsAction", "accepted", "needsAction"] + with self.mock_datetime_and_now("2024-04-22"): + self.organizer_user.sudo()._sync_google_calendar(self.google_service) + for i, event in enumerate(recurrence.calendar_event_ids.sorted("start")): + self.assertEqual(event.attendee_ids[1].state, expected_states[i]) + + @patch.object(GoogleCalendarService, 'get_events') + def test_accepting_recurrent_event_with_all_events_option_synced_by_attendee(self, mock_get_events): + """ + Test accepting a recurring event with the option "All events" on Google Calendar and syncing the attendee's calendar. + Ensure that all events are accepeted by attendee in Odoo. + """ + recurrence_id = "abcd3" + recurrence = self.generate_recurring_event( + mock_dt="2024-04-20", + google_id=recurrence_id, + rrule="FREQ=DAILY;INTERVAL=1;COUNT=4", + start=datetime(2024, 3, 20, 9, 0), + stop=datetime(2024, 3, 20, 10, 0), + partner_ids=[Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])], + ) + google_events = self.google_respond_to_recurrent_event_with_option_all_events(recurrence, "accepted") + mock_get_events.return_value = ( + GoogleEvent(google_events), None, [{'method': 'popup', 'minutes': 30}] + ) + expected_states = ["accepted", "accepted", "accepted", "accepted"] + with self.mock_datetime_and_now("2024-04-22"): + self.attendee_user.sudo()._sync_google_calendar(self.google_service) + for i, event in enumerate(recurrence.calendar_event_ids.sorted("start")): + self.assertEqual(event.attendee_ids[1].state, expected_states[i]) + + @patch.object(GoogleCalendarService, 'get_events') + def test_accepting_recurrent_event_with_all_events_option_synced_by_organizer(self, mock_get_events): + """ + Test accepting a recurring event with the option "All events" on Google Calendar and syncing the organizer's calendar. + Ensure that all events are accepeted by attendee in Odoo. + """ + recurrence_id = "abcd4" + recurrence = self.generate_recurring_event( + mock_dt="2024-04-20", + google_id=recurrence_id, + rrule="FREQ=DAILY;INTERVAL=1;COUNT=4", + start=datetime(2024, 3, 20, 9, 0), + stop=datetime(2024, 3, 20, 10, 0), + partner_ids=[Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])], + ) + google_events = self.google_respond_to_recurrent_event_with_option_all_events(recurrence, "accepted") + mock_get_events.return_value = ( + GoogleEvent(google_events), None, [{'method': 'popup', 'minutes': 30}] + ) + expected_states = ["accepted", "accepted", "accepted", "accepted"] + with self.mock_datetime_and_now("2024-04-22"): + self.organizer_user.sudo()._sync_google_calendar(self.google_service) + for i, event in enumerate(recurrence.calendar_event_ids.sorted("start")): + self.assertEqual(event.attendee_ids[1].state, expected_states[i]) + + @patch.object(GoogleCalendarService, 'get_events') + def test_accepting_recurrent_event_with_following_events_option_synced_by_attendee(self, mock_get_events): + """ + Test accepting a recurring event with the option "This and following events" on Google Calendar and syncing the attendee's calendar. + Ensure that affected events are accepeted by attendee in Odoo. + """ + recurrence_id = "abcd5" + recurrence = self.generate_recurring_event( + mock_dt="2024-04-20", + google_id=recurrence_id, + rrule="FREQ=DAILY;INTERVAL=1;COUNT=4", + start=datetime(2024, 3, 20, 9, 0), + stop=datetime(2024, 3, 20, 10, 0), + partner_ids=[Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])], + ) + google_events = self.google_respond_to_recurrent_event_with_option_following_events( + recurrence=recurrence, + event_index=2, + response_status="accepted", + rrule1="RRULE:FREQ=DAILY;UNTIL=20240321T215959Z", + rrule2="RRULE:FREQ=DAILY;COUNT=2" + ) + mock_get_events.return_value = ( + GoogleEvent(google_events), None, [{'method': 'popup', 'minutes': 30}] + ) + expected_states = ["needsAction", "needsAction", "accepted", "accepted"] + with self.mock_datetime_and_now("2024-04-22"): + self.attendee_user.sudo()._sync_google_calendar(self.google_service) + for i, event in enumerate(recurrence.calendar_event_ids.sorted("start")): + self.assertEqual(event.attendee_ids[1].state, expected_states[i]) + + @patch.object(GoogleCalendarService, 'get_events') + def test_accepting_recurrent_event_with_all_following_option_synced_by_organizer(self, mock_get_events): + """ + Test accepting a recurring event with the option "This and following events" on Google Calendar and syncing the organizer's calendar. + Ensure that affected events are accepeted by attendee in Odoo. + """ + recurrence_id = "abcd6" + recurrence = self.generate_recurring_event( + mock_dt="2024-04-20", + google_id=recurrence_id, + rrule="FREQ=DAILY;INTERVAL=1;COUNT=4", + start=datetime(2024, 3, 20, 9, 0), + stop=datetime(2024, 3, 20, 10, 0), + partner_ids=[Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])], + ) + google_events = self.google_respond_to_recurrent_event_with_option_following_events( + recurrence=recurrence, + event_index=2, + response_status="accepted", + rrule1="RRULE:FREQ=DAILY;UNTIL=20240321T215959Z", + rrule2="RRULE:FREQ=DAILY;COUNT=2" + ) + mock_get_events.return_value = ( + GoogleEvent(google_events), None, [{'method': 'popup', 'minutes': 30}] + ) + expected_states = ["needsAction", "needsAction", "accepted", "accepted"] + with self.mock_datetime_and_now("2024-04-22"): + self.organizer_user.sudo()._sync_google_calendar(self.google_service) + for i, event in enumerate(recurrence.calendar_event_ids.sorted("start")): + self.assertEqual(event.attendee_ids[1].state, expected_states[i])