Initial commit: Security packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit bb469e4763
1399 changed files with 278378 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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='')

View file

@ -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()