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