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,5 +5,6 @@ from . import microsoft_sync
from . import calendar
from . import calendar_recurrence_rule
from . import res_users
from . import res_users_settings
from . import calendar_attendee
from . import calendar_alarm_manager

View file

@ -1,19 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import pytz
import re
from datetime import datetime
from datetime import datetime, timedelta
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.osv import expression
from odoo.exceptions import UserError, ValidationError
from odoo.tools import is_html_empty, email_normalize
from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids
from odoo.fields import Domain
from odoo.tools import email_normalize
ATTENDEE_CONVERTER_O2M = {
'needsAction': 'notresponded',
@ -36,12 +33,11 @@ MAX_RECURRENT_EVENT = 720
_logger = logging.getLogger(__name__)
class Meeting(models.Model):
class CalendarEvent(models.Model):
_name = 'calendar.event'
_inherit = ['calendar.event', 'microsoft.calendar.sync']
# contains organizer event id and universal event id separated by a ':'
microsoft_id = fields.Char('Microsoft Calendar Event Id')
microsoft_recurrence_master_id = fields.Char('Microsoft Recurrence Master Id')
def _get_organizer(self):
@ -51,11 +47,13 @@ class Meeting(models.Model):
def _get_microsoft_synced_fields(self):
return {'name', 'description', 'allday', 'start', 'date_end', 'stop',
'user_id', 'privacy',
'attendee_ids', 'alarm_ids', 'location', 'show_as', 'active'}
'attendee_ids', 'alarm_ids', 'location', 'show_as', 'active', 'videocall_location'}
@api.model
def _restart_microsoft_sync(self):
self.env['calendar.event'].with_context(dont_notify=True).search(self._get_microsoft_sync_domain()).write({
domain = self._get_microsoft_sync_domain()
self.env['calendar.event'].with_context(dont_notify=True).search(domain).write({
'need_sync_m': True,
})
@ -65,7 +63,14 @@ class Meeting(models.Model):
The 'microsoft_synchronization_stopped' variable needs to be 'False' and Outlook account must be connected.
"""
outlook_connected = self.env.user._get_microsoft_calendar_token()
return outlook_connected and self.env.user.microsoft_synchronization_stopped is False
return outlook_connected and self.env.user.sudo().microsoft_synchronization_stopped is False
def _skip_send_mail_status_update(self):
"""If microsoft calendar is not syncing, don't send a mail."""
user_id = self._get_event_user_m()
if self.with_user(user_id)._check_microsoft_sync_status() and user_id._get_microsoft_sync_status() == "sync_active":
return self.microsoft_id or self.need_sync_m
return super()._skip_send_mail_status_update()
@api.model_create_multi
def create(self, vals_list):
@ -76,7 +81,8 @@ class Meeting(models.Model):
if self._check_microsoft_sync_status() and not notify_context and recurrency_in_batch:
self._forbid_recurrence_creation()
for vals in vals_list:
vals_check_organizer = self._check_organizer_validation_conditions(vals_list)
for vals in [vals for vals, check_organizer in zip(vals_list, vals_check_organizer) if check_organizer]:
# If event has a different organizer, check its sync status and verify if the user is listed as attendee.
sender_user, partner_ids = self._get_organizer_user_change_info(vals)
partner_included = partner_ids and len(partner_ids) > 0 and sender_user.partner_id.id in partner_ids
@ -85,7 +91,7 @@ class Meeting(models.Model):
# for a recurrent event, we do not create events separately but we directly
# create the recurrency from the corresponding calendar.recurrence.
# That's why, events from a recurrency have their `need_sync_m` attribute set to False.
return super(Meeting, self.with_context(dont_notify=notify_context)).create([
return super(CalendarEvent, self.with_context(dont_notify=notify_context)).create([
dict(vals, need_sync_m=False) if vals.get('recurrence_id') or vals.get('recurrency') else vals
for vals in vals_list
])
@ -144,7 +150,7 @@ class Meeting(models.Model):
Suggest user to update recurrences in Outlook due to the Outlook Calendar spam limitation.
"""
error_msg = _("Due to an Outlook Calendar limitation, recurrence updates must be done directly in Outlook Calendar.")
if any(not record.microsoft_id for record in self):
if any(not record.ms_universal_event_id for record in self):
# If any event is not synced, suggest deleting it in Odoo and recreating it in Outlook.
error_msg = _(
"Due to an Outlook Calendar limitation, recurrence updates must be done directly in Outlook Calendar.\n"
@ -158,39 +164,51 @@ class Meeting(models.Model):
"""
raise UserError(_("Due to an Outlook Calendar limitation, recurrent events must be created directly in Outlook Calendar."))
def write(self, values):
def write(self, vals):
values = vals
recurrence_update_setting = values.get('recurrence_update')
notify_context = self.env.context.get('dont_notify', False)
# Forbid recurrence updates through Odoo and suggest user to update it in Outlook.
if self._check_microsoft_sync_status():
if not notify_context:
recurrency_in_batch = self.filtered(lambda ev: ev.recurrency)
recurrence_update_attempt = recurrence_update_setting or 'recurrency' in values or recurrency_in_batch and len(recurrency_in_batch) > 0
if not notify_context and recurrence_update_attempt and not 'active' in values:
self._forbid_recurrence_update()
# Check if this is an Outlook recurring event with active sync
if recurrence_update_attempt and 'active' not in values:
recurring_events = self.filtered('microsoft_recurrence_master_id')
if recurring_events and any(
event.with_user(organizer)._check_microsoft_sync_status()
for event in recurring_events
if (organizer := event._get_organizer())
):
self._forbid_recurrence_update()
# When changing the organizer, check its sync status and verify if the user is listed as attendee.
# Updates from Microsoft must skip this check since changing the organizer on their side is not possible.
change_from_microsoft = self.env.context.get('dont_notify', False)
deactivated_events_ids = []
new_user_id = values.get('user_id')
for event in self:
if values.get('user_id') and event.user_id.id != values['user_id'] and not change_from_microsoft:
if new_user_id and event.user_id.id != new_user_id and not change_from_microsoft and event.microsoft_id:
sender_user, partner_ids = event._get_organizer_user_change_info(values)
partner_included = sender_user.partner_id in event.attendee_ids.partner_id or sender_user.partner_id.id in partner_ids
event._check_organizer_validation(sender_user, partner_included)
event._recreate_event_different_organizer(values, sender_user)
deactivated_events_ids.append(event.id)
if event.microsoft_id:
event._recreate_event_different_organizer(values, sender_user)
deactivated_events_ids.append(event.id)
# check a Outlook limitation in overlapping the actual recurrence
if recurrence_update_setting == 'self_only' and 'start' in values:
self._check_recurrence_overlapping(values['start'])
# if a single event becomes the base event of a recurrency, it should be first
# removed from the Outlook calendar.
if 'recurrency' in values and values['recurrency']:
for e in self.filtered(lambda e: not e.recurrency and not e.recurrence_id):
e._microsoft_delete(e._get_organizer(), e.ms_organizer_event_id, timeout=3)
e.microsoft_id = False
# removed from the Outlook calendar. Additionaly, checks if synchronization is not paused.
if self.env.user._get_microsoft_sync_status() != "sync_paused" and values.get('recurrency'):
for event in self:
if not event.recurrency and not event.recurrence_id:
event._microsoft_delete(event._get_organizer(), event.microsoft_id, timeout=3)
event.microsoft_id = False
event.ms_universal_event_id = False
deactivated_events = self.browse(deactivated_events_ids)
# Update attendee status before 'values' variable is overridden in super.
@ -198,11 +216,11 @@ class Meeting(models.Model):
if attendee_ids and values.get('partner_ids'):
(self - deactivated_events)._update_attendee_status(attendee_ids)
res = super(Meeting, (self - deactivated_events).with_context(dont_notify=notify_context)).write(values)
res = super(CalendarEvent, (self - deactivated_events).with_context(dont_notify=notify_context)).write(values)
# Deactivate events that were recreated after changing organizer.
if deactivated_events:
res |= super(Meeting, deactivated_events.with_context(dont_notify=notify_context)).write({**values, 'active': False})
res |= super(CalendarEvent, deactivated_events.with_context(dont_notify=notify_context)).write({**values, 'active': False})
if recurrence_update_setting in ('all_events',) and len(self) == 1 \
and values.keys() & self._get_microsoft_synced_fields():
@ -223,14 +241,18 @@ class Meeting(models.Model):
""" Copy current event values, delete it and recreate it with the new organizer user. """
self.ensure_one()
event_copy = {**self.copy_data()[0], 'microsoft_id': False}
self.env['calendar.event'].with_user(sender_user).create({**event_copy, **values})
self.env['calendar.event'].with_user(sender_user).with_context(skip_contact_description=True).create(
{**event_copy, **values},
)
if self.ms_universal_event_id:
self._microsoft_delete(self._get_organizer(), self.ms_organizer_event_id)
self._microsoft_delete(self._get_organizer(), self.microsoft_id)
@api.model
def _get_organizer_user_change_info(self, values):
""" Return the sender user of the event and the partner ids listed on the event values. """
sender_user_id = values.get('user_id', self.env.user.id)
sender_user_id = values.get('user_id')
if not sender_user_id:
sender_user_id = self.env.user.id
sender_user = self.env['res.users'].browse(sender_user_id)
attendee_values = self._attendees_values(values['partner_ids']) if 'partner_ids' in values else []
partner_ids = []
@ -272,17 +294,17 @@ class Meeting(models.Model):
custom_lower_bound_range = ICP.get_param('microsoft_calendar.sync.lower_bound_range')
if custom_lower_bound_range:
lower_bound = fields.Datetime.subtract(fields.Datetime.now(), days=int(custom_lower_bound_range))
domain = [
('partner_ids.user_ids', 'in', self.env.user.id),
domain = Domain([
('partner_ids.user_ids', 'in', [self.env.user.id]),
('stop', '>', lower_bound),
('start', '<', upper_bound),
'!', '&', '&', ('recurrency', '=', True), ('recurrence_id', '!=', False), ('follow_recurrence', '=', True)
]
])
# Synchronize events that were created after the first synchronization date, when applicable.
first_synchronization_date = ICP.get_param('microsoft_calendar.sync.first_synchronization_date')
if first_synchronization_date:
domain = expression.AND([domain, [('create_date', '>=', first_synchronization_date)]])
domain &= Domain('create_date', '>=', first_synchronization_date)
return self._extend_microsoft_domain(domain)
@ -312,7 +334,7 @@ class Meeting(models.Model):
'description': microsoft_event.body and microsoft_event.body['content'],
'location': microsoft_event.location and microsoft_event.location.get('displayName') or False,
'user_id': microsoft_event.owner_id(self.env),
'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']),
'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, False),
'attendee_ids': commands_attendee,
'allday': microsoft_event.isAllDay,
'start': start,
@ -340,7 +362,9 @@ class Meeting(models.Model):
values['location'] = False
if with_ids:
values['microsoft_id'] = combine_ids(microsoft_event.id, microsoft_event.iCalUId)
values['microsoft_id'] = microsoft_event.id
values['ms_universal_event_id'] = microsoft_event.iCalUId
if microsoft_event.is_recurrent():
values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId
@ -362,7 +386,8 @@ class Meeting(models.Model):
stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None)
values = default_values or {}
values.update({
'microsoft_id': combine_ids(microsoft_event.id, microsoft_event.iCalUId),
'microsoft_id': microsoft_event.id,
'ms_universal_event_id': microsoft_event.iCalUId,
'microsoft_recurrence_master_id': microsoft_event.seriesMasterId,
'start': start,
'stop': stop,
@ -388,9 +413,11 @@ class Meeting(models.Model):
elif self.env.user.partner_id.email not in emails:
commands_attendee += [(0, 0, {'state': 'accepted', 'partner_id': self.env.user.partner_id.id})]
commands_partner += [(4, self.env.user.partner_id.id)]
partners = self.env['mail.thread']._mail_find_partner_from_emails(emails, records=self, force_create=True)
partners = self.env['mail.thread']._partner_find_from_emails_single(emails, no_create=False)
attendees_by_emails = {a.email: a for a in existing_attendees}
for email, partner, attendee_info in zip(emails, partners, microsoft_attendees):
partners_by_emails = {p.email_normalized: p for p in partners}
for email, attendee_info in zip(emails, microsoft_attendees):
partner = partners_by_emails.get(email_normalize(email) or email, self.env['res.partner'])
# Responses from external invitations are stored in the 'responseStatus' field.
# This field only carries the current user's event status because Microsoft hides other user's status.
if self.env.user.email == email and microsoft_event.responseStatus:
@ -476,7 +503,7 @@ class Meeting(models.Model):
return 'organizer'
return ATTENDEE_CONVERTER_O2M.get(attendee.state, 'None')
def _microsoft_values(self, fields_to_sync, initial_values={}):
def _microsoft_values(self, fields_to_sync, initial_values=()):
values = dict(initial_values)
if not fields_to_sync:
return values
@ -492,7 +519,7 @@ class Meeting(models.Model):
if 'description' in fields_to_sync:
values['body'] = {
'content': self.description if not is_html_empty(self.description) else '',
'content': self._get_customer_description(),
'contentType': "html",
}
@ -511,6 +538,12 @@ class Meeting(models.Model):
if 'location' in fields_to_sync:
values['location'] = {'displayName': self.location or ''}
if not self.location and 'videocall_location' in fields_to_sync and self._need_video_call():
values['isOnlineMeeting'] = True
values['onlineMeetingProvider'] = 'teamsForBusiness'
else:
values['isOnlineMeeting'] = False
if 'alarm_ids' in fields_to_sync:
alarm_id = self.alarm_ids.filtered(lambda a: a.alarm_type == 'notification')[:1]
values['isReminderOn'] = bool(alarm_id)
@ -535,6 +568,11 @@ class Meeting(models.Model):
'private': 'private',
'confidential': 'confidential',
}
# Set default privacy in event according to the organizer's calendar default privacy if defined.
if self.user_id:
sensitivity_o2m[False] = sensitivity_o2m.get(self.user_id.calendar_default_privacy)
else:
sensitivity_o2m[False] = 'normal'
values['sensitivity'] = sensitivity_o2m.get(self.privacy)
if 'active' in fields_to_sync and not self.active:
@ -617,11 +655,11 @@ class Meeting(models.Model):
"all attendees must have an email address. However, some events do "
"not respect this condition. As long as the events are incorrect, "
"the calendars will not be synchronized."
"\nEither update the events/attendees or archive these events %s:"
"\n%s", details, invalid_events))
"\nEither update the events/attendees or archive these events %(details)s:"
"\n%(invalid_events)s", details=details, invalid_events=invalid_events))
def _microsoft_values_occurence(self, initial_values={}):
values = initial_values
def _microsoft_values_occurence(self, initial_values=()):
values = dict(initial_values)
values['type'] = 'occurrence'
if self.allday:
@ -646,7 +684,10 @@ class Meeting(models.Model):
"""
user = self.env.user
records = self.filtered(lambda e: not e.user_id or e.user_id == user or user.partner_id in e.partner_ids)
super(Meeting, records)._cancel_microsoft()
for event in records:
# 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, records)._cancel_microsoft()
attendees = (self - records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id)
attendees.do_decline()
@ -661,3 +702,8 @@ class Meeting(models.Model):
if user_id and self.with_user(user_id).sudo()._check_microsoft_sync_status():
return user_id
return self.env.user
def _is_microsoft_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"."microsoft_id" IS NULL'
base = super()._get_notify_alert_extra_conditions()
if self.env.context.get('alarm_type') == 'email':
return SQL("%s AND event.microsoft_id IS NULL", base)
return base

View file

@ -1,25 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService
class Attendee(models.Model):
_name = 'calendar.attendee'
class CalendarAttendee(models.Model):
_inherit = 'calendar.attendee'
def _send_mail_to_attendees(self, mail_template, force_send=False):
""" Override the super method
If not synced with Microsoft Outlook, let Odoo in charge of sending emails
Otherwise, Microsoft Outlook will send them
"""
with microsoft_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()

View file

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.osv import expression
from odoo.fields import Domain
class RecurrenceRule(models.Model):
class CalendarRecurrence(models.Model):
_name = 'calendar.recurrence'
_inherit = ['calendar.recurrence', 'microsoft.calendar.sync']
@ -13,8 +12,6 @@ class RecurrenceRule(models.Model):
# Don't sync by default. Sync only when the recurrence is applied
need_sync_m = fields.Boolean(default=False)
microsoft_id = fields.Char('Microsoft Calendar Recurrence Id')
def _compute_rrule(self):
# Note: 'need_sync_m' is set to False to avoid syncing the updated recurrence with
# Outlook, as this update may already come from Outlook. If not, this modification will
@ -43,14 +40,15 @@ class RecurrenceRule(models.Model):
vals += [{
'name': event.name,
'microsoft_id': event.microsoft_id,
'ms_universal_event_id': event.ms_universal_event_id,
'start': event.start,
'stop': event.stop,
'active': False,
'need_sync_m': True,
}]
event._microsoft_delete(event.user_id, event.ms_organizer_event_id)
event._microsoft_delete(event.user_id, event.microsoft_id)
event.ms_universal_event_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_m = False
return detached_events
@ -108,9 +106,10 @@ class RecurrenceRule(models.Model):
# We archive the old events to recompute the recurrence. These events are already deleted on Microsoft side.
# We can't call _cancel because events without user_id would not be deleted
(self.calendar_event_ids - base_event_id).microsoft_id = False
(self.calendar_event_ids - base_event_id).ms_universal_event_id = False
(self.calendar_event_ids - base_event_id).unlink()
base_event_id.with_context(dont_notify=True).write(dict(
new_event_values, microsoft_id=False, need_sync_m=False
new_event_values, microsoft_id=False, ms_universal_event_id=False, need_sync_m=False
))
if self.rrule == current_rrule:
# if the rrule has changed, it will be recalculated below
@ -132,13 +131,12 @@ class RecurrenceRule(models.Model):
# to base_event start datetime.
if self.rrule != current_rrule:
detached_events = self._apply_recurrence()
detached_events.microsoft_id = False
detached_events.ms_universal_event_id = False
detached_events.unlink()
def _get_microsoft_sync_domain(self):
# Do not sync Odoo recurrences with Outlook Calendar anymore.
domain = expression.FALSE_DOMAIN
return self._extend_microsoft_domain(domain)
return self._extend_microsoft_domain(Domain.FALSE)
def _cancel_microsoft(self):
self.calendar_event_ids.with_context(dont_notify=True)._cancel_microsoft()
@ -151,18 +149,18 @@ class RecurrenceRule(models.Model):
if with_ids:
recurrence = {
**recurrence,
'ms_organizer_event_id': microsoft_recurrence.id,
'microsoft_id': microsoft_recurrence.id,
'ms_universal_event_id': microsoft_recurrence.iCalUId,
}
return recurrence
def _microsoft_values(self, fields_to_sync):
def _microsoft_values(self, fields_to_sync, initial_values=()):
"""
Get values to update the whole Outlook event recurrence.
(done through the first event of the Outlook recurrence).
"""
return self.base_event_id._microsoft_values(fields_to_sync, initial_values={'type': 'seriesMaster'})
return self.base_event_id._microsoft_values(fields_to_sync, initial_values={**dict(initial_values), 'type': 'seriesMaster'})
def _ensure_attendees_have_email(self):
self.calendar_event_ids.filtered(lambda e: e.active)._ensure_attendees_have_email()
@ -178,7 +176,7 @@ class RecurrenceRule(models.Model):
if new_recurrence and new_recurrence.base_event_id.microsoft_id:
new_recurrence.base_event_id._microsoft_delete(
new_recurrence.base_event_id._get_organizer(),
new_recurrence.base_event_id.ms_organizer_event_id
new_recurrence.base_event_id.microsoft_id
)
return new_recurrence
@ -190,3 +188,9 @@ class RecurrenceRule(models.Model):
if event:
return event._get_event_user_m(user_id)
return self.env.user
def _is_microsoft_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,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
@ -8,15 +7,13 @@ import pytz
from dateutil.parser import parse
from datetime import timedelta
from odoo import api, fields, models, registry
from odoo.tools import ormcache_context
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo import api, fields, models
from odoo.fields import Domain
from odoo.modules.registry import Registry
from odoo.sql_db import BaseCursor
from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService
from odoo.addons.microsoft_calendar.utils.event_id_storage import IDS_SEPARATOR, combine_ids, split_ids
from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT
_logger = logging.getLogger(__name__)
@ -40,7 +37,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:
@ -55,48 +52,38 @@ def after_commit(func):
def microsoft_calendar_token(user):
yield user._get_microsoft_calendar_token()
class MicrosoftSync(models.AbstractModel):
class MicrosoftCalendarSync(models.AbstractModel):
_name = 'microsoft.calendar.sync'
_description = "Synchronize a record with Microsoft Calendar"
microsoft_id = fields.Char('Microsoft Calendar Id', copy=False)
ms_organizer_event_id = fields.Char(
'Organizer event Id',
compute='_compute_organizer_event_id',
inverse='_set_event_id',
search='_search_organizer_event_id',
)
ms_universal_event_id = fields.Char(
'Universal event Id',
compute='_compute_universal_event_id',
inverse='_set_event_id',
search='_search_universal_event_id',
)
microsoft_id = fields.Char('Organizer event Id', copy=False, index=True)
ms_universal_event_id = fields.Char('Universal event Id', copy=False, index=True)
# This field helps to know when a microsoft event need to be resynced
need_sync_m = fields.Boolean(default=True, copy=False)
active = fields.Boolean(default=True)
def write(self, vals):
if 'ms_universal_event_id' in vals:
self._from_uids.clear_cache(self)
fields_to_sync = [x for x in vals.keys() if x in self._get_microsoft_synced_fields()]
if fields_to_sync and 'need_sync_m' not in vals and not self.env.user.microsoft_synchronization_stopped:
fields_to_sync = [x for x in vals if x in self._get_microsoft_synced_fields()]
if fields_to_sync and 'need_sync_m' not in vals and self.env.user._get_microsoft_sync_status() == "sync_active":
vals['need_sync_m'] = True
result = super().write(vals)
for record in self.filtered(lambda e: e.need_sync_m and e.ms_organizer_event_id):
if not vals.get('active', True):
# We need to delete the event. Cancel is not sufficant. Errors may occurs
record._microsoft_delete(record._get_organizer(), record.ms_organizer_event_id, timeout=3)
elif fields_to_sync:
values = record._microsoft_values(fields_to_sync)
if not values:
continue
record._microsoft_patch(record._get_organizer(), record.ms_organizer_event_id, values, timeout=3)
if self.env.user._get_microsoft_sync_status() != "sync_paused":
timeout = self._get_microsoft_graph_timeout()
for record in self:
if record.need_sync_m and record.microsoft_id:
if not vals.get('active', True):
# We need to delete the event. Cancel is not sufficient. Errors may occur.
record._microsoft_delete(record._get_organizer(), record.microsoft_id, timeout=timeout)
elif fields_to_sync:
values = record._microsoft_values(fields_to_sync)
if not values:
continue
record._microsoft_patch(record._get_organizer(), record.microsoft_id, values, timeout=timeout)
return result
@ -107,53 +94,14 @@ class MicrosoftSync(models.AbstractModel):
vals.update({'need_sync_m': False})
records = super().create(vals_list)
records_to_sync = records.filtered(lambda r: r.need_sync_m and r.active)
for record in records_to_sync:
record._microsoft_insert(record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3)
if self.env.user._get_microsoft_sync_status() != "sync_paused":
timeout = self._get_microsoft_graph_timeout()
for record in records:
if record.need_sync_m and record.active:
record._microsoft_insert(record._microsoft_values(self._get_microsoft_synced_fields()), timeout=timeout)
return records
@api.depends('microsoft_id')
def _compute_organizer_event_id(self):
for event in self:
event.ms_organizer_event_id = split_ids(event.microsoft_id)[0] if event.microsoft_id else False
@api.depends('microsoft_id')
def _compute_universal_event_id(self):
for event in self:
event.ms_universal_event_id = split_ids(event.microsoft_id)[1] if event.microsoft_id else False
def _set_event_id(self):
for event in self:
event.microsoft_id = combine_ids(event.ms_organizer_event_id, event.ms_universal_event_id)
def _search_event_id(self, operator, value, with_uid):
def _domain(v):
return ('microsoft_id', '=like', f'%{IDS_SEPARATOR}{v}' if with_uid else f'{v}%')
if operator == '=' and not value:
return (
['|', ('microsoft_id', '=', False), ('microsoft_id', '=ilike', f'%{IDS_SEPARATOR}')]
if with_uid
else [('microsoft_id', '=', False)]
)
elif operator == '!=' and not value:
return (
[('microsoft_id', 'ilike', f'{IDS_SEPARATOR}_')]
if with_uid
else [('microsoft_id', '!=', False)]
)
return (
['|'] * (len(value) - 1) + [_domain(v) for v in value]
if operator.lower() == 'in'
else [_domain(value)]
)
def _search_organizer_event_id(self, operator, value):
return self._search_event_id(operator, value, with_uid=False)
def _search_universal_event_id(self, operator, value):
return self._search_event_id(operator, value, with_uid=True)
@api.model
def _get_microsoft_service(self):
return MicrosoftCalendarService(self.env['microsoft.service'])
@ -166,8 +114,9 @@ class MicrosoftSync(models.AbstractModel):
def unlink(self):
synced = self._get_synced_events()
for ev in synced:
ev._microsoft_delete(ev._get_organizer(), ev.ms_organizer_event_id)
if self.env.user._get_microsoft_sync_status() != "sync_paused":
for ev in synced:
ev._microsoft_delete(ev._get_organizer(), ev.microsoft_id)
return super().unlink()
def _write_from_microsoft(self, microsoft_event, vals):
@ -175,14 +124,7 @@ class MicrosoftSync(models.AbstractModel):
@api.model
def _create_from_microsoft(self, microsoft_event, vals_list):
return self.with_context(dont_notify=True).create(vals_list)
@api.model
@ormcache_context('uids', keys=('active_test',))
def _from_uids(self, uids):
if not uids:
return self.browse()
return self.search([('ms_universal_event_id', 'in', uids)])
return self.with_context(dont_notify=True, skip_contact_description=True).create(vals_list)
def _sync_odoo2microsoft(self):
if not self:
@ -198,9 +140,12 @@ class MicrosoftSync(models.AbstractModel):
new_records = records_to_sync - updated_records
for record in cancelled_records._get_synced_events():
record._microsoft_delete(record._get_organizer(), record.ms_organizer_event_id)
record._microsoft_delete(record._get_organizer(), record.microsoft_id)
for record in new_records:
values = record._microsoft_values(self._get_microsoft_synced_fields())
sender_user = record._get_event_user_m()
if record._is_microsoft_insertion_blocked(sender_user):
continue
if isinstance(values, dict):
record._microsoft_insert(values)
else:
@ -210,10 +155,11 @@ class MicrosoftSync(models.AbstractModel):
values = record._microsoft_values(self._get_microsoft_synced_fields())
if not values:
continue
record._microsoft_patch(record._get_organizer(), record.ms_organizer_event_id, values)
record._microsoft_patch(record._get_organizer(), record.microsoft_id, values)
def _cancel_microsoft(self):
self.microsoft_id = False
self.ms_universal_event_id = False
self.unlink()
def _sync_recurrence_microsoft2odoo(self, microsoft_events, new_events=None):
@ -231,7 +177,7 @@ class MicrosoftSync(models.AbstractModel):
need_sync_m=False
)
to_create = recurrents.filter(
lambda e: e.seriesMasterId == new_calendar_recurrence['ms_organizer_event_id']
lambda e: e.seriesMasterId == new_calendar_recurrence['microsoft_id']
)
recurrents -= to_create
base_values = dict(
@ -261,10 +207,7 @@ class MicrosoftSync(models.AbstractModel):
# is specific to the Microsoft user calendar.
ms_recurrence_ids = list({x.seriesMasterId for x in recurrents})
ms_recurrence_uids = {r.id: r.iCalUId for r in microsoft_events if r.id in ms_recurrence_ids}
recurrences = self.env['calendar.recurrence'].search([
('ms_universal_event_id', 'in', ms_recurrence_uids.values())
])
recurrences = self.env['calendar.recurrence'].search([('ms_universal_event_id', 'in', microsoft_events.uids)])
for recurrent_master_id in ms_recurrence_ids:
recurrence_id = recurrences.filtered(
lambda ev: ev.ms_universal_event_id == ms_recurrence_uids[recurrent_master_id]
@ -294,7 +237,7 @@ class MicrosoftSync(models.AbstractModel):
Update Odoo events from Outlook recurrence and events.
"""
# get the list of events to update ...
events_to_update = events.filter(lambda e: e.seriesMasterId == self.ms_organizer_event_id)
events_to_update = events.filter(lambda e: e.seriesMasterId == self.microsoft_id)
if self.end_type in ['count', 'forever']:
events_to_update = list(events_to_update)[:MAX_RECURRENT_EVENT]
@ -345,7 +288,7 @@ class MicrosoftSync(models.AbstractModel):
dict(self._microsoft_to_odoo_values(e, with_ids=True), need_sync_m=False)
for e in (new - new_recurrence)
]
synced_events = self.with_context(dont_notify=True)._create_from_microsoft(new, odoo_values)
synced_events = self.with_context(dont_notify=True, skip_contact_description=True)._create_from_microsoft(new, odoo_values)
synced_recurrences, updated_events = self._sync_recurrence_microsoft2odoo(existing, new_recurrence)
synced_events |= updated_events
@ -353,12 +296,12 @@ class MicrosoftSync(models.AbstractModel):
cancelled_recurrences = self.env['calendar.recurrence'].search([
'|',
('ms_universal_event_id', 'in', cancelled.uids),
('ms_organizer_event_id', 'in', cancelled.ids),
('microsoft_id', 'in', cancelled.ids),
])
cancelled_events = self.browse([
e.odoo_id(self.env)
for e in cancelled
if e.id not in [r.ms_organizer_event_id for r in cancelled_recurrences]
if e.id not in [r.microsoft_id for r in cancelled_recurrences]
])
cancelled_recurrences._cancel_microsoft()
cancelled_events = cancelled_events.exists()
@ -419,11 +362,6 @@ class MicrosoftSync(models.AbstractModel):
stop_date_condition = any(event.stop >= lower_bound for event in self.calendar_event_ids)
return stop_date_condition or update_time_diff >= timedelta(hours=1)
def _impersonate_user(self, user_id):
""" Impersonate a user (mainly the event organizer) to be able to call the Outlook API with its token """
# This method is obsolete, as it has been replaced by the `_get_event_user_m` method, which gets the user who will make the request.
return user_id.with_user(user_id)
@after_commit
def _microsoft_delete(self, user_id, event_id, timeout=TIMEOUT):
"""
@ -433,8 +371,8 @@ class MicrosoftSync(models.AbstractModel):
'self' won't exist when this method will be really called due to @after_commit decorator.
"""
microsoft_service = self._get_microsoft_service()
sender_user = self._get_event_user_m(user_id)
with microsoft_calendar_token(sender_user.sudo()) as token:
sender_user = self._get_event_user_m(user_id).sudo()
with microsoft_calendar_token(sender_user) as token:
if token and not sender_user.microsoft_synchronization_stopped:
microsoft_service.delete(event_id, token=token, timeout=timeout)
@ -475,7 +413,8 @@ class MicrosoftSync(models.AbstractModel):
self._ensure_attendees_have_email()
event_id, uid = microsoft_service.insert(values, token=token, timeout=timeout)
self.with_context(dont_notify=True).write({
'microsoft_id': combine_ids(event_id, uid),
'microsoft_id': event_id,
'ms_universal_event_id': uid,
'need_sync_m': False,
})
@ -518,7 +457,19 @@ class MicrosoftSync(models.AbstractModel):
"""
raise NotImplementedError()
def _microsoft_values(self, fields_to_sync):
@api.model
def _get_microsoft_graph_timeout(self):
"""Return Microsoft Graph request timeout (seconds).
Keep current behavior by default (5s), but allow admins to increase it
through a system parameter.
"""
timeout = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.graph_timeout')
if not timeout or not timeout.isdigit():
return 5
return max(1, int(timeout))
def _microsoft_values(self, fields_to_sync, initial_values=()):
"""
Implements this method to return a dict with values formatted
according to the Microsoft Calendar API
@ -550,19 +501,19 @@ class MicrosoftSync(models.AbstractModel):
"""
raise NotImplementedError()
def _extend_microsoft_domain(self, domain):
def _extend_microsoft_domain(self, domain: Domain):
""" Extends the sync domain based on the full_sync_m context parameter.
In case of full sync it shouldn't include already synced events.
"""
if self._context.get('full_sync_m', True):
domain = expression.AND([domain, [('ms_universal_event_id', '=', False)]])
if self.env.context.get('full_sync_m', True):
domain &= Domain('ms_universal_event_id', '=', False)
else:
is_active_clause = (self._active_name, '=', True) if self._active_name else expression.TRUE_LEAF
domain = expression.AND([domain, [
'|',
'&', ('ms_universal_event_id', '=', False), is_active_clause,
('need_sync_m', '=', True),
]])
is_active_clause = Domain(self._active_name, '=', True) if self._active_name else Domain.TRUE
domain &= (Domain('ms_universal_event_id', '=', False) & is_active_clause) | Domain('need_sync_m', '=', True)
# Sync only events created/updated after last sync date (with 5 min of time acceptance).
if self.env.user.microsoft_last_sync_date:
time_offset = timedelta(minutes=5)
domain &= Domain('write_date', '>=', self.env.user.microsoft_last_sync_date - time_offset)
return domain
def _get_event_user_m(self, user_id=None):
@ -572,3 +523,21 @@ class MicrosoftSync(models.AbstractModel):
the appropriate user accordingly.
"""
raise NotImplementedError()
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
def _is_microsoft_insertion_blocked(self, sender_user):
"""
Returns True if the record insertion to Microsoft should be blocked.
This is a necessary step for ensuring data match between Odoo and Microsoft,
as it prevents attendees to synchronize new records on behalf of the owners,
otherwise the event ownership would be lost in Outlook and it would block the
future record synchronization for the original owner.
"""
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_microsoft_client_id = fields.Char("Microsoft Client_id", config_parameter='microsoft_calendar_client_id', default='')
cal_microsoft_client_secret = fields.Char("Microsoft Client_key", config_parameter='microsoft_calendar_client_secret', default='')
cal_microsoft_sync_paused = fields.Boolean("Microsoft Synchronization Paused", config_parameter='microsoft_calendar_sync_paused',
help="Indicates if synchronization with Outlook Calendar is paused or not.")

