Files
Odoo-Modules/fusion_schedule/models/fusion_calendar_account.py
gsinghpal e56974d46f update
2026-03-16 08:14:56 -04:00

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',
})