diff --git a/addons/calendar/models/calendar_alarm.py b/addons/calendar/models/calendar_alarm.py index 4bcaa6e28d6..392cb25ac63 100644 --- a/addons/calendar/models/calendar_alarm.py +++ b/addons/calendar/models/calendar_alarm.py @@ -26,7 +26,14 @@ class Alarm(models.Model): alarm_type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], string='Type', required=True, default='email') duration = fields.Integer('Remind Before', required=True, default=1) interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours') - duration_minutes = fields.Integer('Duration in minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes") + duration_minutes = fields.Integer('Duration in minutes', search='_search_duration_minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes") + + def _search_duration_minutes(self, operator, value): + return ['|', '|', + '&', ('interval', '=', 'minutes'), ('duration', operator, value), + '&', ('interval', '=', 'hours'), ('duration', operator, value / 60), + '&', ('interval', '=', 'days'), ('duration', operator, value / 60 / 24), + ] @api.onchange('duration', 'interval', 'alarm_type') def _onchange_duration_interval(self): diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index ece5aab156b..4b29d52c980 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -594,7 +594,7 @@ class Meeting(models.Model): detached_events |= recurrence.calendar_event_ids recurrence.calendar_event_ids.recurrence_id = False recurrences_to_unlink |= recurrence - recurrences_to_unlink.unlink() + recurrences_to_unlink.with_context(archive_on_error=True).unlink() return detached_events - self def write(self, values): @@ -627,7 +627,7 @@ class Meeting(models.Model): detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update_setting == 'future_events') (detached_events & self).active = False - (detached_events - self).unlink() + (detached_events - self).with_context(archive_on_error=True).unlink() # Notify attendees if there is an alarm on the modified event, or if there was an alarm # that has just been removed, as it might have changed their next event notification diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index acc5241ac42..0e50ea9ea32 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -235,7 +235,7 @@ class RecurrenceRule(models.Model): events = self._get_events_from(event.start) detached_events = self._detach_events(events) if not self.calendar_event_ids: - self.unlink() + self.with_context(archive_on_error=True).unlink() return detached_events if event.allday: diff --git a/addons/calendar/static/src/js/calendar_view.js b/addons/calendar/static/src/js/calendar_view.js index 88b0e7cd264..427c0608ea3 100644 --- a/addons/calendar/static/src/js/calendar_view.js +++ b/addons/calendar/static/src/js/calendar_view.js @@ -116,4 +116,6 @@ var AttendeeCalendarView = CalendarView.extend({ viewRegistry.add('attendee_calendar', AttendeeCalendarView); +return AttendeeCalendarView + }); diff --git a/addons/google_account/controllers/main.py b/addons/google_account/controllers/main.py index 71e0a86d4ce..94aeacd2c1f 100644 --- a/addons/google_account/controllers/main.py +++ b/addons/google_account/controllers/main.py @@ -10,7 +10,7 @@ from odoo.http import request class GoogleAuth(http.Controller): - @http.route('/google_account/authentication', type='http', auth="none") + @http.route('/google_account/authentication', type='http', auth="public") def oauth2callback(self, **kw): """ This route/function is called by Google when user Accept/Refuse the consent of Google """ state = json.loads(kw['state']) @@ -20,7 +20,9 @@ class GoogleAuth(http.Controller): with registry(dbname).cursor() as cr: if kw.get('code'): - request.env(cr, request.session.uid)['google.%s' % service].set_all_tokens(kw['code']) + access_token, refresh_token, ttl = request.env['google.service']._get_google_tokens(kw['code'], service) + # LUL TODO only defined in google_calendar + request.env.user._set_auth_tokens(access_token, refresh_token, ttl) return redirect(url_return) elif kw.get('error'): return redirect("%s%s%s" % (url_return, "?error=", kw['error'])) diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index c424dd687af..86d099eb745 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -95,7 +95,7 @@ class GoogleService(models.AbstractModel): return "%s?%s" % (GOOGLE_AUTH_ENDPOINT, encoded_params) @api.model - def _get_google_token_json(self, authorize_code, service): + def _get_google_tokens(self, authorize_code, service): """ Call Google API to exchange authorization code against token, with POST request, to not be redirected. """ @@ -114,44 +114,17 @@ class GoogleService(models.AbstractModel): } try: dummy, response, dummy = self._do_request(GOOGLE_TOKEN_ENDPOINT, params=data, headers=headers, type='POST', preuri='') - return response + access_token = response.get('access_token') + refresh_token = response.get('refresh_token') + ttl = response.get('expires_in') + return access_token, refresh_token, ttl except requests.HTTPError: error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid") raise self.env['res.config.settings'].get_config_warning(error_msg) - # FIXME : this method update a field defined in google_calendar module. Since it is used only in that module, maybe it should be moved. - @api.model - def _refresh_google_token_json(self, refresh_token, service): # exchange_AUTHORIZATION vs Token (service = calendar) - get_param = self.env['ir.config_parameter'].sudo().get_param - client_id = get_param('google_%s_client_id' % (service,), default=False) - client_secret = get_param('google_%s_client_secret' % (service,), default=False) - - if not client_id or not client_secret: - raise UserError(_("The account for the Google service '%s' is not configured.") % service) - - headers = {"content-type": "application/x-www-form-urlencoded"} - data = { - 'refresh_token': refresh_token, - 'client_id': client_id, - 'client_secret': client_secret, - 'grant_type': 'refresh_token', - } - - try: - dummy, response, dummy = self._do_request(GOOGLE_TOKEN_ENDPOINT, params=data, headers=headers, type='POST', preuri='') - return response - except requests.HTTPError as error: - if error.response.status_code == 400: # invalid grant - with registry(request.session.db).cursor() as cur: - self.env(cur)['res.users'].browse(self.env.uid).sudo().write({'google_%s_rtoken' % service: False}) - error_key = error.response.json().get("error", "nc") - _logger.exception("Bad google request : %s !", error_key) - error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired [%s]") % error_key - raise self.env['res.config.settings'].get_config_warning(error_msg) - # TODO JEM : remove preuri param, and rename type into method @api.model - def _do_request(self, uri, params={}, headers={}, type='POST', preuri="https://www.googleapis.com"): + def _do_request(self, uri, params=None, headers=None, type='POST', preuri="https://www.googleapis.com", timeout=TIMEOUT): """ Execute the request to Google API. Return a tuple ('HTTP_CODE', 'HTTP_RESPONSE') :param uri : the url to contact :param params : dict or already encoded parameters for the request to make @@ -159,14 +132,19 @@ class GoogleService(models.AbstractModel): :param type : the method to use to make the request :param preuri : pre url to prepend to param uri. """ + if params is None: + params = {} + if headers is None: + headers = {} + _logger.debug("Uri: %s - Type : %s - Headers: %s - Params : %s !", (uri, type, headers, params)) ask_time = fields.Datetime.now() try: if type.upper() in ('GET', 'DELETE'): - res = requests.request(type.lower(), preuri + uri, params=params, timeout=TIMEOUT) + res = requests.request(type.lower(), preuri + uri, params=params, timeout=timeout) elif type.upper() in ('POST', 'PATCH', 'PUT'): - res = requests.request(type.lower(), preuri + uri, data=params, headers=headers, timeout=TIMEOUT) + res = requests.request(type.lower(), preuri + uri, data=params, headers=headers, timeout=timeout) else: raise Exception(_('Method not supported [%s] not in [GET, POST, PUT, PATCH or DELETE]!') % (type)) res.raise_for_status() @@ -182,17 +160,12 @@ class GoogleService(models.AbstractModel): except: pass except requests.HTTPError as error: + # if error.response.status_code == 401: + # raise InvalidToken if error.response.status_code in (204, 404): status = error.response.status_code response = "" else: _logger.exception("Bad google request : %s !", error.response.content) - if error.response.status_code in (400, 401, 410): - raise error - raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google")) + raise error return (status, response, ask_time) - - # TODO : remove me, it is only used in google calendar. Make google_calendar use the constants - @api.model - def get_client_id(self, service): - return self.env['ir.config_parameter'].sudo().get_param('google_%s_client_id' % (service,), default=False) 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 @@ Google Calendar: synchronization - + 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() 12 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): + _name = 'calendar.event' + _inherit = ['calendar.event', 'google.calendar.sync'] - _inherit = "calendar.event" + google_id = fields.Char('Google Calendar Event Id', compute='_compute_google_id', store=True, readonly=False) - oe_update_date = fields.Datetime('Odoo Update Date') + @api.depends('recurrence_id.google_id') + def _compute_google_id(self): + # google ids of recurring events are built from the recurrence id and the + # original starting time in the recurrence. + # The `start` field does not appear in the dependencies on purpose! + # Event if the event is moved, the google_id remains the same. + for event in self: + google_recurrence_id = event.recurrence_id._get_event_google_id(event) + if google_recurrence_id: + event.google_id = google_recurrence_id + elif not event.google_id: + event.google_id = False @api.model - def get_fields_need_update_google(self): - recurrent_fields = self._get_recurrent_fields() - return recurrent_fields | {'name', 'description', 'allday', 'start', 'date_end', 'stop', - 'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active', - 'start_date', 'stop_date'} + def _get_google_synced_fields(self): + return {'name', 'description', 'allday', 'start', 'date_end', 'stop', + 'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active'} + + @api.model_create_multi + def create(self, vals_list): + return super().create([ + dict(vals, need_sync=False) if vals.get('recurrency') else vals + for vals in vals_list + ]) def write(self, values): - sync_fields = set(self.get_fields_need_update_google()) - if (set(values) and sync_fields) and 'oe_update_date' not in values and 'NewMeeting' not in self._context: - if 'oe_update_date' in self._context: - values['oe_update_date'] = self._context.get('oe_update_date') - else: - values['oe_update_date'] = fields.Datetime.now() - return super(Meeting, self).write(values) + recurrence_update_setting = values.get('recurrence_update') + if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: + values = dict(values, need_sync=False) + return super().write(values) - @api.returns('self', lambda value: value.id) - def copy(self, default=None): - default = default or {} - if default.get('write_type', False): - del default['write_type'] - elif default.get('recurrent_id', False): - default['oe_update_date'] = fields.Datetime.now() + def _get_sync_domain(self): + return [('partner_ids.user_ids', 'in', self.env.user.id)] + + @api.model + def _odoo_values(self, google_event, default_reminders=()): + if google_event.is_cancelled(): + return {'active': False} + + alarm_commands = self._odoo_reminders_commands(google_event.reminders.get('overrides') or default_reminders) + values = { + 'name': google_event.summary or _("(No title)"), + 'description': google_event.description, + 'location': google_event.location, + 'user_id': google_event.owner(self.env).id, + 'privacy': google_event.visibility or self.default_get(['privacy'])['privacy'], + 'attendee_ids': self._odoo_attendee_commands(google_event), + 'alarm_ids': alarm_commands, + 'recurrency': google_event.is_recurrent() + } + + if google_event.reminders and google_event.reminders.get('useDefault'): + values + + if not google_event.is_recurrence(): + values['google_id'] = google_event.id + if google_event.start.get('dateTime'): + # starting from python3.7, use the new [datetime, date].fromisoformat method + start = parse(google_event.start.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None) + stop = parse(google_event.end.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None) + values['allday'] = False else: - default['oe_update_date'] = False - return super(Meeting, self).copy(default) + start = parse(google_event.start.get('date')) + stop = parse(google_event.end.get('date')) - relativedelta(days=1) + values['allday'] = True + values['start'] = start + values['stop'] = stop + return values - def unlink(self, can_be_deleted=False): - return super(Meeting, self).unlink(can_be_deleted=can_be_deleted) + @api.model + def _odoo_attendee_commands(self, google_event): + commands = [] + if not google_event.attendees: + return commands + emails = [a.get('email') for a in google_event.attendees] + existing_attendees = self.env['calendar.attendee'] + if google_event.exists(self.env): + existing_attendees = self.env['calendar.attendee'].search([('event_id', '=', google_event.odoo_id(self.env)), ('email', 'in', emails)]) + attendees_by_emails = {a.email: a for a in existing_attendees} + for attendee in google_event.attendees: + email = attendee.get('email') + if email in attendees_by_emails: + # Update existing attendees + commands += [(1, attendees_by_emails[email].id, {'state': attendee.get('responseStatus')})] + else: + # Create new attendees + partner = self.env['res.partner'].find_or_create(attendee.get('email')) + commands += [(0, 0, {'state': attendee.get('responseStatus'), 'partner_id': partner.id})] + if attendee.get('displayName') and not partner.name: + partner.name = attendee.get('displayName') + for odoo_attendee in attendees_by_emails.values(): + # Remove old attendees + if odoo_attendee.email not in emails: + commands += [(2, odoo_attendee.id)] + return commands -class Attendee(models.Model): + @api.model + def _odoo_reminders_commands(self, reminders=()): + commands = [] + for reminder in reminders: + alarm_type = 'email' if reminder.get('method') == 'email' else 'notification' + alarm_type_label = _("Email") if alarm_type == 'email' else _("Notification") - _inherit = 'calendar.attendee' + minutes = reminder.get('minutes', 0) + alarm = self.env['calendar.alarm'].search([ + ('alarm_type', '=', alarm_type), + ('duration_minutes', '=', minutes) + ], limit=1) + if alarm: + commands += [(4, alarm.id)] + else: + if minutes % (60*24) == 0: + interval = 'days' + duration = minutes / 60 / 24 + name = _("%s - %s Days") % (alarm_type_label, duration) + elif minutes % 60 == 0: + interval = 'hours' + duration = minutes / 60 + name = _("%s - %s Hours") % (alarm_type_label, duration) + else: + interval = 'minutes' + duration = minutes + name = _("%s - %s Minutes") % (alarm_type_label, duration) + commands += [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': alarm_type})] + reminders = {()} + return commands - google_internal_event_id = fields.Char('Google Calendar Event Id') - oe_synchro_date = fields.Datetime('Odoo Synchro Date') + def _google_values(self): + if self.allday: + start = {'date': self.start_date.isoformat()} + end = {'date': (self.stop_date + relativedelta(days=1)).isoformat()} + else: + start = {'dateTime': pytz.utc.localize(self.start).isoformat()} + end = {'dateTime': pytz.utc.localize(self.stop).isoformat()} - _sql_constraints = [ - ('google_id_uniq', 'unique(google_internal_event_id,partner_id,event_id)', 'Google ID should be unique!') - ] + reminders = [{ + 'method': "email" if alarm.alarm_type == "email" else "popup", + 'minutes': alarm.duration_minutes + } for alarm in self.alarm_ids] + values = { + 'id': self.google_id, + 'start': start, + 'end': end, + 'summary': self.name, + 'description': self.description or '', + 'location': self.location or '', + 'guestsCanModify': True, + 'organizer': {'email': self.user_id.email, 'self': self.user_id == self.env.user}, + 'attendees': [{'email': attendee.email, 'responseStatus': attendee.state} for attendee in self.attendee_ids], + 'extendedProperties': { + 'shared': { + '%s_odoo_id' % self.env.cr.dbname: self.id, + }, + }, + 'reminders': { + 'overrides': reminders, + 'useDefault': False, + } + } + if self.privacy: + values['visibility'] = self.privacy + if self.user_id != self.env.user: + values['extendedProperties']['shared']['%s_owner_id' % self.env.cr.dbname] = self.user_id.id - def write(self, values): - for attendee in self: - meeting_id_to_update = values.get('event_id', attendee.event_id.id) + if not self.active: + values['status'] = 'cancelled' + return values - # If attendees are updated, we need to specify that next synchro need an action - # Except if it come from an update_from_google - if not self._context.get('curr_attendee', False) and not self._context.get('NewMeeting', False): - self.env['calendar.event'].browse(meeting_id_to_update).write({'oe_update_date': fields.Datetime.now()}) - return super(Attendee, self).write(values) + def _cancel(self): + # only owner can delete => others refuse the event + user = self.env.user + my_cancelled_records = self.filtered(lambda e: e.user_id == user) + super(Meeting, my_cancelled_records)._cancel() + attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) + attendees.state = 'declined' 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 +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: ''+ @@ -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 @@ + + + + calendar.event.view.calendar.inherit + calendar.event + + + + google_sync_calendar + + + + \ 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 @@ + +