[REF] google_calendar: Refactor synchronisation

This commit refactors calendar synchronization between Odoo and Google after the
main calendar application refactoring.

This refactoring takes advantage of two new features from the Google API:

- New way of synchronizing resources efficiently[1]
Incremental sync is performed repeatedly and updates Odoo with all the changes that
happened ever since the previous sync. Each time, Odoo provides the previous sync
token it obtained from Google and stores the new sync token from the response.

- Event metadata[2]
Ability to set hidden key-value pairs with an event, called extended properties.
These extended properties are used to store the related odoo event id and the Odoo
owner id (see known limitations)

Known limitations
=================
- Let A and B be two new users (no tokens available). A creates an event in Odoo and
invites B. A is the owner of the event (user_id). Now B authenticates to his Google Calendar
account and synchronizes his calendar. We cannot send the event to A's calendar since we
don't have any access to his Google Calendar. Hence the event his sent to B's calendar.
This leads to data de-synchronisation: The owner is A in Odoo but B in Google.
The "real" owner (user A) is stored in the Google event's metadata to be able to
reconcile the owner for following synchronizations.

- Let A and B be two users of Odoo and Google Calendar. And let the Google Calendar of B
be private (e.g. if A creates an event in Google Calendar and invites B, B won't see the
event in his calendar). If A creates an event in Odoo and invites B. The event is synced
to Google Calendar of A. Now B can see the event in Odoo but he can't see it in his
Google Calendar.

Task 2126717
PR #42031
PR Enterprise odoo/enterprise#8006

