summaryrefslogtreecommitdiff
path: root/addons/google_calendar/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/google_calendar/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/google_calendar/models')
-rw-r--r--addons/google_calendar/models/__init__.py9
-rw-r--r--addons/google_calendar/models/calendar.py235
-rw-r--r--addons/google_calendar/models/calendar_attendee.py28
-rw-r--r--addons/google_calendar/models/calendar_recurrence_rule.py187
-rw-r--r--addons/google_calendar/models/google_sync.py266
-rw-r--r--addons/google_calendar/models/res_config_settings.py11
-rw-r--r--addons/google_calendar/models/res_users.py117
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))