View file

@ -1,33 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token
from datetime import timedelta
from datetime import datetime, timedelta
from odoo import api, fields, models, _
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError
from odoo.loglevels import exception_to_unicode
from odoo.addons.microsoft_account.models.microsoft_service import DEFAULT_MICROSOFT_TOKEN_ENDPOINT
from odoo.addons.microsoft_account.models import microsoft_service
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import InvalidSyncToken
from odoo.tools import str2bool
_logger = logging.getLogger(__name__)
class User(models.Model):
class ResUsers(models.Model):
_inherit = 'res.users'
microsoft_calendar_sync_token = fields.Char('Microsoft Next Sync Token', copy=False)
microsoft_synchronization_stopped = fields.Boolean('Outlook Synchronization stopped', copy=False)
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['microsoft_synchronization_stopped']
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + ['microsoft_synchronization_stopped']
microsoft_calendar_sync_token = fields.Char(related='res_users_settings_id.microsoft_calendar_sync_token', groups='base.group_system')
microsoft_synchronization_stopped = fields.Boolean(related='res_users_settings_id.microsoft_synchronization_stopped', readonly=False, groups='base.group_system')
microsoft_last_sync_date = fields.Datetime(related='res_users_settings_id.microsoft_last_sync_date', readonly=False, groups='base.group_system')
def _microsoft_calendar_authenticated(self):
return bool(self.sudo().microsoft_calendar_rtoken)
@ -37,48 +30,32 @@ class User(models.Model):
return None
self.ensure_one()
if self.microsoft_calendar_rtoken and not self._is_microsoft_calendar_valid():
if self.sudo().microsoft_calendar_rtoken and not self._is_microsoft_calendar_valid():
self._refresh_microsoft_calendar_token()
return self.microsoft_calendar_token
return self.sudo().microsoft_calendar_token
def _is_microsoft_calendar_valid(self):
return self.microsoft_calendar_token_validity and self.microsoft_calendar_token_validity >= (fields.Datetime.now() + timedelta(minutes=1))
return self.sudo().microsoft_calendar_token_validity and self.sudo().microsoft_calendar_token_validity >= (fields.Datetime.now() + timedelta(minutes=1))
def _refresh_microsoft_calendar_token(self):
def _refresh_microsoft_calendar_token(self, service='calendar'):
self.ensure_one()
get_param = self.env['ir.config_parameter'].sudo().get_param
client_id = get_param('microsoft_calendar_client_id')
client_secret = get_param('microsoft_calendar_client_secret')
if not client_id or not client_secret:
raise UserError(_("The account for the Outlook Calendar service is not configured."))
headers = {"content-type": "application/x-www-form-urlencoded"}
data = {
'refresh_token': self.microsoft_calendar_rtoken,
'client_id': client_id,
'client_secret': client_secret,
'grant_type': 'refresh_token',
}
try:
dummy, response, dummy = self.env['microsoft.service']._do_request(
DEFAULT_MICROSOFT_TOKEN_ENDPOINT, params=data, headers=headers, method='POST', preuri=''
)
ttl = response.get('expires_in')
self.write({
'microsoft_calendar_token': response.get('access_token'),
access_token, ttl = self.env['microsoft.service']._refresh_microsoft_token('calendar', self.sudo().microsoft_calendar_rtoken)
self.sudo().write({
'microsoft_calendar_token': access_token,
'microsoft_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.write({
self.sudo().write({
'microsoft_calendar_rtoken': False,
'microsoft_calendar_token': False,
'microsoft_calendar_token_validity': False,
'microsoft_calendar_sync_token': False,
})
self.res_users_settings_id.sudo().write({
'microsoft_calendar_sync_token': False
})
self.env.cr.commit()
error_key = error.response.json().get("error", "nc")
@ -88,9 +65,21 @@ class User(models.Model):
error_key)
raise UserError(error_msg)
def _get_microsoft_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("microsoft_calendar_sync_paused"), default=False):
status = "sync_paused"
elif self.sudo().microsoft_calendar_token and not self.sudo().microsoft_synchronization_stopped:
status = "sync_active"
elif self.sudo().microsoft_synchronization_stopped:
status = "sync_stopped"
return status
def _sync_microsoft_calendar(self):
self.ensure_one()
if self.microsoft_synchronization_stopped:
self.sudo().microsoft_last_sync_date = datetime.now()
if self._get_microsoft_sync_status() != "sync_active":
return False
# Set the first synchronization date as an ICP parameter before writing the variable
@ -98,14 +87,14 @@ class User(models.Model):
self._set_ICP_first_synchronization_date(fields.Datetime.now())
calendar_service = self.env["calendar.event"]._get_microsoft_service()
full_sync = not bool(self.microsoft_calendar_sync_token)
full_sync = not bool(self.sudo().microsoft_calendar_sync_token)
with microsoft_calendar_token(self) as token:
try:
events, next_sync_token = calendar_service.get_events(self.microsoft_calendar_sync_token, token=token)
events, next_sync_token = calendar_service.get_events(self.sudo().microsoft_calendar_sync_token, token=token)
except InvalidSyncToken:
events, next_sync_token = calendar_service.get_events(token=token)
full_sync = True
self.microsoft_calendar_sync_token = next_sync_token
self.res_users_settings_id.sudo().microsoft_calendar_sync_token = next_sync_token
# Microsoft -> Odoo
synced_events, synced_recurrences = self.env['calendar.event']._sync_microsoft2odoo(events) if events else (self.env['calendar.event'], self.env['calendar.recurrence'])
@ -118,32 +107,80 @@ class User(models.Model):
events = self.env['calendar.event']._get_microsoft_records_to_sync(full_sync=full_sync)
(events - synced_events)._sync_odoo2microsoft()
self.sudo().microsoft_last_sync_date = datetime.now()
return bool(events | synced_events) or bool(recurrences | synced_recurrences)
@api.model
def _sync_all_microsoft_calendar(self):
""" Cron job """
users = self.env['res.users'].search([('microsoft_calendar_rtoken', '!=', False), ('microsoft_synchronization_stopped', '=', False)])
users = self.env['res.users'].sudo().search([('microsoft_calendar_rtoken', '!=', False), ('microsoft_synchronization_stopped', '=', False)])
for user in users:
_logger.info("Calendar Synchro - Starting synchronization for %s", user)
try:
user.with_user(user).sudo()._sync_microsoft_calendar()
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 stop_microsoft_synchronization(self):
self.ensure_one()
self.microsoft_synchronization_stopped = True
self.sudo().microsoft_synchronization_stopped = True
self.sudo().microsoft_last_sync_date = None
def restart_microsoft_synchronization(self):
self.ensure_one()
self.microsoft_synchronization_stopped = False
self.sudo().microsoft_last_sync_date = datetime.now()
self.sudo().microsoft_synchronization_stopped = False
self.env['calendar.recurrence']._restart_microsoft_sync()
self.env['calendar.event']._restart_microsoft_sync()
def unpause_microsoft_synchronization(self):
self.env['ir.config_parameter'].sudo().set_param("microsoft_calendar_sync_paused", False)
def pause_microsoft_synchronization(self):
self.env['ir.config_parameter'].sudo().set_param("microsoft_calendar_sync_paused", True)
@api.model
def _has_setup_microsoft_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['microsoft.service']._get_microsoft_client_id('calendar')
client_secret = microsoft_service._get_microsoft_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['microsoft_calendar'] = self._has_setup_microsoft_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('microsoft_calendar'):
sync_status = self._get_microsoft_sync_status()
if sync_status == 'sync_active' and not self.sudo().microsoft_calendar_token:
sync_status = 'sync_stopped'
res['microsoft_calendar'] = sync_status
return res
def _has_any_active_synchronization(self):
"""
Check if synchronization is active for Microsoft Calendar.
This function retrieves the synchronization status from the user's environment
and checks if the Microsoft 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('microsoft_calendar') == 'sync_active':
return True
return res
def _set_ICP_first_synchronization_date(self, now):
"""
Set the first synchronization date as an ICP parameter when applicable (param not defined yet

View file

@ -0,0 +1,22 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResUsersSettings(models.Model):
_inherit = "res.users.settings"
# Microsoft Calendar settings.
microsoft_calendar_sync_token = fields.Char('Microsoft Next Sync Token', copy=False, groups='base.group_system')
microsoft_synchronization_stopped = fields.Boolean('Outlook Synchronization stopped', copy=False, groups='base.group_system')
microsoft_last_sync_date = fields.Datetime('Last Sync Date', copy=False, help='Last synchronization date with Outlook Calendar', groups='base.group_system')
@api.model
def _get_fields_blacklist(self):
""" Get list of microsoft fields that won't be formatted in session_info. """
microsoft_fields_blacklist = [
'microsoft_calendar_sync_token',
'microsoft_synchronization_stopped',
'microsoft_last_sync_date',
]
return super()._get_fields_blacklist() + microsoft_fields_blacklist