19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:28 +01:00
parent 20ddc1b4a3
commit c0efcc53f5
1162 changed files with 125577 additions and 105287 deletions

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_config_settings
@ -6,6 +5,6 @@ from . import google_sync
from . import calendar
from . import calendar_recurrence_rule
from . import res_users
from . import res_users_settings
from . import calendar_attendee
from . import google_credentials
from . import calendar_alarm_manager

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
@ -7,15 +6,23 @@ from dateutil.relativedelta import relativedelta
from uuid import uuid4
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
from odoo.fields import Domain
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService
class Meeting(models.Model):
class CalendarEvent(models.Model):
_name = 'calendar.event'
_inherit = ['calendar.event', 'google.calendar.sync']
MEET_ROUTE = 'meet.google.com'
google_id = fields.Char(
'Google Calendar Event Id', compute='_compute_google_id', store=True, readonly=False)
guests_readonly = fields.Boolean(
'Guests Event Modification Permission', default=False)
videocall_source = fields.Selection(selection_add=[('google_meet', 'Google Meet')], ondelete={'google_meet': 'set discuss'})
@api.depends('recurrence_id.google_id')
def _compute_google_id(self):
@ -30,10 +37,16 @@ class Meeting(models.Model):
elif not event.google_id:
event.google_id = False
@api.depends('videocall_location')
def _compute_videocall_source(self):
events_with_google_url = self.filtered(lambda event: self.MEET_ROUTE in (event.videocall_location or ''))
events_with_google_url.videocall_source = 'google_meet'
super(CalendarEvent, self - events_with_google_url)._compute_videocall_source()
@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'}
'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active', 'show_as', 'videocall_location'}
@api.model
def _restart_google_sync(self):
@ -43,8 +56,9 @@ class Meeting(models.Model):
@api.model_create_multi
def create(self, vals_list):
description_context = self.env.context.get('skip_contact_description', False)
notify_context = self.env.context.get('dont_notify', False)
return super(Meeting, self.with_context(dont_notify=notify_context)).create([
return super(CalendarEvent, self.with_context(dont_notify=notify_context, skip_contact_description=description_context)).create([
dict(vals, need_sync=False) if vals.get('recurrence_id') or vals.get('recurrency') else vals
for vals in vals_list
])
@ -74,29 +88,54 @@ class Meeting(models.Model):
archive_values = super()._get_archive_values()
return {**archive_values, 'need_sync': False}
def write(self, values):
recurrence_update_setting = values.get('recurrence_update')
def write(self, vals):
recurrence_update_setting = vals.get('recurrence_update')
if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1:
values = dict(values, need_sync=False)
vals = dict(vals, 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():
if not notify_context and ([self.env.user.id != record.user_id.id for record in self]):
self._check_modify_event_permission(vals)
res = super(CalendarEvent, self.with_context(dont_notify=notify_context)).write(vals)
if recurrence_update_setting == 'all_events' and len(self) == 1 and vals.keys() & self._get_google_synced_fields():
self.recurrence_id.need_sync = True
return res
def _check_modify_event_permission(self, values):
""" Check if event modification attempt by attendee is valid to avoid duplicate events creation. """
# Edge case: when restarting the synchronization, guests can write 'need_sync=True' on events.
google_sync_restart = values.get('need_sync') and len(values)
# Edge case 2: when resetting an account, we must be able to erase the event's google_id.
skip_event_permission = self.env.context.get('skip_event_permission', False)
# Edge case 3: check if event is synchronizable in order to make sure the error is worth it.
is_synchronizable = self._check_values_to_sync(values)
if google_sync_restart or skip_event_permission or not is_synchronizable:
return
if any(event.guests_readonly and self.env.user.id != event.user_id.id for event in self):
raise ValidationError(
_("The following event can only be updated by the organizer "
"according to the event permissions set on Google Calendar.")
)
def _skip_send_mail_status_update(self):
"""If a google calendar is not syncing with the user, don't send a mail."""
user_id = self._get_event_user()
if user_id.is_google_calendar_synced() and user_id.res_users_settings_id._is_google_calendar_valid():
return True
return super()._skip_send_mail_status_update()
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 [
return Domain([
('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=()):
@ -118,12 +157,13 @@ class Meeting(models.Model):
'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'],
'privacy': google_event.visibility or False,
'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'
'show_as': 'free' if google_event.is_available() else 'busy',
'guests_readonly': not bool(google_event.guestsCanModify)
}
# Remove 'videocall_location' when not sent by Google, otherwise the local videocall will be discarded.
if not values.get('videocall_location'):
@ -251,7 +291,7 @@ class Meeting(models.Model):
# 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)
super().action_mass_archive(recurrence_update_setting)
def _google_values(self):
# In Google API, all-day events must have their 'dateTime' information set
@ -260,12 +300,15 @@ class Meeting(models.Model):
start = {'date': None, 'dateTime': None}
end = {'date': None, 'dateTime': None}
if self.allday:
# For all-day events, 'dateTime' must be set to None to indicate that it's an all-day event.
# Otherwise, if both 'date' and 'dateTime' are set, Google may not recognize it as an all-day event.
start['date'] = self.start_date.isoformat()
end['date'] = (self.stop_date + relativedelta(days=1)).isoformat()
else:
# For timed events, 'date' must be set to None to indicate that it's not an all-day event.
# Otherwise, if both 'date' and 'dateTime' are set, Google may not recognize it as a timed event
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
@ -273,9 +316,9 @@ class Meeting(models.Model):
attendees = self.attendee_ids
attendee_values = [{
'email': attendee.partner_id.sudo().email_normalized,
'email': attendee.partner_id.email_normalized,
'responseStatus': attendee.state or 'needsAction',
} for attendee in attendees if attendee.partner_id.sudo().email_normalized]
} for attendee in attendees if attendee.partner_id.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 = {
@ -283,9 +326,9 @@ class Meeting(models.Model):
'start': start,
'end': end,
'summary': self.name,
'description': tools.html_sanitize(self.description) if not tools.is_html_empty(self.description) else '',
'description': self._get_customer_description(),
'location': self.location or '',
'guestsCanModify': True,
'guestsCanModify': not self.guests_readonly,
'organizer': {'email': self.user_id.email, 'self': self.user_id == self.env.user},
'attendees': attendee_values,
'extendedProperties': {
@ -300,6 +343,8 @@ class Meeting(models.Model):
}
if not self.google_id and not self.videocall_location and not self.location:
values['conferenceData'] = {'createRequest': {'requestId': uuid4().hex}}
if self.google_id and not self.videocall_location:
values['conferenceData'] = None
if self.privacy:
values['visibility'] = self.privacy
if self.show_as:
@ -329,7 +374,10 @@ class Meeting(models.Model):
# 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()
for event in self:
# remove the tracking data to avoid calling _track_template in the pre-commit phase
self.env.cr.precommit.data.pop(f'mail.tracking.create.{event._name}.{event.id}', None)
super(CalendarEvent, my_cancelled_records)._cancel()
attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id)
attendees.state = 'declined'
@ -338,3 +386,8 @@ class Meeting(models.Model):
if self.user_id and self.user_id.sudo().google_calendar_token:
return self.user_id
return self.env.user
def _is_google_insertion_blocked(self, sender_user):
self.ensure_one()
has_different_owner = self.user_id and self.user_id != sender_user
return has_different_owner

View file

@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.tools import SQL
class AlarmManager(models.AbstractModel):
@ -8,5 +9,7 @@ class AlarmManager(models.AbstractModel):
@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'
base = super()._get_notify_alert_extra_conditions()
if self.env.context.get('alarm_type') == 'email':
return SQL("%s AND event.google_id IS NULL", base)
return base

View file

@ -1,23 +1,12 @@
# -*- 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)
class CalendarAttendee(models.Model):
_inherit = 'calendar.attendee'
def do_tentative(self):
# Synchronize event after state change
@ -42,14 +31,10 @@ class Attendee(models.Model):
# 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)
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

@ -1,17 +1,18 @@
# -*- 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 import api, models
from odoo.fields import Command, Domain
from odoo.tools import email_normalize
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService
_logger = logging.getLogger(__name__)
class RecurrenceRule(models.Model):
class CalendarRecurrence(models.Model):
_name = 'calendar.recurrence'
_inherit = ['calendar.recurrence', 'google.calendar.sync']
@ -42,7 +43,7 @@ class RecurrenceRule(models.Model):
}]
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.env['calendar.event'].with_context(skip_contact_description=True).create(vals)
self.calendar_event_ids.need_sync = False
return detached_events
@ -121,7 +122,7 @@ class RecurrenceRule(models.Model):
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):
if old_event_values and any(new_event_values.get(key) and 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 = [
@ -173,7 +174,7 @@ class RecurrenceRule(models.Model):
# 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)
base_event = self.env['calendar.event'].with_context(skip_contact_description=True).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
@ -184,7 +185,7 @@ class RecurrenceRule(models.Model):
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)
recurrence = super(CalendarRecurrence, 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)
@ -197,7 +198,7 @@ class RecurrenceRule(models.Model):
# 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)]
return Domain('calendar_event_ids.user_id', '=', self.env.user.id) & Domain('rrule', '!=', False)
@api.model
def _odoo_values(self, google_recurrence, default_reminders=()):
@ -239,3 +240,9 @@ class RecurrenceRule(models.Model):
if event:
return event._get_event_user()
return self.env.user
def _is_google_insertion_blocked(self, sender_user):
self.ensure_one()
has_base_event = self.base_event_id
has_different_owner = self.base_event_id.user_id and self.base_event_id.user_id != sender_user
return has_base_event and has_different_owner

View file

@ -1,79 +0,0 @@
# -*- 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

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
@ -7,10 +6,12 @@ from functools import wraps
from requests import HTTPError
import pytz
from dateutil.parser import parse
from markupsafe import Markup
from odoo import api, fields, models, registry, _
from odoo.tools import ormcache_context, email_normalize
from odoo.osv import expression
from odoo import api, fields, models, _
from odoo.fields import Domain
from odoo.modules.registry import Registry
from odoo.tools import email_normalize
from odoo.sql_db import BaseCursor
from odoo.addons.google_calendar.utils.google_event import GoogleEvent
@ -37,7 +38,7 @@ def after_commit(func):
@self.env.cr.postcommit.add
def called_after():
db_registry = registry(dbname)
db_registry = Registry(dbname)
with db_registry.cursor() as cr:
env = api.Environment(cr, uid, context)
try:
@ -53,43 +54,45 @@ def google_calendar_token(user):
yield user._get_google_calendar_token()
class GoogleSync(models.AbstractModel):
class GoogleCalendarSync(models.AbstractModel):
_name = 'google.calendar.sync'
_description = "Synchronize a record with Google Calendar"
google_id = fields.Char('Google Calendar Id', copy=False)
google_id = fields.Char('Google Calendar Id', index='btree_not_null', 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)
if self.env.user._get_google_sync_status() != "sync_paused":
for record in self:
if record.need_sync and 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:
user_ids = {v['user_id'] for v in vals_list if v.get('user_id')}
users_with_sync = self.env['res.users'].browse(user_ids).filtered(lambda u: not u.sudo().google_synchronization_stopped)
users_with_sync_set = set(users_with_sync.ids)
for vals in vals_list:
if vals.get('user_id', False) and vals['user_id'] not in users_with_sync_set:
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)
if self.env.user._get_google_sync_status() != "sync_paused":
for record in records:
if record.need_sync and record.active:
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):
@ -124,12 +127,7 @@ class GoogleSync(models.AbstractModel):
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
return self.search([('google_id', 'in', google_ids)])
def _sync_odoo2google(self, google_service: GoogleCalendarService):
if not self:
@ -142,36 +140,42 @@ class GoogleSync(models.AbstractModel):
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())
if self.env.user._get_google_sync_status() != "sync_paused":
for record in cancelled_records:
if record.google_id and record.need_sync:
record.with_user(record._get_event_user())._google_delete(google_service, record.google_id)
for record in new_records:
if record._is_google_insertion_blocked(sender_user=self.env.user):
continue
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=()):
def _sync_google2odoo(self, google_events: GoogleEvent, write_dates=None, default_reminders=()):
"""Synchronize Google recurrences in Odoo. Creates new recurrences, updates
existing ones.
:param google_recurrences: Google recurrences to synchronize in Odoo
:param google_events: Google recurrences to synchronize in Odoo
:param write_dates: A dictionary mapping Odoo record IDs to their write dates.
:param default_reminders:
:return: synchronized odoo recurrences
"""
write_dates = dict(write_dates or {})
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)
new_odoo = self.with_context(dont_notify=True, skip_contact_description=True)._create_from_google(new, odoo_values)
cancelled = existing.cancelled()
cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)).exists()
cancelled_odoo = self.browse(cancelled.odoo_ids(self.env))
# Check if it is a recurring event that has been rescheduled.
# We have to check if an event already exists in Odoo.
@ -184,7 +188,7 @@ class GoogleSync(models.AbstractModel):
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()
cancelled_odoo.exists()._cancel()
synced_records = new_odoo + cancelled_odoo
pending = existing - cancelled
pending_odoo = self.browse(pending.odoo_ids(self.env)).exists()
@ -244,10 +248,9 @@ class GoogleSync(models.AbstractModel):
'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)
body = _("The following event could not be synced with Google Calendar.") + Markup("<br/>") + \
_("It will not be synced as long at it is not updated.") + Markup("<br/>") + \
reason
if event:
event.message_post(
@ -260,7 +263,7 @@ class GoogleSync(models.AbstractModel):
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)
is_recurrence = self.env.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
@ -272,6 +275,8 @@ class GoogleSync(models.AbstractModel):
with google_calendar_token(self.env.user.sudo()) as token:
if token:
try:
send_updates = not self._is_event_over()
google_service.google_service = google_service.google_service.with_context(send_updates=send_updates)
google_service.patch(google_id, values, token=token, timeout=timeout)
except HTTPError as e:
if e.response.status_code in (400, 403):
@ -279,6 +284,21 @@ class GoogleSync(models.AbstractModel):
if values:
self.exists().with_context(dont_notify=True).need_sync = False
def _get_post_sync_values(self, request_values, google_values):
""" Return the values to be written in the event right after its insertion in Google side. """
writeable_values = {
'google_id': request_values['id'],
'need_sync': False,
}
return writeable_values
def _need_video_call(self):
""" Implement this method to return True if the event needs a video call
:return: bool
"""
self.ensure_one()
return True
@after_commit
def _google_insert(self, google_service: GoogleCalendarService, values, timeout=TIMEOUT):
if not values:
@ -286,14 +306,10 @@ class GoogleSync(models.AbstractModel):
with google_calendar_token(self.env.user.sudo()) as token:
if token:
try:
send_updates = self._context.get('send_updates', True)
send_updates = self.env.context.get('send_updates', True) and not self._is_event_over()
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,
})
google_values = google_service.insert(values, token=token, timeout=timeout, need_video_call=self._need_video_call())
self.with_context(dont_notify=True).write(self._get_post_sync_values(values, google_values))
except HTTPError as e:
if e.response.status_code in (400, 403):
self._google_error_handling(e)
@ -307,18 +323,21 @@ class GoogleSync(models.AbstractModel):
"""
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),
]])
is_active_clause = Domain(self._active_name, '=', True) if self._active_name else Domain.TRUE
domain &= (Domain('google_id', '=', False) & is_active_clause) | Domain('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 _check_any_records_to_sync(self):
""" Returns True if there are pending records to be synchronized from Odoo to Google, False otherwise. """
is_active_clause = Domain(self._active_name, '=', True) if self._active_name else Domain.TRUE
domain = self._get_sync_domain()
domain &= (Domain('google_id', '=', False) & is_active_clause) | Domain('need_sync', '=', True)
return self.search_count(domain, limit=1) > 0
def _write_from_google(self, gevent, vals):
self.write(vals)
@ -329,17 +348,10 @@ class GoogleSync(models.AbstractModel):
@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 = self.env['mail.thread']._partner_find_from_emails_single(normalized_emails)
# 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
return partners.sorted(key=lambda p: k.get(p.email_normalized, -1))
@api.model
def _odoo_values(self, google_event: GoogleEvent, default_reminders=()):
@ -382,3 +394,12 @@ class GoogleSync(models.AbstractModel):
the appropriate user accordingly.
"""
raise NotImplementedError()
def _is_google_insertion_blocked(self, sender_user):
"""
Returns True if the record insertion to Google should be blocked.
This is a necessary step for ensuring data match between Odoo and Google,
as it avoids that events have permanently the wrong organizer in Google
by not synchronizing records through owner and not through the attendees.
"""
raise NotImplementedError()

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
@ -9,3 +8,5 @@ class ResConfigSettings(models.TransientModel):
cal_client_id = fields.Char("Client_id", config_parameter='google_calendar_client_id', default='')
cal_client_secret = fields.Char("Client_key", config_parameter='google_calendar_client_secret', default='')
cal_sync_paused = fields.Boolean("Google Synchronization Paused", config_parameter='google_calendar_sync_paused',
help="Indicates if synchronization with Google Calendar is paused or not.")

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
@ -7,61 +6,55 @@ 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.addons.google_account.models import google_service
from odoo.exceptions import LockError
from odoo.loglevels import exception_to_unicode
from odoo.tools import str2bool
_logger = logging.getLogger(__name__)
class User(models.Model):
class ResUsers(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']
google_calendar_rtoken = fields.Char(related='res_users_settings_id.google_calendar_rtoken', groups="base.group_system")
google_calendar_token = fields.Char(related='res_users_settings_id.google_calendar_token', groups="base.group_system")
google_calendar_token_validity = fields.Datetime(related='res_users_settings_id.google_calendar_token_validity', groups="base.group_system")
google_calendar_sync_token = fields.Char(related='res_users_settings_id.google_calendar_sync_token', groups="base.group_system")
google_calendar_cal_id = fields.Char(related='res_users_settings_id.google_calendar_cal_id', groups="base.group_system")
google_synchronization_stopped = fields.Boolean(related='res_users_settings_id.google_synchronization_stopped', readonly=False, groups="base.group_system")
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
if self.res_users_settings_id.sudo().google_calendar_rtoken and not self.res_users_settings_id._is_google_calendar_valid():
self.sudo().res_users_settings_id._refresh_google_calendar_token()
return self.res_users_settings_id.sudo().google_calendar_token
def _get_google_sync_status(self):
""" Returns the calendar synchronization status (active, paused or stopped). """
status = "sync_active"
if str2bool(self.env['ir.config_parameter'].sudo().get_param("google_calendar_sync_paused"), default=False):
status = "sync_paused"
elif self.sudo().google_calendar_rtoken and not self.sudo().google_synchronization_stopped:
status = "sync_active"
elif self.sudo().google_synchronization_stopped:
status = "sync_stopped"
return status
def _check_pending_odoo_records(self):
""" Returns True if sync is active and there are records to be synchronized to Google. """
if self._get_google_sync_status() != "sync_active":
return False
pending_events = self.env['calendar.event']._check_any_records_to_sync()
pending_recurrences = self.env['calendar.recurrence']._check_any_records_to_sync()
return pending_events or pending_recurrences
def _sync_google_calendar(self, calendar_service: GoogleCalendarService):
self.ensure_one()
if self.google_synchronization_stopped:
results = self._sync_request(calendar_service)
if not results or (not results.get('events') and not self._check_pending_odoo_records()):
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
events, default_reminders, full_sync = results.values()
# Google -> Odoo
send_updates = not full_sync
events.clear_type_ambiguity(self.env)
@ -75,8 +68,8 @@ class User(models.Model):
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)
synced_recurrences = self.env['calendar.recurrence']._sync_google2odoo(recurrences, recurrences_write_dates)
synced_events = self.env['calendar.event']._sync_google2odoo(events - recurrences, events_write_dates, default_reminders=default_reminders)
# Odoo -> Google
recurrences = self.env['calendar.recurrence']._get_records_to_sync(full_sync=full_sync)
@ -87,12 +80,63 @@ class User(models.Model):
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)
return bool(results) and (bool(events | synced_events) or bool(recurrences | synced_recurrences))
def _sync_single_event(self, calendar_service: GoogleCalendarService, odoo_event, event_id):
self.ensure_one()
results = self._sync_request(calendar_service, event_id)
if not results or not results.get('events'):
return False
event, default_reminders, full_sync = results.values()
# Google -> Odoo
send_updates = not full_sync
event.clear_type_ambiguity(self.env)
synced_events = self.env['calendar.event']._sync_google2odoo(event, default_reminders=default_reminders)
# Odoo -> Google
odoo_event.with_context(send_updates=send_updates)._sync_odoo2google(calendar_service)
return bool(odoo_event | synced_events)
def _sync_request(self, calendar_service, event_id=None):
if self._get_google_sync_status() != "sync_active":
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.ensure_one()
try:
self.lock_for_update(allow_referencing=True)
except LockError:
_logger.info("skipping calendar sync, locked user %s", self.login)
return False
full_sync = not bool(self.sudo().google_calendar_sync_token)
with google_calendar_token(self) as token:
try:
if not event_id:
events, next_sync_token, default_reminders = calendar_service.get_events(self.res_users_settings_id.sudo().google_calendar_sync_token, token=token)
else:
# We force the sync_token parameter to avoid doing a full sync.
# Other events are fetched when the calendar view is displayed.
events, next_sync_token, default_reminders = calendar_service.get_events(sync_token=token, token=token, event_id=event_id)
except InvalidSyncToken:
events, next_sync_token, default_reminders = calendar_service.get_events(token=token)
full_sync = True
if next_sync_token:
self.res_users_settings_id.sudo().google_calendar_sync_token = next_sync_token
return {
'events': events,
'default_reminders': default_reminders,
'full_sync': full_sync,
}
@api.model
def _sync_all_google_calendar(self):
""" Cron job """
users = self.env['res.users'].search([('google_calendar_rtoken', '!=', False), ('google_synchronization_stopped', '=', False)])
domain = [('google_calendar_rtoken', '!=', False), ('google_synchronization_stopped', '=', False)]
# google_calendar_token_validity is not stored on res.users
if not self:
users = self.env['res.users'].sudo().search(domain).sorted('google_calendar_token_validity')
else:
users = self.filtered_domain(domain).sorted('google_calendar_token_validity')
google = GoogleCalendarService(self.env['google.service'])
for user in users:
_logger.info("Calendar Synchro - Starting synchronization for %s", user)
@ -100,17 +144,66 @@ class User(models.Model):
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))
_logger.exception("[%s] Calendar Synchro - Exception : %s!", user, exception_to_unicode(e))
self.env.cr.rollback()
def is_google_calendar_synced(self):
""" True if Google Calendar settings are filled (Client ID / Secret) and user calendar is synced
meaning we can make API calls, false otherwise."""
self.ensure_one()
return self.sudo().google_calendar_token and self._get_google_sync_status() == 'sync_active'
def stop_google_synchronization(self):
self.ensure_one()
self.google_synchronization_stopped = True
self.sudo().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.sudo().google_synchronization_stopped = False
self.env['calendar.recurrence']._restart_google_sync()
self.env['calendar.event']._restart_google_sync()
def unpause_google_synchronization(self):
self.env['ir.config_parameter'].sudo().set_param("google_calendar_sync_paused", False)
def pause_google_synchronization(self):
self.env['ir.config_parameter'].sudo().set_param("google_calendar_sync_paused", True)
@api.model
def _has_setup_credentials(self):
""" Checks if both Client ID and Client Secret are defined in the database. """
ICP_sudo = self.env['ir.config_parameter'].sudo()
client_id = self.env['google.service']._get_client_id('calendar')
client_secret = google_service._get_client_secret(ICP_sudo, 'calendar')
return bool(client_id and client_secret)
@api.model
def check_calendar_credentials(self):
res = super().check_calendar_credentials()
res['google_calendar'] = self._has_setup_credentials()
return res
def check_synchronization_status(self):
res = super().check_synchronization_status()
credentials_status = self.check_calendar_credentials()
sync_status = 'missing_credentials'
if credentials_status.get('google_calendar'):
sync_status = self._get_google_sync_status()
if sync_status == 'sync_active' and not self.sudo().google_calendar_rtoken:
sync_status = 'sync_stopped'
res['google_calendar'] = sync_status
return res
def _has_any_active_synchronization(self):
"""
Check if synchronization is active for Google Calendar.
This function retrieves the synchronization status from the user's environment
and checks if the Google Calendar synchronization is active.
:return: Action to delete the event
"""
sync_status = self.check_synchronization_status()
res = super()._has_any_active_synchronization()
if sync_status.get('google_calendar') == 'sync_active':
return True
return res

