diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/google_calendar/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/google_calendar/models')
| -rw-r--r-- | addons/google_calendar/models/__init__.py | 9 | ||||
| -rw-r--r-- | addons/google_calendar/models/calendar.py | 235 | ||||
| -rw-r--r-- | addons/google_calendar/models/calendar_attendee.py | 28 | ||||
| -rw-r--r-- | addons/google_calendar/models/calendar_recurrence_rule.py | 187 | ||||
| -rw-r--r-- | addons/google_calendar/models/google_sync.py | 266 | ||||
| -rw-r--r-- | addons/google_calendar/models/res_config_settings.py | 11 | ||||
| -rw-r--r-- | addons/google_calendar/models/res_users.py | 117 |
7 files changed, 853 insertions, 0 deletions
diff --git a/addons/google_calendar/models/__init__.py b/addons/google_calendar/models/__init__.py new file mode 100644 index 00000000..38e16223 --- /dev/null +++ b/addons/google_calendar/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# 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 calendar_recurrence_rule +from . import res_users +from . import calendar_attendee diff --git a/addons/google_calendar/models/calendar.py b/addons/google_calendar/models/calendar.py new file mode 100644 index 00000000..0079c9c0 --- /dev/null +++ b/addons/google_calendar/models/calendar.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz +from dateutil.parser import parse +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, tools, _ + + +class Meeting(models.Model): + _name = 'calendar.event' + _inherit = ['calendar.event', 'google.calendar.sync'] + + google_id = fields.Char( + 'Google Calendar Event Id', compute='_compute_google_id', store=True, readonly=False) + + @api.depends('recurrence_id.google_id') + def _compute_google_id(self): + # google ids of recurring events are built from the recurrence id and the + # original starting time in the recurrence. + # The `start` field does not appear in the dependencies on purpose! + # Event if the event is moved, the google_id remains the same. + for event in self: + google_recurrence_id = event.recurrence_id._get_event_google_id(event) + if not event.google_id and google_recurrence_id: + event.google_id = google_recurrence_id + elif not event.google_id: + event.google_id = False + + @api.model + 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('recurrence_id') or vals.get('recurrency') else vals + for vals in vals_list + ]) + + def write(self, values): + recurrence_update_setting = values.get('recurrence_update') + if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: + values = dict(values, need_sync=False) + res = super().write(values) + if recurrence_update_setting in ('all_events',) and len(self) == 1 and values.keys() & self._get_google_synced_fields(): + self.recurrence_id.need_sync = True + return res + + 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) + attendee_commands, partner_commands = self._odoo_attendee_commands(google_event) + 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': attendee_commands, + 'partner_ids': partner_commands, + 'alarm_ids': alarm_commands, + 'recurrency': google_event.is_recurrent() + } + + if not google_event.is_recurrence(): + values['google_id'] = google_event.id + if google_event.start.get('dateTime'): + # starting from python3.7, use the new [datetime, date].fromisoformat method + start = parse(google_event.start.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None) + stop = parse(google_event.end.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None) + values['allday'] = False + else: + start = parse(google_event.start.get('date')) + stop = parse(google_event.end.get('date')) - relativedelta(days=1) + values['allday'] = True + values['start'] = start + values['stop'] = stop + return values + + @api.model + def _odoo_attendee_commands(self, google_event): + attendee_commands = [] + partner_commands = [] + google_attendees = google_event.attendees or [] + if len(google_attendees) == 0 and google_event.organizer and google_event.organizer.get('self', False): + user = google_event.owner(self.env) + google_attendees += [{ + 'email': user.partner_id.email, + 'status': {'response': 'accepted'}, + }] + emails = [a.get('email') for a in google_attendees] + existing_attendees = self.env['calendar.attendee'] + if google_event.exists(self.env): + existing_attendees = self.browse(google_event.odoo_id(self.env)).attendee_ids + attendees_by_emails = {tools.email_normalize(a.email): a for a in existing_attendees} + for attendee in google_attendees: + email = attendee.get('email') + + if email in attendees_by_emails: + # Update existing attendees + attendee_commands += [(1, attendees_by_emails[email].id, {'state': attendee.get('responseStatus')})] + else: + # Create new attendees + partner = self.env.user.partner_id if attendee.get('self') else self.env['res.partner'].find_or_create(attendee.get('email')) + attendee_commands += [(0, 0, {'state': attendee.get('responseStatus'), 'partner_id': partner.id})] + partner_commands += [(4, 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 tools.email_normalize(odoo_attendee.email) not in emails: + attendee_commands += [(2, odoo_attendee.id)] + partner_commands += [(3, odoo_attendee.partner_id.id)] + return attendee_commands, partner_commands + + @api.model + def _odoo_reminders_commands(self, reminders=()): + commands = [] + for reminder in reminders: + alarm_type = 'email' if reminder.get('method') == 'email' else 'notification' + alarm_type_label = _("Email") if alarm_type == 'email' else _("Notification") + + minutes = reminder.get('minutes', 0) + alarm = self.env['calendar.alarm'].search([ + ('alarm_type', '=', alarm_type), + ('duration_minutes', '=', minutes) + ], limit=1) + if alarm: + commands += [(4, alarm.id)] + else: + if minutes % (60*24) == 0: + interval = 'days' + duration = minutes / 60 / 24 + name = _( + "%(reminder_type)s - %(duration)s Days", + reminder_type=alarm_type_label, + duration=duration, + ) + elif minutes % 60 == 0: + interval = 'hours' + duration = minutes / 60 + name = _( + "%(reminder_type)s - %(duration)s Hours", + reminder_type=alarm_type_label, + duration=duration, + ) + else: + interval = 'minutes' + duration = minutes + name = _( + "%(reminder_type)s - %(duration)s Minutes", + reminder_type=alarm_type_label, + duration=duration, + ) + commands += [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': alarm_type})] + return commands + + def _google_values(self): + if self.allday: + start = {'date': self.start_date.isoformat()} + end = {'date': (self.stop_date + relativedelta(days=1)).isoformat()} + else: + start = {'dateTime': pytz.utc.localize(self.start).isoformat()} + end = {'dateTime': pytz.utc.localize(self.stop).isoformat()} + + reminders = [{ + 'method': "email" if alarm.alarm_type == "email" else "popup", + 'minutes': alarm.duration_minutes + } for alarm in self.alarm_ids] + attendee_ids = self.attendee_ids.filtered(lambda a: a.partner_id not in self.user_id.partner_id) + 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 not self.active: + values['status'] = 'cancelled' + if self.user_id and self.user_id != self.env.user: + values['extendedProperties']['shared']['%s_owner_id' % self.env.cr.dbname] = self.user_id.id + elif not self.user_id: + # We don't store the real owner identity (mail) + # We can't store on the shared properties in that case without getting a 403 + # If several odoo users are attendees but the owner is not in odoo, the event will be duplicated on odoo database + # if we are not the owner, we should change the post values to avoid errors because we don't have enough rights + # See https://developers.google.com/calendar/concepts/sharing + keep_keys = ['id', 'attendees', 'start', 'end', 'reminders'] + values = {key: val for key, val in values.items() if key in keep_keys} + # values['extendedProperties']['private] should be used if the owner is not an odoo user + values['extendedProperties'] = { + 'private': { + '%s_odoo_id' % self.env.cr.dbname: self.id, + }, + } + return values + + def _cancel(self): + # only owner can delete => others refuse the event + user = self.env.user + my_cancelled_records = self.filtered(lambda e: e.user_id == user) + super(Meeting, my_cancelled_records)._cancel() + attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) + attendees.state = 'declined' + + def _notify_attendees(self): + # filter events before notifying attendees through calendar_alarm_manager + need_notifs = self.filtered(lambda event: event.alarm_ids and event.stop >= fields.Datetime.now()) + partners = need_notifs.partner_ids + if partners: + self.env['calendar.alarm_manager']._notify_next_alarm(partners.ids) diff --git a/addons/google_calendar/models/calendar_attendee.py b/addons/google_calendar/models/calendar_attendee.py new file mode 100644 index 00000000..3e3af0f5 --- /dev/null +++ b/addons/google_calendar/models/calendar_attendee.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +from odoo.addons.google_calendar.models.google_sync import google_calendar_token +from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService + +class Attendee(models.Model): + _name = 'calendar.attendee' + _inherit = 'calendar.attendee' + + def _send_mail_to_attendees(self, template_xmlid, force_send=False, ignore_recurrence=False): + """ Override + If not synced with Google, let Odoo in charge of sending emails + Otherwise, nothing to do: Google will send them + """ + with google_calendar_token(self.env.user.sudo()) as token: + if not token: + super()._send_mail_to_attendees(template_xmlid, force_send, ignore_recurrence) + + def write(self, vals): + res = super().write(vals) + if vals.get('state'): + # When the state is changed, the corresponding event must be sync with google + google_service = GoogleCalendarService(self.env['google.service']) + self.event_id.filtered('google_id')._sync_odoo2google(google_service) + return res 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 00000000..c9ff1566 --- /dev/null +++ b/addons/google_calendar/models/calendar_recurrence_rule.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + +from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService + + +class RecurrenceRule(models.Model): + _name = 'calendar.recurrence' + _inherit = ['calendar.recurrence', 'google.calendar.sync'] + + + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): + events = self.filtered('need_sync').calendar_event_ids + detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit) + + 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) + + 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. + values['need_sync'] = bool(dtstart) + return super()._write_events(values, dtstart=dtstart) + + def _cancel(self): + self.calendar_event_ids._cancel() + super()._cancel() + + def _get_google_synced_fields(self): + return {'rrule'} + + def _write_from_google(self, gevent, vals): + current_rrule = self.rrule + # event_tz is written on event in Google but on recurrence in Odoo + vals['event_tz'] = gevent.start.get('timeZone') + super()._write_from_google(gevent, vals) + + base_event_time_fields = ['start', 'stop', 'allday'] + new_event_values = self.env["calendar.event"]._odoo_values(gevent) + old_event_values = self.base_event_id and self.base_event_id.read(base_event_time_fields)[0] + if old_event_values and any(new_event_values[key] != old_event_values[key] for key in base_event_time_fields): + # we need to recreate the recurrence, time_fields were modified. + base_event_id = self.base_event_id + # We archive the old events to recompute the recurrence. These events are already deleted on Google side. + # We can't call _cancel because events without user_id would not be deleted + (self.calendar_event_ids - base_event_id).google_id = False + (self.calendar_event_ids - base_event_id).unlink() + base_event_id.write(dict(new_event_values, google_id=False, need_sync=False)) + if self.rrule == current_rrule: + # if the rrule has changed, it will be recalculated below + # There is no detached event now + self._apply_recurrence() + else: + time_fields = ( + self.env["calendar.event"]._get_time_fields() + | self.env["calendar.event"]._get_recurrent_fields() + ) + # We avoid to write time_fields because they are not shared between events. + self._write_events(dict({ + field: value + for field, value in new_event_values.items() + if field not in time_fields + }, need_sync=False) + ) + + # We apply the rrule check after the time_field check because the google_id are generated according + # to base_event start datetime. + if self.rrule != current_rrule: + detached_events = self._apply_recurrence() + detached_events.google_id = False + detached_events.unlink() + + def _create_from_google(self, gevents, vals_list): + for gevent, vals in zip(gevents, vals_list): + base_values = dict( + self.env['calendar.event']._odoo_values(gevent), # FIXME default reminders + need_sync=False, + ) + # If we convert a single event into a recurrency on Google, we should reuse this event on Odoo + # Google reuse the event google_id to identify the recurrence in that case + base_event = self.env['calendar.event'].search([('google_id', '=', vals['google_id'])]) + if not base_event: + base_event = self.env['calendar.event'].create(base_values) + else: + # We override the base_event values because they could have been changed in Google interface + # The event google_id will be recalculated once the recurrence is created + base_event.write(dict(base_values, google_id=False)) + vals['base_event_id'] = base_event.id + vals['calendar_event_ids'] = [(4, base_event.id)] + # event_tz is written on event in Google but on recurrence in Odoo + vals['event_tz'] = gevent.start.get('timeZone') + recurrence = super(RecurrenceRule, self.with_context(dont_notify=True))._create_from_google(gevents, vals_list) + recurrence.with_context(dont_notify=True)._apply_recurrence() + if not recurrence._context.get("dont_notify"): + recurrence._notify_attendees() + return recurrence + + def _get_sync_domain(self): + return [('calendar_event_ids.user_id', '=', self.env.user.id)] + + @api.model + def _odoo_values(self, google_recurrence, default_reminders=()): + return { + 'rrule': google_recurrence.rrule, + 'google_id': google_recurrence.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) + # UNTIL must be in UTC (appending Z) + # We want to only add a 'Z' to non UTC UNTIL values and avoid adding a second. + # 'RRULE:FREQ=DAILY;UNTIL=20210224T235959;INTERVAL=3 --> match UNTIL=20210224T235959 + # 'RRULE:FREQ=DAILY;UNTIL=20210224T235959 --> match + rrule = re.sub(r"(UNTIL=\d{8}T\d{6})($|;)", r"\1Z\2", rrule) + values['recurrence'] = ['RRULE:%s' % rrule] if 'RRULE:' not in rrule else [rrule] + property_location = 'shared' if event.user_id else 'private' + values['extendedProperties'] = { + property_location: { + '%s_odoo_id' % self.env.cr.dbname: self.id, + }, + } + return values + + def _notify_attendees(self): + recurrences = self.filtered( + lambda recurrence: recurrence.base_event_id.alarm_ids and ( + not recurrence.until or recurrence.until >= fields.Date.today() - relativedelta(days=1) + ) and (max(recurrence.calendar_event_ids.mapped('stop')) >= fields.Datetime.now()) + ) + partners = recurrences.base_event_id.partner_ids + if partners: + self.env['calendar.alarm_manager']._notify_next_alarm(partners.ids) diff --git a/addons/google_calendar/models/google_sync.py b/addons/google_calendar/models/google_sync.py new file mode 100644 index 00000000..94be4051 --- /dev/null +++ b/addons/google_calendar/models/google_sync.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from contextlib import contextmanager +from functools import wraps +import requests +import pytz +from dateutil.parser import parse + +from odoo import api, fields, models, registry, _ +from odoo.tools import ormcache_context +from odoo.exceptions import UserError +from odoo.osv import expression + +from odoo.addons.google_calendar.utils.google_event import GoogleEvent +from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService +from odoo.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 + + @self.env.cr.postcommit.add + 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) + + 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: + # Since we can not delete such an event (see method comment), we archive it. + # Notice that archiving an event will delete the associated event on Google. + # Then, since it has been deleted on Google, the event is also deleted on Odoo DB (_sync_google2odoo). + self.action_archive() + return True + 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.with_context(dont_notify=True)._create_from_google(new, odoo_values) + # Synced recurrences attendees will be notified once _apply_recurrence is called. + if not self._context.get("dont_notify") and all(not e.is_recurrence() for e in google_events): + new_odoo._notify_attendees() + + cancelled = existing.cancelled() + cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)) + cancelled_odoo._cancel() + synced_records = (new_odoo + cancelled_odoo).with_context(dont_notify=self._context.get("dont_notify", False)) + 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)) + # Migration from 13.4 does not fill write_date. Therefore, we force the update from Google. + if not odoo_record.write_date or updated >= pytz.utc.localize(odoo_record.write_date): + vals = dict(self._odoo_values(gevent, default_reminders), need_sync=False) + odoo_record._write_from_google(gevent, vals) + synced_records |= odoo_record + + return synced_records + + @after_commit + def _google_delete(self, google_service: GoogleCalendarService, google_id, timeout=TIMEOUT): + with google_calendar_token(self.env.user.sudo()) as token: + if token: + google_service.delete(google_id, token=token, timeout=timeout) + # When the record has been deleted on our side, we need to delete it on google but we don't want + # to raise an error because the record don't exists anymore. + self.exists().need_sync = False + + @after_commit + def _google_patch(self, google_service: GoogleCalendarService, google_id, values, timeout=TIMEOUT): + with google_calendar_token(self.env.user.sudo()) as token: + if token: + google_service.patch(google_id, values, token=token, timeout=timeout) + self.need_sync = False + + @after_commit + def _google_insert(self, google_service: GoogleCalendarService, values, timeout=TIMEOUT): + 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) + + def _write_from_google(self, gevent, vals): + self.write(vals) + + @api.model + def _create_from_google(self, gevents, vals_list): + return self.create(vals_list) + + @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() + + def _notify_attendees(self): + """ Notify calendar event partners. + This is called when creating new calendar events in _sync_google2odoo. + At the initialization of a synced calendar, Odoo requests all events for a specific + GoogleCalendar. Among those there will probably be lots of events that will never triggers a notification + (e.g. single events that occured in the past). Processing all these events through the notification procedure + of calendar.event.create is a possible performance bottleneck. This method aimed at alleviating that. + """ + raise NotImplementedError() diff --git a/addons/google_calendar/models/res_config_settings.py b/addons/google_calendar/models/res_config_settings.py new file mode 100644 index 00000000..1e08535d --- /dev/null +++ b/addons/google_calendar/models/res_config_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + 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='') diff --git a/addons/google_calendar/models/res_users.py b/addons/google_calendar/models/res_users.py new file mode 100644 index 00000000..9b7b21e4 --- /dev/null +++ b/addons/google_calendar/models/res_users.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import requests +from odoo.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 odoo.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, 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_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, method='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 + events.clear_type_ambiguity(self.env) + 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)) |
