caldav_sync: all CRUD operations working. Recurrences working to some degree but not fully.

This commit is contained in:
Marc Durepos 2024-11-05 16:42:42 -05:00
parent 0104e3897c
commit 068d1fb29f
3 changed files with 147 additions and 73 deletions

View file

@ -8,7 +8,7 @@
{
"name": "CalDAV Synchronization",
"version": "17.0.0.5.9",
"version": "17.0.0.6.0",
"license": "LGPL-3",
"category": "Productivity",
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",

View file

@ -1,12 +1,11 @@
import uuid
from wsgiref.util import request_uri
from odoo import models, api, fields, Command
from odoo import models, api, fields
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
import caldav
import logging
from datetime import datetime, date
from icalendar import Calendar, Event, vCalAddress, vText
from icalendar import vCalAddress, vText, vDatetime, vRecur
from bs4 import BeautifulSoup
import re
from pytz import timezone, utc
@ -105,7 +104,7 @@ class CalendarEvent(models.Model):
def _compute_is_base_event(self):
for rec in self:
rec.is_base_event = (
not rec.recurrency or rec.recurrence_id.base_event_id == rec
not rec.recurrence_id or rec.recurrence_id.base_event_id == rec
)
@api.depends("user_id", "partner_ids", "partner_ids.user_id")
@ -126,15 +125,25 @@ class CalendarEvent(models.Model):
for vals in vals_list:
if not vals.get("caldav_uid"):
vals["caldav_uid"] = str(uuid.uuid4())
events = super(CalendarEvent, self).create(vals_list)
events = super(
CalendarEvent, self.with_context({"caldav_no_sync": True})
).create(vals_list)
if not self.env.context.get("caldav_no_sync"):
events._to_sync()._sync_create_to_caldav()
return events
def write(self, vals):
res = super().write(vals)
res = super(CalendarEvent, self.with_context({"caldav_no_sync": True})).write(
vals
)
# Events sometimes get archived in Odoo in the process of updating recurrence
# In that case, we will delete them from the CalDAV server before recreating
if "active" in vals and not vals.get("active"):
for event in self:
event._sync_remove_from_caldav()
return res
to_sync = self._to_sync()
if to_sync and self.env.context.get("caldav_no_sync"):
if to_sync and not self.env.context.get("caldav_no_sync"):
for rec in to_sync:
_logger.debug(
f"Updating event {rec.name} in CalDAV. " f"{rec.fields_get()}"
@ -153,33 +162,43 @@ class CalendarEvent(models.Model):
return super(CalendarEvent, self).unlink()
def _is_caldav_enabled(self):
return self.env.user.is_caldav_enabled
return self.user_id.is_caldav_enabled
def _sync_create_to_caldav(self):
for event in self:
for user in event.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
try:
_logger.debug(f"Creating new CalDAV event for {event.name}")
caldav_events = event._create_in_icalendar(calendar)
for caldav_event in caldav_events:
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}
)
except Exception as e:
_logger.error(f"Failed to sync event to CalDAV server: {e}")
# try:
_logger.debug(f"Creating new CalDAV event for {event.name}")
caldav_events = event._create_in_icalendar(calendar)
for caldav_event in caldav_events:
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}
)
# except Exception as e:
# _logger.error(f"Failed to sync event to CalDAV server: {e}")
def _sync_update_to_caldav(self):
ical_event_data = self._create_event_data()
for user in self.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
event = self._find_in_icalendar(calendar, user)
calendar = client.calendar(url=user.caldav_calendar_url)
event = None
try:
event = self._find_in_icalendar(calendar, user)
except caldav.error.NotFoundError:
_logger.info(
f"CalDAV event {self.caldav_uid} not found on server during update."
f" Creating it instead."
)
if event:
event.save(**ical_event_data)
event.icalendar_component.update(ical_event_data)
event.save()
else:
calendar.save_event(**ical_event_data)
@ -187,32 +206,34 @@ class CalendarEvent(models.Model):
if self.caldav_uid:
for user in self.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
calendar = client.calendar(url=user.caldav_calendar_url)
try:
_logger.debug(f"Removing CalDAV event {self.caldav_uid}")
ical_event = self._find_in_icalendar(calendar, user)
if ical_event:
ical_event.delete()
except caldav.error.NotFoundError:
_logger.warning(
_logger.info(
f"CalDAV event {self.caldav_uid} not found on server during deletion."
)
except Exception as e:
_logger.error(f"Failed to remove event from CalDAV server: {e}")
def _find_in_icalendar(self, calendar, user):
def _find_in_icalendar(self, calendar, user, force_recurrence_id=False):
# Since search by uid and recurrence-id is not yet supported,
# we do it manually
match_recurrence_id = self.recurrency and not self.is_base_event
match_recurrence_id = (
force_recurrence_id or self.recurrence_id and not self.is_base_event
)
if not match_recurrence_id:
return calendar.event_by_uid(self.caldav_uid)
else:
events = user._get_caldav_events()
for event in events:
event = event.icalendar_instance
event_instance = event.icalendar_instance
if (
event.get("uid") == self.caldav_uid
and event.get("recurrence-id") == self.caldav_recurrence_id
event_instance.get("uid") == self.caldav_uid
and event_instance.get("recurrence-id") == self.caldav_recurrence_id
):
return event
return None
@ -220,7 +241,7 @@ class CalendarEvent(models.Model):
def _create_in_icalendar(self, calendar):
ical_event_data = self._create_event_data()
caldav_event = calendar.save_event(**ical_event_data)
if self.recurrency and self.is_base_event and not self.follow_recurrence:
if self.recurrence_id and self.is_base_event and not self.follow_recurrence:
ical_event_data = self._create_event_data()
second_caldav_event = calendar.save_event(**ical_event_data)
return [caldav_event, second_caldav_event]
@ -231,44 +252,48 @@ class CalendarEvent(models.Model):
self._add_event_dates(event_data)
self._add_event_header_info(event_data)
self._add_event_attendees(event_data)
if self.is_base_event and self.recurrency:
if self.is_base_event and self.recurrence_id:
self._add_event_recurrence(event_data)
elif self.recurrency:
elif self.recurrence_id:
self._add_event_recurrence_id(event_data)
return event_data
def _add_event_header_info(self, event_data):
event_data["uid"] = self.caldav_uid
event_data["uid"] = vText(self.caldav_uid)
if self.name:
event_data["summary"] = self.name
event_data["summary"] = vText(self.name)
# TODO: Consider using X-ALT-DESC to stick HTML into the iCal event desc.
if self.description and self._html_to_text(self.description):
event_data["description"] = self._html_to_text(self.description)
event_data["description"] = vText(self._html_to_text(self.description))
if self.location:
event_data["location"] = self.location
event_data["location"] = vText(self.location)
if self.videocall_location:
event_data["CONFERENCE"] = self.videocall_location
event_data["conference"] = self.videocall_location
def _add_event_dates(self, event_data):
user_tz = timezone("UTC")
if self.user_id.tz:
user_tz = timezone(self.user_id.tz)
event_data["last-modified"] = utc.localize(self.write_date).astimezone(user_tz)
event_data["created"] = utc.localize(self.create_date).astimezone(user_tz)
event_data["dtstart"] = utc.localize(self.start).astimezone(user_tz)
event_data["dtend"] = utc.localize(self.stop).astimezone(user_tz)
event_data["last-modified"] = vDatetime(
utc.localize(self.write_date).astimezone(user_tz)
)
event_data["created"] = vDatetime(
utc.localize(self.create_date).astimezone(user_tz)
)
event_data["dtstart"] = vDatetime(utc.localize(self.start).astimezone(user_tz))
event_data["dtend"] = vDatetime(utc.localize(self.stop).astimezone(user_tz))
return event_data
def _add_event_recurrence_id(self, event_data):
if self.recurrency:
if self.recurrence_id:
event_data["recurrence-id"] = self._get_ical_recurrence_id()
def _add_event_recurrence(self, event_data):
# Add RRULE if the event is recurrent
if self.recurrency:
rrule = self.recurrence_id._get_rrule()
rrule_dict = _parse_rrule_string(str(rrule))
event_data["rrule"] = rrule_dict
if self.recurrence_id:
rrule = str(self.recurrence_id._get_rrule())
rrule_dict = _parse_rrule_string(rrule)
event_data["rrule"] = vRecur(**rrule_dict)
def _add_event_attendees(self, event_data):
attendee_lines = []
@ -293,9 +318,11 @@ class CalendarEvent(models.Model):
def _get_ical_recurrence_id(self):
user_tz = timezone(self.user_id.tz)
recurrence_id = utc.localize(
datetime.strptime(self.caldav_recurrence_id, "%Y%m%dT%H%M%S")
).astimezone(user_tz)
recurrence_id = (
utc.localize(datetime.strptime(self.caldav_recurrence_id, "%Y%m%dT%H%M%S"))
.astimezone(user_tz)
.strftime("%Y%m%dT%H%M%S")
)
return recurrence_id
@api.model
@ -321,7 +348,8 @@ class CalendarEvent(models.Model):
("id", "not " "in", synced_events.ids),
("user_id", "=", user.id),
]
)._to_sync()
)
orphaned_events = orphaned_events._to_sync()
if orphaned_events:
_logger.info(f"Deleting orphaned events {orphaned_events.ids}")
orphaned_events.with_context(caldav_no_sync=True).with_user(user).unlink()
@ -368,6 +396,7 @@ class CalendarEvent(models.Model):
]
)
@api.model
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"""
@ -379,15 +408,9 @@ class CalendarEvent(models.Model):
follow_recurrence = (
component.get("recurrence-id").dt == component.get("dtstart").dt
)
if self and self.follow_recurrence == follow_recurrence:
return {
"follow_recurrence": follow_recurrence,
}
else:
return {
"follow_recurrence": follow_recurrence,
"recurrence_update": "self-only",
}
if not self.follow_recurrence:
return {"recurrency": False}
rrule = [item[1] for item in component.property_items() if item[0] == "RRULE"]
rrule = rrule[0] if rrule else None
@ -396,7 +419,7 @@ class CalendarEvent(models.Model):
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
rrule_str, component.decoded("dtstart")
"RRULE:" + rrule_str, component.decoded("dtstart")
)
vals = {
"recurrency": True,
@ -418,11 +441,11 @@ class CalendarEvent(models.Model):
return vals
def _get_recurrence_changes(self, recurrency_vals):
if not recurrency_vals and not self.recurrency:
if not recurrency_vals and not self.recurrence_id:
return {}
if not recurrency_vals and self.recurrency:
if not recurrency_vals and self.recurrence_id:
return {"recurrence_update": "all_events", "recurrency": False}
if recurrency_vals and not self.recurrency:
if recurrency_vals and not self.recurrence_id:
return recurrency_vals
changed_fields = {
key: recurrency_vals[key]
@ -483,10 +506,13 @@ class CalendarEvent(models.Model):
existing_instance and existing_instance.partner_id == user.partner_id
)
values = self._get_values_from_ical_component(component, user)
recurrency_vals = existing_instance._get_recurrency_values_from_ical_event(
component
)
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
if not existing_instance:
# If we're creating an instance and it doesn't follow the recurrence,
# just scrap the recurrency vals, they're not useful
if not recurrency_vals.get("follow_recurrence"):
recurrency_vals = {}
_logger.info(f"Creating with vals: {values | recurrency_vals}")
new_event = self.with_context(caldav_no_sync=True).create(
values | recurrency_vals
@ -504,7 +530,6 @@ class CalendarEvent(models.Model):
f"{'not owned by user' + user.name if not owned else ''}."
f" Skipping."
)
# Do nothing, it's not this user's event to modify or it's outdated
else:
changed_vals = existing_instance._get_recurrence_changes(
recurrency_vals
@ -516,6 +541,7 @@ class CalendarEvent(models.Model):
existing_instance.with_context(
caldav_no_sync=True,
).write(changed_vals)
if existing_instance.recurrency and existing_instance.is_base_event:
synced_events |= existing_instance.recurrence_id.calendar_event_ids
else:
@ -635,3 +661,36 @@ class CalendarEvent(models.Model):
"tentative": "TENTATIVE",
}
return mapping.get(state, "NEEDS-ACTION")
def _post_recurrence_detach(self):
"""After we detach events from a recurrence, we need to split them up on the
CalDAV server. The caldav_uid that unites events of a series needs to be
separated, making a new uid for each base event (and its recurrences)."""
caldav_events = []
# Start by deleting the old calendar events since we will need to remake them
for event in self:
for user in event.caldav_user_ids:
calendar = user._get_caldav_client().calendar(
url=user.caldav_calendar_url
)
try:
caldav_event = event._find_in_icalendar(
calendar, user, force_recurrence_id=True
)
if caldav_event:
caldav_event.delete()
except caldav.error.NotFoundError:
pass
base_events = self.filtered("is_base_event")
# Assign a new UID for each base event and its recurrences
for event in base_events:
recurrence_events = (
event.recurrence_id and event.recurrence_id.calendar_event_ids | event
)
new_uid = str(uuid.uuid4())
recurrence_events.write({"caldav_uid": new_uid})
def _break_recurrence(self, future=True):
super()._break_recurrence(future)
self._post_recurrence_detach()

View file

@ -5,14 +5,29 @@ from pytz import timezone
class RecurrenceRule(models.Model):
_inherit = "calendar.recurrence"
def _apply_recurrence(self, **kwargs):
detached_events = super()._apply_recurrence(**kwargs)
def _recompute_event_caldav_recurrence_ids(self):
def _get_recurrence_id(event):
format = "%Y%m%d" if event.allday else "%Y%m%dT%H%M%S"
return event.start.astimezone(timezone(event.user_id.tz)).strftime(format)
date_format = "%Y%m%d" if event.allday else "%Y%m%dT%H%M%S"
return event.start.astimezone(timezone(event.user_id.tz)).strftime(
date_format
)
for event in self.calendar_event_ids:
event.caldav_recurrence_id = _get_recurrence_id(event)
def _apply_recurrence(
self,
specific_values_creation=None,
no_send_edit=False,
generic_values_creation=None,
):
detached_events = super()._apply_recurrence(
specific_values_creation, no_send_edit, generic_values_creation
)
self._recompute_event_caldav_recurrence_ids()
return detached_events
def _detach_events(self, events):
events = super()._detach_events(events)
events.with_context(dont_notify=True)._post_recurrence_detach()
return events