View file

@ -0,0 +1,71 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
import requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ResUsersSettings(models.Model):
_inherit = "res.users.settings"
# Google Calendar tokens and synchronization information.
google_calendar_rtoken = fields.Char('Refresh Token', copy=False, groups='base.group_system')
google_calendar_token = fields.Char('User token', copy=False, groups='base.group_system')
google_calendar_token_validity = fields.Datetime('Token Validity', copy=False, groups='base.group_system')
google_calendar_sync_token = fields.Char('Next Sync Token', copy=False, groups='base.group_system')
google_calendar_cal_id = fields.Char('Calendar ID', copy=False, groups='base.group_system',
help='Last Calendar ID who has been synchronized. If it is changed, we remove all links between GoogleID and Odoo Google Internal ID')
google_synchronization_stopped = fields.Boolean('Google Synchronization stopped', copy=False, groups='base.group_system')
@api.model
def _get_fields_blacklist(self):
""" Get list of google fields that won't be formatted in session_info. """
google_fields_blacklist = [
'google_calendar_rtoken',
'google_calendar_token',
'google_calendar_token_validity',
'google_calendar_sync_token',
'google_calendar_cal_id',
'google_synchronization_stopped'
]
return super()._get_fields_blacklist() + google_fields_blacklist
def _set_google_auth_tokens(self, access_token, refresh_token, ttl):
self.sudo().write({
'google_calendar_rtoken': refresh_token,
'google_calendar_token': access_token,
'google_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl) if ttl else False,
})
def _google_calendar_authenticated(self):
self.ensure_one()
return bool(self.sudo().google_calendar_rtoken)
def _is_google_calendar_valid(self):
self.ensure_one()
return self.sudo().google_calendar_token_validity and self.sudo().google_calendar_token_validity >= (fields.Datetime.now() + timedelta(minutes=1))
def _refresh_google_calendar_token(self):
self.ensure_one()
try:
access_token, ttl = self.env['google.service']._refresh_google_token('calendar', self.sudo().google_calendar_rtoken)
self.sudo().write({
'google_calendar_token': access_token,
'google_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.sudo()._set_google_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 synchronization.",
error_key)
raise UserError(error_msg)