1170 lines
46 KiB
Python
1170 lines
46 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
import requests
|
|
from datetime import datetime, timedelta
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
TIMEOUT = 20
|
|
MAX_THROTTLE_RETRIES = 3
|
|
DEFAULT_RETRY_SECONDS = 10
|
|
|
|
# Google OAuth endpoints
|
|
GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/auth'
|
|
GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
|
GOOGLE_CALENDAR_API = 'https://www.googleapis.com/calendar/v3'
|
|
GOOGLE_USERINFO_API = 'https://www.googleapis.com/oauth2/v2/userinfo'
|
|
GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/userinfo.email'
|
|
|
|
# Microsoft OAuth endpoints
|
|
MICROSOFT_AUTH_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
|
|
MICROSOFT_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
|
|
MICROSOFT_GRAPH_API = 'https://graph.microsoft.com/v1.0'
|
|
MICROSOFT_SCOPES = 'offline_access openid Calendars.ReadWrite User.Read'
|
|
MICROSOFT_SELECT_FIELDS = ','.join([
|
|
'id', 'subject', 'bodyPreview', 'body', 'start', 'end',
|
|
'location', 'isAllDay', 'sensitivity', 'showAs',
|
|
'iCalUId', 'isCancelled',
|
|
])
|
|
|
|
|
|
class FusionCalendarAccount(models.Model):
|
|
_name = 'fusion.calendar.account'
|
|
_description = 'Connected Calendar Account'
|
|
_order = 'x_fc_provider, x_fc_email'
|
|
|
|
x_fc_user_id = fields.Many2one(
|
|
'res.users', string='User', required=True,
|
|
ondelete='cascade', default=lambda self: self.env.user, index=True,
|
|
)
|
|
x_fc_provider = fields.Selection([
|
|
('google', 'Google'),
|
|
('microsoft', 'Microsoft'),
|
|
], string='Provider', required=True)
|
|
x_fc_email = fields.Char(string='Account Email')
|
|
x_fc_name = fields.Char(string='Account Label', compute='_compute_name', store=True)
|
|
x_fc_active = fields.Boolean(string='Active', default=True)
|
|
|
|
# OAuth tokens — restricted to system group
|
|
x_fc_rtoken = fields.Char(string='Refresh Token', groups='base.group_system')
|
|
x_fc_token = fields.Char(string='Access Token', groups='base.group_system')
|
|
x_fc_token_validity = fields.Datetime(string='Token Expiry', groups='base.group_system')
|
|
|
|
# Sync state
|
|
x_fc_sync_token = fields.Char(string='Sync Token', groups='base.group_system')
|
|
x_fc_calendar_id = fields.Char(string='Calendar ID', default='primary')
|
|
x_fc_last_sync = fields.Datetime(string='Last Sync')
|
|
x_fc_sync_status = fields.Selection([
|
|
('active', 'Active'),
|
|
('error', 'Error'),
|
|
('paused', 'Paused'),
|
|
], string='Sync Status', default='active')
|
|
x_fc_error_message = fields.Text(string='Last Error')
|
|
|
|
# Links
|
|
x_fc_link_ids = fields.One2many(
|
|
'fusion.calendar.event.link', 'x_fc_account_id', string='Event Links',
|
|
)
|
|
|
|
@api.depends('x_fc_provider', 'x_fc_email')
|
|
def _compute_name(self):
|
|
for rec in self:
|
|
provider_label = dict(self._fields['x_fc_provider'].selection).get(rec.x_fc_provider, '')
|
|
rec.x_fc_name = '%s — %s' % (provider_label, rec.x_fc_email or 'Not connected')
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OAuth Credentials — fallback to Odoo defaults
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _get_google_client_id(self):
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
return (
|
|
ICP.get_param('fusion_schedule_google_client_id')
|
|
or ICP.get_param('google_calendar_client_id', '')
|
|
)
|
|
|
|
def _get_google_client_secret(self):
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
return (
|
|
ICP.get_param('fusion_schedule_google_client_secret')
|
|
or ICP.get_param('google_calendar_client_secret', '')
|
|
)
|
|
|
|
def _get_microsoft_client_id(self):
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
return (
|
|
ICP.get_param('fusion_schedule_microsoft_client_id')
|
|
or ICP.get_param('microsoft_calendar_client_id', '')
|
|
)
|
|
|
|
def _get_microsoft_client_secret(self):
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
return (
|
|
ICP.get_param('fusion_schedule_microsoft_client_secret')
|
|
or ICP.get_param('microsoft_calendar_client_secret', '')
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OAuth Token Management
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _get_valid_token(self):
|
|
"""Return a valid access token, refreshing if necessary."""
|
|
self.ensure_one()
|
|
if not self.sudo().x_fc_rtoken:
|
|
raise UserError(_("No refresh token. Please reconnect this calendar account."))
|
|
|
|
# Check if current token is still valid (with 1 min buffer)
|
|
if (self.sudo().x_fc_token_validity
|
|
and self.sudo().x_fc_token_validity >= fields.Datetime.now() + timedelta(minutes=1)):
|
|
return self.sudo().x_fc_token
|
|
|
|
return self._refresh_token()
|
|
|
|
def _refresh_token(self):
|
|
"""Refresh the access token using the refresh token."""
|
|
self.ensure_one()
|
|
try:
|
|
if self.x_fc_provider == 'google':
|
|
return self._refresh_google_token()
|
|
elif self.x_fc_provider == 'microsoft':
|
|
return self._refresh_microsoft_token()
|
|
except requests.HTTPError as e:
|
|
if e.response and e.response.status_code in (400, 401):
|
|
self.sudo().write({
|
|
'x_fc_token': False,
|
|
'x_fc_token_validity': False,
|
|
'x_fc_sync_status': 'error',
|
|
'x_fc_error_message': _('Token expired or revoked. Please reconnect this account.'),
|
|
})
|
|
return False
|
|
raise
|
|
|
|
def _refresh_google_token(self):
|
|
"""Refresh Google OAuth token."""
|
|
self.ensure_one()
|
|
data = {
|
|
'client_id': self._get_google_client_id(),
|
|
'client_secret': self._get_google_client_secret(),
|
|
'refresh_token': self.sudo().x_fc_rtoken,
|
|
'grant_type': 'refresh_token',
|
|
}
|
|
resp = requests.post(GOOGLE_TOKEN_ENDPOINT, data=data, timeout=TIMEOUT)
|
|
resp.raise_for_status()
|
|
token_data = resp.json()
|
|
|
|
access_token = token_data['access_token']
|
|
expires_in = token_data.get('expires_in', 3600)
|
|
self.sudo().write({
|
|
'x_fc_token': access_token,
|
|
'x_fc_token_validity': fields.Datetime.now() + timedelta(seconds=expires_in),
|
|
})
|
|
return access_token
|
|
|
|
def _refresh_microsoft_token(self):
|
|
"""Refresh Microsoft OAuth token."""
|
|
self.ensure_one()
|
|
data = {
|
|
'client_id': self._get_microsoft_client_id(),
|
|
'client_secret': self._get_microsoft_client_secret(),
|
|
'refresh_token': self.sudo().x_fc_rtoken,
|
|
'grant_type': 'refresh_token',
|
|
}
|
|
resp = requests.post(MICROSOFT_TOKEN_ENDPOINT, data=data, timeout=TIMEOUT)
|
|
resp.raise_for_status()
|
|
token_data = resp.json()
|
|
|
|
access_token = token_data['access_token']
|
|
expires_in = token_data.get('expires_in', 3600)
|
|
# Microsoft may return a new refresh token
|
|
new_rtoken = token_data.get('refresh_token')
|
|
vals = {
|
|
'x_fc_token': access_token,
|
|
'x_fc_token_validity': fields.Datetime.now() + timedelta(seconds=expires_in),
|
|
}
|
|
if new_rtoken:
|
|
vals['x_fc_rtoken'] = new_rtoken
|
|
self.sudo().write(vals)
|
|
return access_token
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OAuth Code Exchange (called from controller callback)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _exchange_google_code(self, code, redirect_uri):
|
|
"""Exchange Google auth code for tokens."""
|
|
data = {
|
|
'code': code,
|
|
'client_id': self._get_google_client_id(),
|
|
'client_secret': self._get_google_client_secret(),
|
|
'redirect_uri': redirect_uri,
|
|
'grant_type': 'authorization_code',
|
|
}
|
|
resp = requests.post(GOOGLE_TOKEN_ENDPOINT, data=data, timeout=TIMEOUT)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def _exchange_microsoft_code(self, code, redirect_uri):
|
|
"""Exchange Microsoft auth code for tokens."""
|
|
data = {
|
|
'code': code,
|
|
'client_id': self._get_microsoft_client_id(),
|
|
'client_secret': self._get_microsoft_client_secret(),
|
|
'redirect_uri': redirect_uri,
|
|
'grant_type': 'authorization_code',
|
|
'scope': MICROSOFT_SCOPES,
|
|
}
|
|
resp = requests.post(MICROSOFT_TOKEN_ENDPOINT, data=data, timeout=TIMEOUT)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Fetch Account Info
|
|
# -------------------------------------------------------------------------
|
|
|
|
@api.model
|
|
def _fetch_google_email(self, access_token):
|
|
"""Get the email of the authenticated Google account."""
|
|
resp = requests.get(
|
|
GOOGLE_USERINFO_API,
|
|
headers={'Authorization': 'Bearer %s' % access_token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json().get('email', '')
|
|
|
|
@api.model
|
|
def _fetch_microsoft_email(self, access_token):
|
|
"""Get the email of the authenticated Microsoft account."""
|
|
resp = requests.get(
|
|
'%s/me' % MICROSOFT_GRAPH_API,
|
|
headers={'Authorization': 'Bearer %s' % access_token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get('mail') or data.get('userPrincipalName', '')
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Sync Engine — Pull (External → Odoo)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _sync_pull(self):
|
|
"""Pull events from the external calendar into Odoo."""
|
|
self.ensure_one()
|
|
_logger.warning("Sync pull starting for account %s (%s)", self.id, self.x_fc_email)
|
|
token = self._get_valid_token()
|
|
if not token:
|
|
_logger.error("No valid token for account %s (%s), skipping sync", self.id, self.x_fc_email)
|
|
return False
|
|
|
|
try:
|
|
if self.x_fc_provider == 'google':
|
|
return self._sync_pull_google(token)
|
|
elif self.x_fc_provider == 'microsoft':
|
|
return self._sync_pull_microsoft(token)
|
|
except requests.HTTPError as e:
|
|
error_msg = str(e)
|
|
if e.response is not None:
|
|
try:
|
|
error_msg = e.response.json().get('error', {}).get('message', str(e))
|
|
except Exception:
|
|
error_msg = e.response.text[:500]
|
|
_logger.exception("Sync pull error for account %s: %s", self.id, error_msg)
|
|
self.sudo().write({
|
|
'x_fc_sync_status': 'error',
|
|
'x_fc_error_message': error_msg[:500],
|
|
})
|
|
return False
|
|
except Exception as e:
|
|
_logger.exception("Sync pull error for account %s: %s", self.id, e)
|
|
self.sudo().write({
|
|
'x_fc_sync_status': 'error',
|
|
'x_fc_error_message': str(e)[:500],
|
|
})
|
|
return False
|
|
|
|
def _sync_pull_google(self, token):
|
|
"""Pull events from Google Calendar."""
|
|
self.ensure_one()
|
|
calendar_id = self.x_fc_calendar_id or 'primary'
|
|
url = '%s/calendars/%s/events' % (GOOGLE_CALENDAR_API, calendar_id)
|
|
params = {
|
|
'singleEvents': 'true',
|
|
'maxResults': 100,
|
|
}
|
|
|
|
if self.x_fc_sync_token:
|
|
params['syncToken'] = self.x_fc_sync_token
|
|
else:
|
|
time_min = (datetime.utcnow() - timedelta(days=14)).isoformat() + 'Z'
|
|
time_max = (datetime.utcnow() + timedelta(days=30)).isoformat() + 'Z'
|
|
params['timeMin'] = time_min
|
|
params['timeMax'] = time_max
|
|
|
|
headers = {'Authorization': 'Bearer %s' % token}
|
|
all_events = []
|
|
next_sync_token = self.x_fc_sync_token
|
|
|
|
while True:
|
|
resp = self._google_request_with_retry(url, params, headers)
|
|
|
|
if resp.status_code == 410:
|
|
_logger.info("Google sync token expired for account %s, doing full sync", self.id)
|
|
self.sudo().x_fc_sync_token = False
|
|
return self._sync_pull_google(token)
|
|
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
all_events.extend(data.get('items', []))
|
|
next_page_token = data.get('nextPageToken')
|
|
if next_page_token:
|
|
params['pageToken'] = next_page_token
|
|
params.pop('syncToken', None)
|
|
params.pop('timeMin', None)
|
|
params.pop('timeMax', None)
|
|
else:
|
|
next_sync_token = data.get('nextSyncToken', next_sync_token)
|
|
break
|
|
|
|
created = 0
|
|
updated = 0
|
|
deleted = 0
|
|
for event_data in all_events:
|
|
result = self._process_google_event(event_data)
|
|
if result == 'created':
|
|
created += 1
|
|
elif result == 'updated':
|
|
updated += 1
|
|
elif result == 'deleted':
|
|
deleted += 1
|
|
|
|
self.sudo().write({
|
|
'x_fc_sync_token': next_sync_token,
|
|
'x_fc_last_sync': fields.Datetime.now(),
|
|
'x_fc_sync_status': 'active',
|
|
'x_fc_error_message': False,
|
|
})
|
|
|
|
_logger.info(
|
|
"Google sync for account %s (%s): %d created, %d updated, %d deleted",
|
|
self.id, self.x_fc_email, created, updated, deleted,
|
|
)
|
|
return True
|
|
|
|
def _google_request_with_retry(self, url, params, headers):
|
|
"""GET request with automatic retry on 429 / 503 and connection errors."""
|
|
for attempt in range(MAX_THROTTLE_RETRIES + 1):
|
|
try:
|
|
resp = requests.get(url, params=params, headers=headers, timeout=TIMEOUT)
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
wait = min(DEFAULT_RETRY_SECONDS * (attempt + 1), 30)
|
|
_logger.warning(
|
|
"Google connection error for account %s: %s (retry in %ds, attempt %d/%d)",
|
|
self.id, e, wait, attempt + 1, MAX_THROTTLE_RETRIES,
|
|
)
|
|
if attempt < MAX_THROTTLE_RETRIES:
|
|
time.sleep(wait)
|
|
continue
|
|
raise
|
|
if resp.status_code not in (429, 503):
|
|
return resp
|
|
retry_after = int(resp.headers.get('Retry-After', DEFAULT_RETRY_SECONDS))
|
|
retry_after = min(retry_after, 60)
|
|
_logger.warning(
|
|
"Google throttled account %s (HTTP %s), retry in %ds (attempt %d/%d)",
|
|
self.id, resp.status_code, retry_after, attempt + 1, MAX_THROTTLE_RETRIES,
|
|
)
|
|
if attempt < MAX_THROTTLE_RETRIES:
|
|
time.sleep(retry_after)
|
|
return resp
|
|
|
|
def _silent_ctx(self):
|
|
"""Context flags to suppress all email notifications during sync."""
|
|
return {
|
|
'no_calendar_sync': True,
|
|
'dont_notify': True,
|
|
'mail_create_nosubscribe': True,
|
|
'mail_create_nolog': True,
|
|
'no_mail_notification': True,
|
|
'no_mail_to_attendees': True,
|
|
'skip_attendee_notification': True,
|
|
}
|
|
|
|
def _find_existing_event(self, CalendarEvent, vals):
|
|
"""Find an existing calendar event matching name+start+stop to avoid duplicates."""
|
|
start_val = vals.get('start') or vals.get('start_date')
|
|
stop_val = vals.get('stop') or vals.get('stop_date')
|
|
if not (start_val and stop_val and vals.get('name')):
|
|
return None
|
|
domain = [('name', '=', vals['name']), ('active', '=', True)]
|
|
if vals.get('allday'):
|
|
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
|
|
else:
|
|
domain += [('start', '=', start_val), ('stop', '=', stop_val)]
|
|
return CalendarEvent.search(domain, limit=1)
|
|
|
|
def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid):
|
|
"""Create or update a link between an Odoo event and an external event.
|
|
|
|
If this account already has a link to the same Odoo event, update the
|
|
external_id rather than creating a duplicate link row. Returns the
|
|
link record.
|
|
"""
|
|
existing = EventLink.search([
|
|
('x_fc_account_id', '=', self.id),
|
|
('x_fc_event_id', '=', odoo_event_id),
|
|
], limit=1)
|
|
now = fields.Datetime.now()
|
|
if existing:
|
|
existing.write({
|
|
'x_fc_external_id': external_id,
|
|
'x_fc_universal_id': ical_uid or existing.x_fc_universal_id,
|
|
'x_fc_last_synced': now,
|
|
})
|
|
return existing
|
|
return EventLink.create({
|
|
'x_fc_event_id': odoo_event_id,
|
|
'x_fc_account_id': self.id,
|
|
'x_fc_external_id': external_id,
|
|
'x_fc_universal_id': ical_uid,
|
|
'x_fc_sync_direction': 'pull',
|
|
'x_fc_last_synced': now,
|
|
})
|
|
|
|
def _process_google_event(self, event_data):
|
|
"""Process a single Google Calendar event."""
|
|
self.ensure_one()
|
|
external_id = event_data.get('id')
|
|
if not external_id:
|
|
return None
|
|
|
|
ctx = self._silent_ctx()
|
|
EventLink = self.env['fusion.calendar.event.link'].sudo()
|
|
CalendarEvent = self.env['calendar.event'].sudo().with_context(**ctx)
|
|
|
|
link = EventLink.search([
|
|
('x_fc_account_id', '=', self.id),
|
|
('x_fc_external_id', '=', external_id),
|
|
], limit=1)
|
|
|
|
status = event_data.get('status', 'confirmed')
|
|
if status == 'cancelled':
|
|
if link and link.x_fc_event_id:
|
|
link.x_fc_event_id.with_context(**ctx).write({'active': False})
|
|
link.unlink()
|
|
return 'deleted'
|
|
|
|
vals = self._google_event_to_odoo_vals(event_data)
|
|
if not vals:
|
|
return None
|
|
|
|
ical_uid = event_data.get('iCalUID', '')
|
|
|
|
if link:
|
|
if link.x_fc_event_id and link.x_fc_event_id.active:
|
|
link.x_fc_event_id.with_context(**ctx).write(vals)
|
|
link.write({'x_fc_last_synced': fields.Datetime.now()})
|
|
return 'updated'
|
|
|
|
existing_link = EventLink.search([
|
|
('x_fc_universal_id', '=', ical_uid),
|
|
('x_fc_universal_id', '!=', False),
|
|
], limit=1) if ical_uid else None
|
|
|
|
if existing_link and existing_link.x_fc_event_id:
|
|
self._upsert_event_link(EventLink, existing_link.x_fc_event_id.id, external_id, ical_uid)
|
|
return 'updated'
|
|
|
|
reuse_event = self._find_existing_event(CalendarEvent, vals)
|
|
if reuse_event:
|
|
self._upsert_event_link(EventLink, reuse_event.id, external_id, ical_uid)
|
|
return 'updated'
|
|
|
|
vals['partner_ids'] = [(4, self.x_fc_user_id.partner_id.id)]
|
|
event = CalendarEvent.create(vals)
|
|
self._upsert_event_link(EventLink, event.id, external_id, ical_uid)
|
|
return 'created'
|
|
|
|
def _google_event_to_odoo_vals(self, event_data):
|
|
"""Convert Google Calendar event dict to Odoo calendar.event vals."""
|
|
start_data = event_data.get('start', {})
|
|
end_data = event_data.get('end', {})
|
|
|
|
if not start_data or not end_data:
|
|
return None
|
|
|
|
allday = 'date' in start_data
|
|
if allday:
|
|
try:
|
|
start_dt = datetime.strptime(start_data['date'], '%Y-%m-%d')
|
|
end_dt = datetime.strptime(end_data['date'], '%Y-%m-%d') - timedelta(days=1)
|
|
except (ValueError, KeyError):
|
|
return None
|
|
vals = {
|
|
'start_date': start_dt.strftime('%Y-%m-%d'),
|
|
'stop_date': end_dt.strftime('%Y-%m-%d'),
|
|
'allday': True,
|
|
}
|
|
else:
|
|
try:
|
|
start_str = start_data.get('dateTime', '')
|
|
end_str = end_data.get('dateTime', '')
|
|
start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
|
|
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
|
|
# Convert to naive UTC for Odoo
|
|
start_utc = start_dt.astimezone(tz=None).replace(tzinfo=None) if start_dt.tzinfo else start_dt
|
|
end_utc = end_dt.astimezone(tz=None).replace(tzinfo=None) if end_dt.tzinfo else end_dt
|
|
except (ValueError, KeyError):
|
|
return None
|
|
vals = {
|
|
'start': start_utc,
|
|
'stop': end_utc,
|
|
'allday': False,
|
|
}
|
|
|
|
vals.update({
|
|
'name': event_data.get('summary', '(No Title)'),
|
|
'description': event_data.get('description', ''),
|
|
'location': event_data.get('location', ''),
|
|
'privacy': 'private' if event_data.get('visibility') == 'private' else 'public',
|
|
'show_as': 'busy' if event_data.get('transparency', 'opaque') == 'opaque' else 'free',
|
|
'x_fc_source_account_id': self.id,
|
|
})
|
|
return vals
|
|
|
|
def _sync_pull_microsoft(self, token):
|
|
"""Pull events from Microsoft Calendar."""
|
|
self.ensure_one()
|
|
headers = {
|
|
'Authorization': 'Bearer %s' % token,
|
|
'Content-Type': 'application/json',
|
|
'Prefer': 'odata.maxpagesize=50',
|
|
}
|
|
|
|
if self.x_fc_sync_token:
|
|
url = '%s/me/calendarView/delta?$select=%s&$deltatoken=%s' % (
|
|
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, self.x_fc_sync_token,
|
|
)
|
|
else:
|
|
start_dt = (datetime.utcnow() - timedelta(days=14)).strftime('%Y-%m-%dT00:00:00Z')
|
|
end_dt = (datetime.utcnow() + timedelta(days=30)).strftime('%Y-%m-%dT23:59:59Z')
|
|
url = '%s/me/calendarView/delta?$select=%s&startDateTime=%s&endDateTime=%s' % (
|
|
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt,
|
|
)
|
|
|
|
all_events = []
|
|
next_sync_token = self.x_fc_sync_token
|
|
page_num = 0
|
|
max_events = 5000 if self.x_fc_sync_token else 2000
|
|
|
|
while url:
|
|
page_num += 1
|
|
_logger.warning("MS sync account %s page %d: fetching...", self.id, page_num)
|
|
resp = self._microsoft_request_with_retry(url, headers)
|
|
_logger.warning("MS sync account %s page %d: HTTP %s, %d bytes", self.id, page_num, resp.status_code, len(resp.content))
|
|
|
|
if resp.status_code in (400, 410):
|
|
error_data = {}
|
|
try:
|
|
error_data = resp.json().get('error', {})
|
|
except Exception:
|
|
pass
|
|
error_code = error_data.get('code', '')
|
|
if 'fullSyncRequired' in error_code or 'SyncStateNotFound' in error_code:
|
|
_logger.warning("Microsoft sync token expired for account %s, doing full sync", self.id)
|
|
self.sudo().x_fc_sync_token = False
|
|
return self._sync_pull_microsoft(token)
|
|
resp.raise_for_status()
|
|
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
page_events = data.get('value', [])
|
|
all_events.extend(page_events)
|
|
_logger.warning("MS sync account %s page %d: %d events (total %d)", self.id, page_num, len(page_events), len(all_events))
|
|
|
|
if len(all_events) >= max_events:
|
|
_logger.warning(
|
|
"MS sync account %s: hit event limit (%d/%d), stopping fetch",
|
|
self.id, len(all_events), max_events,
|
|
)
|
|
break
|
|
|
|
url = data.get('@odata.nextLink')
|
|
if not url:
|
|
delta_link = data.get('@odata.deltaLink', '')
|
|
if '$deltatoken=' in delta_link:
|
|
next_sync_token = delta_link.split('$deltatoken=')[-1]
|
|
|
|
_logger.warning("MS sync account %s: processing %d events...", self.id, len(all_events))
|
|
created = 0
|
|
updated = 0
|
|
deleted = 0
|
|
for i, event_data in enumerate(all_events):
|
|
result = self._process_microsoft_event(event_data)
|
|
if result == 'created':
|
|
created += 1
|
|
elif result == 'updated':
|
|
updated += 1
|
|
elif result == 'deleted':
|
|
deleted += 1
|
|
if (i + 1) % 25 == 0:
|
|
_logger.warning("MS sync account %s: processed %d/%d events", self.id, i + 1, len(all_events))
|
|
|
|
self.sudo().write({
|
|
'x_fc_sync_token': next_sync_token,
|
|
'x_fc_last_sync': fields.Datetime.now(),
|
|
'x_fc_sync_status': 'active',
|
|
'x_fc_error_message': False,
|
|
})
|
|
|
|
_logger.warning(
|
|
"MS sync account %s (%s) DONE: %d created, %d updated, %d deleted",
|
|
self.id, self.x_fc_email, created, updated, deleted,
|
|
)
|
|
return True
|
|
|
|
def _microsoft_request_with_retry(self, url, headers):
|
|
"""GET request with automatic retry on 429 / 503 and connection errors."""
|
|
last_exc = None
|
|
for attempt in range(MAX_THROTTLE_RETRIES + 1):
|
|
try:
|
|
resp = requests.get(url, headers=headers, timeout=TIMEOUT)
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
last_exc = e
|
|
wait = min(DEFAULT_RETRY_SECONDS * (attempt + 1), 30)
|
|
_logger.warning(
|
|
"Microsoft connection error for account %s: %s (retry in %ds, attempt %d/%d)",
|
|
self.id, e, wait, attempt + 1, MAX_THROTTLE_RETRIES,
|
|
)
|
|
if attempt < MAX_THROTTLE_RETRIES:
|
|
time.sleep(wait)
|
|
continue
|
|
raise
|
|
if resp.status_code not in (429, 503):
|
|
return resp
|
|
retry_after = int(resp.headers.get('Retry-After', DEFAULT_RETRY_SECONDS))
|
|
retry_after = min(retry_after, 60)
|
|
_logger.warning(
|
|
"Microsoft throttled account %s (HTTP %s), retry in %ds (attempt %d/%d)",
|
|
self.id, resp.status_code, retry_after, attempt + 1, MAX_THROTTLE_RETRIES,
|
|
)
|
|
if attempt < MAX_THROTTLE_RETRIES:
|
|
time.sleep(retry_after)
|
|
return resp
|
|
|
|
def _process_microsoft_event(self, event_data):
|
|
"""Process a single Microsoft Calendar event."""
|
|
self.ensure_one()
|
|
external_id = event_data.get('id')
|
|
if not external_id:
|
|
return None
|
|
|
|
ctx = self._silent_ctx()
|
|
EventLink = self.env['fusion.calendar.event.link'].sudo()
|
|
CalendarEvent = self.env['calendar.event'].sudo().with_context(**ctx)
|
|
|
|
link = EventLink.search([
|
|
('x_fc_account_id', '=', self.id),
|
|
('x_fc_external_id', '=', external_id),
|
|
], limit=1)
|
|
|
|
if event_data.get('@removed') or event_data.get('isCancelled'):
|
|
if link and link.x_fc_event_id:
|
|
link.x_fc_event_id.with_context(**ctx).write({'active': False})
|
|
link.unlink()
|
|
return 'deleted'
|
|
|
|
vals = self._microsoft_event_to_odoo_vals(event_data)
|
|
if not vals:
|
|
return None
|
|
|
|
ical_uid = event_data.get('iCalUId', '')
|
|
|
|
if link:
|
|
if link.x_fc_event_id and link.x_fc_event_id.active:
|
|
link.x_fc_event_id.with_context(**ctx).write(vals)
|
|
link.write({'x_fc_last_synced': fields.Datetime.now()})
|
|
return 'updated'
|
|
|
|
existing_link = EventLink.search([
|
|
('x_fc_universal_id', '=', ical_uid),
|
|
('x_fc_universal_id', '!=', False),
|
|
], limit=1) if ical_uid else None
|
|
|
|
if existing_link and existing_link.x_fc_event_id:
|
|
self._upsert_event_link(EventLink, existing_link.x_fc_event_id.id, external_id, ical_uid)
|
|
return 'updated'
|
|
|
|
reuse_event = self._find_existing_event(CalendarEvent, vals)
|
|
if reuse_event:
|
|
self._upsert_event_link(EventLink, reuse_event.id, external_id, ical_uid)
|
|
return 'updated'
|
|
|
|
vals['partner_ids'] = [(4, self.x_fc_user_id.partner_id.id)]
|
|
event = CalendarEvent.create(vals)
|
|
self._upsert_event_link(EventLink, event.id, external_id, ical_uid)
|
|
return 'created'
|
|
|
|
def _microsoft_event_to_odoo_vals(self, event_data):
|
|
"""Convert Microsoft Graph API event dict to Odoo calendar.event vals."""
|
|
start_data = event_data.get('start', {})
|
|
end_data = event_data.get('end', {})
|
|
if not start_data or not end_data:
|
|
return None
|
|
|
|
allday = event_data.get('isAllDay', False)
|
|
|
|
try:
|
|
if allday:
|
|
start_dt = datetime.fromisoformat(start_data['dateTime'][:10])
|
|
end_dt = datetime.fromisoformat(end_data['dateTime'][:10]) - timedelta(days=1)
|
|
vals = {
|
|
'start_date': start_dt.strftime('%Y-%m-%d'),
|
|
'stop_date': end_dt.strftime('%Y-%m-%d'),
|
|
'allday': True,
|
|
}
|
|
else:
|
|
# Microsoft returns dateTime in UTC with timeZone field
|
|
start_str = start_data.get('dateTime', '')
|
|
end_str = end_data.get('dateTime', '')
|
|
# Remove fractional seconds if present and parse
|
|
start_utc = datetime.fromisoformat(start_str.split('.')[0])
|
|
end_utc = datetime.fromisoformat(end_str.split('.')[0])
|
|
vals = {
|
|
'start': start_utc,
|
|
'stop': end_utc,
|
|
'allday': False,
|
|
}
|
|
except (ValueError, KeyError):
|
|
return None
|
|
|
|
subject = event_data.get('subject') or ''
|
|
description = event_data.get('bodyPreview') or ''
|
|
if not description:
|
|
body = event_data.get('body', {})
|
|
if body.get('contentType') == 'text':
|
|
description = body.get('content', '')
|
|
location_data = event_data.get('location', {})
|
|
location = location_data.get('displayName', '') if location_data else ''
|
|
sensitivity = event_data.get('sensitivity', 'normal')
|
|
show_as = event_data.get('showAs', 'busy')
|
|
|
|
if not subject:
|
|
subject = self._fetch_microsoft_event_subject(event_data.get('id'))
|
|
|
|
if not subject:
|
|
subject = '(No Title)'
|
|
|
|
vals.update({
|
|
'name': subject,
|
|
'description': description,
|
|
'location': location,
|
|
'privacy': 'private' if sensitivity == 'private' else 'public',
|
|
'show_as': 'busy' if show_as in ('busy', 'tentative', 'oof') else 'free',
|
|
'x_fc_source_account_id': self.id,
|
|
})
|
|
return vals
|
|
|
|
def _fetch_microsoft_event_subject(self, event_id):
|
|
"""Fetch subject from the full event when delta response omits it."""
|
|
if not event_id:
|
|
return ''
|
|
try:
|
|
token = self.sudo().x_fc_token
|
|
if not token:
|
|
return ''
|
|
resp = requests.get(
|
|
'%s/me/events/%s?$select=subject' % (MICROSOFT_GRAPH_API, event_id),
|
|
headers={'Authorization': 'Bearer %s' % token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json().get('subject', '')
|
|
except Exception:
|
|
pass
|
|
return ''
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Sync Engine — Push (Odoo → External)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _sync_push_event(self, event):
|
|
"""Push an Odoo calendar event to this external calendar."""
|
|
self.ensure_one()
|
|
token = self._get_valid_token()
|
|
if not token:
|
|
return False
|
|
|
|
EventLink = self.env['fusion.calendar.event.link'].sudo()
|
|
|
|
# Check if already linked
|
|
link = EventLink.search([
|
|
('x_fc_event_id', '=', event.id),
|
|
('x_fc_account_id', '=', self.id),
|
|
], limit=1)
|
|
|
|
try:
|
|
if self.x_fc_provider == 'google':
|
|
if link:
|
|
self._google_patch_event(link.x_fc_external_id, event, token)
|
|
link.write({'x_fc_last_synced': fields.Datetime.now()})
|
|
else:
|
|
external_id, ical_uid = self._google_insert_event(event, token)
|
|
EventLink.create({
|
|
'x_fc_event_id': event.id,
|
|
'x_fc_account_id': self.id,
|
|
'x_fc_external_id': external_id,
|
|
'x_fc_universal_id': ical_uid,
|
|
'x_fc_sync_direction': 'push',
|
|
'x_fc_last_synced': fields.Datetime.now(),
|
|
})
|
|
elif self.x_fc_provider == 'microsoft':
|
|
if link:
|
|
self._microsoft_patch_event(link.x_fc_external_id, event, token)
|
|
link.write({'x_fc_last_synced': fields.Datetime.now()})
|
|
else:
|
|
external_id, ical_uid = self._microsoft_insert_event(event, token)
|
|
EventLink.create({
|
|
'x_fc_event_id': event.id,
|
|
'x_fc_account_id': self.id,
|
|
'x_fc_external_id': external_id,
|
|
'x_fc_universal_id': ical_uid,
|
|
'x_fc_sync_direction': 'push',
|
|
'x_fc_last_synced': fields.Datetime.now(),
|
|
})
|
|
return True
|
|
except Exception as e:
|
|
_logger.warning("Failed to push event %s to account %s: %s", event.id, self.id, e)
|
|
return False
|
|
|
|
def _google_insert_event(self, event, token):
|
|
"""Create a new event on Google Calendar."""
|
|
calendar_id = self.x_fc_calendar_id or 'primary'
|
|
url = '%s/calendars/%s/events' % (GOOGLE_CALENDAR_API, calendar_id)
|
|
values = self._odoo_event_to_google(event)
|
|
resp = requests.post(
|
|
url, json=values,
|
|
headers={'Authorization': 'Bearer %s' % token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get('id', ''), data.get('iCalUID', '')
|
|
|
|
def _google_patch_event(self, external_id, event, token):
|
|
"""Update an existing event on Google Calendar."""
|
|
calendar_id = self.x_fc_calendar_id or 'primary'
|
|
url = '%s/calendars/%s/events/%s' % (GOOGLE_CALENDAR_API, calendar_id, external_id)
|
|
values = self._odoo_event_to_google(event)
|
|
resp = requests.patch(
|
|
url, json=values,
|
|
headers={'Authorization': 'Bearer %s' % token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _google_delete_event(self, external_id, token):
|
|
"""Delete an event from Google Calendar."""
|
|
calendar_id = self.x_fc_calendar_id or 'primary'
|
|
url = '%s/calendars/%s/events/%s' % (GOOGLE_CALENDAR_API, calendar_id, external_id)
|
|
resp = requests.delete(
|
|
url,
|
|
headers={'Authorization': 'Bearer %s' % token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
if resp.status_code not in (204, 404, 410):
|
|
resp.raise_for_status()
|
|
|
|
def _microsoft_insert_event(self, event, token):
|
|
"""Create a new event on Microsoft Calendar."""
|
|
url = '%s/me/events' % MICROSOFT_GRAPH_API
|
|
values = self._odoo_event_to_microsoft(event)
|
|
resp = requests.post(
|
|
url, json=values,
|
|
headers={
|
|
'Authorization': 'Bearer %s' % token,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout=TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get('id', ''), data.get('iCalUId', '')
|
|
|
|
def _microsoft_patch_event(self, external_id, event, token):
|
|
"""Update an existing event on Microsoft Calendar."""
|
|
url = '%s/me/events/%s' % (MICROSOFT_GRAPH_API, external_id)
|
|
values = self._odoo_event_to_microsoft(event)
|
|
resp = requests.patch(
|
|
url, json=values,
|
|
headers={
|
|
'Authorization': 'Bearer %s' % token,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout=TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _microsoft_delete_event(self, external_id, token):
|
|
"""Delete an event from Microsoft Calendar."""
|
|
url = '%s/me/events/%s' % (MICROSOFT_GRAPH_API, external_id)
|
|
resp = requests.delete(
|
|
url,
|
|
headers={'Authorization': 'Bearer %s' % token},
|
|
timeout=TIMEOUT,
|
|
)
|
|
if resp.status_code not in (204, 404):
|
|
resp.raise_for_status()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Event Format Conversion (Odoo → External)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _odoo_event_to_google(self, event):
|
|
"""Convert Odoo calendar.event to Google Calendar API format."""
|
|
values = {
|
|
'summary': event.name or '',
|
|
'description': event.description or '',
|
|
'location': event.location or '',
|
|
}
|
|
if event.allday:
|
|
values['start'] = {'date': event.start_date.strftime('%Y-%m-%d')}
|
|
# Google end date is exclusive
|
|
values['end'] = {'date': (event.stop_date + timedelta(days=1)).strftime('%Y-%m-%d')}
|
|
else:
|
|
values['start'] = {'dateTime': event.start.strftime('%Y-%m-%dT%H:%M:%S'), 'timeZone': 'UTC'}
|
|
values['end'] = {'dateTime': event.stop.strftime('%Y-%m-%dT%H:%M:%S'), 'timeZone': 'UTC'}
|
|
|
|
if event.privacy == 'private':
|
|
values['visibility'] = 'private'
|
|
if hasattr(event, 'show_as') and event.show_as == 'free':
|
|
values['transparency'] = 'transparent'
|
|
else:
|
|
values['transparency'] = 'opaque'
|
|
|
|
return values
|
|
|
|
def _odoo_event_to_microsoft(self, event):
|
|
"""Convert Odoo calendar.event to Microsoft Graph API format."""
|
|
values = {
|
|
'subject': event.name or '',
|
|
'body': {
|
|
'contentType': 'text',
|
|
'content': event.description or '',
|
|
},
|
|
}
|
|
if event.location:
|
|
values['location'] = {'displayName': event.location}
|
|
|
|
if event.allday:
|
|
values['isAllDay'] = True
|
|
values['start'] = {
|
|
'dateTime': event.start_date.strftime('%Y-%m-%dT00:00:00'),
|
|
'timeZone': 'UTC',
|
|
}
|
|
values['end'] = {
|
|
'dateTime': (event.stop_date + timedelta(days=1)).strftime('%Y-%m-%dT00:00:00'),
|
|
'timeZone': 'UTC',
|
|
}
|
|
else:
|
|
values['start'] = {
|
|
'dateTime': event.start.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
'timeZone': 'UTC',
|
|
}
|
|
values['end'] = {
|
|
'dateTime': event.stop.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
'timeZone': 'UTC',
|
|
}
|
|
|
|
if event.privacy == 'private':
|
|
values['sensitivity'] = 'private'
|
|
if hasattr(event, 'show_as') and event.show_as == 'free':
|
|
values['showAs'] = 'free'
|
|
else:
|
|
values['showAs'] = 'busy'
|
|
|
|
return values
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Cross-Calendar Busy Blocking
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _cross_calendar_push(self):
|
|
"""Push Odoo-native events to the FIRST active external calendar only.
|
|
|
|
Only pushes events that were created in Odoo (not pulled from any
|
|
external calendar) to avoid a pull->push->pull feedback loop.
|
|
Pushes to a single calendar to prevent duplicates across calendars.
|
|
"""
|
|
self.ensure_one()
|
|
user = self.x_fc_user_id
|
|
all_accounts = self.sudo().search([
|
|
('x_fc_user_id', '=', user.id),
|
|
('x_fc_active', '=', True),
|
|
('x_fc_sync_status', '=', 'active'),
|
|
], order='id ASC')
|
|
if not all_accounts:
|
|
return
|
|
|
|
target_account = all_accounts[0]
|
|
|
|
EventLink = self.env['fusion.calendar.event.link'].sudo()
|
|
events = self.env['calendar.event'].sudo().search([
|
|
('partner_ids', 'in', [user.partner_id.id]),
|
|
('start', '>=', fields.Datetime.now() - timedelta(days=1)),
|
|
('start', '<=', fields.Datetime.now() + timedelta(days=90)),
|
|
('active', '=', True),
|
|
])
|
|
|
|
for event in events:
|
|
existing_links = EventLink.search([('x_fc_event_id', '=', event.id)])
|
|
if existing_links:
|
|
continue
|
|
try:
|
|
target_account._sync_push_event(event)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Failed cross-calendar push of event %s to account %s: %s",
|
|
event.id, target_account.id, e,
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Backend RPC Methods
|
|
# -------------------------------------------------------------------------
|
|
|
|
@api.model
|
|
def get_user_accounts_status(self):
|
|
"""Return connected calendar accounts for the current user (called from backend calendar)."""
|
|
accounts = self.sudo().search([
|
|
('x_fc_user_id', '=', self.env.user.id),
|
|
('x_fc_active', '=', True),
|
|
])
|
|
return [{
|
|
'id': a.id,
|
|
'email': a.x_fc_email,
|
|
'provider': a.x_fc_provider,
|
|
'status': a.x_fc_sync_status,
|
|
'last_sync': str(a.x_fc_last_sync) if a.x_fc_last_sync else False,
|
|
} for a in accounts]
|
|
|
|
@api.model
|
|
def sync_current_user(self):
|
|
"""Trigger immediate sync for the current user's accounts (called from backend calendar)."""
|
|
accounts = self.sudo().search([
|
|
('x_fc_user_id', '=', self.env.user.id),
|
|
('x_fc_active', '=', True),
|
|
('x_fc_sync_status', 'in', ['active', 'error']),
|
|
('x_fc_rtoken', '!=', False),
|
|
])
|
|
if not accounts:
|
|
return {'success': False, 'error': 'No connected calendar accounts found.'}
|
|
|
|
synced = 0
|
|
errors = []
|
|
for account in accounts:
|
|
try:
|
|
account._sync_pull()
|
|
self.env.cr.commit()
|
|
synced += 1
|
|
except Exception as e:
|
|
_logger.exception("Manual sync error for account %s: %s", account.id, e)
|
|
self.env.cr.rollback()
|
|
errors.append('%s: %s' % (account.x_fc_email, str(e)[:100]))
|
|
|
|
if errors:
|
|
return {
|
|
'success': synced > 0,
|
|
'message': 'Synced %d account(s). Errors: %s' % (synced, '; '.join(errors)),
|
|
}
|
|
return {'success': True, 'message': 'Synced %d account(s) successfully.' % synced}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Cron Job
|
|
# -------------------------------------------------------------------------
|
|
|
|
@api.model
|
|
def _cron_sync_all_accounts(self):
|
|
"""Cron entry point: sync all active accounts."""
|
|
all_accounts = self.sudo().search([
|
|
('x_fc_active', '=', True),
|
|
('x_fc_sync_status', 'in', ['active', 'error']),
|
|
('x_fc_rtoken', '!=', False),
|
|
])
|
|
never_synced = all_accounts.filtered(lambda a: not a.x_fc_last_sync)
|
|
previously_synced = all_accounts - never_synced
|
|
accounts = never_synced + previously_synced
|
|
|
|
users_synced = set()
|
|
|
|
for account in accounts:
|
|
try:
|
|
account._sync_pull()
|
|
self.env.cr.commit()
|
|
users_synced.add(account.x_fc_user_id.id)
|
|
except Exception as e:
|
|
_logger.exception("Cron sync error for account %s: %s", account.id, e)
|
|
self.env.cr.rollback()
|
|
try:
|
|
account.sudo().write({
|
|
'x_fc_sync_status': 'error',
|
|
'x_fc_error_message': str(e)[:500],
|
|
})
|
|
self.env.cr.commit()
|
|
except Exception:
|
|
self.env.cr.rollback()
|
|
|
|
# Cross-calendar push for each user
|
|
for user_id in users_synced:
|
|
user_accounts = accounts.filtered(lambda a: a.x_fc_user_id.id == user_id)
|
|
if len(user_accounts) > 1:
|
|
try:
|
|
user_accounts[0]._cross_calendar_push()
|
|
self.env.cr.commit()
|
|
except Exception as e:
|
|
_logger.exception("Cross-calendar push error for user %s: %s", user_id, e)
|
|
self.env.cr.rollback()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Delete External Events on Account Disconnect
|
|
# -------------------------------------------------------------------------
|
|
|
|
def action_disconnect(self):
|
|
"""Disconnect this calendar account and clean up."""
|
|
self.ensure_one()
|
|
# Remove all pushed events from external calendar
|
|
pushed_links = self.x_fc_link_ids.filtered(lambda l: l.x_fc_sync_direction == 'push')
|
|
token = False
|
|
try:
|
|
token = self._get_valid_token()
|
|
except Exception:
|
|
pass
|
|
|
|
if token:
|
|
for link in pushed_links:
|
|
try:
|
|
if self.x_fc_provider == 'google':
|
|
self._google_delete_event(link.x_fc_external_id, token)
|
|
elif self.x_fc_provider == 'microsoft':
|
|
self._microsoft_delete_event(link.x_fc_external_id, token)
|
|
except Exception as e:
|
|
_logger.warning("Failed to delete external event %s: %s", link.x_fc_external_id, e)
|
|
|
|
# Clean up
|
|
self.x_fc_link_ids.unlink()
|
|
self.write({
|
|
'x_fc_active': False,
|
|
'x_fc_rtoken': False,
|
|
'x_fc_token': False,
|
|
'x_fc_sync_token': False,
|
|
'x_fc_sync_status': 'paused',
|
|
})
|