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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user