Cherry pick of 3c8bdde81d508fdee301f13bcbc6e4eefc9fb02c failed

stdout:

stderr:
17:49:30.703303 git.c:344               trace: built-in: git cherry-pick 3c8bdde81d508fdee301f13bcbc6e4eefc9fb02c
error: Cherry-picking is not possible because you have unmerged files.
hint: Fix them up in the work tree, and then use 'git add/rm <file>'
hint: as appropriate to mark resolution and make a commit.
fatal: cherry-pick failed
----------
status:

closes odoo/odoo#71390

X-original-commit: 7033f4aa7d1c65ad13dcd19e66378b101b56238b
Signed-off-by: Arnaud Joset <arj-odoo@users.noreply.github.com>
Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
This commit is contained in:
fw-bot
2021-05-28 13:12:42 +00:00
committed by Arnaud Joset
parent 359f517c0d
commit 05446fcc99
9 changed files with 223 additions and 42 deletions
+7 -1
View File
@@ -460,8 +460,14 @@ class Meeting(models.Model):
update_recurrence = recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1
break_recurrence = values.get('recurrency') is False
update_alarms = False
if 'partner_ids' in values:
values['attendee_ids'] = self._attendees_values(values['partner_ids'])
update_alarms = True
# master arj todo: factorize use of _get_time_fields()
if any([values.get(key) for key in self.env['calendar.event']._get_time_fields()]) or 'alarm_ids' in values:
update_alarms = True
if (not recurrence_update_setting or recurrence_update_setting == 'self_only' and len(self) == 1) and 'follow_recurrence' not in values:
if any({field: values.get(field) for field in self.env['calendar.event']._get_time_fields() if field in values}):
@@ -489,7 +495,7 @@ class Meeting(models.Model):
(detached_events & self).active = False
(detached_events - self).with_context(archive_on_error=True).unlink()
if not self.env.context.get('dont_notify'):
if not self.env.context.get('dont_notify') and update_alarms:
self._setup_alarms()
current_attendees = self.filtered('active').attendee_ids
@@ -312,7 +312,7 @@ class RecurrenceRule(models.Model):
data = {}
day_list = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
if 'Z' in rule_str and not date_start.tzinfo:
if 'Z' in rule_str and date_start and not date_start.tzinfo:
date_start = pytz.utc.localize(date_start)
rule = rrule.rrulestr(rule_str, dtstart=date_start)
+27 -14
View File
@@ -66,7 +66,9 @@ class Meeting(models.Model):
return [
('partner_ids.user_ids', 'in', self.env.user.id),
('stop', '>', lower_bound),
('start', '<', upper_bound)
('start', '<', upper_bound),
# Do not sync events that follow the recurrence, they are already synced at recurrence creation
'!', '&', '&', ('recurrency', '=', True), ('recurrence_id', '!=', False), ('follow_recurrence', '=', True)
]
@api.model
@@ -76,20 +78,27 @@ class Meeting(models.Model):
alarm_commands = self._odoo_reminders_commands(google_event.reminders.get('overrides') or default_reminders)
attendee_commands, partner_commands = self._odoo_attendee_commands(google_event)
related_event = self.search([('google_id', '=', google_event.id)], limit=1)
name = google_event.summary or related_event and related_event.name or _("(No title)")
values = {
'name': google_event.summary or _("(No title)"),
'name': name,
'description': google_event.description,
'location': google_event.location,
'user_id': google_event.owner(self.env).id,
'privacy': google_event.visibility or self.default_get(['privacy'])['privacy'],
'attendee_ids': attendee_commands,
'partner_ids': partner_commands,
'alarm_ids': alarm_commands,
'recurrency': google_event.is_recurrent()
}
if partner_commands:
# Add partner_commands only if set from Google. The write method on calendar_events will
# override attendee commands if the partner_ids command is set but empty.
values['partner_ids'] = partner_commands
if not google_event.is_recurrence():
values['google_id'] = google_event.id
if google_event.is_recurrent() and not google_event.is_recurrence():
# Propagate the follow_recurrence according to the google result
values['follow_recurrence'] = google_event.is_recurrence_follower()
if google_event.start.get('dateTime'):
# starting from python3.7, use the new [datetime, date].fromisoformat method
start = parse(google_event.start.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None)
@@ -190,13 +199,16 @@ class Meeting(models.Model):
start = {'date': self.start_date.isoformat()}
end = {'date': (self.stop_date + relativedelta(days=1)).isoformat()}
else:
start = {'dateTime': pytz.utc.localize(self.start).isoformat()}
end = {'dateTime': pytz.utc.localize(self.stop).isoformat()}
event_tz = self.event_tz or 'Etc/UTC'
start = {'dateTime': self.start.isoformat(), 'timeZone': event_tz}
end = {'dateTime': self.stop.isoformat(), 'timeZone': event_tz}
reminders = [{
'method': "email" if alarm.alarm_type == "email" else "popup",
'minutes': alarm.duration_minutes
} for alarm in self.alarm_ids]
attendee_values = [{'email': attendee.partner_id.email_normalized, 'responseStatus': attendee.state} for attendee in self.attendee_ids if attendee.partner_id.email_normalized]
# We sort the attendees to avoid undeterministic test fails. It's not mandatory for Google.
attendee_values.sort(key=lambda k: k['email'])
values = {
'id': self.google_id,
'start': start,
@@ -206,7 +218,7 @@ class Meeting(models.Model):
'location': self.location or '',
'guestsCanModify': True,
'organizer': {'email': self.user_id.email, 'self': self.user_id == self.env.user},
'attendees': [{'email': attendee.email, 'responseStatus': attendee.state} for attendee in self.attendee_ids],
'attendees': attendee_values,
'extendedProperties': {
'shared': {
'%s_odoo_id' % self.env.cr.dbname: self.id,
@@ -221,15 +233,16 @@ class Meeting(models.Model):
values['visibility'] = self.privacy
if not self.active:
values['status'] = 'cancelled'
if self.user_id and self.user_id != self.env.user:
if self.user_id and self.user_id != self.env.user and not bool(self.user_id.sudo().google_calendar_token):
# The organizer is an Odoo user that do not sync his calendar
values['extendedProperties']['shared']['%s_owner_id' % self.env.cr.dbname] = self.user_id.id
elif not self.user_id:
# We don't store the real owner identity (mail)
# We can't store on the shared properties in that case without getting a 403
# If several odoo users are attendees but the owner is not in odoo, the event will be duplicated on odoo database
# if we are not the owner, we should change the post values to avoid errors because we don't have enough rights
# We can't store on the shared properties in that case without getting a 403. It can happen when
# the owner is not an Odoo user: We don't store the real owner identity (mail)
# If we are not the owner, we should change the post values to avoid errors because we don't have
# write permissions
# See https://developers.google.com/calendar/concepts/sharing
keep_keys = ['id', 'attendees', 'start', 'end', 'reminders']
keep_keys = ['id', 'summary', 'attendees', 'start', 'end', 'reminders']
values = {key: val for key, val in values.items() if key in keep_keys}
# values['extendedProperties']['private] should be used if the owner is not an odoo user
values['extendedProperties'] = {
@@ -19,10 +19,25 @@ class Attendee(models.Model):
if not token:
super()._send_mail_to_attendees(mail_template, force_send)
def write(self, vals):
res = super().write(vals)
if vals.get('state'):
# When the state is changed, the corresponding event must be sync with google
google_service = GoogleCalendarService(self.env['google.service'])
self.event_id.filtered('google_id')._sync_odoo2google(google_service)
def do_tentative(self):
# Synchronize event after state change
res = super().do_tentative()
self._sync_event()
return res
def do_accept(self):
# Synchronize event after state change
res = super().do_accept()
self._sync_event()
return res
def do_decline(self):
# Synchronize event after state change
res = super().do_decline()
self._sync_event()
return res
def _sync_event(self):
google_service = GoogleCalendarService(self.env['google.service'])
self.event_id.filtered(lambda e: e.google_id)._sync_odoo2google(google_service)
@@ -92,11 +92,11 @@ class RecurrenceRule(models.Model):
# We can't call _cancel because events without user_id would not be deleted
(self.calendar_event_ids - base_event_id).google_id = False
(self.calendar_event_ids - base_event_id).unlink()
base_event_id.write(dict(new_event_values, google_id=False, need_sync=False))
base_event_id.with_context(dont_notify=True).write(dict(new_event_values, google_id=False, need_sync=False))
if self.rrule == current_rrule:
# if the rrule has changed, it will be recalculated below
# There is no detached event now
self._apply_recurrence()
self.with_context(dont_notify=True)._apply_recurrence()
else:
time_fields = (
self.env["calendar.event"]._get_time_fields()
@@ -160,10 +160,9 @@ class RecurrenceRule(models.Model):
return {}
values = event._google_values()
values['id'] = self.google_id
if not self._is_allday():
values['start']['timeZone'] = self.event_tz
values['end']['timeZone'] = self.event_tz
values['start']['timeZone'] = self.event_tz or 'Etc/UTC'
values['end']['timeZone'] = self.event_tz or 'Etc/UTC'
# DTSTART is not allowed by Google Calendar API.
# Event start and end times are specified in the start and end fields.
+61 -8
View File
@@ -4,7 +4,7 @@
import logging
from contextlib import contextmanager
from functools import wraps
import requests
from requests import HTTPError
import pytz
from dateutil.parser import parse
@@ -166,6 +166,49 @@ class GoogleSync(models.AbstractModel):
return synced_records
def _google_error_handling(self, http_error):
# We only handle the most problematic errors of sync events.
if http_error.response.status_code in (403, 400):
response = http_error.response.json()
if self._name == 'calendar.event':
start = self.start.strftime('%Y-%m-%d at %H:%M')
event_ids = self.id
name = self.name
error_log = "Error while syncing event: "
event = self
else:
# calendar recurrence is triggering the error
start = self.base_event_id.start.strftime('%Y-%m-%d at %H:%M')
event_ids = _("%(id)s and %(length)s following", id=self.base_event_id.id, length=len(self.calendar_event_ids.ids))
name = self.base_event_id.name
# prevent to sync other events
self.calendar_event_ids.need_sync = False
error_log = "Error while syncing recurrence: "
event = self.base_event_id
# We don't have right access on the event or the request paramaters were bad.
# https://developers.google.com/calendar/v3/errors#403_forbidden_for_non-organizer
if http_error.response.status_code == 403 and "forbiddenForNonOrganizer" in http_error.response.text:
reason = _("you don't seem to have permission to modify this event on Google Calendar")
else:
reason = _("Google gave the following explanation: %s", response['error'].get('message'))
error_log += "The event (%(id)s - %(name)s at %(start)s) could not be synced. It will not be synced while " \
"it is not updated. Reason: %(reason)s" % {'id': event_ids, 'start': start, 'name': name,
'reason': reason}
_logger.error(error_log)
body = _(
"The following event could not be synced with Google Calendar. </br>"
"It will not be synced as long at it is not updated.</br>"
"%(reason)s", reason=reason)
event.message_post(
body=body,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
@after_commit
def _google_delete(self, google_service: GoogleCalendarService, google_id, timeout=TIMEOUT):
with google_calendar_token(self.env.user.sudo()) as token:
@@ -173,13 +216,17 @@ class GoogleSync(models.AbstractModel):
google_service.delete(google_id, token=token, timeout=timeout)
# When the record has been deleted on our side, we need to delete it on google but we don't want
# to raise an error because the record don't exists anymore.
self.exists().need_sync = False
self.exists().with_context(dont_notify=True).need_sync = False
@after_commit
def _google_patch(self, google_service: GoogleCalendarService, google_id, values, timeout=TIMEOUT):
with google_calendar_token(self.env.user.sudo()) as token:
if token:
google_service.patch(google_id, values, token=token, timeout=timeout)
try:
google_service.patch(google_id, values, token=token, timeout=timeout)
except HTTPError as e:
if e.response.status_code in (400, 403):
self._google_error_handling(e)
self.need_sync = False
@after_commit
@@ -188,11 +235,17 @@ class GoogleSync(models.AbstractModel):
return
with google_calendar_token(self.env.user.sudo()) as token:
if token:
google_id = google_service.insert(values, token=token, timeout=timeout)
self.write({
'google_id': google_id,
'need_sync': False,
})
try:
google_id = google_service.insert(values, token=token, timeout=timeout)
# Everything went smoothly
self.with_context(dont_notify=True).write({
'google_id': google_id,
'need_sync': False,
})
except HTTPError as e:
if e.response.status_code in (400, 403):
self._google_error_handling(e)
self.need_sync = False
def _get_records_to_sync(self, full_sync=False):
"""Return records that should be synced from Odoo to Google
@@ -157,10 +157,30 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
user_attendee = event.attendee_ids
self.assertTrue(user_attendee)
self.assertEqual(user_attendee.state, 'declined')
self.assertGoogleAPINotCalled()
@patch_api
def test_private_extended_properties(self):
google_id = 'oj44nep1ldf8a3ll02uip0c9aa'
event = self.env['calendar.event'].create({
'name': 'coucou',
'start': date(2020, 1, 6),
'stop': date(2020, 1, 6),
'allday': True,
'google_id': google_id,
'need_sync': False,
'user_id': False, # Not the current user
'partner_ids': [(6, 0, self.env.user.partner_id.ids)] # current user is attendee
})
user_attendee = event.attendee_ids
self.assertTrue(user_attendee)
self.assertEqual(user_attendee.state, 'accepted')
user_attendee.do_decline()
# To avoid 403 errors, we send a limited dictionnary when we don't have write access.
# guestsCanModify property is not properly handled yet
self.assertGoogleEventPatched(event.google_id, {
'id': event.google_id,
'summary': 'coucou',
'start': {'date': str(event.start_date)},
'end': {'date': str(event.stop_date + relativedelta(days=1))},
'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'declined'}],
@@ -746,3 +766,78 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
new_triggers = triggers_after - triggers_before
self.assertFalse(new_triggers, "The event should not be created with triggers.")
self.assertGoogleAPINotCalled()
@patch_api
def test_attendee_state(self):
user = new_test_user(self.env, login='calendar-user')
google_id = 'oj44nep1ldf8a3ll02uip0c9aa'
event = self.env['calendar.event'].with_user(user).create({
'name': 'Event with me',
'start': date(2020, 1, 6),
'stop': date(2020, 1, 6),
'google_id': google_id,
'user_id': False, # user is not owner
'need_sync': False,
'partner_ids': [(6, 0, user.partner_id.ids)], # but user is attendee
})
self.assertEqual(event.attendee_ids.state, 'accepted')
# The event is declined from Google
values = {
'id': google_id,
'description': 'Changed my mind',
"updated": self.now,
'organizer': {'email': 'odoocalendarref@gmail.com', 'self': True},
'summary': """I don't want to be with me anymore""",
'visibility': 'public',
'attendees': [{
'displayName': 'calendar-user (base.group_user)',
'email': 'c.c@example.com',
'responseStatus': 'declined'
}, ],
'reminders': {'useDefault': True},
'start': {
'dateTime': '2020-01-13T16:55:00+01:00',
'timeZone': 'Europe/Brussels'
},
'end': {
'dateTime': '2020-01-13T19:55:00+01:00',
'timeZone': 'Europe/Brussels'
},
}
self.env['calendar.event']._sync_google2odoo(GoogleEvent([values]))
self.assertEqual(event.attendee_ids.state, 'declined')
self.assertGoogleAPINotCalled()
@patch_api
def test_attendees_same_event_both_share(self):
google_id = 'oj44nep1ldf8a3ll02uip0c9aa'
other_user = new_test_user(self.env, login='calendar-user')
event = self.env['calendar.event'].create({
'name': 'coucou',
'start': date(2020, 1, 6),
'stop': date(2020, 1, 6),
'allday': True,
'google_id': google_id,
'need_sync': False,
'user_id': other_user.id, # Not the current user
'partner_ids': [(6, 0, [self.env.user.partner_id.id, other_user.partner_id.id], )] # current user is attendee
})
event.write({'start': date(2020, 1, 7), 'stop': date(2020, 1, 8)})
# To avoid 403 errors, we send a limited dictionnary when we don't have write access.
# guestsCanModify property is not properly handled yet
self.assertGoogleEventPatched(event.google_id, {
'id': event.google_id,
'start': {'date': str(event.start_date)},
'end': {'date': str(event.stop_date + relativedelta(days=1))},
'summary': 'coucou',
'description': '',
'location': '',
'guestsCanModify': True,
'organizer': {'email': 'c.c@example.com', 'self': False},
'attendees': [{'email': 'c.c@example.com', 'responseStatus': 'needsAction'},
{'email': 'odoobot@example.com', 'responseStatus': 'accepted'},],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id,
'%s_owner_id' % self.env.cr.dbname: other_user.id}},
'reminders': {'overrides': [], 'useDefault': False},
'visibility': 'public',
}, timeout=3)
@@ -40,8 +40,8 @@ class TestSyncOdoo2Google(TestSyncGoogle):
event._sync_odoo2google(self.google_service)
self.assertGoogleEventInserted({
'id': False,
'start': {'dateTime': '2020-01-15T08:00:00+00:00'},
'end': {'dateTime': '2020-01-15T18:00:00+00:00'},
'start': {'dateTime': '2020-01-15T08:00:00', 'timeZone': 'Etc/UTC'},
'end': {'dateTime': '2020-01-15T18:00:00', 'timeZone': 'Etc/UTC'},
'summary': 'Event',
'description': '',
'location': '',
@@ -269,8 +269,8 @@ class TestSyncOdoo2Google(TestSyncGoogle):
user.with_user(user).restart_google_synchronization()
self.assertGoogleEventPatched(event.google_id, {
'id': event.google_id,
'start': {'dateTime': '2020-01-15T08:00:00+00:00'},
'end': {'dateTime': '2020-01-15T18:00:00+00:00'},
'start': {'dateTime': '2020-01-15T08:00:00', 'timeZone': 'Etc/UTC'},
'end': {'dateTime': '2020-01-15T18:00:00', 'timeZone': 'Etc/UTC'},
'summary': 'Event',
'description': '',
'location': '',
@@ -432,7 +432,7 @@ class TestSyncOdoo2Google(TestSyncGoogle):
self.assertEqual(event.attendee_ids.state, 'needsAction',
"The attendee state should be 'needsAction")
event.attendee_ids.write({'state': 'declined'})
event.attendee_ids.do_decline()
self.assertGoogleEventPatched(event.google_id, {
'id': event.google_id,
'start': {'date': str(event.start_date)},
+2 -2
View File
@@ -174,8 +174,8 @@ class GoogleEvent(abc.Set):
def is_cancelled(self):
return self.status == 'cancelled'
def is_recurrence_outlier(self):
return bool(self.originalStartTime)
def is_recurrence_follower(self):
return bool(not self.originalStartTime or self.originalStartTime == self.start)
def cancelled(self):
return self.filter(lambda e: e.status == 'cancelled')