[1] https://developers.google.com/calendar/v3/sync
[2] https://developers.google.com/calendar/extended-properties
This commit is contained in:
Lucas Lefèvre
2020-04-09 10:59:33 +02:00
parent 85907951c5
commit ae3d37c201
30 changed files with 1648 additions and 1149 deletions
+8 -1
View File
@@ -26,7 +26,14 @@ class Alarm(models.Model):
alarm_type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], string='Type', required=True, default='email')
duration = fields.Integer('Remind Before', required=True, default=1)
interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours')
duration_minutes = fields.Integer('Duration in minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes")
duration_minutes = fields.Integer('Duration in minutes', search='_search_duration_minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes")
def _search_duration_minutes(self, operator, value):
return ['|', '|',
'&', ('interval', '=', 'minutes'), ('duration', operator, value),
'&', ('interval', '=', 'hours'), ('duration', operator, value / 60),
'&', ('interval', '=', 'days'), ('duration', operator, value / 60 / 24),
]
@api.onchange('duration', 'interval', 'alarm_type')
def _onchange_duration_interval(self):
+2 -2
View File
@@ -594,7 +594,7 @@ class Meeting(models.Model):
detached_events |= recurrence.calendar_event_ids
recurrence.calendar_event_ids.recurrence_id = False
recurrences_to_unlink |= recurrence
recurrences_to_unlink.unlink()
recurrences_to_unlink.with_context(archive_on_error=True).unlink()
return detached_events - self
def write(self, values):
@@ -627,7 +627,7 @@ class Meeting(models.Model):
detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update_setting == 'future_events')
(detached_events & self).active = False
(detached_events - self).unlink()
(detached_events - self).with_context(archive_on_error=True).unlink()
# Notify attendees if there is an alarm on the modified event, or if there was an alarm
# that has just been removed, as it might have changed their next event notification
@@ -235,7 +235,7 @@ class RecurrenceRule(models.Model):
events = self._get_events_from(event.start)
detached_events = self._detach_events(events)
if not self.calendar_event_ids:
self.unlink()
self.with_context(archive_on_error=True).unlink()
return detached_events
if event.allday:
@@ -116,4 +116,6 @@ var AttendeeCalendarView = CalendarView.extend({
viewRegistry.add('attendee_calendar', AttendeeCalendarView);
return AttendeeCalendarView
});
+4 -2
View File
@@ -10,7 +10,7 @@ from odoo.http import request
class GoogleAuth(http.Controller):
@http.route('/google_account/authentication', type='http', auth="none")
@http.route('/google_account/authentication', type='http', auth="public")
def oauth2callback(self, **kw):
""" This route/function is called by Google when user Accept/Refuse the consent of Google """
state = json.loads(kw['state'])
@@ -20,7 +20,9 @@ class GoogleAuth(http.Controller):
with registry(dbname).cursor() as cr:
if kw.get('code'):
request.env(cr, request.session.uid)['google.%s' % service].set_all_tokens(kw['code'])
access_token, refresh_token, ttl = request.env['google.service']._get_google_tokens(kw['code'], service)
# LUL TODO only defined in google_calendar
request.env.user._set_auth_tokens(access_token, refresh_token, ttl)
return redirect(url_return)
elif kw.get('error'):
return redirect("%s%s%s" % (url_return, "?error=", kw['error']))
+16 -43
View File
@@ -95,7 +95,7 @@ class GoogleService(models.AbstractModel):
return "%s?%s" % (GOOGLE_AUTH_ENDPOINT, encoded_params)
@api.model
def _get_google_token_json(self, authorize_code, service):
def _get_google_tokens(self, authorize_code, service):
""" Call Google API to exchange authorization code against token, with POST request, to
not be redirected.
"""
@@ -114,44 +114,17 @@ class GoogleService(models.AbstractModel):
}
try:
dummy, response, dummy = self._do_request(GOOGLE_TOKEN_ENDPOINT, params=data, headers=headers, type='POST', preuri='')
return response
access_token = response.get('access_token')
refresh_token = response.get('refresh_token')
ttl = response.get('expires_in')
return access_token, refresh_token, ttl
except requests.HTTPError:
error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid")
raise self.env['res.config.settings'].get_config_warning(error_msg)
# FIXME : this method update a field defined in google_calendar module. Since it is used only in that module, maybe it should be moved.
@api.model
def _refresh_google_token_json(self, refresh_token, service): # exchange_AUTHORIZATION vs Token (service = calendar)
get_param = self.env['ir.config_parameter'].sudo().get_param
client_id = get_param('google_%s_client_id' % (service,), default=False)
client_secret = get_param('google_%s_client_secret' % (service,), default=False)
if not client_id or not client_secret:
raise UserError(_("The account for the Google service '%s' is not configured.") % service)
headers = {"content-type": "application/x-www-form-urlencoded"}
data = {
'refresh_token': refresh_token,
'client_id': client_id,
'client_secret': client_secret,
'grant_type': 'refresh_token',
}
try:
dummy, response, dummy = self._do_request(GOOGLE_TOKEN_ENDPOINT, params=data, headers=headers, type='POST', preuri='')
return response
except requests.HTTPError as error:
if error.response.status_code == 400: # invalid grant
with registry(request.session.db).cursor() as cur:
self.env(cur)['res.users'].browse(self.env.uid).sudo().write({'google_%s_rtoken' % service: False})
error_key = error.response.json().get("error", "nc")
_logger.exception("Bad google request : %s !", error_key)
error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired [%s]") % error_key
raise self.env['res.config.settings'].get_config_warning(error_msg)
# TODO JEM : remove preuri param, and rename type into method
@api.model
def _do_request(self, uri, params={}, headers={}, type='POST', preuri="https://www.googleapis.com"):
def _do_request(self, uri, params=None, headers=None, type='POST', preuri="https://www.googleapis.com", timeout=TIMEOUT):
""" Execute the request to Google API. Return a tuple ('HTTP_CODE', 'HTTP_RESPONSE')
:param uri : the url to contact
:param params : dict or already encoded parameters for the request to make
@@ -159,14 +132,19 @@ class GoogleService(models.AbstractModel):
:param type : the method to use to make the request
:param preuri : pre url to prepend to param uri.
"""
if params is None:
params = {}
if headers is None:
headers = {}
_logger.debug("Uri: %s - Type : %s - Headers: %s - Params : %s !", (uri, type, headers, params))
ask_time = fields.Datetime.now()
try:
if type.upper() in ('GET', 'DELETE'):
res = requests.request(type.lower(), preuri + uri, params=params, timeout=TIMEOUT)
res = requests.request(type.lower(), preuri + uri, params=params, timeout=timeout)
elif type.upper() in ('POST', 'PATCH', 'PUT'):
res = requests.request(type.lower(), preuri + uri, data=params, headers=headers, timeout=TIMEOUT)
res = requests.request(type.lower(), preuri + uri, data=params, headers=headers, timeout=timeout)
else:
raise Exception(_('Method not supported [%s] not in [GET, POST, PUT, PATCH or DELETE]!') % (type))
res.raise_for_status()
@@ -182,17 +160,12 @@ class GoogleService(models.AbstractModel):
except:
pass
except requests.HTTPError as error:
# if error.response.status_code == 401:
# raise InvalidToken
if error.response.status_code in (204, 404):
status = error.response.status_code
response = ""
else:
_logger.exception("Bad google request : %s !", error.response.content)
if error.response.status_code in (400, 401, 410):
raise error
raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google"))
raise error
return (status, response, ask_time)
# TODO : remove me, it is only used in google calendar. Make google_calendar use the constants
@api.model
def get_client_id(self, service):
return self.env['ir.config_parameter'].sudo().get_param('google_%s_client_id' % (service,), default=False)
+1
View File
@@ -3,3 +3,4 @@
from . import controllers
from . import models
from . import utils
+1
View File
@@ -11,6 +11,7 @@
'data': [
'data/google_calendar_data.xml',
'security/ir.model.access.csv',
'views/calendar_event_views.xml',
'views/res_config_settings_views.xml',
'views/res_users_views.xml',
'views/google_calendar_templates.xml',
+11 -23
View File
@@ -3,6 +3,7 @@
from odoo import http
from odoo.http import request
from addons.google_calendar.utils.google_calendar import GoogleCalendarService
class GoogleCalendarController(http.Controller):
@@ -14,16 +15,14 @@ class GoogleCalendarController(http.Controller):
The dictionary may contains an url, to allow Odoo Client to redirect user on this URL for authorization for example
"""
if model == 'calendar.event':
GoogleService = request.env['google.service']
GoogleCal = request.env['google.calendar']
GoogleCal = GoogleCalendarService(request.env['google.service'])
# Checking that admin have already configured Google API for google synchronization !
context = kw.get('local_context', {})
client_id = GoogleService.with_context(context).get_client_id('calendar')
client_id = request.env['ir.config_parameter'].sudo().get_param('google_calendar_client_id')
if not client_id or client_id == '':
action_id = ''
if GoogleCal.can_authorize_google():
if GoogleCal._can_authorize_google(request.env.user):
action_id = request.env.ref('base_setup.action_general_configuration').id
return {
"status": "need_config_from_admin",
@@ -32,28 +31,17 @@ class GoogleCalendarController(http.Controller):
}
# Checking that user have already accepted Odoo to access his calendar !
if GoogleCal.need_authorize():
url = GoogleCal.with_context(context).authorize_google_uri(from_url=kw.get('fromurl'))
if not GoogleCal.is_authorized(request.env.user):
url = GoogleCal._google_authentication_url(from_url=kw.get('fromurl'))
return {
"status": "need_auth",
"url": url
}
# If App authorized, and user access accepted, We launch the synchronization
return GoogleCal.with_context(context).synchronize_events()
need_refresh = request.env.user.sudo()._sync_google_calendar(GoogleCal)
return {
"status": "need_refresh" if need_refresh else "no_new_event_from_google",
"url": ''
}
return {"status": "success"}
@http.route('/google_calendar/remove_references', type='json', auth='user')
def remove_references(self, model, **kw):
""" This route/function is called when we want to remove all the references between one calendar Odoo and one Google Calendar """
status = "NOP"
if model == 'calendar.event':
GoogleCal = request.env['google.calendar']
# Checking that user have already accepted Odoo to access his calendar !
context = kw.get('local_context', {})
if GoogleCal.with_context(context).remove_references():
status = "OK"
else:
status = "KO"
return {"status": status}
@@ -3,14 +3,15 @@
<data>
<record forcecreate="True" id="ir_cron_sync_all_cals" model="ir.cron">
<field name="name">Google Calendar: synchronization</field>
<field name="model_id" ref="model_google_calendar"/>
<field name="model_id" ref="model_res_users"/>
<field name="state">code</field>
<field name="code">
# LUL TODO check this comment
# The context key 'last_sync_hours' allows specifying the minimum delay between consecutive syncs.
# Indeed, in case there are many users / events to sync, the cron might time out. In this case, the
# solution is to force the last_sync_hours to the expected synchronization interval expected. This
# will avoid the synchronization of users that succeeded in the previous failing cron.
model.synchronize_events_cron()
model._sync_all_google_calendar()
</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">12</field>
+2 -1
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_config_settings
from . import google_sync
from . import calendar
from . import google_calendar
from . import calendar_recurrence_rule
from . import res_users
+170 -41
View File
@@ -1,63 +1,192 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
import pytz
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
class Meeting(models.Model):
_name = 'calendar.event'
_inherit = ['calendar.event', 'google.calendar.sync']
_inherit = "calendar.event"
google_id = fields.Char('Google Calendar Event Id', compute='_compute_google_id', store=True, readonly=False)
oe_update_date = fields.Datetime('Odoo Update Date')
@api.depends('recurrence_id.google_id')
def _compute_google_id(self):
# google ids of recurring events are built from the recurrence id and the
# original starting time in the recurrence.
# The `start` field does not appear in the dependencies on purpose!
# Event if the event is moved, the google_id remains the same.
for event in self:
google_recurrence_id = event.recurrence_id._get_event_google_id(event)
if google_recurrence_id:
event.google_id = google_recurrence_id
elif not event.google_id:
event.google_id = False
@api.model
def get_fields_need_update_google(self):
recurrent_fields = self._get_recurrent_fields()
return recurrent_fields | {'name', 'description', 'allday', 'start', 'date_end', 'stop',
'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active',
'start_date', 'stop_date'}
def _get_google_synced_fields(self):
return {'name', 'description', 'allday', 'start', 'date_end', 'stop',
'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active'}
@api.model_create_multi
def create(self, vals_list):
return super().create([
dict(vals, need_sync=False) if vals.get('recurrency') else vals
for vals in vals_list
])
def write(self, values):
sync_fields = set(self.get_fields_need_update_google())
if (set(values) and sync_fields) and 'oe_update_date' not in values and 'NewMeeting' not in self._context:
if 'oe_update_date' in self._context:
values['oe_update_date'] = self._context.get('oe_update_date')
else:
values['oe_update_date'] = fields.Datetime.now()
return super(Meeting, self).write(values)
recurrence_update_setting = values.get('recurrence_update')
if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1:
values = dict(values, need_sync=False)
return super().write(values)
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
default = default or {}
if default.get('write_type', False):
del default['write_type']
elif default.get('recurrent_id', False):
default['oe_update_date'] = fields.Datetime.now()
def _get_sync_domain(self):
return [('partner_ids.user_ids', 'in', self.env.user.id)]
@api.model
def _odoo_values(self, google_event, default_reminders=()):
if google_event.is_cancelled():
return {'active': False}
alarm_commands = self._odoo_reminders_commands(google_event.reminders.get('overrides') or default_reminders)
values = {
'name': google_event.summary or _("(No title)"),
'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': self._odoo_attendee_commands(google_event),
'alarm_ids': alarm_commands,
'recurrency': google_event.is_recurrent()
}
if google_event.reminders and google_event.reminders.get('useDefault'):
values
if not google_event.is_recurrence():
values['google_id'] = google_event.id
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)
stop = parse(google_event.end.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None)
values['allday'] = False
else:
default['oe_update_date'] = False
return super(Meeting, self).copy(default)
start = parse(google_event.start.get('date'))
stop = parse(google_event.end.get('date')) - relativedelta(days=1)
values['allday'] = True
values['start'] = start
values['stop'] = stop
return values
def unlink(self, can_be_deleted=False):
return super(Meeting, self).unlink(can_be_deleted=can_be_deleted)
@api.model
def _odoo_attendee_commands(self, google_event):
commands = []
if not google_event.attendees:
return commands
emails = [a.get('email') for a in google_event.attendees]
existing_attendees = self.env['calendar.attendee']
if google_event.exists(self.env):
existing_attendees = self.env['calendar.attendee'].search([('event_id', '=', google_event.odoo_id(self.env)), ('email', 'in', emails)])
attendees_by_emails = {a.email: a for a in existing_attendees}
for attendee in google_event.attendees:
email = attendee.get('email')
if email in attendees_by_emails:
# Update existing attendees
commands += [(1, attendees_by_emails[email].id, {'state': attendee.get('responseStatus')})]
else:
# Create new attendees
partner = self.env['res.partner'].find_or_create(attendee.get('email'))
commands += [(0, 0, {'state': attendee.get('responseStatus'), 'partner_id': partner.id})]
if attendee.get('displayName') and not partner.name:
partner.name = attendee.get('displayName')
for odoo_attendee in attendees_by_emails.values():
# Remove old attendees
if odoo_attendee.email not in emails:
commands += [(2, odoo_attendee.id)]
return commands
class Attendee(models.Model):
@api.model
def _odoo_reminders_commands(self, reminders=()):
commands = []
for reminder in reminders:
alarm_type = 'email' if reminder.get('method') == 'email' else 'notification'
alarm_type_label = _("Email") if alarm_type == 'email' else _("Notification")
_inherit = 'calendar.attendee'
minutes = reminder.get('minutes', 0)
alarm = self.env['calendar.alarm'].search([
('alarm_type', '=', alarm_type),
('duration_minutes', '=', minutes)
], limit=1)
if alarm:
commands += [(4, alarm.id)]
else:
if minutes % (60*24) == 0:
interval = 'days'
duration = minutes / 60 / 24
name = _("%s - %s Days") % (alarm_type_label, duration)
elif minutes % 60 == 0:
interval = 'hours'
duration = minutes / 60
name = _("%s - %s Hours") % (alarm_type_label, duration)
else:
interval = 'minutes'
duration = minutes
name = _("%s - %s Minutes") % (alarm_type_label, duration)
commands += [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': alarm_type})]
reminders = {()}
return commands
google_internal_event_id = fields.Char('Google Calendar Event Id')
oe_synchro_date = fields.Datetime('Odoo Synchro Date')
def _google_values(self):
if self.allday:
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()}
_sql_constraints = [
('google_id_uniq', 'unique(google_internal_event_id,partner_id,event_id)', 'Google ID should be unique!')
]
reminders = [{
'method': "email" if alarm.alarm_type == "email" else "popup",
'minutes': alarm.duration_minutes
} for alarm in self.alarm_ids]
values = {
'id': self.google_id,
'start': start,
'end': end,
'summary': self.name,
'description': self.description or '',
'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],
'extendedProperties': {
'shared': {
'%s_odoo_id' % self.env.cr.dbname: self.id,
},
},
'reminders': {
'overrides': reminders,
'useDefault': False,
}
}
if self.privacy:
values['visibility'] = self.privacy
if self.user_id != self.env.user:
values['extendedProperties']['shared']['%s_owner_id' % self.env.cr.dbname] = self.user_id.id
def write(self, values):
for attendee in self:
meeting_id_to_update = values.get('event_id', attendee.event_id.id)
if not self.active:
values['status'] = 'cancelled'
return values
# If attendees are updated, we need to specify that next synchro need an action
# Except if it come from an update_from_google
if not self._context.get('curr_attendee', False) and not self._context.get('NewMeeting', False):
self.env['calendar.event'].browse(meeting_id_to_update).write({'oe_update_date': fields.Datetime.now()})
return super(Attendee, self).write(values)
def _cancel(self):
# only owner can delete => others refuse the event
user = self.env.user
my_cancelled_records = self.filtered(lambda e: e.user_id == user)
super(Meeting, my_cancelled_records)._cancel()
attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id)
attendees.state = 'declined'
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import api, fields, models
from addons.google_calendar.utils.google_calendar import GoogleCalendarService
class RecurrenceRule(models.Model):
_name = 'calendar.recurrence'
_inherit = ['calendar.recurrence', 'google.calendar.sync']
# Don't sync by default. Sync only when the recurrence is applied
need_sync = fields.Boolean(default=False)
def _apply_recurrence(self):
events = self.calendar_event_ids
detached_events = super()._apply_recurrence()
google_service = GoogleCalendarService(self.env['google.service'])
# If a synced event becomes a recurrence, the event needs to be deleted from
# Google since it's now the recurrence which is synced.
# Those events are kept in the database and their google_id is updated
# according to the recurrence google_id, therefore we need to keep an inactive copy
# of those events with the original google id. The next sync will then correctly
# delete those events from Google.
vals = []
for event in events.filtered('google_id'):
if event.active and event.google_id != event.recurrence_id._get_event_google_id(event):
vals += [{
'name': event.name,
'google_id': event.google_id,
'start': event.start,
'stop': event.stop,
'active': False,
'need_sync': True,
}]
event._google_delete(google_service, event.google_id)
event.google_id = False
self.env['calendar.event'].create(vals)
for recurrence in self:
values = recurrence._google_values()
if not recurrence.google_id:
recurrence._google_insert(google_service, values)
else:
recurrence._google_patch(google_service, recurrence.google_id, values)
self.calendar_event_ids.need_sync = False
return detached_events
def _get_event_google_id(self, event):
"""Return the Google id of recurring event.
Google ids of recurrence instances are formatted as: {recurrence google_id}_{UTC starting time in compacted ISO8601}
"""
if self.google_id:
if event.allday:
time_id = event.start_date.isoformat().replace('-', '')
else:
# '-' and ':' are optional in ISO8601
start_compacted_iso8601 = event.start.isoformat().replace('-', '').replace(':', '')
# Z at the end for UTC
time_id = '%sZ' % start_compacted_iso8601
return '%s_%s' % (self.google_id, time_id)
return False
def _write_events(self, values, dtstart=None):
values.pop('google_id', False)
# If only some events are updated, sync those events.
# If all events are updated, sync the recurrence instead.
values['need_sync'] = bool(dtstart)
if not dtstart:
self.need_sync = True
return super()._write_events(values, dtstart=dtstart)
def _get_google_synced_fields(self):
return {'rrule'}
@api.model
def _sync_google2odoo(self, *args, **kwargs):
synced_recurrences = super()._sync_google2odoo(*args, **kwargs)
detached_events = synced_recurrences._apply_recurrence()
detached_events.unlink()
return synced_recurrences
def _get_sync_domain(self):
return [('calendar_event_ids.partner_id.user_ids', 'in', self.env.user.id)]
@api.model
def _odoo_values(self, google_recurrence, default_reminders=()):
base_values = dict(self.env['calendar.event']._odoo_values(google_recurrence, default_reminders), need_sync=False)
base_event = self.env['calendar.event'].create(base_values)
return {
'rrule': google_recurrence.rrule,
'google_id': google_recurrence.id,
'base_event_id': base_event.id,
'calendar_event_ids': [(4, base_event.id)],
}
def _google_values(self):
event = self._get_first_event()
if not event:
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
# DTSTART is not allowed by Google Calendar API.
# Event start and end times are specified in the start and end fields.
rrule = re.sub('DTSTART:[0-9]{8}T[0-9]{1,8}\\n', '', self.rrule)
values['recurrence'] = ['RRULE:%s' % rrule] if 'RRULE:' not in rrule else [rrule]
values['extendedProperties'] = {
'shared': {
'%s_odoo_id' % self.env.cr.dbname: self.id,
},
}
return values
@@ -1,967 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
import requests
from dateutil import parser
import json
import logging
import operator
import pytz
from werkzeug import urls
from odoo import api, fields, models, tools, _
from odoo.osv import expression
from odoo.tools import exception_to_unicode
_logger = logging.getLogger(__name__)
def status_response(status):
return int(str(status)[0]) == 2
class Meta(type):
""" This Meta class allow to define class as a structure, and so instancied variable
in __init__ to avoid to have side effect alike 'static' variable """
def __new__(typ, name, parents, attrs):
methods = {k: v for k, v in attrs.items() if callable(v)}
attrs = {k: v for k, v in attrs.items() if not callable(v)}
def init(self, **kw):
for key, val in attrs.items():
setattr(self, key, val)
for key, val in kw.items():
assert key in attrs
setattr(self, key, val)
methods['__init__'] = init
methods['__getitem__'] = getattr
return type.__new__(typ, name, parents, methods)
Struct = Meta('Struct', (object,), {})
class OdooEvent(Struct):
event = False
found = False
event_id = False
isRecurrence = False
isInstance = False
update = False
status = False
attendee_id = False
synchro = False
class GmailEvent(Struct):
event = False
found = False
isRecurrence = False
isInstance = False
update = False
status = False
class SyncEvent(object):
def __init__(self):
self.OE = OdooEvent()
self.GG = GmailEvent()
self.OP = None
def __getitem__(self, key):
return getattr(self, key)
def compute_OP(self, modeFull=True):
#If event are already in Gmail and in Odoo
if self.OE.found and self.GG.found:
is_owner = self.OE.event.env.user.id == self.OE.event.user_id.id
#If the event has been deleted from one side, we delete on other side !
if self.OE.status != self.GG.status and is_owner:
self.OP = Delete((self.OE.status and "OE") or (self.GG.status and "GG"),
'The event has been deleted from one side, we delete on other side !')
#If event is not deleted !
elif self.OE.status and (self.GG.status or (not is_owner and self.GG.update)):
if abs(self.OE.update - self.GG.update) > timedelta(seconds=1):
if self.OE.update < self.GG.update:
tmpSrc = 'GG'
elif self.OE.update > self.GG.update:
tmpSrc = 'OE'
assert tmpSrc in ['GG', 'OE']
if self[tmpSrc].isRecurrence:
if self[tmpSrc].status:
self.OP = Update(tmpSrc, 'Only need to update, because i\'m active')
else:
self.OP = Exclude(tmpSrc, 'Need to Exclude (Me = First event from recurrence) from recurrence')
elif self[tmpSrc].isInstance:
self.OP = Update(tmpSrc, 'Only need to update, because already an exclu')
else:
self.OP = Update(tmpSrc, 'Simply Update... I\'m a single event')
else:
if not self.OE.synchro or self.OE.synchro < (self.OE.update + timedelta(seconds=1)):
self.OP = Update('OE', 'Event already updated by another user, but not synchro with my google calendar')
else:
self.OP = NothingToDo("", 'Not update needed')
else:
self.OP = NothingToDo("", "Both are already deleted")
# New in Odoo... Create on create_events of synchronize function
elif self.OE.found and not self.GG.found:
if self.OE.status:
self.OP = Delete('OE', 'Update or delete from GOOGLE')
else:
if not modeFull:
self.OP = Delete('GG', 'Deleted from Odoo, need to delete it from Gmail if already created')
else:
self.OP = NothingToDo("", "Already Deleted in gmail and unlinked in Odoo")
elif self.GG.found and not self.OE.found:
tmpSrc = 'GG'
if not self.GG.status and not self.GG.isInstance:
# don't need to make something... because event has been created and deleted before the synchronization
self.OP = NothingToDo("", 'Nothing to do... Create and Delete directly')
else:
if self.GG.isInstance:
if self[tmpSrc].status:
self.OP = Exclude(tmpSrc, 'Need to create the new exclu')
else:
self.OP = Exclude(tmpSrc, 'Need to copy and Exclude')
else:
self.OP = Create(tmpSrc, 'New EVENT CREATE from GMAIL')
def __str__(self):
return self.__repr__()
def __repr__(self):
event_str = "\n\n---- A SYNC EVENT ---"
event_str += "\n ID OE: %s " % (self.OE.event and self.OE.event.id)
event_str += "\n ID GG: %s " % (self.GG.event and self.GG.event.get('id', False))
event_str += "\n Name OE: %s " % (self.OE.event and self.OE.event.name.encode('utf8'))
event_str += "\n Name GG: %s " % (self.GG.event and self.GG.event.get('summary', '').encode('utf8'))
event_str += "\n Found OE:%5s vs GG: %5s" % (self.OE.found, self.GG.found)
event_str += "\n Recurrence OE:%5s vs GG: %5s" % (self.OE.isRecurrence, self.GG.isRecurrence)
event_str += "\n Instance OE:%5s vs GG: %5s" % (self.OE.isInstance, self.GG.isInstance)
event_str += "\n Synchro OE: %10s " % (self.OE.synchro)
event_str += "\n Update OE: %10s " % (self.OE.update)
event_str += "\n Update GG: %10s " % (self.GG.update)
event_str += "\n Status OE:%5s vs GG: %5s" % (self.OE.status, self.GG.status)
if (self.OP is None):
event_str += "\n Action %s" % "---!!!---NONE---!!!---"
else:
event_str += "\n Action %s" % type(self.OP).__name__
event_str += "\n Source %s" % (self.OP.src)
event_str += "\n comment %s" % (self.OP.info)
return event_str
class SyncOperation(object):
def __init__(self, src, info, **kw):
self.src = src
self.info = info
for key, val in kw.items():
setattr(self, key, val)
def __str__(self):
return 'in__STR__'
class Create(SyncOperation):
pass
class Update(SyncOperation):
pass
class Delete(SyncOperation):
pass
class NothingToDo(SyncOperation):
pass
class Exclude(SyncOperation):
pass
class GoogleCalendar(models.AbstractModel):
STR_SERVICE = 'calendar'
_name = 'google.%s' % STR_SERVICE
_description = 'Google Calendar'
def generate_data(self, event, isCreating=False):
if event.allday:
start_date = fields.Date.to_string(event.start_date)
final_date = fields.Date.to_string(event.stop_date + timedelta(days=1))
type = 'date'
vstype = 'dateTime'
else:
start_date = fields.Datetime.context_timestamp(self, event.start).isoformat('T')
final_date = fields.Datetime.context_timestamp(self, event.stop).isoformat('T')
type = 'dateTime'
vstype = 'date'
attendee_list = []
for attendee in event.attendee_ids:
email = tools.email_split(attendee.email)
email = email[0] if email else 'NoEmail@mail.com'
attendee_list.append({
'email': email,
'displayName': attendee.partner_id.name,
'responseStatus': attendee.state or 'needsAction',
})
reminders = []
for alarm in event.alarm_ids:
reminders.append({
"method": "email" if alarm.alarm_type == "email" else "popup",
"minutes": alarm.duration_minutes
})
data = {
"summary": event.name or '',
"description": event.description or '',
"start": {
type: start_date,
vstype: None,
'timeZone': self.env.context.get('tz') or 'UTC',
},
"end": {
type: final_date,
vstype: None,
'timeZone': self.env.context.get('tz') or 'UTC',
},
"attendees": attendee_list,
"reminders": {
"overrides": reminders,
"useDefault": "false"
},
"location": event.location or '',
"visibility": event['privacy'] or 'public',
}
if event.recurrency and event.rrule:
data["recurrence"] = ["RRULE:" + event.rrule]
if not event.active:
data["state"] = "cancelled"
if not self.get_need_synchro_attendee():
data.pop("attendees")
if isCreating:
other_google_ids = [other_att.google_internal_event_id for other_att in event.attendee_ids
if other_att.google_internal_event_id and not other_att.google_internal_event_id.startswith('_')]
if other_google_ids:
data["id"] = other_google_ids[0]
return data
def create_an_event(self, event):
""" Create a new event in google calendar from the given event in Odoo.
:param event : record of calendar.event to export to google calendar
"""
data = self.generate_data(event, isCreating=True)
url = "/calendar/v3/calendars/%s/events?fields=%s&access_token=%s" % ('primary', urls.url_quote('id,updated'), self.get_token())
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
data_json = json.dumps(data)
return self.env['google.service']._do_request(url, data_json, headers, type='POST')
def delete_an_event(self, event_id):
""" Delete the given event in primary calendar of google cal.
:param event_id : google cal identifier of the event to delete
"""
params = {
'access_token': self.get_token()
}
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', event_id)
return self.env['google.service']._do_request(url, params, headers, type='DELETE')
def get_calendar_primary_id(self):
""" In google calendar, you can have multiple calendar. But only one is
the 'primary' one. This Calendar identifier is 'primary'.
"""
params = {
'fields': 'id',
'access_token': self.get_token()
}
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/primary"
try:
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
except requests.HTTPError as e:
if e.response.status_code == 401: # Token invalid / Acces unauthorized
error_msg = _("Your token is invalid or has been revoked !")
self.env.user.write({'google_calendar_token': False, 'google_calendar_token_validity': False})
self.env.cr.commit()
raise self.env['res.config.settings'].get_config_warning(error_msg)
raise
return (status_response(status), content['id'] or False, ask_time)
def get_event_synchro_dict(self, lastSync=False, token=False, nextPageToken=False):
""" Returns events on the 'primary' calendar from google cal.
:returns dict where the key is the google_cal event id, and the value the details of the event,
defined at https://developers.google.com/google-apps/calendar/v3/reference/events/list
"""
if not token:
token = self.get_token()
params = {
'fields': 'items,nextPageToken',
'access_token': token,
'maxResults': 1000,
}
if lastSync:
params['updatedMin'] = lastSync.strftime("%Y-%m-%dT%H:%M:%S.%fz")
params['showDeleted'] = True
else:
params['timeMin'] = self.get_minTime().strftime("%Y-%m-%dT%H:%M:%S.%fz")
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/%s/events" % 'primary'
if nextPageToken:
params['pageToken'] = nextPageToken
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
google_events_dict = {}
for google_event in content['items']:
google_events_dict[google_event['id']] = google_event
if content.get('nextPageToken'):
google_events_dict.update(
self.get_event_synchro_dict(lastSync=lastSync, token=token, nextPageToken=content['nextPageToken'])
)
return google_events_dict
def get_one_event_synchro(self, google_id):
token = self.get_token()
params = {
'access_token': token,
'maxResults': 1000,
'showDeleted': True,
}
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', google_id)
try:
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
except Exception as e:
_logger.info("Calendar Synchro - In except of get_one_event_synchro")
_logger.info(exception_to_unicode(e))
return False
return status_response(status) and content or False
def update_to_google(self, oe_event, google_event):
url = "/calendar/v3/calendars/%s/events/%s?fields=%s&access_token=%s" % ('primary', google_event['id'], 'id,updated', self.get_token())
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
data = self.generate_data(oe_event)
data['sequence'] = google_event.get('sequence', 0)
data_json = json.dumps(data)
status, content, ask_time = self.env['google.service']._do_request(url, data_json, headers, type='PATCH')
update_date = datetime.strptime(content['updated'], "%Y-%m-%dT%H:%M:%S.%fz")
oe_event.write({'oe_update_date': update_date})
if self.env.context.get('curr_attendee'):
self.env['calendar.attendee'].browse(self.env.context['curr_attendee']).write({'oe_synchro_date': update_date})
def update_an_event(self, event):
data = self.generate_data(event)
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', event.google_internal_event_id)
headers = {}
data['access_token'] = self.get_token()
status, response, ask_time = self.env['google.service']._do_request(url, data, headers, type='GET')
#TO_CHECK : , if http fail, no event, do DELETE ?
return response
def update_recurrent_event_exclu(self, instance_id, event_ori_google_id, event_new):
""" Update event on google calendar
:param instance_id : new google cal identifier
:param event_ori_google_id : origin google cal identifier
:param event_new : record of calendar.event to modify
"""
data = self.generate_data(event_new)
url = "/calendar/v3/calendars/%s/events/%s?access_token=%s" % ('primary', instance_id, self.get_token())
headers = {'Content-type': 'application/json'}
_originalStartTime = dict()
if event_new.allday:
_originalStartTime['date'] = event_new.recurrent_id_date.strftime("%Y-%m-%d")
else:
_originalStartTime['dateTime'] = event_new.recurrent_id_date.strftime("%Y-%m-%dT%H:%M:%S.%fz")
data.update(
recurringEventId=event_ori_google_id,
originalStartTime=_originalStartTime,
sequence=self.get_sequence(instance_id)
)
data_json = json.dumps(data)
return self.env['google.service']._do_request(url, data_json, headers, type='PUT')
def create_from_google(self, event, partner_id):
context_tmp = dict(self._context, NewMeeting=True)
res = self.with_context(context_tmp).update_from_google(False, event.GG.event, "create")
event.OE.event_id = res
meeting = self.env['calendar.event'].browse(res)
attendee_record = self.env['calendar.attendee'].search([('partner_id', '=', partner_id), ('event_id', '=', res)])
attendee_record.with_context(context_tmp).write({'oe_synchro_date': meeting.oe_update_date, 'google_internal_event_id': event.GG.event['id']})
if meeting.recurrency:
attendees = self.env['calendar.attendee'].sudo().search([('google_internal_event_id', '=ilike', '%s\_%%' % event.GG.event['id'])])
excluded_recurrent_event_ids = set(attendee.event_id for attendee in attendees)
for event in excluded_recurrent_event_ids:
event.write({'recurrent_id': meeting.id, 'recurrent_id_date': event.start, 'user_id': meeting.user_id.id})
return event
def update_from_google(self, event, single_event_dict, type):
""" Update an event in Odoo with information from google calendar
:param event : record od calendar.event to update
:param single_event_dict : dict of google cal event data
"""
CalendarEvent = self.env['calendar.event'].with_context(no_mail_to_attendees=True)
ResPartner = self.env['res.partner']
CalendarAlarm = self.env['calendar.alarm']
attendee_record = []
alarm_record = set()
partner_record = [(4, self.env.user.partner_id.id)]
result = {}
if self.get_need_synchro_attendee():
for google_attendee in single_event_dict.get('attendees', []):
partner_email = google_attendee.get('email')
if type == "write":
for oe_attendee in event['attendee_ids']:
if oe_attendee.email == google_attendee['email']:
oe_attendee.write({'state': google_attendee['responseStatus'], 'google_internal_event_id': single_event_dict.get('id')})
google_attendee['found'] = True
continue
if google_attendee.get('found'):
continue
attendee = ResPartner.search([('email', '=ilike', google_attendee['email']), ('user_ids', '!=', False)], limit=1)
if not attendee:
attendee = ResPartner.search([('email', '=ilike', google_attendee['email'])], limit=1)
if not attendee:
data = {
'email': partner_email,
'name': google_attendee.get("displayName", False) or partner_email
}
attendee = ResPartner.create(data)
attendee = attendee.read(['email'])[0]
partner_record.append((4, attendee.get('id')))
attendee['partner_id'] = attendee.pop('id')
attendee['state'] = google_attendee['responseStatus']
attendee_record.append((0, 0, attendee))
for google_alarm in single_event_dict.get('reminders', {}).get('overrides', []):
alarm = CalendarAlarm.search(
[
('alarm_type', '=', google_alarm['method'] if google_alarm['method'] == 'email' else 'notification'),
('duration_minutes', '=', google_alarm['minutes'])
], limit=1
)
if not alarm:
data = {
'alarm_type': google_alarm['method'] if google_alarm['method'] == 'email' else 'notification',
'duration': google_alarm['minutes'],
'interval': 'minutes',
'name': "%s minutes - %s" % (google_alarm['minutes'], google_alarm['method'])
}
alarm = CalendarAlarm.create(data)
alarm_record.add(alarm.id)
UTC = pytz.timezone('UTC')
if single_event_dict.get('start') and single_event_dict.get('end'): # If not cancelled
if single_event_dict['start'].get('dateTime', False) and single_event_dict['end'].get('dateTime', False):
date = parser.parse(single_event_dict['start']['dateTime'])
stop = parser.parse(single_event_dict['end']['dateTime'])
date = str(date.astimezone(UTC))[:-6]
stop = str(stop.astimezone(UTC))[:-6]
allday = False
else:
date = single_event_dict['start']['date']
stop = single_event_dict['end']['date']
d_end = fields.Date.from_string(stop)
allday = True
d_end = d_end + timedelta(days=-1)
stop = fields.Date.to_string(d_end)
update_date = datetime.strptime(single_event_dict['updated'], "%Y-%m-%dT%H:%M:%S.%fz")
result.update({
'start': date,
'stop': stop,
'allday': allday
})
result.update({
'attendee_ids': attendee_record,
'partner_ids': list(set(partner_record)),
'alarm_ids': [(6, 0, list(alarm_record))],
'name': single_event_dict.get('summary', 'Event'),
'description': single_event_dict.get('description', False),
'location': single_event_dict.get('location', False),
'privacy': single_event_dict.get('visibility', 'public'),
'oe_update_date': update_date,
})
if single_event_dict.get("recurrence", False):
rrule = [rule for rule in single_event_dict["recurrence"] if rule.startswith("RRULE:")][0][6:]
result['rrule'] = rrule
if type == "write":
res = CalendarEvent.browse(event['id']).write(result)
elif type == "copy":
result['recurrency'] = True
res = CalendarEvent.browse([event['id']]).write(result)
elif type == "create":
res = CalendarEvent.create(result).id
if self.env.context.get('curr_attendee'):
self.env['calendar.attendee'].with_context(no_mail_to_attendees=True).browse([self.env.context['curr_attendee']]).write({'oe_synchro_date': update_date, 'google_internal_event_id': single_event_dict.get('id', False)})
return res
def remove_references(self):
current_user = self.env.user
reset_data = {
'google_calendar_rtoken': False,
'google_calendar_token': False,
'google_calendar_token_validity': False,
'google_calendar_last_sync_date': False,
'google_calendar_cal_id': False,
}
all_my_attendees = self.env['calendar.attendee'].search([('partner_id', '=', current_user.partner_id.id)])
all_my_attendees.write({'oe_synchro_date': False, 'google_internal_event_id': False})
return current_user.write(reset_data)
@api.model
def synchronize_events_cron(self):
""" Call by the cron. """
domain = [('google_calendar_last_sync_date', '!=', False)]
if self.env.context.get('last_sync_hours'):
last_sync_hours = self.env.context['last_sync_hours']
last_sync_date = datetime.now() - timedelta(hours=last_sync_hours)
domain = expression.AND([
domain,
[('google_calendar_last_sync_date', '<=', fields.Datetime.to_string(last_sync_date))]
])
users = self.env['res.users'].search(domain, order='google_calendar_last_sync_date')
_logger.info("Calendar Synchro - Started by cron")
for user_to_sync in users.ids:
_logger.info("Calendar Synchro - Starting synchronization for a new user [%s]", user_to_sync)
try:
resp = self.with_user(user_to_sync).synchronize_events(lastSync=True)
if resp.get("status") == "need_reset":
_logger.info("[%s] Calendar Synchro - Failed - NEED RESET !", user_to_sync)
else:
_logger.info("[%s] Calendar Synchro - Done with status : %s !", user_to_sync, resp.get("status"))
except Exception as e:
_logger.info("[%s] Calendar Synchro - Exception : %s !", user_to_sync, exception_to_unicode(e))
_logger.info("Calendar Synchro - Ended by cron")
def synchronize_events(self, lastSync=True):
""" This method should be called as the user to sync. """
user_to_sync = self.ids and self.ids[0] or self.env.uid
current_user = self.env['res.users'].sudo().browse(user_to_sync)
recs = self.with_user(user_to_sync)
status, current_google, ask_time = recs.get_calendar_primary_id()
if current_user.google_calendar_cal_id:
if current_google != current_user.google_calendar_cal_id:
return {
"status": "need_reset",
"info": {
"old_name": current_user.google_calendar_cal_id,
"new_name": current_google
},
"url": ''
}
if lastSync and recs.get_last_sync_date() and not recs.get_disable_since_synchro():
lastSync = recs.get_last_sync_date()
_logger.info("[%s] Calendar Synchro - MODE SINCE_MODIFIED : %s !", user_to_sync, fields.Datetime.to_string(lastSync))
else:
lastSync = False
_logger.info("[%s] Calendar Synchro - MODE FULL SYNCHRO FORCED", user_to_sync)
else:
current_user.write({'google_calendar_cal_id': current_google})
lastSync = False
_logger.info("[%s] Calendar Synchro - MODE FULL SYNCHRO - NEW CAL ID", user_to_sync)
new_ids = []
new_ids += recs.create_new_events()
new_ids += recs.bind_recurring_events_to_google()
res = recs.update_events(lastSync)
current_user.write({'google_calendar_last_sync_date': ask_time})
return {
"status": res and "need_refresh" or "no_new_event_from_google",
"url": ''
}
def create_new_events(self):
""" Create event in google calendar for the event not already
synchronized, for the current user.
:returns list of new created event identifier in google calendar
"""
new_ids = []
my_partner_id = self.env.user.partner_id.id
my_attendees = self.env['calendar.attendee'].with_context(virtual_id=False).search([('partner_id', '=', my_partner_id),
('google_internal_event_id', '=', False),
'|',
('event_id.stop', '>', fields.Datetime.to_string(self.get_minTime())),
('event_id.final_date', '>', fields.Datetime.to_string(self.get_minTime())),
])
for att in my_attendees:
other_google_ids = [other_att.google_internal_event_id for other_att in att.event_id.attendee_ids if
other_att.google_internal_event_id and other_att.id != att.id and not other_att.google_internal_event_id.startswith('_')]
for other_google_id in other_google_ids:
if self.get_one_event_synchro(other_google_id):
att.write({'google_internal_event_id': other_google_id})
break
else:
if not att.event_id.recurrent_id or att.event_id.recurrent_id == 0:
status, response, ask_time = self.create_an_event(att.event_id)
if status_response(status):
update_date = datetime.strptime(response['updated'], "%Y-%m-%dT%H:%M:%S.%fz")
att.event_id.write({'oe_update_date': update_date})
new_ids.append(response['id'])
att.write({'google_internal_event_id': response['id'], 'oe_synchro_date': update_date})
self.env.cr.commit()
else:
_logger.warning("Impossible to create event %s. [%s] Enable DEBUG for response detail.", att.event_id.id, status)
_logger.debug("Response : %s", response)
return new_ids
def get_context_no_virtual(self):
""" get the current context modified to prevent virtual ids and active test. """
return dict(self.env.context, virtual_id=False, active_test=False)
def bind_recurring_events_to_google(self):
new_ids = []
CalendarAttendee = self.env['calendar.attendee']
my_partner_id = self.env.user.partner_id.id
context_norecurrent = self.get_context_no_virtual()
my_attendees = CalendarAttendee.with_context(context_norecurrent).search([('partner_id', '=', my_partner_id), ('google_internal_event_id', '=', False)])
for att in my_attendees:
new_google_internal_event_id = False
source_event_record = self.env['calendar.event'].browse(att.event_id.recurrent_id)
source_attendee_record = CalendarAttendee.search([('partner_id', '=', my_partner_id), ('event_id', '=', source_event_record.id)], limit=1)
if not source_attendee_record:
continue
recurrent_id_date = fields.Datetime.to_string(att.event_id.recurrent_id_date)
if recurrent_id_date and source_event_record.allday and source_attendee_record.google_internal_event_id:
new_google_internal_event_id = source_attendee_record.google_internal_event_id + '_' + recurrent_id_date.split(' ')[0].replace('-', '')
elif recurrent_id_date and source_attendee_record.google_internal_event_id:
new_google_internal_event_id = source_attendee_record.google_internal_event_id + '_' + recurrent_id_date.replace('-', '').replace(' ', 'T').replace(':', '') + 'Z'
if new_google_internal_event_id:
#TODO WARNING, NEED TO CHECK THAT EVENT and ALL instance NOT DELETE IN GMAIL BEFORE !
try:
status, response, ask_time = self.update_recurrent_event_exclu(new_google_internal_event_id, source_attendee_record.google_internal_event_id, att.event_id)
if status_response(status):
att.write({'google_internal_event_id': new_google_internal_event_id})
new_ids.append(new_google_internal_event_id)
self.env.cr.commit()
else:
_logger.warning("Impossible to create event %s. [%s]", att.event_id.id, status)
_logger.debug("Response : %s", response)
except Exception as e:
_logger.warning("Exception when updating recurrent event exclusions on google: %s", e)
return new_ids
def update_events(self, lastSync=False):
""" Synchronze events with google calendar : fetching, creating, updating, deleting, ... """
CalendarEvent = self.env['calendar.event']
CalendarAttendee = self.env['calendar.attendee']
my_partner_id = self.env.user.partner_id.id
context_novirtual = self.get_context_no_virtual()
if lastSync:
try:
all_event_from_google = self.get_event_synchro_dict(lastSync=lastSync)
except requests.HTTPError as e:
if e.response.status_code == 410: # GONE, Google is lost.
# we need to force the rollback from this cursor, because it locks my res_users but I need to write in this tuple before to raise.
self.env.cr.rollback()
self.env.user.write({'google_calendar_last_sync_date': False})
self.env.cr.commit()
error_key = e.response.json()
error_key = error_key.get('error', {}).get('message', 'nc')
error_msg = _("Google is lost... the next synchro will be a full synchro. \n\n %s") % error_key
raise self.env['res.config.settings'].get_config_warning(error_msg)
my_google_attendees = CalendarAttendee.with_context(context_novirtual).search([
('partner_id', '=', my_partner_id),
('google_internal_event_id', 'in', list(all_event_from_google))
])
my_google_att_ids = my_google_attendees.ids
my_odoo_attendees = CalendarAttendee.with_context(context_novirtual).search([
('partner_id', '=', my_partner_id),
('event_id.oe_update_date', '>', lastSync and fields.Datetime.to_string(lastSync) or self.get_minTime().fields.Datetime.to_string()),
('google_internal_event_id', '!=', False),
])
my_odoo_googleinternal_records = my_odoo_attendees.read(['google_internal_event_id', 'event_id'])
if self.get_print_log():
_logger.info("Calendar Synchro - \n\nUPDATE IN GOOGLE\n%s\n\nRETRIEVE FROM OE\n%s\n\nUPDATE IN OE\n%s\n\nRETRIEVE FROM GG\n%s\n\n", all_event_from_google, my_google_att_ids, my_odoo_attendees.ids, my_odoo_googleinternal_records)
for gi_record in my_odoo_googleinternal_records:
active = True # if not sure, we request google
if gi_record.get('event_id'):
active = CalendarEvent.with_context(context_novirtual).browse(int(gi_record.get('event_id')[0])).active
if gi_record.get('google_internal_event_id') and not all_event_from_google.get(gi_record.get('google_internal_event_id')) and active:
one_event = self.get_one_event_synchro(gi_record.get('google_internal_event_id'))
if one_event:
all_event_from_google[one_event['id']] = one_event
my_attendees = (my_google_attendees | my_odoo_attendees)
else:
domain = [
('partner_id', '=', my_partner_id),
('google_internal_event_id', '!=', False),
'|',
('event_id.stop', '>', fields.Datetime.to_string(self.get_minTime())),
('event_id.final_date', '>', fields.Datetime.to_string(self.get_minTime())),
]
# Select all events from Odoo which have been already synchronized in gmail
my_attendees = CalendarAttendee.with_context(context_novirtual).search(domain)
all_event_from_google = self.get_event_synchro_dict(lastSync=False)
event_to_synchronize = {}
for att in my_attendees:
event = att.event_id
base_event_id = att.google_internal_event_id.rsplit('_', 1)[0]
if base_event_id not in event_to_synchronize:
event_to_synchronize[base_event_id] = {}
if att.google_internal_event_id not in event_to_synchronize[base_event_id]:
event_to_synchronize[base_event_id][att.google_internal_event_id] = SyncEvent()
ev_to_sync = event_to_synchronize[base_event_id][att.google_internal_event_id]
ev_to_sync.OE.attendee_id = att.id
ev_to_sync.OE.event = event
ev_to_sync.OE.found = True
ev_to_sync.OE.event_id = event.id
ev_to_sync.OE.isRecurrence = event.recurrency
ev_to_sync.OE.isInstance = bool(event.recurrent_id and event.recurrent_id > 0)
ev_to_sync.OE.update = event.oe_update_date
ev_to_sync.OE.status = event.active
ev_to_sync.OE.synchro = att.oe_synchro_date
for event in all_event_from_google.values():
event_id = event.get('id')
base_event_id = event_id.rsplit('_', 1)[0]
if base_event_id not in event_to_synchronize:
event_to_synchronize[base_event_id] = {}
if event_id not in event_to_synchronize[base_event_id]:
event_to_synchronize[base_event_id][event_id] = SyncEvent()
ev_to_sync = event_to_synchronize[base_event_id][event_id]
ev_to_sync.GG.event = event
ev_to_sync.GG.found = True
ev_to_sync.GG.isRecurrence = bool(event.get('recurrence', ''))
ev_to_sync.GG.isInstance = bool(event.get('recurringEventId', 0))
ev_to_sync.GG.update = event.get('updated') and parser.parse(event['updated']) or None # if deleted, no date without browse event
if ev_to_sync.GG.update:
ev_to_sync.GG.update = ev_to_sync.GG.update.replace(tzinfo=None)
ev_to_sync.GG.status = (event.get('status') != 'cancelled')
######################
# PRE-PROCESSING #
######################
for base_event in event_to_synchronize:
for current_event in event_to_synchronize[base_event]:
event_to_synchronize[base_event][current_event].compute_OP(modeFull=not lastSync)
if self.get_print_log():
if not isinstance(event_to_synchronize[base_event][current_event].OP, NothingToDo):
_logger.info(event_to_synchronize[base_event])
######################
# DO ACTION #
######################
for base_event in event_to_synchronize:
event_to_synchronize[base_event] = sorted(event_to_synchronize[base_event].items(), key=operator.itemgetter(0))
for current_event in event_to_synchronize[base_event]:
self.env.cr.commit()
event = current_event[1] # event is an Sync Event !
actToDo = event.OP
actSrc = event.OP.src
# To avoid redefining 'self', all method below should use 'recs' instead of 'self'
recs = self.with_context(curr_attendee=event.OE.attendee_id)
if isinstance(actToDo, NothingToDo):
continue
elif isinstance(actToDo, Create):
if actSrc == 'GG':
self.create_from_google(event, my_partner_id)
elif actSrc == 'OE':
raise AssertionError("Should be never here, creation for OE is done before update !")
#TODO Add to batch
elif isinstance(actToDo, Update):
if actSrc == 'GG':
recs.update_from_google(event.OE.event, event.GG.event, 'write')
elif actSrc == 'OE':
recs.update_to_google(event.OE.event, event.GG.event)
elif isinstance(actToDo, Exclude):
if actSrc == 'OE':
recs.delete_an_event(current_event[0])
elif actSrc == 'GG':
new_google_event_id = event.GG.event['id'].rsplit('_', 1)[1]
parent_oe = event_to_synchronize[base_event][0][1].OE.event
if 'T' in new_google_event_id:
new_google_event_id = new_google_event_id.replace('T', '')[:-1]
else:
#allday event, need to match the changes that will be applied with _inverse_dates otherwise the exclusion will not occur
if parent_oe:
new_google_event_id = new_google_event_id + parent_oe.start.strftime("%H%M%S")
else:
new_google_event_id = new_google_event_id + "000000"
if event.GG.status:
parent_event = {}
if not event_to_synchronize[base_event][0][1].OE.event_id:
main_ev = CalendarAttendee.with_context(context_novirtual).search([('google_internal_event_id', '=', event.GG.event['id'].rsplit('_', 1)[0])], limit=1)
event_to_synchronize[base_event][0][1].OE.event_id = main_ev.event_id.id
if event_to_synchronize[base_event][0][1].OE.event_id:
parent_event['id'] = "%s-%s" % (event_to_synchronize[base_event][0][1].OE.event_id, new_google_event_id)
res = recs.update_from_google(parent_event, event.GG.event, "copy")
else:
recs.create_from_google(event, my_partner_id)
else:
if parent_oe:
CalendarEvent.browse("%s-%s" % (parent_oe.id, new_google_event_id)).with_context(curr_attendee=event.OE.attendee_id).unlink(can_be_deleted=True)
else:
main_att = CalendarAttendee.with_context(context_novirtual).search([('partner_id', '=', my_partner_id), ('google_internal_event_id', '=', event.GG.event['id'].rsplit('_', 1)[0])], limit=1)
if main_att:
excluded_event_id = str(main_att.event_id.id) + '-' + new_google_event_id
CalendarEvent.browse(excluded_event_id).with_context(google_internal_event_id=event.GG.event.get('id'), oe_update_date=False).unlink(can_be_deleted=False)
else:
_logger.warning("Could not create the correct exclusion for event")
elif isinstance(actToDo, Delete):
if actSrc == 'GG':
try:
# if already deleted from gmail or never created
recs.delete_an_event(current_event[0])
except requests.exceptions.HTTPError as e:
if e.response.status_code in (401, 410,):
pass
else:
raise e
elif actSrc == 'OE':
CalendarEvent.browse(event.OE.event_id).unlink(can_be_deleted=False)
return True
def check_and_sync(self, oe_event, google_event):
if oe_event.oe_update_date > datetime.strptime(google_event['updated'], "%Y-%m-%dT%H:%M:%S.%fz"):
self.update_to_google(oe_event, google_event)
elif oe_event.oe_update_date < datetime.strptime(google_event['updated'], "%Y-%m-%dT%H:%M:%S.%fz"):
self.update_from_google(oe_event, google_event, 'write')
def get_sequence(self, instance_id):
params = {
'fields': 'sequence',
'access_token': self.get_token()
}
headers = {'Content-type': 'application/json'}
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', instance_id)
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
return content.get('sequence', 0)
#################################
## MANAGE CONNEXION TO GMAIL ##
#################################
def get_token(self):
current_user = self.env.user
if not current_user.google_calendar_token_validity or \
current_user.google_calendar_token_validity < (datetime.now() + timedelta(minutes=1)):
self.do_refresh_token()
current_user.refresh()
return current_user.google_calendar_token
def get_last_sync_date(self):
current_user = self.env.user
return current_user.google_calendar_last_sync_date and fields.Datetime.from_string(current_user.google_calendar_last_sync_date) + timedelta(minutes=0) or False
def do_refresh_token(self):
current_user = self.env.user
all_token = self.env['google.service']._refresh_google_token_json(current_user.google_calendar_rtoken, self.STR_SERVICE)
vals = {}
vals['google_%s_token_validity' % self.STR_SERVICE] = datetime.now() + timedelta(seconds=all_token.get('expires_in'))
vals['google_%s_token' % self.STR_SERVICE] = all_token.get('access_token')
self.env.user.sudo().write(vals)
def need_authorize(self):
current_user = self.env.user
return current_user.google_calendar_rtoken is False
def get_calendar_scope(self, RO=False):
readonly = '.readonly' if RO else ''
return 'https://www.googleapis.com/auth/calendar%s' % (readonly)
def authorize_google_uri(self, from_url='http://www.odoo.com'):
url = self.env['google.service']._get_authorize_uri(from_url, self.STR_SERVICE, scope=self.get_calendar_scope())
return url
def can_authorize_google(self):
return self.env['res.users'].has_group('base.group_erp_manager')
@api.model
def set_all_tokens(self, authorization_code):
all_token = self.env['google.service']._get_google_token_json(authorization_code, self.STR_SERVICE)
vals = {}
vals['google_%s_rtoken' % self.STR_SERVICE] = all_token.get('refresh_token')
vals['google_%s_token_validity' % self.STR_SERVICE] = datetime.now() + timedelta(seconds=all_token.get('expires_in'))
vals['google_%s_token' % self.STR_SERVICE] = all_token.get('access_token')
self.env.user.sudo().write(vals)
def get_minTime(self):
number_of_week = self.env['ir.config_parameter'].sudo().get_param('calendar.week_synchro', default=13)
return datetime.now() - timedelta(weeks=int(number_of_week))
def get_need_synchro_attendee(self):
return self.env['ir.config_parameter'].sudo().get_param('calendar.block_synchro_attendee', default=True)
def get_disable_since_synchro(self):
return self.env['ir.config_parameter'].sudo().get_param('calendar.block_since_synchro', default=False)
def get_print_log(self):
return self.env['ir.config_parameter'].sudo().get_param('calendar.debug_print', default=False)
@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
import pytz
from functools import wraps
from dateutil.parser import parse
from contextlib import contextmanager
from odoo import api, fields, models, registry, _
from odoo.tools import ormcache_context
from odoo.exceptions import UserError
from odoo.osv import expression
from addons.google_calendar.utils.google_event import GoogleEvent
from addons.google_calendar.utils.google_calendar import GoogleCalendarService
from addons.google_account.models.google_service import TIMEOUT
_logger = logging.getLogger(__name__)
# API requests are sent to Google Calendar after the current transaction ends.
# This ensures changes are sent to Google only if they really happened in the Odoo database.
# It is particularly important for event creation , otherwise the event might be created
# twice in Google if the first creation crashed in Odoo.
def after_commit(func):
@wraps(func)
def wrapped(self, *args, **kwargs):
dbname = self.env.cr.dbname
context = self.env.context
uid = self.env.uid
def called_after():
db_registry = registry(dbname)
with api.Environment.manage(), db_registry.cursor() as cr:
env = api.Environment(cr, uid, context)
try:
func(self.with_env(env), *args, **kwargs)
except Exception as e:
_logger.warning("Could not sync record now: %s" % self)
_logger.exception(e)
self.env.cr.after('commit', called_after)
return wrapped
@contextmanager
def google_calendar_token(user):
try:
yield user._get_google_calendar_token()
except requests.HTTPError as e:
if e.response.status_code == 401: # Invalid token.
# The transaction should be rolledback, but the user's tokens
# should be reset. The user will be asked to authenticate again next time.
# Rollback manually first to avoid concurrent access errors/deadlocks.
user.env.cr.rollback()
with user.pool.cursor() as cr:
env= user.env(cr=cr)
user.with_env(env)._set_auth_tokens(False, False, 0)
raise e
class GoogleSync(models.AbstractModel):
_name = 'google.calendar.sync'
_description = "Synchronize a record with Google Calendar"
google_id = fields.Char('Google Calendar Id', copy=False)
need_sync = fields.Boolean(default=True, copy=False)
active = fields.Boolean(default=True)
def write(self, vals):
google_service = GoogleCalendarService(self.env['google.service'])
if 'google_id' in vals:
self._from_google_ids.clear_cache(self)
synced_fields = self._get_google_synced_fields()
if 'need_sync' not in vals and vals.keys() & synced_fields:
vals['need_sync'] = True
result = super().write(vals)
for record in self.filtered('need_sync'):
if record.google_id:
record._google_patch(google_service, record.google_id, record._google_values(), timeout=3)
return result
@api.model_create_multi
def create(self, vals_list):
if any(vals.get('google_id') for vals in vals_list):
self._from_google_ids.clear_cache(self)
records = super().create(vals_list)
google_service = GoogleCalendarService(self.env['google.service'])
records_to_sync = records.filtered(lambda r: r.need_sync and r.active)
for record in records_to_sync:
record._google_insert(google_service, record._google_values(), timeout=3)
return records
def unlink(self):
"""We can't delete an event that is also in Google Calendar. Otherwise we would
have no clue that the event must must deleted from Google Calendar at the next sync.
"""
synced = self.filtered('google_id')
# LUL TODO find a way to get rid of this context key
if self.env.context.get('archive_on_error') and self._active_name:
synced.write({self._active_name: False})
self = self - synced
elif synced:
raise UserError(_("You cannot delete a record synchronized with Google Calendar, archive it instead."))
return super().unlink()
@api.model
@ormcache_context('google_ids', keys=('active_test',))
def _from_google_ids(self, google_ids):
if not google_ids:
return self.browse()
return self.search([('google_id', 'in', google_ids)])
def _sync_odoo2google(self, google_service: GoogleCalendarService):
if not self:
return
if self._active_name:
records_to_sync = self.filtered(self._active_name)
else:
records_to_sync = self
cancelled_records = self - records_to_sync
updated_records = records_to_sync.filtered('google_id')
new_records = records_to_sync - updated_records
for record in cancelled_records.filtered('google_id'):
record._google_delete(google_service, record.google_id)
for record in new_records:
record._google_insert(google_service, record._google_values())
for record in updated_records:
record._google_patch(google_service, record.google_id, record._google_values())
def _cancel(self):
self.google_id = False
self.unlink()
@api.model
def _sync_google2odoo(self, google_events: GoogleEvent, default_reminders=()):
"""Synchronize Google recurrences in Odoo. Creates new recurrences, updates
existing ones.
:param google_recurrences: Google recurrences to synchronize in Odoo
:return: synchronized odoo recurrences
"""
existing = google_events.exists(self.env)
new = google_events - existing - google_events.cancelled()
odoo_values = [
dict(self._odoo_values(e, default_reminders), need_sync=False)
for e in new
]
new_odoo = self.create(odoo_values)
cancelled = existing.cancelled()
cancelled_odoo = self.browse(cancelled.odoo_ids(self.env))
cancelled_odoo._cancel()
synced_records = new_odoo + cancelled_odoo
for gevent in existing - cancelled :
# Last updated wins.
# 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))
if updated >= pytz.utc.localize(odoo_record.write_date):
vals = dict(self._odoo_values(gevent, default_reminders), need_sync=False)
odoo_record.write(vals)
synced_records |= odoo_record
return synced_records
@after_commit
def _google_delete(self, google_service: GoogleCalendarService, google_id, timeout=TIMEOUT):
print('DELETE', google_id)
with google_calendar_token(self.env.user.sudo()) as token:
if token:
google_service.delete(google_id, token=token, timeout=timeout)
self.need_sync = False
@after_commit
def _google_patch(self, google_service: GoogleCalendarService, google_id, values, timeout=TIMEOUT):
print('Patch', google_id)
with google_calendar_token(self.env.user.sudo()) as token:
if token:
google_service.patch(google_id, values, token=token, timeout=timeout)
self.need_sync = False
@after_commit
def _google_insert(self, google_service: GoogleCalendarService, values, timeout=TIMEOUT):
print('insert')
if not values:
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,
})
def _get_records_to_sync(self, full_sync=False):
"""Return records that should be synced from Odoo to Google
:param full_sync: If True, all events attended by the user are returned
:return: events
"""
domain = self._get_sync_domain()
if not full_sync:
is_active_clause = (self._active_name, '=', True) if self._active_name else expression.TRUE_LEAF
domain = expression.AND([domain, [
'|',
'&', ('google_id', '=', False), is_active_clause,
('need_sync', '=', True),
]])
return self.with_context(active_test=False).search(domain)
@api.model
def _odoo_values(self, google_event: GoogleEvent, default_reminders=()):
"""Implements this method to return a dict of Odoo values corresponding
to the Google event given as parameter
:return: dict of Odoo formatted values
"""
raise NotImplementedError()
def _google_values(self):
"""Implements this method to return a dict with values formatted
according to the Google Calendar API
:return: dict of Google formatted values
"""
raise NotImplementedError()
def _get_sync_domain(self):
"""Return a domain used to search records to synchronize.
e.g. return a domain to synchronize records owned by the current user.
"""
raise NotImplementedError()
def _get_google_synced_fields(self):
"""Return a set of field names. Changing one of these fields
marks the record to be re-synchronized.
"""
raise NotImplementedError()
@@ -9,13 +9,3 @@ class ResConfigSettings(models.TransientModel):
cal_client_id = fields.Char("Client_id", config_parameter='google_calendar_client_id', default='')
cal_client_secret = fields.Char("Client_key", config_parameter='google_calendar_client_secret', default='')
server_uri = fields.Char('URI for tuto')
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
get_param = self.env['ir.config_parameter'].sudo().get_param
res.update(
server_uri="%s/google_account/authentication" % get_param('web.base.url', default="http://yourcompany.odoo.com"),
)
return res
+106 -5
View File
@@ -1,15 +1,116 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
import logging
import requests
from addons.google_calendar.models.google_sync import google_calendar_token
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.loglevels import exception_to_unicode
from odoo.addons.google_account.models.google_service import GOOGLE_TOKEN_ENDPOINT
from addons.google_calendar.utils.google_calendar import GoogleCalendarService, InvalidSyncToken
_logger = logging.getLogger(__name__)
class User(models.Model):
_inherit = 'res.users'
google_calendar_rtoken = fields.Char('Refresh Token', copy=False)
google_calendar_token = fields.Char('User token', copy=False)
google_calendar_rtoken = fields.Char('Refresh Token', copy=False, groups="base.group_system")
google_calendar_token = fields.Char('User token', copy=False, groups="base.group_system")
google_calendar_token_validity = fields.Datetime('Token Validity', copy=False)
google_calendar_last_sync_date = fields.Datetime('Last synchro date', copy=False)
google_calendar_sync_token = fields.Char('Next Sync Token', copy=False)
google_calendar_cal_id = fields.Char('Calendar ID', copy=False, help='Last Calendar ID who has been synchronized. If it is changed, we remove all links between GoogleID and Odoo Google Internal ID')
def _set_auth_tokens(self, access_token, refresh_token, ttl):
self.write({
'google_calendar_rtoken': refresh_token,
'google_calendar_token': access_token,
'google_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl) if ttl else False,
})
def _google_calendar_authenticated(self):
return bool(self.sudo().google_calendar_rtoken)
def _get_google_calendar_token(self):
self.ensure_one()
if self._is_google_calendar_valid():
self._refresh_google_calendar_token()
return self.google_calendar_token
def _is_google_calendar_valid(self):
return self.google_calendar_token_validity and self.google_calendar_token_validity < (fields.Datetime.now() + timedelta(minutes=1))
def _refresh_google_calendar_token(self):
# LUL TODO similar code exists in google_drive. Should be factorized in google_account
self.ensure_one()
get_param = self.env['ir.config_parameter'].sudo().get_param
client_id = get_param('google_calendar_client_id')
client_secret = get_param('google_calendar_client_secret')
if not client_id or not client_secret:
raise UserError(_("The account for the Google Calendar service is not configured."))
headers = {"content-type": "application/x-www-form-urlencoded"}
data = {
'refresh_token': self.google_calendar_rtoken,
'client_id': client_id,
'client_secret': client_secret,
'grant_type': 'refresh_token',
}
try:
dummy, response, dummy = self.env['google.service']._do_request(GOOGLE_TOKEN_ENDPOINT, params=data, headers=headers, type='POST', preuri='')
ttl = response.get('expires_in')
self.write({
'google_calendar_token': response.get('access_token'),
'google_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl),
})
except requests.HTTPError as error:
if error.response.status_code == 400: # invalid grant
# Delete refresh token and make sure it's commited
with self.pool.cursor() as cr:
self.env.user.with_env(self.env(cr=cr)).write({'google_calendar_rtoken': False})
error_key = error.response.json().get("error", "nc")
error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired [%s]") % error_key
raise UserError(error_msg)
def _sync_google_calendar(self, calendar_service: GoogleCalendarService):
self.ensure_one()
full_sync = not bool(self.google_calendar_sync_token)
with google_calendar_token(self) as token:
try:
events, next_sync_token, default_reminders = calendar_service.get_events(self.google_calendar_sync_token, token=token)
except InvalidSyncToken:
events, next_sync_token, default_reminders = calendar_service.get_events(token=token)
full_sync = True
self.google_calendar_sync_token = next_sync_token
# Google -> Odoo
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)
# Odoo -> Google
recurrences = self.env['calendar.recurrence']._get_records_to_sync(full_sync=full_sync)
recurrences -= synced_recurrences
recurrences._sync_odoo2google(calendar_service)
synced_events |= recurrences.calendar_event_ids - recurrences._get_outliers()
events = self.env['calendar.event']._get_records_to_sync(full_sync=full_sync)
(events - synced_events)._sync_odoo2google(calendar_service)
return bool(events | synced_events) or bool(recurrences | synced_recurrences)
@api.model
def _sync_all_google_calendar(self):
""" Cron job """
users = self.env['res.users'].search([('google_calendar_rtoken', '!=', False)])
google = GoogleCalendarService(self.env['google.service'])
for user in users:
_logger.info("Calendar Synchro - Starting synchronization for %s", user)
try:
user.with_user(user).sudo()._sync_google_calendar(google)
except Exception as e:
_logger.exception("[%s] Calendar Synchro - Exception : %s !", user, exception_to_unicode(e))
@@ -1,3 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_google_calendar_all,access_google_calendar_all,model_google_calendar,,1,0,0,0
access_google_calendar,access_google_calendar,model_google_calendar,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_google_calendar_all access_google_calendar_all model_google_calendar 1 0 0 0
access_google_calendar access_google_calendar model_google_calendar base.group_system 1 1 1 1
@@ -1,19 +1,58 @@
odoo.define('google_calendar.google_calendar', function (require) {
odoo.define('google_calendar.CalendarView', function (require) {
"use strict";
var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var CalendarRenderer = require('web.CalendarRenderer');
var CalendarController = require('web.CalendarController');
const CalendarView = require('calendar.CalendarView');
const CalendarRenderer = require('calendar.CalendarRenderer');
const CalendarController = require('calendar.CalendarController');
const CalendarModel = require('calendar.CalendarModel');
const viewRegistry = require('web.view_registry');
var _t = core._t;
CalendarController.include({
const GoogleCalendarModel = CalendarModel.extend({
/**
* @override
* @returns {Promise}
*/
async _loadCalendar() {
const _super = this._super.bind(this);
try {
await Promise.race([
new Promise(resolve => setTimeout(resolve, 1000)),
this._syncCalendar(true)
]);
} catch (error) {
if (error.event) {
error.event.preventDefault();
}
console.error("Could not synchronize Google events now.", error);
}
return _super(...arguments);
},
_syncCalendar(shadow = false) {
var context = this.getSession().user_context;
return this._rpc({
route: '/google_calendar/sync_data',
params: {
model: this.modelName,
fromurl: window.location.href,
local_context: context, // LUL TODO remove this local_context
}
}, {shadow});
},
})
const GoogleCalendarController = CalendarController.extend({
custom_events: _.extend({}, CalendarController.prototype.custom_events, {
syncCalendar: '_onSyncCalendar',
}),
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -28,16 +67,8 @@ CalendarController.include({
*/
_onSyncCalendar: function (event) {
var self = this;
var context = this.getSession().user_context;
this._rpc({
route: '/google_calendar/sync_data',
params: {
model: this.modelName,
fromurl: window.location.href,
local_context: context,
}
}).then(function (o) {
return this.model._syncCalendar().then(function (o) {
if (o.status === "need_auth") {
Dialog.alert(self, _t("You will be redirected to Google to authorize access to your calendar!"), {
confirm_callback: function() {
@@ -60,40 +91,12 @@ CalendarController.include({
}
} else if (o.status === "need_refresh") {
self.reload();
} else if (o.status === "need_reset") {
var confirmText1 = _t("The account you are trying to synchronize (%s) is not the same as the last one used (%s)!");
var confirmText2 = _t("In order to do this, you first need to disconnect all existing events from the old account.");
var confirmText3 = _t("Do you want to do this now?");
var text = _.str.sprintf(confirmText1 + "\n" + confirmText2 + "\n\n" + confirmText3, o.info.new_name, o.info.old_name);
Dialog.confirm(self, text, {
confirm_callback: function() {
self._rpc({
route: '/google_calendar/remove_references',
params: {
model: self.state.model,
local_context: context,
},
})
.then(function(o) {
if (o.status === "OK") {
Dialog.alert(self, _t("All events have been disconnected from your previous account. You can now restart the synchronization"), {
title: _t('Event disconnection success'),
});
} else if (o.status === "KO") {
Dialog.alert(self, _t("An error occured while disconnecting events from your previous account. Please retry or contact your administrator."), {
title: _t('Event disconnection error'),
});
} // else NOP
});
},
title: _t('Accounts'),
});
}
}).then(event.data.on_always, event.data.on_always);
}
});
CalendarRenderer.include({
const GoogleCalendarRenderer = CalendarRenderer.extend({
events: _.extend({}, CalendarRenderer.prototype.events, {
'click .o_google_sync_button': '_onSyncCalendar',
}),
@@ -142,4 +145,21 @@ CalendarRenderer.include({
},
});
var GoogleCalendarView = CalendarView.extend({
config: _.extend({}, CalendarView.prototype.config, {
Controller: GoogleCalendarController,
Model: GoogleCalendarModel,
Renderer: GoogleCalendarRenderer,
}),
});
viewRegistry.add('google_sync_calendar', GoogleCalendarView);
return {
GoogleCalendarView,
GoogleCalendarController,
GoogleCalendarModel,
GoogleCalendarRenderer,
};
});
@@ -1,7 +1,7 @@
odoo.define('google_calendar.calendar_tests', function (require) {
"use strict";
var CalendarView = require('web.CalendarView');
var { GoogleCalendarView } = require('google_calendar.CalendarView');
var testUtils = require('web.test_utils');
var createCalendarView = testUtils.createCalendarView;
@@ -70,14 +70,15 @@ QUnit.module('Google Calendar', {
}, function () {
QUnit.test('sync google calendar', async function (assert) {
assert.expect(6);
assert.expect(8);
var calendar = await createCalendarView({
View: CalendarView,
View: GoogleCalendarView,
model: 'calendar.event',
data: this.data,
arch:
'<calendar class="o_calendar_test" '+
'js_class="google_sync_calendar" '+
'date_start="start" '+
'date_stop="stop" '+
'mode="month">'+
@@ -100,17 +101,19 @@ QUnit.module('Google Calendar', {
},
});
assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the month");
assert.containsN(calendar, '.fc-event', 3, "should display 3 events on the month");
await testUtils.dom.click(calendar.$('.o_google_sync_button'));
assert.verifySteps([
'/google_calendar/sync_data',
'/web/dataset/call_kw/calendar.event/search_read',
'/google_calendar/sync_data',
'/google_calendar/sync_data',
'/web/dataset/call_kw/calendar.event/search_read',
], 'should do a search_read before and after the call to sync_data');
assert.containsN(calendar, '.fc-event', 3, "should now display 3 events on the month");
assert.containsN(calendar, '.fc-event', 5, "should now display 4 events on the month");
calendar.destroy();
});
@@ -0,0 +1,20 @@
odoo.define('google_calendar.MockServer', function (require) {
'use strict';
var MockServer = require('web.MockServer');
MockServer.include({
/**
* @override
* @private
* @returns {Promise}
*/
_performRpc(route, args) {
if (route === '/google_calendar/sync_data') {
return Promise.resolve({status: 'no_new_event_from_google'});
} else {
return this._super(...arguments);
}
},
});
});
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sync_google2odoo
from . import test_sync_odoo2google
@@ -0,0 +1,311 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from addons.google_calendar.utils.google_calendar import GoogleEvent
import pytz
from datetime import datetime, date
from odoo.tests.common import SavepointCase
class TestSyncGoogle2Odoo(SavepointCase):
@property
def now(self):
return pytz.utc.localize(datetime.now()).isoformat()
def sync(self, events):
google_recurrence = events.filter(GoogleEvent.is_recurrence)
self.env['calendar.recurrence']._sync_google2odoo(google_recurrence)
self.env['calendar.event']._sync_google2odoo(events - google_recurrence)
def test_new_google_event(self):
values = {
'id': 'oj44nep1ldf8a3ll02uip0c9aa',
'description': 'Small mini desc',
'organizer': {'email': 'odoocalendarref@gmail.com', 'self': True},
'summary': 'Pricing new update',
'visibility': 'public',
'attendees': [{
'displayName': 'Mitchell Admin',
'email': 'admin@yourcompany.example.com',
'responseStatus': 'needsAction'
},],
'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]))
event = self.env['calendar.event'].search([('google_id', '=', values.get('id'))])
self.assertTrue(event, "It should have created an event")
self.assertEqual(event.name, values.get('summary'))
self.assertFalse(event.allday)
self.assertEqual(event.description, values.get('description'))
self.assertEqual(event.start, datetime(2020, 1, 13, 15, 55))
self.assertEqual(event.stop, datetime(2020, 1, 13, 18, 55))
self.assertEqual('admin@yourcompany.example.com', event.attendee_ids.email)
self.assertEqual('Mitchell Admin', event.attendee_ids.partner_id.name)
self.assertEqual('needsAction', event.attendee_ids.state)
def test_cancelled(self):
google_id = 'oj44nep1ldf8a3ll02uip0c9aa'
event = self.env['calendar.event'].create({
'name': 'coucou',
'start': date(2020, 1, 6),
'stop': date(2020, 1, 6),
'google_id': google_id,
'user_id': self.env.user.id,
'partner_ids': [(6, 0, self.env.user.partner_id.ids)] # current user is attendee
})
gevent = GoogleEvent([{
'id': google_id,
'status': 'cancelled',
}])
self.sync(gevent)
self.assertFalse(event.exists())
def test_attendee_cancelled(self):
google_id = 'oj44nep1ldf8a3ll02uip0c9aa'
event = self.env['calendar.event'].create({
'name': 'coucou',
'start': date(2020, 1, 6),
'stop': date(2020, 1, 6),
'google_id': google_id,
'user_id': False, # Not the current user
'partner_ids': [(6, 0, self.env.user.partner_id.ids)] # current user is attendee
})
gevent = GoogleEvent([{
'id': google_id,
'status': 'cancelled',
}])
self.sync(gevent)
self.assertTrue(event.active)
user_attendee = event.attendee_ids
self.assertTrue(user_attendee)
self.assertEqual(user_attendee.state, 'declined')
def test_recurrence(self):
recurrence_id = 'oj44nep1ldf8a3ll02uip0c9aa'
values = {
'id': recurrence_id,
'description': 'Small mini desc',
'organizer': {'email': 'odoocalendarref@gmail.com', 'self': True},
'summary': 'Pricing new update',
'visibility': 'public',
'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=MO'],
'reminders': {'useDefault': True},
'start': {'date': '2020-01-6'},
'end': {'date': '2020-01-7'},
}
self.env['calendar.recurrence']._sync_google2odoo(GoogleEvent([values]))
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', values.get('id'))])
self.assertTrue(recurrence, "it should have created a recurrence")
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 3, "it should have created a recurrence with 3 events")
self.assertTrue(all(events.mapped('recurrency')))
self.assertEqual(events[0].start_date, date(2020, 1, 6))
self.assertEqual(events[1].start_date, date(2020, 1, 13))
self.assertEqual(events[2].start_date, date(2020, 1, 20))
self.assertEqual(events[0].start_date, date(2020, 1, 6))
self.assertEqual(events[1].start_date, date(2020, 1, 13))
self.assertEqual(events[2].start_date, date(2020, 1, 20))
self.assertEqual(events[0].google_id, '%s_20200106' % recurrence_id)
self.assertEqual(events[1].google_id, '%s_20200113' % recurrence_id)
self.assertEqual(events[2].google_id, '%s_20200120' % recurrence_id)
def test_recurrence_datetime(self):
recurrence_id = 'oj44nep1ldf8a3ll02uip0c9aa'
values = {
'id': recurrence_id,
'description': 'Small mini desc',
'organizer': {'email': 'odoocalendarref@gmail.com', 'self': True},
'summary': 'Pricing new update',
'visibility': 'public',
'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=MO'],
'reminders': {'useDefault': True},
'start': {'dateTime': '2020-01-06T18:00:00+01:00'},
'end': {'dateTime': '2020-01-06T19:00:00+01:00'},
}
self.env['calendar.recurrence']._sync_google2odoo(GoogleEvent([values]))
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', values.get('id'))])
self.assertTrue(recurrence, "it should have created a recurrence")
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 3, "it should have created a recurrence with 3 events")
self.assertTrue(all(events.mapped('recurrency')))
self.assertEqual(events[0].start, datetime(2020, 1, 6, 17, 0))
self.assertEqual(events[1].start, datetime(2020, 1, 13, 17, 0))
self.assertEqual(events[2].start, datetime(2020, 1, 20, 17, 0))
self.assertEqual(events[0].google_id, '%s_20200106T170000Z' % recurrence_id)
self.assertEqual(events[1].google_id, '%s_20200113T170000Z' % recurrence_id)
self.assertEqual(events[2].google_id, '%s_20200120T170000Z' % recurrence_id)
def test_recurrence_exdate(self):
recurrence_id = 'oj44nep1ldf8a3ll02uip0c9aa'
events = GoogleEvent([{
'id': recurrence_id,
'summary': 'Pricing new update',
'organizer': {'email': self.env.user.email, 'self': True},
'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=MO'],
'reminders': {'useDefault': True},
'start': {'date': '2020-01-6'},
'end': {'date': '2020-01-7'},
}, { # Third event has been deleted
'id': '%s_20200113' % recurrence_id,
'originalStartTime': {'dateTime': '2020-01-13'},
'recurringEventId': 'oj44nep1ldf8a3ll02uip0c9pk',
'reminders': {'useDefault': True},
'status': 'cancelled',
}])
self.sync(events)
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', recurrence_id)])
self.assertTrue(recurrence, "it should have created a recurrence")
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 2, "it should have created a recurrence with 2 events")
self.assertEqual(events[0].start_date, date(2020, 1, 6))
self.assertEqual(events[1].start_date, date(2020, 1, 20))
def test_recurrence_first_exdate(self):
recurrence_id = "4c0de517evkk3ra294lmut57vm"
events = GoogleEvent([{
"id": recurrence_id,
"updated": "2020-01-13T16:17:03.806Z",
"summary": "r rul",
"start": {"date": "2020-01-6"},
'organizer': {'email': self.env.user.email, 'self': True},
"end": {"date": "2020-01-7"},
'reminders': {'useDefault': True},
"recurrence": ["RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=MO"],
}, {
"id": "%s_20200106" % recurrence_id,
"status": "cancelled",
"recurringEventId": "4c0de517evkk3ra294lmut57vm",
'reminders': {'useDefault': True},
"originalStartTime": {
"date": "2020-01-06"
}
}])
self.sync(events)
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', recurrence_id)])
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 2, "it should have created a recurrence with 2 events")
self.assertEqual(events[0].start_date, date(2020, 1, 13))
self.assertEqual(events[1].start_date, date(2020, 1, 20))
def test_recurrencde_first_updated(self):
recurrence_id = "4c0de517evkk3ra294lmut57vm"
events = GoogleEvent([{
'id': recurrence_id,
'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=WE'],
'start': {'date': '2020-01-01'},
'end': {'date': '2020-01-02'},
'status': 'confirmed',
'summary': 'rrule',
'reminders': {'useDefault': True},
'updated': self.now
}, {
'summary': 'edited', # Name changed
'id': '%s_20200101' % recurrence_id,
'originalStartTime': {'date': '2020-01-01'},
'recurringEventId': recurrence_id,
'start': {'date': '2020-01-01'},
'end': {'date': '2020-01-02'},
'reminders': {'useDefault': True},
'updated': self.now,
}])
self.sync(events)
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', recurrence_id)])
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 3, "it should have created a recurrence with 3 events")
self.assertEqual(events[0].name, 'edited')
self.assertEqual(events[1].name, 'rrule')
self.assertEqual(events[2].name, 'rrule')
def test_existing_recurrence_first_updated(self):
recurrence_id = "4c0de517evkk3ra294lmut57vm"
recurrence = self.env['calendar.recurrence'].create({
'google_id': recurrence_id,
'rrule': 'FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=MO',
'base_event_id': self.env['calendar.event'].create({'name': 'coucou', 'allday': True, 'start': datetime(2020, 1, 6), 'stop': datetime(2020, 1, 6)}).id,
})
recurrence._apply_recurrence()
values = [{
'summary': 'edited', # Name changed
'id': '%s_20200106' % recurrence_id,
'originalStartTime': {'date': '2020-01-06'},
'recurringEventId': recurrence_id,
'start': {'date': '2020-01-06'},
'end': {'date': '2020-01-07'},
'reminders': {'useDefault': True},
'updated': self.now,
}]
self.env['calendar.event']._sync_google2odoo(GoogleEvent(values))
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', recurrence_id)])
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 3, "it should have created a recurrence with 3 events")
self.assertEqual(events[0].name, 'edited')
self.assertEqual(events[1].name, 'coucou')
self.assertEqual(events[2].name, 'coucou')
def test_recurrence_outlier(self):
recurrence_id = 'oj44nep1ldf8a3ll02uip0c9aa'
events = GoogleEvent([{
'id': recurrence_id,
'summary': 'Pricing new update',
'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=MO'],
'start': {'date': '2020-01-6'},
'end': {'date': '2020-01-7'},
'reminders': {'useDefault': True},
'updated': self.now,
},
{ # Third event has been moved
'id': '%s_20200113' % recurrence_id,
'summary': 'Pricing new update',
'start': {'date': '2020-01-18'},
'end': {'date': '2020-01-19'},
'originalStartTime': {'date': '2020-01-13'},
'reminders': {'useDefault': True},
'updated': self.now,
}])
self.sync(events)
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', recurrence_id)])
self.assertTrue(recurrence, "it should have created a recurrence")
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(len(events), 3, "it should have created a recurrence with 3 events")
self.assertEqual(events[0].start_date, date(2020, 1, 6))
self.assertEqual(events[1].start_date, date(2020, 1, 18), "It should not be in sync with the recurrence")
self.assertEqual(events[2].start_date, date(2020, 1, 20))
def test_recurrence_moved(self):
recurrence_id = 'aaaaaa'
event = self.env['calendar.event'].create({
'name': 'coucou',
'start': date(2020, 1, 6),
'stop': date(2020, 1, 6),
'rrule': 'FREQ=WEEKLY;COUNT=2;BYDAY=MO',
'recurrency': True,
'allday': True,
})
event.recurrence_id.google_id = recurrence_id
values = {
'id': recurrence_id,
'summary': 'coucou',
'recurrence': ['RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=WE'], # Now wednesday
'start': {'date': '2020-01-08'},
'end': {'date': '2020-01-09'},
'reminders': {'useDefault': True},
'updated': self.now,
}
self.env['calendar.recurrence']._sync_google2odoo(GoogleEvent([values]))
recurrence = self.env['calendar.recurrence'].search([('google_id', '=', recurrence_id)])
events = recurrence.calendar_event_ids.sorted('start')
self.assertEqual(recurrence.rrule, 'FREQ=WEEKLY;COUNT=2;BYDAY=WE')
self.assertEqual(events[0].start_date, date(2020, 1, 8))
self.assertEqual(events[1].start_date, date(2020, 1, 15))
self.assertEqual(events[0].google_id, '%s_20200108' % recurrence_id)
self.assertEqual(events[1].google_id, '%s_20200115' % recurrence_id)
@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from unittest.mock import MagicMock, patch
from odoo.tests.common import SavepointCase
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService
from odoo.addons.google_calendar.models.res_users import User
from odoo.addons.google_calendar.models.google_sync import GoogleSync
from odoo.modules.registry import Registry
from addons.google_account.models.google_service import TIMEOUT
def patch_api(func):
@patch.object(GoogleSync, '_google_insert', MagicMock())
@patch.object(GoogleSync, '_google_delete', MagicMock())
@patch.object(GoogleSync, '_google_patch', MagicMock())
def patched(self, *args, **kwargs):
return func(self, *args, **kwargs)
return patched
@patch.object(User, '_get_google_calendar_token', lambda user: 'dummy-token')
class TestSyncOdoo2Google(SavepointCase):
def setUp(self):
super().setUp()
self.google_service = GoogleCalendarService(self.env['google.service'])
def assertGoogleEventDeleted(self, google_id):
GoogleSync._google_delete.assert_called()
args, kwargs = GoogleSync._google_delete.call_args
self.assertEqual(args[1], google_id, "Event should have been deleted")
def assertGoogleEventNotDeleted(self):
GoogleSync._google_delete.assert_not_called()
def assertGoogleEventInserted(self, values):
GoogleSync._google_insert.assert_called_once_with(self.google_service, values)
def assertGoogleEventNotInserted(self):
GoogleSync._google_insert.assert_not_called()
def assertGoogleEventPatched(self, google_id, values, timeout=None):
expected_args = (google_id, values)
expected_kwargs = {'timeout': timeout} if timeout else {}
GoogleSync._google_patch.assert_called_once()
args, kwargs = GoogleSync._google_patch.call_args
self.assertEqual(args[1:], expected_args) # skip Google service arg
self.assertEqual(kwargs, expected_kwargs)
@patch_api
def test_event_creation(self):
partner = self.env['res.partner'].create({'name': 'Jean-Luc', 'email': 'jean-luc@opoo.com'})
alarm = self.env['calendar.alarm'].create({
'name': 'Notif',
'alarm_type': 'notification',
'interval': 'minutes',
'duration': 18,
})
event = self.env['calendar.event'].create({
'name': "Event",
'start': datetime(2020, 1, 15, 8, 0),
'stop': datetime(2020, 1, 15, 18, 0),
'partner_ids': [(4, partner.id)],
'alarm_ids': [(4, alarm.id)],
'privacy': 'private',
'need_sync': False,
})
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'},
'summary': 'Event',
'description': '',
'location': '',
'visibility': 'private',
'guestsCanModify': True,
'reminders': {'useDefault': False, 'overrides': [{'method': 'popup', 'minutes': alarm.duration_minutes}]},
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [{'email': 'jean-luc@opoo.com', 'responseStatus': 'needsAction'}],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id}}
})
@patch_api
def test_event_allday_creation(self):
event = self.env['calendar.event'].create({
'name': "Event",
'allday': True,
'start': datetime(2020, 1, 15),
'stop': datetime(2020, 1, 15),
'need_sync': False,
})
event._sync_odoo2google(self.google_service)
self.assertGoogleEventInserted({
'id': False,
'start': {'date': '2020-01-15'},
'end': {'date': '2020-01-16'},
'summary': 'Event',
'description': '',
'location': '',
'visibility': 'public',
'guestsCanModify': True,
'reminders': {'overrides': [], 'useDefault': False},
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id}}
})
@patch_api
def test_inactive_event(self):
event = self.env['calendar.event'].create({
'name': "Event",
'start': datetime(2020, 1, 15),
'stop': datetime(2020, 1, 15),
'active': False,
'need_sync': False,
})
event._sync_odoo2google(self.google_service)
self.assertGoogleEventNotInserted()
self.assertGoogleEventNotDeleted()
@patch_api
def test_synced_inactive_event(self):
google_id = 'aaaaaaaaa'
event = self.env['calendar.event'].create({
'google_id': google_id,
'name': "Event",
'start': datetime(2020, 1, 15),
'stop': datetime(2020, 1, 15),
'active': False,
'need_sync': False,
})
event._sync_odoo2google(self.google_service)
self.assertGoogleEventDeleted(google_id)
@patch_api
def test_recurrence(self):
google_id = 'aaaaaaaaa'
event = self.env['calendar.event'].create({
'google_id': google_id,
'name': "Event",
'start': datetime(2020, 1, 15),
'stop': datetime(2020, 1, 15),
'allday': True,
'need_sync': False,
})
recurrence = self.env['calendar.recurrence'].create({
'rrule': 'FREQ=WEEKLY;COUNT=2;BYDAY=WE',
'calendar_event_ids': [(4, event.id)],
'need_sync': False,
})
recurrence._sync_odoo2google(self.google_service)
self.assertGoogleEventInserted({
'id': False,
'start': {'date': '2020-01-15'},
'end': {'date': '2020-01-16'},
'summary': 'Event',
'description': '',
'location': '',
'visibility': 'public',
'guestsCanModify': True,
'reminders': {'overrides': [], 'useDefault': False},
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'recurrence': ['RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=WE'],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: recurrence.id}}
})
@patch_api
def test_event_added_to_recurrence(self):
google_id = 'aaaaaaaaa'
event = self.env['calendar.event'].create({
'google_id': google_id,
'name': "Event",
'start': datetime(2020, 1, 15),
'stop': datetime(2020, 1, 15),
'allday': True,
'need_sync': False,
})
event.write({
'recurrency': True,
'rrule': 'FREQ=WEEKLY;COUNT=2;BYDAY=WE',
})
event.recurrence_id
to_delete = self.env['calendar.event'].with_context(active_test=False).search([('google_id', '=', google_id)])
self.assertTrue(to_delete)
self.assertFalse(to_delete.active)
self.assertFalse(event.google_id, "The google id will be set after the API call")
self.assertGoogleEventDeleted(google_id)
@patch_api
def test_following_event_updated(self):
google_id = 'aaaaaaaaa'
event_1 = self.env['calendar.event'].create({
'name': "Event",
'start': datetime(2020, 1, 15),
'stop': datetime(2020, 1, 15),
'allday': True,
'need_sync': False,
})
event_2 = self.env['calendar.event'].create({
'name': "Event",
'start': datetime(2020, 1, 22),
'stop': datetime(2020, 1, 22),
'allday': True,
'need_sync': False,
})
self.env['calendar.recurrence'].create({
'google_id': google_id,
'rrule': 'FREQ=WEEKLY;COUNT=2;BYDAY=WE',
'calendar_event_ids': [(4, event_1.id), (4, event_2.id)],
'need_sync': False,
})
event = event_2
# Update only some events in the recurrence
event.write({
'name': 'New name',
'recurrence_update': 'future_events',
})
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': 'New name',
'description': '',
'location': '',
'guestsCanModify': True,
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id}},
'reminders': {'overrides': [], 'useDefault': False},
'visibility': 'public',
}, timeout=3)
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import google_calendar
from . import google_event
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from uuid import uuid4
import requests
import json
import logging
from odoo import api, _
from odoo.tools import exception_to_unicode
from addons.google_calendar.utils.google_event import GoogleEvent
from addons.google_account.models.google_service import TIMEOUT
_logger = logging.getLogger(__name__)
def requires_auth_token(func):
def wrapped(self, *args, **kwargs):
if not kwargs.get('token'):
raise AttributeError("An authentication token is required")
return func(self, *args, **kwargs)
return wrapped
class InvalidSyncToken(Exception):
pass
class GoogleCalendarService():
def __init__(self, google_service):
self.google_service = google_service
@requires_auth_token
def get_events(self, sync_token=None, token=None, timeout=TIMEOUT):
url = "/calendar/v3/calendars/primary/events"
headers = {'Content-type': 'application/json'}
params = {'access_token': token}
if sync_token:
params['syncToken'] = sync_token
try:
status, data, time = self.google_service._do_request(url, params, headers, type='GET', timeout=timeout)
except requests.HTTPError as e:
if e.response.status_code == 410 and 'fullSyncRequired' in str(e.response.content):
raise InvalidSyncToken("Invalid sync token. Full sync required")
raise e
import pprint
pprint.pprint(data)
events = data.get('items', [])
next_page_token = data.get('nextPageToken')
while next_page_token:
params = {'access_token': token, 'pageToken': next_page_token}
status, data, time = self.google_service._do_request(url, params, headers, type='GET', timeout=timeout)
next_page_token = data.get('nextPageToken')
events += data.get('items', [])
pprint.pprint(data)
next_sync_token = data.get('nextSyncToken')
default_reminders = data.get('defaultReminders')
return GoogleEvent(events), next_sync_token, default_reminders
@requires_auth_token
def insert(self, values, token=None, timeout=TIMEOUT):
print("INSERT")
url = "/calendar/v3/calendars/primary/events"
headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token}
if not values.get('id'):
values['id'] = uuid4().hex
import pprint
pprint.pprint(values)
self.google_service._do_request(url, json.dumps(values), headers, type='POST', timeout=timeout)
return values['id']
@requires_auth_token
def patch(self, event_id, values, token=None, timeout=TIMEOUT):
print("PATCH")
url = "/calendar/v3/calendars/primary/events/%s" % event_id
headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token}
self.google_service._do_request(url, json.dumps(values), headers, type='PUT', timeout=timeout)
@requires_auth_token
def delete(self, event_id, token=None, timeout=TIMEOUT):
print("DELETE")
url = "/calendar/v3/calendars/primary/events/%s" % event_id
headers = {'Content-type': 'application/json'}
params = {'access_token': token}
try:
self.google_service._do_request(url, params, headers=headers, type='DELETE', timeout=timeout)
except requests.HTTPError as e:
# For some unknown reason Google can also return a 403 response when the event is already cancelled.
if e.response.status_code not in (410, 403):
raise e
_logger.info("Google event %s was already deleted" % event_id)
#################################
## MANAGE CONNEXION TO GMAIL ##
#################################
def is_authorized(self, user):
return bool(user.sudo().google_calendar_rtoken)
def _get_calendar_scope(self, RO=False):
readonly = '.readonly' if RO else ''
return 'https://www.googleapis.com/auth/calendar%s' % (readonly)
def _google_authentication_url(self, from_url='http://www.odoo.com'):
return self.google_service._get_authorize_uri(from_url, service='calendar', scope=self._get_calendar_scope())
def _can_authorize_google(self, user):
return user.has_group('base.group_erp_manager')
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.api import model
from odoo.tools.sql import existing_tables
import pytz
from typing import Iterator, Mapping
from collections import abc
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from odoo import _
class GoogleEvent(abc.Set):
"""This helper class holds the values of a Google event.
Inspired by Odoo recordset, one instance can be a single Google event or a
(immutable) set of Google events.
All usual set operations are supported (union, intersection, etc).
A list of all attributes can be found in the API documentation.
https://developers.google.com/calendar/v3/reference/events#resource
:param iterable: iterable of GoogleCalendar instances or iterable of dictionnaries
"""
def __init__(self, iterable=()):
self._events = {}
for item in iterable:
if isinstance(item, self.__class__):
self._events[item.id] = item._events[item.id]
elif isinstance(item, Mapping):
self._events[item.get('id')] = item
else:
raise ValueError("Only %s or iterable of dict are supported" % self.__class__.__name__)
def __iter__(self) -> Iterator['GoogleEvent']:
return iter(GoogleEvent([vals]) for vals in self._events.values())
def __contains__(self, google_event):
return google_event.id in self._events
def __len__(self):
return len(self._events)
def __bool__(self):
return bool(self._events)
def __getattr__(self, name):
# ensure_one
try:
event, = self._events.keys()
except ValueError:
raise ValueError("Expected singleton: %s" % self)
event_id = list(self._events.keys())[0]
return self._events[event_id].get(name)
def __repr__(self):
return '%s%s' % (self.__class__.__name__, self.ids)
@property
def ids(self):
return tuple(e.id for e in self)
@property
def rrule(self):
if self.recurrence and 'RRULE:' in self.recurrence[0]: # LUL TODO what if there are something else in the list?
return self.recurrence[0][6:] # skip "RRULE:" in the rrule string
def odoo_id(self, env):
self.odoo_ids(env) # load ids
return self._odoo_id
def _meta_odoo_id(self, dbname):
"""Returns the Odoo id stored in the Google Event metadata.
This id might not actually exists in the database.
"""
properties = self.extendedProperties and self.extendedProperties.get('shared', {}) or {}
o_id = properties.get('%s_odoo_id' % dbname)
if o_id:
return int(o_id)
def odoo_ids(self, env):
ids = tuple(e._odoo_id for e in self if e._odoo_id)
if len(ids) == len(self):
return ids
found = self._load_odoo_ids_from_db(env)
unsure = self - found
if unsure:
unsure._load_odoo_ids_from_metadata(env)
return tuple(e._odoo_id for e in self)
def _load_odoo_ids_from_metadata(self, env):
model = self._get_model(env)
unsure_odoo_ids = tuple(e._meta_odoo_id(env.cr.dbname) for e in self)
odoo_events = model.browse(_id for _id in unsure_odoo_ids if _id)
# Extended properties are copied when splitting a recurrence Google side.
# Hence, we may have two Google recurrences linked to the same Odoo id.
# Therefore, we only consider Odoo records without google id when trying
# to match events.
o_ids = odoo_events.exists().filtered(lambda e: not e.google_id).ids
for e in self:
odoo_id = e._meta_odoo_id(env.cr.dbname)
if odoo_id in o_ids:
e._events[e.id]['_odoo_id'] = odoo_id
def _load_odoo_ids_from_db(self, env):
model = self._get_model(env)
odoo_events = model.with_context(active_test=False)._from_google_ids(self.ids)
mapping = {e.google_id: e.id for e in odoo_events} # {google_id: odoo_id}
existing_google_ids = odoo_events.mapped('google_id')
for e in self:
odoo_id = mapping.get(e.id)
if odoo_id:
e._events[e.id]['_odoo_id'] = odoo_id
return self.filter(lambda e: e.id in existing_google_ids)
def owner(self, env):
# Owner/organizer could be desynchronised between Google and Odoo.
# Let userA, userB be two new users (never synced to Google before).
# UserA creates an event in Odoo (he is the owner) but userB syncs first.
# There is no way to insert the event into userA's calendar since we don't have
# any authentication access. The event is therefore inserted into userB's calendar
# (he is the orginizer in Google). The "real" owner (in Odoo) is stored as an
# extended property. There is currently no support to "transfert" ownership when
# userA syncs his calendar the first time.
real_owner_id = self.extendedProperties and self.extendedProperties.get('shared', {}).get('%s_owner_id' % env.cr.dbname)
real_owner = real_owner_id and env['res.users'].browse(int(real_owner_id))
if real_owner_id and real_owner.exists():
return real_owner
elif self.organizer and self.organizer.get('self'):
return env.user
elif self.organizer and self.organizer.get('email'):
# In Google: 1 email = 1 user; but in Odoo several users might have the same email :/
return env['res.users'].search([('email', '=', self.organizer.get('email'))], limit=1)
else:
return env['res.users']
def filter(self, func) -> 'GoogleEvent':
return GoogleEvent(e for e in self if func(e))
def is_recurrence(self):
return bool(self.recurrence)
def is_recurrent(self):
return bool(self.recurringEventId or self.is_recurrence())
def is_cancelled(self):
return self.status == 'cancelled'
def is_recurrence_outlier(self):
return bool(self.originalStartTime)
def cancelled(self):
return self.filter(lambda e: e.status == 'cancelled')
def exists(self, env) -> 'GoogleEvent':
recurrences = self.filter(GoogleEvent.is_recurrence)
events = self - recurrences
recurrences.odoo_ids(env)
events.odoo_ids(env)
return self.filter(lambda e: e._odoo_id)
def _get_model(self, env):
if all([e.is_recurrence() for e in self]):
return env['calendar.recurrence']
elif all([not e.is_recurrence() for e in self]):
return env['calendar.event']
raise TypeError("Mixing Google events and Google recurrences")
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="calendar_event_view_calendar" model="ir.ui.view">
<field name="name">calendar.event.view.calendar.inherit</field>
<field name="model">calendar.event</field>
<field name="inherit_id" ref="calendar.view_calendar_event_calendar"/>
<field name="arch" type="xml">
<xpath expr="//calendar" position="attributes">
<attribute name="js_class">google_sync_calendar</attribute>
</xpath>
</field>
</record>
</odoo>
@@ -9,7 +9,14 @@
<template id="qunit_suite" name="google_calendar_tests" inherit_id="web.qunit_suite_tests">
<xpath expr="." position="inside">
<script type="text/javascript" src="/google_calendar/static/tests/mock_server.js" />
<script type="text/javascript" src="/google_calendar/static/tests/google_calendar_tests.js"></script>
</xpath>
</template>
<template id="qunit_mobile_suite" name="mail_mobile_tests" inherit_id="web.qunit_mobile_suite_tests">
<xpath expr="." position="inside">
<script type="text/javascript" src="/google_calendar/static/tests/mock_server.js" />
</xpath>
</template>
</odoo>
@@ -7,12 +7,12 @@
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<notebook colspan="4" position="inside">
<page string="Calendar" name="calendar">
<page string="Calendar" name="calendar" groups="base.group_system">
<group>
<field name="google_calendar_rtoken"/>
<field name="google_calendar_token"/>
<field name="google_calendar_token_validity"/>
<field name="google_calendar_last_sync_date"/>
<field name="google_calendar_sync_token"/>
<field name="google_calendar_cal_id"/>
</group>
</page>