mirror of
https://github.com/bringout/oca-ocb-security.git
synced 2026-04-24 14:42:04 +02:00
Initial commit: Security packages
This commit is contained in:
commit
bb469e4763
1399 changed files with 278378 additions and 0 deletions
|
|
@ -0,0 +1,11 @@
|
|||
# -*- 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
|
||||
from . import google_credentials
|
||||
from . import calendar_alarm_manager
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
# -*- 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 uuid import uuid4
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
|
||||
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService
|
||||
|
||||
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', 'show_as'}
|
||||
|
||||
@api.model
|
||||
def _restart_google_sync(self):
|
||||
self.env['calendar.event'].search(self._get_sync_domain()).write({
|
||||
'need_sync': True,
|
||||
})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
notify_context = self.env.context.get('dont_notify', False)
|
||||
return super(Meeting, self.with_context(dont_notify=notify_context)).create([
|
||||
dict(vals, need_sync=False) if vals.get('recurrence_id') or vals.get('recurrency') else vals
|
||||
for vals in vals_list
|
||||
])
|
||||
|
||||
@api.model
|
||||
def _check_values_to_sync(self, values):
|
||||
""" Return True if values being updated intersects with Google synced values and False otherwise. """
|
||||
synced_fields = self._get_google_synced_fields()
|
||||
values_to_sync = any(key in synced_fields for key in values)
|
||||
return values_to_sync
|
||||
|
||||
@api.model
|
||||
def _get_update_future_events_values(self):
|
||||
""" Add parameters for updating events within the _update_future_events function scope. """
|
||||
update_future_events_values = super()._get_update_future_events_values()
|
||||
return {**update_future_events_values, 'need_sync': False}
|
||||
|
||||
@api.model
|
||||
def _get_remove_sync_id_values(self):
|
||||
""" Add parameters for removing event synchronization while updating the events in super class. """
|
||||
remove_sync_id_values = super()._get_remove_sync_id_values()
|
||||
return {**remove_sync_id_values, 'google_id': False}
|
||||
|
||||
@api.model
|
||||
def _get_archive_values(self):
|
||||
""" Return the parameters for archiving events. Do not synchronize events after archiving. """
|
||||
archive_values = super()._get_archive_values()
|
||||
return {**archive_values, 'need_sync': False}
|
||||
|
||||
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)
|
||||
notify_context = self.env.context.get('dont_notify', False)
|
||||
res = super(Meeting, self.with_context(dont_notify=notify_context)).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):
|
||||
# in case of full sync, limit to a range of 1y in past and 1y in the future by default
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
day_range = int(ICP.get_param('google_calendar.sync.range_days', default=365))
|
||||
lower_bound = fields.Datetime.subtract(fields.Datetime.now(), days=day_range)
|
||||
upper_bound = fields.Datetime.add(fields.Datetime.now(), days=day_range)
|
||||
return [
|
||||
('partner_ids.user_ids', 'in', self.env.user.id),
|
||||
('stop', '>', lower_bound),
|
||||
('start', '<', upper_bound),
|
||||
# Do not sync events that follow the recurrence, they are already synced at recurrence creation
|
||||
'!', '&', '&', ('recurrency', '=', True), ('recurrence_id', '!=', False), ('follow_recurrence', '=', True)
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _odoo_values(self, google_event, default_reminders=()):
|
||||
if google_event.is_cancelled():
|
||||
return {'active': False}
|
||||
|
||||
# default_reminders is never () it is set to google's default reminder (30 min before)
|
||||
# we need to check 'useDefault' for the event to determine if we have to use google's
|
||||
# default reminder or not
|
||||
reminder_command = google_event.reminders.get('overrides')
|
||||
if not reminder_command:
|
||||
reminder_command = google_event.reminders.get('useDefault') and default_reminders or ()
|
||||
alarm_commands = self._odoo_reminders_commands(reminder_command)
|
||||
attendee_commands, partner_commands = self._odoo_attendee_commands(google_event)
|
||||
related_event = self.search([('google_id', '=', google_event.id)], limit=1)
|
||||
name = google_event.summary or related_event and related_event.name or _("(No title)")
|
||||
values = {
|
||||
'name': name,
|
||||
'description': google_event.description and tools.html_sanitize(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,
|
||||
'alarm_ids': alarm_commands,
|
||||
'recurrency': google_event.is_recurrent(),
|
||||
'videocall_location': google_event.get_meeting_url(),
|
||||
'show_as': 'free' if google_event.is_available() else 'busy'
|
||||
}
|
||||
# Remove 'videocall_location' when not sent by Google, otherwise the local videocall will be discarded.
|
||||
if not values.get('videocall_location'):
|
||||
values.pop('videocall_location', False)
|
||||
if partner_commands:
|
||||
# Add partner_commands only if set from Google. The write method on calendar_events will
|
||||
# override attendee commands if the partner_ids command is set but empty.
|
||||
values['partner_ids'] = partner_commands
|
||||
if not google_event.is_recurrence():
|
||||
values['google_id'] = google_event.id
|
||||
if google_event.is_recurrent() and not google_event.is_recurrence():
|
||||
# Propagate the follow_recurrence according to the google result
|
||||
values['follow_recurrence'] = google_event.is_recurrence_follower()
|
||||
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)
|
||||
# Stop date should be exclusive as defined here https://developers.google.com/calendar/v3/reference/events#resource
|
||||
# but it seems that's not always the case for old event
|
||||
if stop < start:
|
||||
stop = parse(google_event.end.get('date'))
|
||||
values['allday'] = True
|
||||
if related_event['start'] != start:
|
||||
values['start'] = start
|
||||
if related_event['stop'] != stop:
|
||||
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,
|
||||
'responseStatus': 'accepted',
|
||||
}]
|
||||
emails = [a.get('email') for a in google_attendees]
|
||||
existing_attendees = self.env['calendar.attendee']
|
||||
if google_event.exists(self.env):
|
||||
event = google_event.get_odoo_event(self.env)
|
||||
existing_attendees = event.attendee_ids
|
||||
attendees_by_emails = {tools.email_normalize(a.email): a for a in existing_attendees}
|
||||
partners = self._get_sync_partner(emails)
|
||||
for attendee in zip(emails, partners, google_attendees):
|
||||
email = attendee[0]
|
||||
if email in attendees_by_emails:
|
||||
# Update existing attendees
|
||||
attendee_commands += [(1, attendees_by_emails[email].id, {'state': attendee[2].get('responseStatus')})]
|
||||
else:
|
||||
# Create new attendees
|
||||
if attendee[2].get('self'):
|
||||
partner = self.env.user.partner_id
|
||||
elif attendee[1]:
|
||||
partner = attendee[1]
|
||||
else:
|
||||
continue
|
||||
attendee_commands += [(0, 0, {'state': attendee[2].get('responseStatus'), 'partner_id': partner.id})]
|
||||
partner_commands += [(4, partner.id)]
|
||||
if attendee[2].get('displayName') and not partner.name:
|
||||
partner.name = attendee[2].get('displayName')
|
||||
for odoo_attendee in attendees_by_emails.values():
|
||||
# Remove old attendees but only if it does not correspond to the current user.
|
||||
email = tools.email_normalize(odoo_attendee.email)
|
||||
if email not in emails and email != self.env.user.email:
|
||||
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 action_mass_archive(self, recurrence_update_setting):
|
||||
""" Delete recurrence in Odoo if in 'all_events' or in 'future_events' edge case, triggering one mail. """
|
||||
self.ensure_one()
|
||||
google_service = GoogleCalendarService(self.env['google.service'])
|
||||
archive_future_events = recurrence_update_setting == 'future_events' and self == self.recurrence_id.base_event_id
|
||||
if recurrence_update_setting == 'all_events' or archive_future_events:
|
||||
self.recurrence_id.with_context(is_recurrence=True)._google_delete(google_service, self.recurrence_id.google_id)
|
||||
# Increase performance handling 'future_events' edge case as it was an 'all_events' update.
|
||||
if archive_future_events:
|
||||
recurrence_update_setting = 'all_events'
|
||||
super(Meeting, self).action_mass_archive(recurrence_update_setting)
|
||||
|
||||
def _google_values(self):
|
||||
# In Google API, all-day events must have their 'dateTime' information set
|
||||
# as null and timed events must have their 'date' information set as null.
|
||||
# This is mandatory for allowing changing timed events to all-day and vice versa.
|
||||
start = {'date': None, 'dateTime': None}
|
||||
end = {'date': None, 'dateTime': None}
|
||||
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]
|
||||
|
||||
attendees = self.attendee_ids
|
||||
attendee_values = [{
|
||||
'email': attendee.partner_id.sudo().email_normalized,
|
||||
'responseStatus': attendee.state or 'needsAction',
|
||||
} for attendee in attendees if attendee.partner_id.sudo().email_normalized]
|
||||
# We sort the attendees to avoid undeterministic test fails. It's not mandatory for Google.
|
||||
attendee_values.sort(key=lambda k: k['email'])
|
||||
values = {
|
||||
'id': self.google_id,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'summary': self.name,
|
||||
'description': tools.html_sanitize(self.description) if not tools.is_html_empty(self.description) else '',
|
||||
'location': self.location or '',
|
||||
'guestsCanModify': True,
|
||||
'organizer': {'email': self.user_id.email, 'self': self.user_id == self.env.user},
|
||||
'attendees': attendee_values,
|
||||
'extendedProperties': {
|
||||
'shared': {
|
||||
'%s_odoo_id' % self.env.cr.dbname: self.id,
|
||||
},
|
||||
},
|
||||
'reminders': {
|
||||
'overrides': reminders,
|
||||
'useDefault': False,
|
||||
}
|
||||
}
|
||||
if not self.google_id and not self.videocall_location and not self.location:
|
||||
values['conferenceData'] = {'createRequest': {'requestId': uuid4().hex}}
|
||||
if self.privacy:
|
||||
values['visibility'] = self.privacy
|
||||
if self.show_as:
|
||||
values['transparency'] = 'opaque' if self.show_as == 'busy' else 'transparent'
|
||||
if not self.active:
|
||||
values['status'] = 'cancelled'
|
||||
if self.user_id and self.user_id != self.env.user and not bool(self.user_id.sudo().google_calendar_token):
|
||||
# The organizer is an Odoo user that do not sync his calendar
|
||||
values['extendedProperties']['shared']['%s_owner_id' % self.env.cr.dbname] = self.user_id.id
|
||||
elif not self.user_id:
|
||||
# We can't store on the shared properties in that case without getting a 403. It can happen when
|
||||
# the owner is not an Odoo user: We don't store the real owner identity (mail)
|
||||
# If we are not the owner, we should change the post values to avoid errors because we don't have
|
||||
# write permissions
|
||||
# See https://developers.google.com/calendar/concepts/sharing
|
||||
keep_keys = ['id', 'summary', '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 _get_event_user(self):
|
||||
self.ensure_one()
|
||||
if self.user_id and self.user_id.sudo().google_calendar_token:
|
||||
return self.user_id
|
||||
return self.env.user
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class AlarmManager(models.AbstractModel):
|
||||
_inherit = 'calendar.alarm_manager'
|
||||
|
||||
@api.model
|
||||
def _get_notify_alert_extra_conditions(self):
|
||||
res = super()._get_notify_alert_extra_conditions()
|
||||
return f'{res} AND "event"."google_id" IS NULL'
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# -*- 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, mail_template, force_send=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(mail_template, force_send)
|
||||
|
||||
def do_tentative(self):
|
||||
# Synchronize event after state change
|
||||
res = super().do_tentative()
|
||||
self._sync_event()
|
||||
return res
|
||||
|
||||
def do_accept(self):
|
||||
# Synchronize event after state change
|
||||
res = super().do_accept()
|
||||
self._sync_event()
|
||||
return res
|
||||
|
||||
|
||||
def do_decline(self):
|
||||
# Synchronize event after state change
|
||||
res = super().do_decline()
|
||||
self._sync_event()
|
||||
return res
|
||||
|
||||
def _sync_event(self):
|
||||
# For weird reasons, we can't sync status when we are not the responsible
|
||||
# We can't adapt google_value to only keep ['id', 'summary', 'attendees', 'start', 'end', 'reminders']
|
||||
# and send that. We get a Forbidden for non-organizer error even if we only send start, end that are mandatory !
|
||||
if self._context.get('all_events'):
|
||||
service = GoogleCalendarService(self.env['google.service'].with_user(self.recurrence_id.base_event_id.user_id))
|
||||
self.recurrence_id.with_user(self.recurrence_id.base_event_id.user_id)._sync_odoo2google(service)
|
||||
else:
|
||||
all_events = self.mapped('event_id').filtered(lambda e: e.google_id)
|
||||
other_events = all_events.filtered(lambda e: e.user_id and e.user_id.id != self.env.user.id)
|
||||
for user in other_events.mapped('user_id'):
|
||||
service = GoogleCalendarService(self.env['google.service'].with_user(user))
|
||||
other_events.filtered(lambda ev: ev.user_id.id == user.id).with_user(user)._sync_odoo2google(service)
|
||||
google_service = GoogleCalendarService(self.env['google.service'])
|
||||
(all_events - other_events)._sync_odoo2google(google_service)
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
from odoo import api, models, Command
|
||||
from odoo.tools import email_normalize
|
||||
|
||||
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
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, generic_values_creation=None):
|
||||
events = self.filtered('need_sync').calendar_event_ids
|
||||
detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit,
|
||||
generic_values_creation)
|
||||
|
||||
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.with_user(event._get_event_user())._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)
|
||||
# Events will be updated by patch requests, do not sync events for avoiding spam.
|
||||
values['need_sync'] = False
|
||||
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'}
|
||||
|
||||
@api.model
|
||||
def _restart_google_sync(self):
|
||||
self.env['calendar.recurrence'].search(self._get_sync_domain()).write({
|
||||
'need_sync': True,
|
||||
})
|
||||
|
||||
def _write_from_google(self, gevent, vals):
|
||||
current_rrule = self.rrule
|
||||
current_parsed_rrule = self._rrule_parse(current_rrule, self.dtstart)
|
||||
# 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)
|
||||
new_parsed_rrule = self._rrule_parse(self.rrule, self.dtstart)
|
||||
# We update the attendee status for all events in the recurrence
|
||||
google_attendees = gevent.attendees or []
|
||||
emails = [a.get('email') for a in google_attendees]
|
||||
partners = self._get_sync_partner(emails)
|
||||
existing_attendees = self.calendar_event_ids.attendee_ids
|
||||
for attendee in zip(emails, partners, google_attendees):
|
||||
email = attendee[0]
|
||||
if email in existing_attendees.mapped('email'):
|
||||
# Update existing attendees
|
||||
existing_attendees.filtered(lambda att: att.email == email).write({'state': attendee[2].get('responseStatus')})
|
||||
else:
|
||||
# Create new attendees
|
||||
if attendee[2].get('self'):
|
||||
partner = self.env.user.partner_id
|
||||
elif attendee[1]:
|
||||
partner = attendee[1]
|
||||
else:
|
||||
continue
|
||||
self.calendar_event_ids.write({'attendee_ids': [(0, 0, {'state': attendee[2].get('responseStatus'), 'partner_id': partner.id})]})
|
||||
if attendee[2].get('displayName') and not partner.name:
|
||||
partner.name = attendee[2].get('displayName')
|
||||
|
||||
organizers_partner_ids = [event.user_id.partner_id for event in self.calendar_event_ids if event.user_id]
|
||||
for odoo_attendee_email in set(existing_attendees.mapped('email')):
|
||||
# Sometimes, several partners have the same email. Remove old attendees except organizer, otherwise the events will disappear.
|
||||
if email_normalize(odoo_attendee_email) not in emails:
|
||||
attendees = existing_attendees.exists().filtered(lambda att: att.email == email_normalize(odoo_attendee_email) and att.partner_id not in organizers_partner_ids)
|
||||
self.calendar_event_ids.write({'need_sync': False, 'partner_ids': [Command.unlink(att.partner_id.id) for att in attendees]})
|
||||
|
||||
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
|
||||
non_equal_values = [
|
||||
(key, old_event_values[key] and old_event_values[key].strftime('%m/%d/%Y, %H:%M:%S'), '-->',
|
||||
new_event_values[key] and new_event_values[key].strftime('%m/%d/%Y, %H:%M:%S')
|
||||
) for key in ['start', 'stop'] if new_event_values[key] != old_event_values[key]
|
||||
]
|
||||
log_msg = f"Recurrence {self.id} {self.rrule} has all events ({len(self.calendar_event_ids.ids)}) deleted because of base event value change: {non_equal_values}"
|
||||
_logger.info(log_msg)
|
||||
# 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.with_context(dont_notify=True).write(dict(new_event_values, google_id=False, need_sync=False))
|
||||
if new_parsed_rrule == current_parsed_rrule:
|
||||
# if the rrule has changed, it will be recalculated below
|
||||
# There is no detached event now
|
||||
self.with_context(dont_notify=True)._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 new_parsed_rrule != current_parsed_rrule:
|
||||
detached_events = self._apply_recurrence()
|
||||
detached_events.google_id = False
|
||||
log_msg = f"Recurrence #{self.id} | current rule: {current_rrule} | new rule: {self.rrule} | remaining: {len(self.calendar_event_ids)} | removed: {len(detached_events)}"
|
||||
_logger.info(log_msg)
|
||||
detached_events.unlink()
|
||||
|
||||
def _create_from_google(self, gevents, vals_list):
|
||||
attendee_values = {}
|
||||
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')
|
||||
attendee_values[base_event.id] = {'attendee_ids': base_values.get('attendee_ids')}
|
||||
|
||||
recurrence = super(RecurrenceRule, self.with_context(dont_notify=True))._create_from_google(gevents, vals_list)
|
||||
generic_values_creation = {
|
||||
rec.id: attendee_values[rec.base_event_id.id]
|
||||
for rec in recurrence if attendee_values.get(rec.base_event_id.id)
|
||||
}
|
||||
recurrence.with_context(dont_notify=True)._apply_recurrence(generic_values_creation=generic_values_creation)
|
||||
return recurrence
|
||||
|
||||
def _get_sync_domain(self):
|
||||
# Empty rrule may exists in historical data. It is not a desired behavior but it could have been created with
|
||||
# older versions of the module. When synced, these recurrency may come back from Google after database cleaning
|
||||
# and trigger errors as the records are not properly populated.
|
||||
# We also prevent sync of other user recurrent events.
|
||||
return [('calendar_event_ids.user_id', '=', self.env.user.id), ('rrule', '!=', False)]
|
||||
|
||||
@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 or 'Etc/UTC'
|
||||
values['end']['timeZone'] = self.event_tz or 'Etc/UTC'
|
||||
|
||||
# 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 _get_event_user(self):
|
||||
self.ensure_one()
|
||||
event = self._get_first_event()
|
||||
if event:
|
||||
return event._get_event_user()
|
||||
return self.env.user
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.addons.google_account.models.google_service import GOOGLE_TOKEN_ENDPOINT
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class GoogleCredentials(models.Model):
|
||||
""""Google Account of res_users"""
|
||||
|
||||
_name = 'google.calendar.credentials'
|
||||
_description = 'Google Calendar Account Data'
|
||||
|
||||
user_ids = fields.One2many('res.users', 'google_calendar_account_id', required=True)
|
||||
calendar_rtoken = fields.Char('Refresh Token', copy=False)
|
||||
calendar_token = fields.Char('User token', copy=False)
|
||||
calendar_token_validity = fields.Datetime('Token Validity', copy=False)
|
||||
calendar_sync_token = fields.Char('Next Sync Token', copy=False)
|
||||
|
||||
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')
|
||||
synchronization_stopped = fields.Boolean('Google Synchronization stopped', copy=False)
|
||||
|
||||
def _set_auth_tokens(self, access_token, refresh_token, ttl):
|
||||
self.write({
|
||||
'calendar_rtoken': refresh_token,
|
||||
'calendar_token': access_token,
|
||||
'calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl) if ttl else False,
|
||||
})
|
||||
|
||||
def _google_calendar_authenticated(self):
|
||||
self.ensure_one()
|
||||
return bool(self.sudo().calendar_rtoken)
|
||||
|
||||
def _is_google_calendar_valid(self):
|
||||
self.ensure_one()
|
||||
return self.calendar_token_validity and self.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.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({
|
||||
'calendar_token': response.get('access_token'),
|
||||
'calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl),
|
||||
})
|
||||
except requests.HTTPError as error:
|
||||
if error.response.status_code in (400, 401): # invalid grant or invalid client
|
||||
# Delete refresh token and make sure it's commited
|
||||
self.env.cr.rollback()
|
||||
self._set_auth_tokens(False, False, 0)
|
||||
self.env.cr.commit()
|
||||
error_key = error.response.json().get("error", "nc")
|
||||
error_msg = _("An error occurred while generating the token. Your authorization code may be invalid or has already expired [%s]. "
|
||||
"You should check your Client ID and secret on the Google APIs plateform or try to stop and restart your calendar synchronisation.",
|
||||
error_key)
|
||||
raise UserError(error_msg)
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
# -*- 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
|
||||
from requests import HTTPError
|
||||
import pytz
|
||||
from dateutil.parser import parse
|
||||
|
||||
from odoo import api, fields, models, registry, _
|
||||
from odoo.tools import ormcache_context, email_normalize
|
||||
from odoo.osv import expression
|
||||
from odoo.sql_db import BaseCursor
|
||||
|
||||
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):
|
||||
assert isinstance(self.env.cr, BaseCursor)
|
||||
dbname = self.env.cr.dbname
|
||||
context = self.env.context
|
||||
uid = self.env.uid
|
||||
|
||||
if self.env.context.get('no_calendar_sync'):
|
||||
return
|
||||
|
||||
@self.env.cr.postcommit.add
|
||||
def called_after():
|
||||
db_registry = registry(dbname)
|
||||
with 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):
|
||||
yield user._get_google_calendar_token()
|
||||
|
||||
|
||||
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._event_ids_from_google_ids.clear_cache(self)
|
||||
synced_fields = self._get_google_synced_fields()
|
||||
if 'need_sync' not in vals and vals.keys() & synced_fields and not self.env.user.google_synchronization_stopped:
|
||||
vals['need_sync'] = True
|
||||
|
||||
result = super().write(vals)
|
||||
for record in self.filtered('need_sync'):
|
||||
if record.google_id:
|
||||
record.with_user(record._get_event_user())._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._event_ids_from_google_ids.clear_cache(self)
|
||||
if self.env.user.google_synchronization_stopped:
|
||||
for vals in vals_list:
|
||||
vals.update({'need_sync': False})
|
||||
records = super().create(vals_list)
|
||||
self._handle_allday_recurrences_edge_case(records, 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.with_user(record._get_event_user())._google_insert(google_service, record._google_values(), timeout=3)
|
||||
return records
|
||||
|
||||
def _handle_allday_recurrences_edge_case(self, records, vals_list):
|
||||
"""
|
||||
When creating 'All Day' recurrent event, the first event is wrongly synchronized as
|
||||
a single event and then its recurrence creates a duplicated event. We must manually
|
||||
set the 'need_sync' attribute as False in order to avoid this unwanted behavior.
|
||||
"""
|
||||
if vals_list and self._name == 'calendar.event':
|
||||
forbid_sync = all(not vals.get('need_sync', True) for vals in vals_list)
|
||||
records_to_skip = records.filtered(lambda r: r.need_sync and r.allday and r.recurrency and not r.recurrence_id)
|
||||
if forbid_sync and records_to_skip:
|
||||
records_to_skip.with_context(send_updates=False).need_sync = False
|
||||
|
||||
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()
|
||||
|
||||
def _from_google_ids(self, google_ids):
|
||||
if not google_ids:
|
||||
return self.browse()
|
||||
return self.browse(self._event_ids_from_google_ids(google_ids))
|
||||
|
||||
@api.model
|
||||
@ormcache_context('google_ids', keys=('active_test',))
|
||||
def _event_ids_from_google_ids(self, google_ids):
|
||||
return self.search([('google_id', 'in', google_ids)]).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(lambda e: e.google_id and e.need_sync):
|
||||
record.with_user(record._get_event_user())._google_delete(google_service, record.google_id)
|
||||
for record in new_records:
|
||||
record.with_user(record._get_event_user())._google_insert(google_service, record._google_values())
|
||||
for record in updated_records:
|
||||
record.with_user(record._get_event_user())._google_patch(google_service, record.google_id, record._google_values())
|
||||
|
||||
def _cancel(self):
|
||||
self.with_context(dont_notify=True).write({'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()
|
||||
write_dates = self._context.get('write_dates', {})
|
||||
|
||||
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)
|
||||
cancelled = existing.cancelled()
|
||||
cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)).exists()
|
||||
|
||||
# Check if it is a recurring event that has been rescheduled.
|
||||
# We have to check if an event already exists in Odoo.
|
||||
# Explanation:
|
||||
# A recurrent event with `google_id` is equal to ID_RANGE_TIMESTAMP can be rescheduled.
|
||||
# The new `google_id` will be equal to ID_TIMESTAMP.
|
||||
# We have to delete the event created under the old `google_id`.
|
||||
rescheduled_events = new.filter(lambda gevent: not gevent.is_recurrence_follower())
|
||||
if rescheduled_events:
|
||||
google_ids_to_remove = [event.full_recurring_event_id() for event in rescheduled_events]
|
||||
cancelled_odoo += self.env['calendar.event'].search([('google_id', 'in', google_ids_to_remove)])
|
||||
|
||||
cancelled_odoo._cancel()
|
||||
synced_records = new_odoo + cancelled_odoo
|
||||
pending = existing - cancelled
|
||||
pending_odoo = self.browse(pending.odoo_ids(self.env)).exists()
|
||||
for gevent in pending:
|
||||
odoo_record = self.browse(gevent.odoo_id(self.env))
|
||||
if odoo_record not in pending_odoo:
|
||||
# The record must have been deleted in the mean time; nothing left to sync
|
||||
continue
|
||||
# Last updated wins.
|
||||
# This could be dangerous if google server time and odoo server time are different
|
||||
updated = parse(gevent.updated)
|
||||
# Use the record's write_date to apply Google updates only if they are newer than Odoo's write_date.
|
||||
odoo_record_write_date = write_dates.get(odoo_record.id, odoo_record.write_date)
|
||||
# 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.with_context(dont_notify=True)._write_from_google(gevent, vals)
|
||||
synced_records |= odoo_record
|
||||
|
||||
return synced_records
|
||||
|
||||
def _google_error_handling(self, http_error):
|
||||
# We only handle the most problematic errors of sync events.
|
||||
if http_error.response.status_code in (403, 400):
|
||||
response = http_error.response.json()
|
||||
if not self.exists():
|
||||
reason = "Google gave the following explanation: %s" % response['error'].get('message')
|
||||
error_log = "Error while syncing record. It does not exists anymore in the database. %s" % reason
|
||||
_logger.error(error_log)
|
||||
return
|
||||
|
||||
if self._name == 'calendar.event':
|
||||
start = self.start and self.start.strftime('%Y-%m-%d at %H:%M') or _("undefined time")
|
||||
event_ids = self.id
|
||||
name = self.name
|
||||
error_log = "Error while syncing event: "
|
||||
event = self
|
||||
else:
|
||||
# calendar recurrence is triggering the error
|
||||
event = self.base_event_id or self._get_first_event(include_outliers=True)
|
||||
start = event.start and event.start.strftime('%Y-%m-%d at %H:%M') or _("undefined time")
|
||||
event_ids = _("%(id)s and %(length)s following", id=event.id, length=len(self.calendar_event_ids.ids))
|
||||
name = event.name
|
||||
# prevent to sync other events
|
||||
self.calendar_event_ids.need_sync = False
|
||||
error_log = "Error while syncing recurrence [{id} - {name} - {rrule}]: ".format(id=self.id, name=self.name, rrule=self.rrule)
|
||||
|
||||
# We don't have right access on the event or the request paramaters were bad.
|
||||
# https://developers.google.com/calendar/v3/errors#403_forbidden_for_non-organizer
|
||||
if http_error.response.status_code == 403 and "forbiddenForNonOrganizer" in http_error.response.text:
|
||||
reason = _("you don't seem to have permission to modify this event on Google Calendar")
|
||||
else:
|
||||
reason = _("Google gave the following explanation: %s", response['error'].get('message'))
|
||||
|
||||
error_log += "The event (%(id)s - %(name)s at %(start)s) could not be synced. It will not be synced while " \
|
||||
"it is not updated. Reason: %(reason)s" % {'id': event_ids, 'start': start, 'name': name,
|
||||
'reason': reason}
|
||||
_logger.warning(error_log)
|
||||
|
||||
body = _(
|
||||
"The following event could not be synced with Google Calendar. </br>"
|
||||
"It will not be synced as long at it is not updated.</br>"
|
||||
"%(reason)s", reason=reason)
|
||||
|
||||
if event:
|
||||
event.message_post(
|
||||
body=body,
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
@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:
|
||||
is_recurrence = self._context.get('is_recurrence', False)
|
||||
google_service.google_service = google_service.google_service.with_context(is_recurrence=is_recurrence)
|
||||
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().with_context(dont_notify=True).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:
|
||||
try:
|
||||
google_service.patch(google_id, values, token=token, timeout=timeout)
|
||||
except HTTPError as e:
|
||||
if e.response.status_code in (400, 403):
|
||||
self._google_error_handling(e)
|
||||
if values:
|
||||
self.exists().with_context(dont_notify=True).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:
|
||||
try:
|
||||
send_updates = self._context.get('send_updates', True)
|
||||
google_service.google_service = google_service.google_service.with_context(send_updates=send_updates)
|
||||
google_id = google_service.insert(values, token=token, timeout=timeout)
|
||||
# Everything went smoothly
|
||||
self.with_context(dont_notify=True).write({
|
||||
'google_id': google_id,
|
||||
'need_sync': False,
|
||||
})
|
||||
except HTTPError as e:
|
||||
if e.response.status_code in (400, 403):
|
||||
self._google_error_handling(e)
|
||||
self.with_context(dont_notify=True).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),
|
||||
]])
|
||||
# We want to limit to 200 event sync per transaction, it shouldn't be a problem for the day to day
|
||||
# but it allows to run the first synchro within an acceptable time without timeout.
|
||||
# If there is a lot of event to synchronize to google the first time,
|
||||
# they will be synchronized eventually with the cron running few times a day
|
||||
return self.with_context(active_test=False).search(domain, limit=200)
|
||||
|
||||
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 _get_sync_partner(self, emails):
|
||||
normalized_emails = [email_normalize(contact) for contact in emails if email_normalize(contact)]
|
||||
user_partners = self.env['mail.thread']._mail_search_on_user(normalized_emails, extra_domain=[('share', '=', False)])
|
||||
partners = [user_partner for user_partner in user_partners if user_partner.type != 'private']
|
||||
remaining = [email for email in normalized_emails if
|
||||
email not in [partner.email_normalized for partner in partners]]
|
||||
if remaining:
|
||||
partners += self.env['mail.thread']._mail_find_partner_from_emails(remaining, records=self, force_create=True, extra_domain=[('type', '!=', 'private')])
|
||||
unsorted_partners = self.env['res.partner'].browse([p.id for p in partners if p.id])
|
||||
# partners needs to be sorted according to the emails order provided by google
|
||||
k = {value: idx for idx, value in enumerate(emails)}
|
||||
result = unsorted_partners.sorted(key=lambda p: k.get(p.email_normalized, -1))
|
||||
return result
|
||||
|
||||
@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()
|
||||
|
||||
@api.model
|
||||
def _restart_google_sync(self):
|
||||
""" Turns on the google synchronization for all the events of
|
||||
a given user.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_event_user(self):
|
||||
""" Return the correct user to send the request to Google.
|
||||
It's possible that a user creates an event and sets another user as the organizer. Using self.env.user will
|
||||
cause some issues, and It might not be possible to use this user for sending the request, so this method gets
|
||||
the appropriate user accordingly.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
|
@ -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='')
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
from odoo import api, fields, models, Command
|
||||
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService, InvalidSyncToken
|
||||
from odoo.addons.google_calendar.models.google_sync import google_calendar_token
|
||||
from odoo.loglevels import exception_to_unicode
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class User(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
google_calendar_account_id = fields.Many2one('google.calendar.credentials')
|
||||
google_calendar_rtoken = fields.Char(related='google_calendar_account_id.calendar_rtoken', groups="base.group_system")
|
||||
google_calendar_token = fields.Char(related='google_calendar_account_id.calendar_token')
|
||||
google_calendar_token_validity = fields.Datetime(related='google_calendar_account_id.calendar_token_validity')
|
||||
google_calendar_sync_token = fields.Char(related='google_calendar_account_id.calendar_sync_token')
|
||||
google_calendar_cal_id = fields.Char(related='google_calendar_account_id.calendar_cal_id')
|
||||
google_synchronization_stopped = fields.Boolean(related='google_calendar_account_id.synchronization_stopped', readonly=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('google_token_uniq', 'unique (google_calendar_account_id)', "The user has already a google account"),
|
||||
]
|
||||
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + ['google_synchronization_stopped', 'google_calendar_account_id']
|
||||
|
||||
@property
|
||||
def SELF_WRITEABLE_FIELDS(self):
|
||||
return super().SELF_WRITEABLE_FIELDS + ['google_synchronization_stopped', 'google_calendar_account_id']
|
||||
|
||||
def _get_google_calendar_token(self):
|
||||
self.ensure_one()
|
||||
if self.google_calendar_account_id.calendar_rtoken and not self.google_calendar_account_id._is_google_calendar_valid():
|
||||
self.sudo().google_calendar_account_id._refresh_google_calendar_token()
|
||||
return self.google_calendar_account_id.calendar_token
|
||||
|
||||
def _sync_google_calendar(self, calendar_service: GoogleCalendarService):
|
||||
self.ensure_one()
|
||||
if self.google_synchronization_stopped:
|
||||
return False
|
||||
|
||||
# don't attempt to sync when another sync is already in progress, as we wouldn't be
|
||||
# able to commit the transaction anyway (row is locked)
|
||||
self.env.cr.execute("""SELECT id FROM res_users WHERE id = %s FOR NO KEY UPDATE SKIP LOCKED""", [self.id])
|
||||
if not self.env.cr.rowcount:
|
||||
_logger.info("skipping calendar sync, locked user %s", self.login)
|
||||
return False
|
||||
|
||||
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_account_id.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_account_id.calendar_sync_token = next_sync_token
|
||||
|
||||
# Google -> Odoo
|
||||
send_updates = not full_sync
|
||||
events.clear_type_ambiguity(self.env)
|
||||
recurrences = events.filter(lambda e: e.is_recurrence())
|
||||
|
||||
# We apply Google updates only if their write date is later than the write date in Odoo.
|
||||
# It's possible that multiple updates affect the same record, maybe not directly.
|
||||
# To handle this, we preserve the write dates in Odoo before applying any updates,
|
||||
# and use these dates instead of the current live dates.
|
||||
odoo_events = self.env['calendar.event'].browse((events - recurrences).odoo_ids(self.env))
|
||||
odoo_recurrences = self.env['calendar.recurrence'].browse(recurrences.odoo_ids(self.env))
|
||||
recurrences_write_dates = {r.id: r.write_date for r in odoo_recurrences}
|
||||
events_write_dates = {e.id: e.write_date for e in odoo_events}
|
||||
synced_recurrences = self.env['calendar.recurrence'].with_context(write_dates=recurrences_write_dates)._sync_google2odoo(recurrences)
|
||||
synced_events = self.env['calendar.event'].with_context(write_dates=events_write_dates)._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.with_context(send_updates=send_updates)._sync_odoo2google(calendar_service)
|
||||
synced_events |= recurrences.calendar_event_ids - recurrences._get_outliers()
|
||||
synced_events |= synced_recurrences.calendar_event_ids - synced_recurrences._get_outliers()
|
||||
events = self.env['calendar.event']._get_records_to_sync(full_sync=full_sync)
|
||||
(events - synced_events).with_context(send_updates=send_updates)._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_synchronization_stopped', '=', 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)
|
||||
self.env.cr.commit()
|
||||
except Exception as e:
|
||||
_logger.exception("[%s] Calendar Synchro - Exception : %s !", user, exception_to_unicode(e))
|
||||
self.env.cr.rollback()
|
||||
|
||||
def stop_google_synchronization(self):
|
||||
self.ensure_one()
|
||||
self.google_synchronization_stopped = True
|
||||
|
||||
def restart_google_synchronization(self):
|
||||
self.ensure_one()
|
||||
if not self.google_calendar_account_id:
|
||||
self.google_calendar_account_id = self.env['google.calendar.credentials'].sudo().create([{'user_ids': [Command.set(self.ids)]}])
|
||||
self.google_synchronization_stopped = False
|
||||
self.env['calendar.recurrence']._restart_google_sync()
|
||||
self.env['calendar.event']._restart_google_sync()
|
||||
Loading…
Add table
Add a link
Reference in a new issue