caldav_sync: Added some error handling for almost-correct calendar URLs.

This commit is contained in:
Marc Durepos 2024-06-22 09:43:47 -04:00
parent 8410bb64d7
commit cd03903ea4
2 changed files with 195 additions and 139 deletions

View file

@ -7,28 +7,28 @@
# For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html # For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html
{ {
'name': 'CalDAV Synchronization', "name": "CalDAV Synchronization",
'version': '17.0.0.5.3', "version": "17.0.0.5.4",
'license': 'LGPL-3', "license": "LGPL-3",
'category': 'Productivity', "category": "Productivity",
'summary': 'Synchronize Odoo Calendar Events with CalDAV Servers', "summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
'description': """ "description": """
This module allows Odoo to synchronize calendar events with CalDAV servers. This module allows Odoo to synchronize calendar events with CalDAV servers.
Users can synchronize their Odoo calendar with external applications like Users can synchronize their Odoo calendar with external applications like
Apple Calendar or Thunderbird using CalDAV protocol. Apple Calendar or Thunderbird using CalDAV protocol.
""", """,
'author': 'Bemade Inc.', "author": "Bemade Inc.",
'website': 'https://www.bemade.org', "website": "https://www.bemade.org",
'depends': ['base', 'calendar'], "depends": ["base", "calendar"],
'external_dependencies': { "external_dependencies": {
'python': ['caldav', 'icalendar', 'bs4'], "python": ["caldav", "icalendar", "bs4"],
}, },
'images': ['static/description/images/main_screenshot.png'], "images": ["static/description/images/main_screenshot.png"],
'data': [ "data": [
'views/res_users_views.xml', "views/res_users_views.xml",
'data/caldav_sync_data.xml', "data/caldav_sync_data.xml",
], ],
'installable': True, "installable": True,
'application': True, "application": True,
'auto_install': False, "auto_install": False,
} }

View file

