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

View file

@ -1,11 +1,11 @@
import uuid
from odoo import models, api, fields
from odoo.exceptions import UserError
import caldav
import logging
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 datetime import timedelta
import re
from pytz import timezone, utc
@ -33,26 +33,26 @@ def _parse_rrule_string(rrule_str):
regex = re.compile(regex_str)
params_match = regex.search(rrule_str)
params_part = params_match.groups()[0]
params = params_part.split(';')
params = params_part.split(";")
params_dict = {}
for param in params:
parts = param.split('=')
parts = param.split("=")
params_dict.update({parts[0]: try_to_int(parts[1])})
return params_dict
class CalendarEvent(models.Model):
_inherit = 'calendar.event'
_inherit = "calendar.event"
caldav_uid = fields.Char(string='CalDAV UID', readonly=True)
caldav_recurrence_id = fields.Char(string='CalDAV Recurrence ID', readonly=True)
caldav_uid = fields.Char(string="CalDAV UID", readonly=True)
caldav_recurrence_id = fields.Char(string="CalDAV Recurrence ID", readonly=True)
@api.model
def create(self, vals):
if not vals.get('caldav_uid'):
vals['caldav_uid'] = str(uuid.uuid4())
if not vals.get("caldav_uid"):
vals["caldav_uid"] = str(uuid.uuid4())
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:
_logger.debug(f"Creating event {event.name} in CalDAV")
event.sync_create_to_caldav()
@ -62,7 +62,7 @@ class CalendarEvent(models.Model):
def write(self, 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:
try:
_logger.debug(f"Updating event {self.name} in CalDAV")
@ -72,7 +72,7 @@ class CalendarEvent(models.Model):
return res
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:
try:
_logger.debug(f"Removing event {event.name} from CalDAV")
@ -89,7 +89,7 @@ class CalendarEvent(models.Model):
return caldav.DAVClient(
url=user.caldav_calendar_url,
username=user.caldav_username,
password=user.caldav_password
password=user.caldav_password,
)
def sync_create_to_caldav(self):
@ -104,7 +104,9 @@ class CalendarEvent(models.Model):
caldav_event = calendar.add_event(ical_event)
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
_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:
_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.delete()
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:
_logger.error(f"Failed to remove event from CalDAV server: {e}")
def _get_icalendar(self):
calendar = Calendar()
calendar.add('prodid', '-//Odoo//mxm.dk//')
calendar.add('version', '2.0')
calendar.add("prodid", "-//Odoo//mxm.dk//")
calendar.add("version", "2.0")
for event in self:
user_tz = timezone('UTC')
user_tz = timezone("UTC")
if event.user_id.tz:
user_tz = timezone(event.user_id.tz)
ical_event = Event()
ical_event.add('uid', event.caldav_uid)
ical_event.add('dtstamp', utc.localize(event.write_date).astimezone(user_tz))
ical_event.add("uid", event.caldav_uid)
ical_event.add(
"dtstamp", utc.localize(event.write_date).astimezone(user_tz)
)
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):
ical_event.add('description', self._html_to_text(event.description))
ical_event.add("description", self._html_to_text(event.description))
if event.location:
ical_event.add('location', event.location)
ical_event.add("location", event.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:
if partner == event.user_id.partner_id:
continue
attendee = vCalAddress(f'MAILTO:{partner.email}')
attendee.params['cn'] = vText(partner.name)
attendee_record = self.env['calendar.attendee'].search([('event_id', '=', event.id), ('partner_id', '=', partner.id)], limit=1)
attendee = vCalAddress(f"MAILTO:{partner.email}")
attendee.params["cn"] = vText(partner.name)
attendee_record = self.env["calendar.attendee"].search(
[("event_id", "=", event.id), ("partner_id", "=", partner.id)],
limit=1,
)
if attendee_record:
attendee.params['partstat'] = vText(self._map_attendee_status(attendee_record.state))
ical_event.add('attendee', attendee, encode=0)
attendee.params["partstat"] = vText(
self._map_attendee_status(attendee_record.state)
)
ical_event.add("attendee", attendee, encode=0)
organizer = vCalAddress(f"MAILTO:{event.user_id.email}")
organizer.params['cn'] = event.user_id.name
ical_event.add('organizer', organizer)
organizer.params["cn"] = event.user_id.name
ical_event.add("organizer", organizer)
# Add RRULE if the event is recurrent
if event.recurrency and event.recurrence_id:
rrule = event.recurrence_id._get_rrule()
rrule_dict = _parse_rrule_string(str(rrule))
ical_event.add('rrule', rrule_dict)
ical_event.add("rrule", rrule_dict)
# Add DTSTART and DTEND
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("dtstart", utc.localize(event.start).astimezone(user_tz))
ical_event.add("dtend", utc.localize(event.stop).astimezone(user_tz))
calendar.add_component(ical_event)
@ -185,7 +196,9 @@ class CalendarEvent(models.Model):
@api.model
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:
self.with_user(user).poll_user_caldav_server()
@ -194,11 +207,23 @@ class CalendarEvent(models.Model):
if not self._is_caldav_enabled():
return
client = self._get_caldav_client()
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
events = calendar.events()
try:
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()
now = datetime.now()
one_year_later = now + timedelta(days=365)
_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)
for component in ical_event.subcomponents:
if isinstance(component, Event):
uid = str(component.get('uid'))
recurrence_id = str(component.get('recurrence-id'))
if recurrence_id == 'None':
recurrence_id = ''
uid = str(component.get("uid"))
recurrence_id = str(component.get("recurrence-id"))
if recurrence_id == "None":
recurrence_id = ""
caldav_uids.add(f"{uid}{recurrence_id}")
_logger.info(f"CalDAV UIDs fetched: {caldav_uids}")
# 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:
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 ''}"
if event_uid not in caldav_uids:
_logger.info(f"Deleting orphan event {event.name} with UID {event.caldav_uid} "
f"and Recurrence ID {event.caldav_recurrence_id or ''}")
_logger.info(
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()
@api.model
def _get_existing_instance(self, uid, recurrence_id):
instance = self.env['calendar.event'].search([
('caldav_uid', '=', uid),
('recurrence_id', '=', recurrence_id)
])
return instance or self.env['calendar.event'].search([
('caldav_uid', '=', 'uid'),
('recurrence_id', '=', False),
])
instance = self.env["calendar.event"].search(
[("caldav_uid", "=", uid), ("recurrence_id", "=", recurrence_id)]
)
return instance or self.env["calendar.event"].search(
[
("caldav_uid", "=", "uid"),
("recurrence_id", "=", False),
]
)
def _get_recurrency_values_from_ical_event(self, component):
""" 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 """
"""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"""
rrule = component.get('rrule')
rrule = component.get("rrule")
if not rrule:
if not self.recurrency:
# 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
if self.recurrence_id.base_event_id != self:
# 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:
# This is the base event, so change all events in the list
return {'recurrence_update': 'all_events', 'recurrency': False}
rrule_str = rrule.to_ical().decode('utf-8')
sequence = component.get('sequence')
return {"recurrence_update": "all_events", "recurrency": False}
rrule_str = rrule.to_ical().decode("utf-8")
sequence = component.get("sequence")
if sequence and sequence != 0:
# This is not the base event so we can't change recurrence properties
return {}
caldav_recurrence_id = component.get('recurrence-id')
rrule_params = self.env['calendar.recurrence']._rrule_parse(rrule_str, component.decoded('dtstart'))
caldav_recurrence_id = component.get("recurrence-id")
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
rrule_str, component.decoded("dtstart")
)
vals = {
'recurrency': True,
'follow_recurrence': True,
'caldav_recurrence_id': caldav_recurrence_id,
'recurrence_update': 'all_events',
'rrule_type': rrule_params.get('rrule_type'),
'end_type': rrule_params.get('end_type'),
'interval': rrule_params.get('interval'),
'count': rrule_params.get('count'),
'month_by': rrule_params.get('monty_by'),
'day': rrule_params.get('day'),
'byday': rrule_params.get('byday'),
'until': rrule_params.get('until'),
"recurrency": True,
"follow_recurrence": True,
"caldav_recurrence_id": caldav_recurrence_id,
"recurrence_update": "all_events",
"rrule_type": rrule_params.get("rrule_type"),
"end_type": rrule_params.get("end_type"),
"interval": rrule_params.get("interval"),
"count": rrule_params.get("count"),
"month_by": rrule_params.get("monty_by"),
"day": rrule_params.get("day"),
"byday": rrule_params.get("byday"),
"until": rrule_params.get("until"),
}
if rrule_params.get('weekday'):
vals.update(rrule_params.get('weekday'))
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})
if rrule_params.get("weekday"):
vals.update(rrule_params.get("weekday"))
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}
)
return vals
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()
for component in ical_event.subcomponents:
if isinstance(component, Event):
uid = component.get('uid')
uid = component.get("uid")
recurrence_id = component.get(
'recurrence_id') # Unique identifier for a single event in a recurrence set
attendees = component.get('attendee', [])
"recurrence_id"
) # Unique identifier for a single event in a recurrence set
attendees = component.get("attendee", [])
if isinstance(attendees, vCalAddress):
attendees = [attendees]
@ -301,7 +338,8 @@ class CalendarEvent(models.Model):
attendees_emails = [
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}")
@ -310,59 +348,77 @@ class CalendarEvent(models.Model):
if current_user_email not in attendees_emails:
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)
start = component.decoded('dtstart')
start = component.decoded("dtstart")
if isinstance(start, datetime):
start = start.astimezone(utc).replace(tzinfo=None)
end = component.decoded('dtend')
end = component.decoded("dtend")
if isinstance(end, datetime):
end = end.astimezone(utc).replace(tzinfo=None)
values = {
'name': str(component.get('summary')),
'start': start,
'stop': end,
'description': self._extract_component_text(component, 'description'),
'location': self._extract_component_text(component, 'location'),
'videocall_location': self._extract_component_text(component, 'conference'),
'caldav_uid': uid,
'partner_ids': [(6, 0, attendee_ids.ids)],
"name": str(component.get("summary")),
"start": start,
"stop": end,
"description": self._extract_component_text(
component, "description"
),
"location": self._extract_component_text(component, "location"),
"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)
if recurrency_vals:
values.update(recurrency_vals)
if not existing_instance:
_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:
_logger.info(f"Updating with vals: {values}")
changed_vals = {}
# Don't update partner_ids if no change
if attendee_ids - existing_instance.partner_ids:
changed_vals.update({
'partner_ids',values.pop('partner_ids'),
})
changed_vals.update(
{
"partner_ids",
values.pop("partner_ids"),
}
)
# Don't write values that haven't changed
for key, val in values.items():
if getattr(existing_instance, key) != val:
changed_vals.update({key: values.pop(key)})
if (recurrency_vals and recurrency_vals.get('recurrency')
and (not existing_instance.recurrency or not
existing_instance.follow_recurrence)):
existing_instance.write({
'recurrency': True,
'follow_recurrence': True,
})
existing_instance.with_context({
'caldav_no_sync': True,
}).write(changed_vals)
if (
recurrency_vals
and recurrency_vals.get("recurrency")
and (
not existing_instance.recurrency
or not existing_instance.follow_recurrence
)
):
existing_instance.write(
{
"recurrency": True,
"follow_recurrence": True,
}
)
existing_instance.with_context(
{
"caldav_no_sync": True,
}
).write(changed_vals)
@staticmethod
def _extract_component_text(component, subcomponent_name):
text = str(component.get(subcomponent_name))
text = text if text != 'None' else ''
text = text if text != "None" else ""
@staticmethod
def _html_to_text(html):
@ -371,19 +427,19 @@ class CalendarEvent(models.Model):
@staticmethod
def _map_attendee_status(state):
mapping = {
'needsAction': 'NEEDS-ACTION',
'accepted': 'ACCEPTED',
'declined': 'DECLINED',
'tentative': 'TENTATIVE',
"needsAction": "NEEDS-ACTION",
"accepted": "ACCEPTED",
"declined": "DECLINED",
"tentative": "TENTATIVE",
}
return mapping.get(state, 'NEEDS-ACTION')
return mapping.get(state, "NEEDS-ACTION")
@staticmethod
def _map_ical_status(ical_status):
mapping = {
'NEEDS-ACTION': 'needsAction',
'ACCEPTED': 'accepted',
'DECLINED': 'declined',
'TENTATIVE': 'tentative',
"NEEDS-ACTION": "needsAction",
"ACCEPTED": "accepted",
"DECLINED": "declined",
"TENTATIVE": "tentative",
}
return mapping.get(ical_status, 'needsAction')
return mapping.get(ical_status, "needsAction")