summaryrefslogtreecommitdiff
path: root/addons/google_calendar
diff options
context:
space:
mode:
authorLucas Lefèvre <lul@odoo.com>2020-01-15 14:59:13 +0100
committerLucas Lefèvre <lul@odoo.com>2020-04-09 10:59:33 +0200
commitae3d37c201bb1e5064096c6fc13e9db18e566611 (patch)
tree9fc2fbec68f299015da25897724b250940ea1848 /addons/google_calendar
parent85907951c52b40c6dfdaeffa68ce43ccc8bfe0f5 (diff)
[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
Diffstat (limited to 'addons/google_calendar')
-rw-r--r--addons/google_calendar/__init__.py1
-rw-r--r--addons/google_calendar/__manifest__.py1
-rw-r--r--addons/google_calendar/controllers/main.py34
-rw-r--r--addons/google_calendar/data/google_calendar_data.xml5
-rw-r--r--addons/google_calendar/models/__init__.py3
-rw-r--r--addons/google_calendar/models/calendar.py227
-rw-r--r--addons/google_calendar/models/calendar_recurrence_rule.py124
-rw-r--r--addons/google_calendar/models/google_calendar.py967
-rw-r--r--addons/google_calendar/models/google_sync.py240
-rw-r--r--addons/google_calendar/models/res_config_settings.py10
-rw-r--r--addons/google_calendar/models/res_users.py111
-rw-r--r--addons/google_calendar/security/ir.model.access.csv2
-rw-r--r--addons/google_calendar/static/src/js/google_calendar.js104
-rw-r--r--addons/google_calendar/static/tests/google_calendar_tests.js13
-rw-r--r--addons/google_calendar/static/tests/mock_server.js20
-rw-r--r--addons/google_calendar/tests/__init__.py5
-rw-r--r--addons/google_calendar/tests/test_sync_google2odoo.py311
-rw-r--r--addons/google_calendar/tests/test_sync_odoo2google.py237
-rw-r--r--addons/google_calendar/utils/__init__.py4
-rw-r--r--addons/google_calendar/utils/google_calendar.py114
-rw-r--r--addons/google_calendar/utils/google_event.py174
-rw-r--r--addons/google_calendar/views/calendar_event_views.xml13
-rw-r--r--addons/google_calendar/views/google_calendar_templates.xml7
-rw-r--r--addons/google_calendar/views/res_users_views.xml4
24 files changed, 1623 insertions, 1108 deletions
diff --git a/addons/google_calendar/__init__.py b/addons/google_calendar/__init__.py
index 7d34c7c054a..c135ed963d0 100644
--- a/addons/google_calendar/__init__.py
+++ b/addons/google_calendar/__init__.py
@@ -3,3 +3,4 @@
from . import controllers
from . import models
+from . import utils
diff --git a/addons/google_calendar/__manifest__.py b/addons/google_calendar/__manifest__.py
index 40b7b8878e0..ca8aa5e97b8 100644
--- a/addons/google_calendar/__manifest__.py
+++ b/addons/google_calendar/__manifest__.py
@@ -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',
diff --git a/addons/google_calendar/controllers/main.py b/addons/google_calendar/controllers/main.py
index fb40a631a03..465b0fa48b0 100644
--- a/addons/google_calendar/controllers/main.py
+++ b/addons/google_calendar/controllers/main.py
@@ -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}
diff --git a/addons/google_calendar/data/google_calendar_data.xml b/addons/google_calendar/data/google_calendar_data.xml
index 4fe2a9ee3e5..d0ed9c9fc33 100644
--- a/addons/google_calendar/data/google_calendar_data.xml
+++ b/addons/google_calendar/data/google_calendar_data.xml
@@ -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>
diff --git a/addons/google_calendar/models/__init__.py b/addons/google_calendar/models/__init__.py
index ad0d5c09861..01dc462d175 100644
--- a/addons/google_calendar/models/__init__.py
+++ b/addons/google_calendar/models/__init__.py
@@ -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
diff --git a/addons/google_calendar/models/calendar.py b/addons/google_calendar/models/calendar.py
index 21c802786e7..943e8efe820 100644
--- a/addons/google_calendar/models/calendar.py
+++ b/addons/google_calendar/models/calendar.py
@@ -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):
-
- _inherit = "calendar.event"
- oe_update_date = fields.Datetime('Odoo Update Date')
+class Meeting(models.Model):
+ _name = 'calendar.event'
+ _inherit = ['calendar.event', 'google.calendar.sync']
+
+ google_id = fields.Char('Google Calendar Event Id', compute='_compute_google_id', store=True, readonly=False)
+
+ @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'}
- 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)
-
- @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()
- else:
- default['oe_update_date'] = False
- return super(Meeting, self).copy(default)
-
- def unlink(self, can_be_deleted=False):
- return super(Meeting, self).unlink(can_be_deleted=can_be_deleted)
+ @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):
+ 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)
-class Attendee(models.Model):
-
- _inherit = 'calendar.attendee'
+ def _get_sync_domain(self):
+ return [('partner_ids.user_ids', 'in', self.env.user.id)]
- google_internal_event_id = fields.Char('Google Calendar Event Id')
- oe_synchro_date = fields.Datetime('Odoo Synchro Date')
+ @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:
+ 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
- _sql_constraints = [
- ('google_id_uniq', 'unique(google_internal_event_id,partner_id,event_id)', 'Google ID should be unique!')
- ]
+ @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
- def write(self, values):
- for attendee in self:
- meeting_id_to_update = values.get('event_id', attendee.event_id.id)
-
- # 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)
+ @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")
+
+ 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
+
+ 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()}
+
+ 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
+
+ if not self.active:
+ values['status'] = 'cancelled'
+ return 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'
diff --git a/addons/google_calendar/models/calendar_recurrence_rule.py b/addons/google_calendar/models/calendar_recurrence_rule.py
new file mode 100644
index 00000000000..0462bb013f8
--- /dev/null
+++ b/addons/google_calendar/models/calendar_recurrence_rule.py
@@ -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 \ No newline at end of file
diff --git a/addons/google_calendar/models/google_calendar.py b/addons/google_calendar/models/google_calendar.py
deleted file mode 100644
index 45c673986d1..00000000000
--- a/addons/google_calendar/models/google_calendar.py
+++ /dev/null
@@ -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)
diff --git a/addons/google_calendar/models/google_sync.py b/addons/google_calendar/models/google_sync.py
new file mode 100644
index 00000000000..bf8ab27e454
--- /dev/null
+++ b/addons/google_calendar/models/google_sync.py
@@ -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()
diff --git a/addons/google_calendar/models/res_config_settings.py b/addons/google_calendar/models/res_config_settings.py
index 1086ce02f38..1e08535d537 100644
--- a/addons/google_calendar/models/res_config_settings.py
+++ b/addons/google_calendar/models/res_config_settings.py
@@ -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
diff --git a/addons/google_calendar/models/res_users.py b/addons/google_calendar/models/res_users.py
index 290606dfe80..28a08f69558 100644
--- a/addons/google_calendar/models/res_users.py
+++ b/addons/google_calendar/models/res_users.py
@@ -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
-class User(models.Model):
+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))
diff --git a/addons/google_calendar/security/ir.model.access.csv b/addons/google_calendar/security/ir.model.access.csv
index e4a591d5616..4a08a4aa60d 100644
--- a/addons/google_calendar/security/ir.model.access.csv
+++ b/addons/google_calendar/security/ir.model.access.csv
@@ -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
diff --git a/addons/google_calendar/static/src/js/google_calendar.js b/addons/google_calendar/static/src/js/google_calendar.js
index ad4714f05f0..4ba739e67ea 100644
--- a/addons/google_calendar/static/src/js/google_calendar.js
+++ b/addons/google_calendar/static/src/js/google_calendar.js
@@ -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,
+};
+
});
diff --git a/addons/google_calendar/static/tests/google_calendar_tests.js b/addons/google_calendar/static/tests/google_calendar_tests.js
index 0470d95d730..6ab0f8dacab 100644
--- a/addons/google_calendar/static/tests/google_calendar_tests.js
+++ b/addons/google_calendar/static/tests/google_calendar_tests.js
@@ -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();
});
diff --git a/addons/google_calendar/static/tests/mock_server.js b/addons/google_calendar/static/tests/mock_server.js
new file mode 100644
index 00000000000..17fb56a9499
--- /dev/null
+++ b/addons/google_calendar/static/tests/mock_server.js
@@ -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);
+ }
+ },
+ });
+});
diff --git a/addons/google_calendar/tests/__init__.py b/addons/google_calendar/tests/__init__.py
new file mode 100644
index 00000000000..760fa8a0d4c
--- /dev/null
+++ b/addons/google_calendar/tests/__init__.py
@@ -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
diff --git a/addons/google_calendar/tests/test_sync_google2odoo.py b/addons/google_calendar/tests/test_sync_google2odoo.py
new file mode 100644
index 00000000000..8194584439b
--- /dev/null
+++ b/addons/google_calendar/tests/test_sync_google2odoo.py
@@ -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)
+
diff --git a/addons/google_calendar/tests/test_sync_odoo2google.py b/addons/google_calendar/tests/test_sync_odoo2google.py
new file mode 100644
index 00000000000..6cc4ff72e71
--- /dev/null
+++ b/addons/google_calendar/tests/test_sync_odoo2google.py
@@ -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)
diff --git a/addons/google_calendar/utils/__init__.py b/addons/google_calendar/utils/__init__.py
new file mode 100644
index 00000000000..e8ec7702783
--- /dev/null
+++ b/addons/google_calendar/utils/__init__.py
@@ -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
diff --git a/addons/google_calendar/utils/google_calendar.py b/addons/google_calendar/utils/google_calendar.py
new file mode 100644
index 00000000000..4532ce1b708
--- /dev/null
+++ b/addons/google_calendar/utils/google_calendar.py
@@ -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')
diff --git a/addons/google_calendar/utils/google_event.py b/addons/google_calendar/utils/google_event.py
new file mode 100644
index 00000000000..a1274049224
--- /dev/null
+++ b/addons/google_calendar/utils/google_event.py
@@ -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") \ No newline at end of file
diff --git a/addons/google_calendar/views/calendar_event_views.xml b/addons/google_calendar/views/calendar_event_views.xml
new file mode 100644
index 00000000000..0f787832525
--- /dev/null
+++ b/addons/google_calendar/views/calendar_event_views.xml
@@ -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> \ No newline at end of file
diff --git a/addons/google_calendar/views/google_calendar_templates.xml b/addons/google_calendar/views/google_calendar_templates.xml
index 1448a2dedde..3b79f6a5a1e 100644
--- a/addons/google_calendar/views/google_calendar_templates.xml
+++ b/addons/google_calendar/views/google_calendar_templates.xml
@@ -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>
diff --git a/addons/google_calendar/views/res_users_views.xml b/addons/google_calendar/views/res_users_views.xml
index bc4a1d0e9ab..5fd1d71efbc 100644
--- a/addons/google_calendar/views/res_users_views.xml
+++ b/addons/google_calendar/views/res_users_views.xml
@@ -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>