@ -1,11 +1,11 @@
import uuid import uuid
from odoo import models, api, fields from odoo import models, api, fields
from odoo.exceptions import UserError
import caldav import caldav
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from icalendar import Calendar, Event, vCalAddress, vText, vWeekday from icalendar import Calendar, Event, vCalAddress, vText
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from datetime import timedelta
import re import re
from pytz import timezone, utc from pytz import timezone, utc
@ -33,26 +33,26 @@ def _parse_rrule_string(rrule_str):
regex = re.compile(regex_str) regex = re.compile(regex_str)
params_match = regex.search(rrule_str) params_match = regex.search(rrule_str)
params_part = params_match.groups()[0] params_part = params_match.groups()[0]
params = params_part.split(';') params = params_part.split(";")
params_dict = {} params_dict = {}
for param in params: for param in params:
parts = param.split('=') parts = param.split("=")
params_dict.update({parts[0]: try_to_int(parts[1])}) params_dict.update({parts[0]: try_to_int(parts[1])})
return params_dict return params_dict
class CalendarEvent(models.Model): class CalendarEvent(models.Model):
_inherit = 'calendar.event' _inherit = "calendar.event"
caldav_uid = fields.Char(string='CalDAV UID', readonly=True) caldav_uid = fields.Char(string="CalDAV UID", readonly=True)
caldav_recurrence_id = fields.Char(string='CalDAV Recurrence ID', readonly=True) caldav_recurrence_id = fields.Char(string="CalDAV Recurrence ID", readonly=True)
@api.model @api.model
def create(self, vals): def create(self, vals):
if not vals.get('caldav_uid'): if not vals.get("caldav_uid"):
vals['caldav_uid'] = str(uuid.uuid4()) vals["caldav_uid"] = str(uuid.uuid4())
event = super(CalendarEvent, self).create(vals) event = super(CalendarEvent, self).create(vals)
if not self.env.context.get('caldav_no_sync'): if not self.env.context.get("caldav_no_sync"):
try: try:
_logger.debug(f"Creating event {event.name} in CalDAV") _logger.debug(f"Creating event {event.name} in CalDAV")
event.sync_create_to_caldav() event.sync_create_to_caldav()
@ -62,7 +62,7 @@ class CalendarEvent(models.Model):
def write(self, vals): def write(self, vals):
res = super(CalendarEvent, self).write(vals) res = super(CalendarEvent, self).write(vals)
if not self.env.context.get('caldav_no_sync') and self.ids: if not self.env.context.get("caldav_no_sync") and self.ids:
for rec in self: for rec in self:
try: try:
_logger.debug(f"Updating event {self.name} in CalDAV") _logger.debug(f"Updating event {self.name} in CalDAV")
@ -72,7 +72,7 @@ class CalendarEvent(models.Model):
return res return res
def unlink(self): def unlink(self):
if not self.env.context.get('caldav_no_sync'): if not self.env.context.get("caldav_no_sync"):
for event in self: for event in self:
try: try:
_logger.debug(f"Removing event {event.name} from CalDAV") _logger.debug(f"Removing event {event.name} from CalDAV")
@ -89,7 +89,7 @@ class CalendarEvent(models.Model):
return caldav.DAVClient( return caldav.DAVClient(
url=user.caldav_calendar_url, url=user.caldav_calendar_url,
username=user.caldav_username, username=user.caldav_username,
password=user.caldav_password password=user.caldav_password,
) )
def sync_create_to_caldav(self): def sync_create_to_caldav(self):
@ -104,7 +104,9 @@ class CalendarEvent(models.Model):
caldav_event = calendar.add_event(ical_event) caldav_event = calendar.add_event(ical_event)
caldav_uid = caldav_event.vobject_instance.vevent.uid.value caldav_uid = caldav_event.vobject_instance.vevent.uid.value
_logger.debug(f"New CalDAV UID: {caldav_uid}") _logger.debug(f"New CalDAV UID: {caldav_uid}")
event.with_context(caldav_no_sync=True).write({'caldav_uid': caldav_uid}) event.with_context(caldav_no_sync=True).write(
{"caldav_uid": caldav_uid}
)
except Exception as e: except Exception as e:
_logger.error(f"Failed to sync event to CalDAV server: {e}") _logger.error(f"Failed to sync event to CalDAV server: {e}")
@ -133,51 +135,60 @@ class CalendarEvent(models.Model):
caldav_event = calendar.object_by_uid(event.caldav_uid) caldav_event = calendar.object_by_uid(event.caldav_uid)
caldav_event.delete() caldav_event.delete()
except caldav.error.NotFoundError: except caldav.error.NotFoundError:
_logger.warning(f"CalDAV event {event.caldav_uid} not found on server.") _logger.warning(
f"CalDAV event {event.caldav_uid} not found on server."
)
except Exception as e: except Exception as e:
_logger.error(f"Failed to remove event from CalDAV server: {e}") _logger.error(f"Failed to remove event from CalDAV server: {e}")
def _get_icalendar(self): def _get_icalendar(self):
calendar = Calendar() calendar = Calendar()
calendar.add('prodid', '-//Odoo//mxm.dk//') calendar.add("prodid", "-//Odoo//mxm.dk//")
calendar.add('version', '2.0') calendar.add("version", "2.0")
for event in self: for event in self:
user_tz = timezone('UTC') user_tz = timezone("UTC")
if event.user_id.tz: if event.user_id.tz:
user_tz = timezone(event.user_id.tz) user_tz = timezone(event.user_id.tz)
ical_event = Event() ical_event = Event()
ical_event.add('uid', event.caldav_uid) ical_event.add("uid", event.caldav_uid)
ical_event.add('dtstamp', utc.localize(event.write_date).astimezone(user_tz)) ical_event.add(
"dtstamp", utc.localize(event.write_date).astimezone(user_tz)
)
if event.name: if event.name:
ical_event.add('summary', event.name) ical_event.add("summary", event.name)
if event.description and self._html_to_text(event.description): if event.description and self._html_to_text(event.description):
ical_event.add('description', self._html_to_text(event.description)) ical_event.add("description", self._html_to_text(event.description))
if event.location: if event.location:
ical_event.add('location', event.location) ical_event.add("location", event.location)
if event.videocall_location: if event.videocall_location:
ical_event.add('CONFERENCE', event.videocall_location) ical_event.add("CONFERENCE", event.videocall_location)
for partner in event.partner_ids: for partner in event.partner_ids:
if partner == event.user_id.partner_id: if partner == event.user_id.partner_id:
continue continue
attendee = vCalAddress(f'MAILTO:{partner.email}') attendee = vCalAddress(f"MAILTO:{partner.email}")
attendee.params['cn'] = vText(partner.name) attendee.params["cn"] = vText(partner.name)
attendee_record = self.env['calendar.attendee'].search([('event_id', '=', event.id), ('partner_id', '=', partner.id)], limit=1) attendee_record = self.env["calendar.attendee"].search(
[("event_id", "=", event.id), ("partner_id", "=", partner.id)],
limit=1,
)
if attendee_record: if attendee_record:
attendee.params['partstat'] = vText(self._map_attendee_status(attendee_record.state)) attendee.params["partstat"] = vText(
ical_event.add('attendee', attendee, encode=0) self._map_attendee_status(attendee_record.state)
)
ical_event.add("attendee", attendee, encode=0)
organizer = vCalAddress(f"MAILTO:{event.user_id.email}") organizer = vCalAddress(f"MAILTO:{event.user_id.email}")
organizer.params['cn'] = event.user_id.name organizer.params["cn"] = event.user_id.name
ical_event.add('organizer', organizer) ical_event.add("organizer", organizer)
# Add RRULE if the event is recurrent # Add RRULE if the event is recurrent
if event.recurrency and event.recurrence_id: if event.recurrency and event.recurrence_id:
rrule = event.recurrence_id._get_rrule() rrule = event.recurrence_id._get_rrule()
rrule_dict = _parse_rrule_string(str(rrule)) rrule_dict = _parse_rrule_string(str(rrule))
ical_event.add('rrule', rrule_dict) ical_event.add("rrule", rrule_dict)
# Add DTSTART and DTEND # Add DTSTART and DTEND
ical_event.add('dtstart', utc.localize(event.start).astimezone(user_tz)) ical_event.add("dtstart", utc.localize(event.start).astimezone(user_tz))
ical_event.add('dtend', utc.localize(event.stop).astimezone(user_tz)) ical_event.add("dtend", utc.localize(event.stop).astimezone(user_tz))
calendar.add_component(ical_event) calendar.add_component(ical_event)
@ -185,7 +196,9 @@ class CalendarEvent(models.Model):
@api.model @api.model
def poll_caldav_server(self): def poll_caldav_server(self):
all_users = self.env['res.users'].search([]).filtered(lambda u: u.is_caldav_enabled()) all_users = (
self.env["res.users"].search([]).filtered(lambda u: u.is_caldav_enabled())
)
for user in all_users: for user in all_users:
self.with_user(user).poll_user_caldav_server() self.with_user(user).poll_user_caldav_server()
@ -194,11 +207,23 @@ class CalendarEvent(models.Model):
if not self._is_caldav_enabled(): if not self._is_caldav_enabled():
return return
client = self._get_caldav_client() client = self._get_caldav_client()
calendar = client.calendar(url=self.env.user.caldav_calendar_url) try:
events = calendar.events() calendar = client.calendar(url=self.env.user.caldav_calendar_url)
events = calendar.events()
except Exception as e:
_logger.warning(e)
principal = client.principal()
msg = f"""Failed to connect to the calendar, but successfully connected to the
server at {client.url}.
You may need to select another calendar URL from those below.
Available calendars:
"""
for calendar in principal.calendars():
msg += f"{calendar.name}: {calendar.url}\n"
raise UserError(msg)
caldav_uids = set() caldav_uids = set()
now = datetime.now()
one_year_later = now + timedelta(days=365)
_logger.info(f"Polling CalDAV server for user {self.env.user.name}") _logger.info(f"Polling CalDAV server for user {self.env.user.name}")
@ -207,40 +232,43 @@ class CalendarEvent(models.Model):
self.sync_event_from_ical(ical_event) self.sync_event_from_ical(ical_event)
for component in ical_event.subcomponents: for component in ical_event.subcomponents:
if isinstance(component, Event): if isinstance(component, Event):
uid = str(component.get('uid')) uid = str(component.get("uid"))
recurrence_id = str(component.get('recurrence-id')) recurrence_id = str(component.get("recurrence-id"))
if recurrence_id == 'None': if recurrence_id == "None":
recurrence_id = '' recurrence_id = ""
caldav_uids.add(f"{uid}{recurrence_id}") caldav_uids.add(f"{uid}{recurrence_id}")
_logger.info(f"CalDAV UIDs fetched: {caldav_uids}") _logger.info(f"CalDAV UIDs fetched: {caldav_uids}")
# Remove Odoo events that no longer exist on the CalDAV server # Remove Odoo events that no longer exist on the CalDAV server
odoo_events = self.search([('caldav_uid', '!=', False)]) odoo_events = self.search([("caldav_uid", "!=", False)])
for event in odoo_events: for event in odoo_events:
recurrence_id = event.caldav_recurrence_id or '' recurrence_id = event.caldav_recurrence_id or ""
event_uid = f"{event.caldav_uid}{event.caldav_recurrence_id or ''}" event_uid = f"{event.caldav_uid}{event.caldav_recurrence_id or ''}"
if event_uid not in caldav_uids: if event_uid not in caldav_uids:
_logger.info(f"Deleting orphan event {event.name} with UID {event.caldav_uid} " _logger.info(
f"and Recurrence ID {event.caldav_recurrence_id or ''}") f"Deleting orphan event {event.name} with UID {event.caldav_uid} "
f"and Recurrence ID {event.caldav_recurrence_id or ''}"
)
event.with_context(caldav_no_sync=True).unlink() event.with_context(caldav_no_sync=True).unlink()
@api.model @api.model
def _get_existing_instance(self, uid, recurrence_id): def _get_existing_instance(self, uid, recurrence_id):
instance = self.env['calendar.event'].search([ instance = self.env["calendar.event"].search(
('caldav_uid', '=', uid), [("caldav_uid", "=", uid), ("recurrence_id", "=", recurrence_id)]
('recurrence_id', '=', recurrence_id) )
]) return instance or self.env["calendar.event"].search(
return instance or self.env['calendar.event'].search([ [
('caldav_uid', '=', 'uid'), ("caldav_uid", "=", "uid"),
('recurrence_id', '=', False), ("recurrence_id", "=", False),
]) ]
)
def _get_recurrency_values_from_ical_event(self, component): def _get_recurrency_values_from_ical_event(self, component):
""" Match the fields from calendar.event (recurring fields) to the fields specified in RRULE at """Match the fields from calendar.event (recurring fields) to the fields specified in RRULE at
https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html """ https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html"""
rrule = component.get('rrule') rrule = component.get("rrule")
if not rrule: if not rrule:
if not self.recurrency: if not self.recurrency:
# No change, this was already not a recurring event # No change, this was already not a recurring event
@ -249,50 +277,59 @@ class CalendarEvent(models.Model):
# This was a recurring event and has been made non-recurring # This was a recurring event and has been made non-recurring
if self.recurrence_id.base_event_id != self: if self.recurrence_id.base_event_id != self:
# This is not the base event, so change its recurrency only # This is not the base event, so change its recurrency only
return {'recurrence_update': 'self_only', 'recurrency': False, 'follow_recurrence': False} return {
"recurrence_update": "self_only",
"recurrency": False,
"follow_recurrence": False,
}
else: else:
# This is the base event, so change all events in the list # This is the base event, so change all events in the list
return {'recurrence_update': 'all_events', 'recurrency': False} return {"recurrence_update": "all_events", "recurrency": False}
rrule_str = rrule.to_ical().decode('utf-8') rrule_str = rrule.to_ical().decode("utf-8")
sequence = component.get('sequence') sequence = component.get("sequence")
if sequence and sequence != 0: if sequence and sequence != 0:
# This is not the base event so we can't change recurrence properties # This is not the base event so we can't change recurrence properties
return {} return {}
caldav_recurrence_id = component.get('recurrence-id') caldav_recurrence_id = component.get("recurrence-id")
rrule_params = self.env['calendar.recurrence']._rrule_parse(rrule_str, component.decoded('dtstart')) rrule_params = self.env["calendar.recurrence"]._rrule_parse(
rrule_str, component.decoded("dtstart")
)
vals = { vals = {
'recurrency': True, "recurrency": True,
'follow_recurrence': True, "follow_recurrence": True,
'caldav_recurrence_id': caldav_recurrence_id, "caldav_recurrence_id": caldav_recurrence_id,
'recurrence_update': 'all_events', "recurrence_update": "all_events",
'rrule_type': rrule_params.get('rrule_type'), "rrule_type": rrule_params.get("rrule_type"),
'end_type': rrule_params.get('end_type'), "end_type": rrule_params.get("end_type"),
'interval': rrule_params.get('interval'), "interval": rrule_params.get("interval"),
'count': rrule_params.get('count'), "count": rrule_params.get("count"),
'month_by': rrule_params.get('monty_by'), "month_by": rrule_params.get("monty_by"),
'day': rrule_params.get('day'), "day": rrule_params.get("day"),
'byday': rrule_params.get('byday'), "byday": rrule_params.get("byday"),
'until': rrule_params.get('until'), "until": rrule_params.get("until"),
} }
if rrule_params.get('weekday'): if rrule_params.get("weekday"):
vals.update(rrule_params.get('weekday')) vals.update(rrule_params.get("weekday"))
day_list = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] day_list = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
vals.update({day: rrule_params.get(day) for day in day_list if day in rrule_params}) vals.update(
{day: rrule_params.get(day) for day in day_list if day in rrule_params}
)
return vals return vals
def sync_event_from_ical(self, ical_event): def sync_event_from_ical(self, ical_event):
email_regex = re.compile(r'[a-z0-9\.\-+_]+@[a-z0-9\.\-+_]+\.[a-z]+') email_regex = re.compile(r"[a-z0-9.\-+_]+@[a-z0-9.\-+_]+\.[a-z]+")
current_user_email = self.env.user.email.lower() current_user_email = self.env.user.email.lower()
for component in ical_event.subcomponents: for component in ical_event.subcomponents:
if isinstance(component, Event): if isinstance(component, Event):
uid = component.get('uid') uid = component.get("uid")
recurrence_id = component.get( recurrence_id = component.get(
'recurrence_id') # Unique identifier for a single event in a recurrence set "recurrence_id"
attendees = component.get('attendee', []) ) # Unique identifier for a single event in a recurrence set
attendees = component.get("attendee", [])
if isinstance(attendees, vCalAddress): if isinstance(attendees, vCalAddress):
attendees = [attendees] attendees = [attendees]
@ -301,7 +338,8 @@ class CalendarEvent(models.Model):
attendees_emails = [ attendees_emails = [
email_regex.search(str(attendee)).group(0).lower().strip() email_regex.search(str(attendee)).group(0).lower().strip()
for attendee in attendees if email_regex.search(str(attendee)) for attendee in attendees
if email_regex.search(str(attendee))
] ]
_logger.info(f"Attendees emails: {attendees_emails}") _logger.info(f"Attendees emails: {attendees_emails}")
@ -310,59 +348,77 @@ class CalendarEvent(models.Model):
if current_user_email not in attendees_emails: if current_user_email not in attendees_emails:
attendees_emails.append(current_user_email) attendees_emails.append(current_user_email)
attendee_ids = self.env['res.partner'].search([('email', 'in', attendees_emails)]) attendee_ids = self.env["res.partner"].search(
[("email", "in", attendees_emails)]
)
existing_instance = self._get_existing_instance(uid, recurrence_id) existing_instance = self._get_existing_instance(uid, recurrence_id)
start = component.decoded('dtstart') start = component.decoded("dtstart")
if isinstance(start, datetime): if isinstance(start, datetime):
start = start.astimezone(utc).replace(tzinfo=None) start = start.astimezone(utc).replace(tzinfo=None)
end = component.decoded('dtend') end = component.decoded("dtend")
if isinstance(end, datetime): if isinstance(end, datetime):
end = end.astimezone(utc).replace(tzinfo=None) end = end.astimezone(utc).replace(tzinfo=None)
values = { values = {
'name': str(component.get('summary')), "name": str(component.get("summary")),
'start': start, "start": start,
'stop': end, "stop": end,
'description': self._extract_component_text(component, 'description'), "description": self._extract_component_text(
'location': self._extract_component_text(component, 'location'), component, "description"
'videocall_location': self._extract_component_text(component, 'conference'), ),
'caldav_uid': uid, "location": self._extract_component_text(component, "location"),
'partner_ids': [(6, 0, attendee_ids.ids)], "videocall_location": self._extract_component_text(
component, "conference"
),
"caldav_uid": uid,
"partner_ids": [(6, 0, attendee_ids.ids)],
} }
recurrency_vals = self._get_recurrency_values_from_ical_event(component) recurrency_vals = self._get_recurrency_values_from_ical_event(component)
if recurrency_vals: if recurrency_vals:
values.update(recurrency_vals) values.update(recurrency_vals)
if not existing_instance: if not existing_instance:
_logger.info(f"Creating with vals: {values}") _logger.info(f"Creating with vals: {values}")
self.with_context({'caldav_no_sync': True}).create(values) self.with_context({"caldav_no_sync": True}).create(values)
else: else:
_logger.info(f"Updating with vals: {values}") _logger.info(f"Updating with vals: {values}")
changed_vals = {} changed_vals = {}
# Don't update partner_ids if no change # Don't update partner_ids if no change
if attendee_ids - existing_instance.partner_ids: if attendee_ids - existing_instance.partner_ids:
changed_vals.update({ changed_vals.update(
'partner_ids',values.pop('partner_ids'), {
}) "partner_ids",
values.pop("partner_ids"),
}
)
# Don't write values that haven't changed # Don't write values that haven't changed
for key, val in values.items(): for key, val in values.items():
if getattr(existing_instance, key) != val: if getattr(existing_instance, key) != val:
changed_vals.update({key: values.pop(key)}) changed_vals.update({key: values.pop(key)})
if (recurrency_vals and recurrency_vals.get('recurrency') if (
and (not existing_instance.recurrency or not recurrency_vals
existing_instance.follow_recurrence)): and recurrency_vals.get("recurrency")
existing_instance.write({ and (
'recurrency': True, not existing_instance.recurrency
'follow_recurrence': True, or not existing_instance.follow_recurrence
}) )
existing_instance.with_context({ ):
'caldav_no_sync': True, existing_instance.write(
}).write(changed_vals) {
"recurrency": True,
"follow_recurrence": True,
}
)
existing_instance.with_context(
{
"caldav_no_sync": True,
}
).write(changed_vals)
@staticmethod @staticmethod
def _extract_component_text(component, subcomponent_name): def _extract_component_text(component, subcomponent_name):
text = str(component.get(subcomponent_name)) text = str(component.get(subcomponent_name))
text = text if text != 'None' else '' text = text if text != "None" else ""
@staticmethod @staticmethod
def _html_to_text(html): def _html_to_text(html):
@ -371,19 +427,19 @@ class CalendarEvent(models.Model):
@staticmethod @staticmethod
def _map_attendee_status(state): def _map_attendee_status(state):
mapping = { mapping = {
'needsAction': 'NEEDS-ACTION', "needsAction": "NEEDS-ACTION",
'accepted': 'ACCEPTED', "accepted": "ACCEPTED",
'declined': 'DECLINED', "declined": "DECLINED",
'tentative': 'TENTATIVE', "tentative": "TENTATIVE",
} }
return mapping.get(state, 'NEEDS-ACTION') return mapping.get(state, "NEEDS-ACTION")
@staticmethod @staticmethod
def _map_ical_status(ical_status): def _map_ical_status(ical_status):
mapping = { mapping = {
'NEEDS-ACTION': 'needsAction', "NEEDS-ACTION": "needsAction",
'ACCEPTED': 'accepted', "ACCEPTED": "accepted",
'DECLINED': 'declined', "DECLINED": "declined",
'TENTATIVE': 'tentative', "TENTATIVE": "tentative",
} }
return mapping.get(ical_status, 'needsAction') return mapping.get(ical_status, "needsAction")