caldav_sync: recurrence working from Odoo to CalDAV. Need further testing for the other direction.

This commit is contained in:
Marc Durepos 2024-11-07 16:13:31 -05:00
parent ce7b27cc65
commit 5ca1d56e54
2 changed files with 155 additions and 141 deletions

View file

@ -6,8 +6,8 @@ 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 vCalAddress, vText, vDatetime, vRecur, Event, vInt
from datetime import datetime
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
import re
from pytz import timezone, utc
from typing import List, Dict, TypeVar, Optional
@ -87,22 +87,10 @@ class CalendarEvent(models.Model):
#### Field Computation Methods ####
###################################
def _recompute_caldav_uid(self):
def _recompute_caldav_ids(self):
for event in self:
_logger.info(f"Recomputing CalDAV UID from {event.caldav_uid}")
if event.recurrence_id:
event.caldav_uid = event.recurrence_id.caldav_uid
else:
event.caldav_uid = uuid.uuid4()
_logger.info(f"New CalDAV UID: {event.caldav_uid}")
@api.depends("recurrence_id", "recurrence_id.dtstart")
def _recompute_caldav_recurrence_id(self):
for event in self:
_logger.info(
f"Recomputing caldav_recurrence_id for event {event} at {event.start}"
)
if event.recurrence_id:
time = event.recurrence_id.dtstart.time()
date = event.start.date()
event.caldav_recurrence_id = datetime(
@ -114,6 +102,7 @@ class CalendarEvent(models.Model):
time.second,
)
else:
event.caldav_uid = uuid.uuid4()
event.caldav_recurrence_id = False
@api.depends("name", "description", "partner_ids", "location", "videocall_location")
@ -192,8 +181,7 @@ class CalendarEvent(models.Model):
CalendarEvent, self.with_context({"caldav_no_sync": True})
).create(vals_list)
ctx = {"caldav_no_sync": True}
events.with_context(ctx)._recompute_caldav_uid()
events.with_context(ctx)._recompute_caldav_recurrence_id()
events.with_context(ctx)._recompute_caldav_ids()
if not self.env.context.get("caldav_no_sync"):
events._to_sync()._sync_create_to_caldav()
return events
@ -203,17 +191,15 @@ class CalendarEvent(models.Model):
for user in event.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
# try:
_logger.info(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.info(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:
caldav_events = event._create_in_icalendar(calendar)
for caldav_event in caldav_events:
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
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 write(self, vals):
res = super(CalendarEvent, self.with_context({"caldav_no_sync": True})).write(
@ -228,7 +214,6 @@ class CalendarEvent(models.Model):
to_sync = self._to_sync()
if to_sync and not self.env.context.get("caldav_no_sync"):
for rec in to_sync:
_logger.info(f"Updating event {rec.name} at {rec.start} in " f"CalDAV.")
rec._sync_write_to_caldav()
return res
@ -238,84 +223,159 @@ class CalendarEvent(models.Model):
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
event = None
try:
event = self._find_in_caldav_calendar(calendar)
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()
elif self.recurrence_id and not self.is_base_event:
base_event = self._get_caldav_base_event_by_uid(
calendar, self.caldav_uid
)
base_event.icalendar_instance.add_component(Event(**ical_event_data))
if self.caldav_recurrence_id != self.start:
tz = timezone(self.event_tz)
base_event.save()
base_event = self._get_caldav_base_event_by_uid(calendar, self.caldav_uid)
if self.recurrence_id:
tz = timezone(self.event_tz or self.env.user.tz)
start = utc.localize(self.start).astimezone(tz)
# We have an event in the recurrence chain that differs from the base
# We need to add an iCalendar VEVENT with recurrence-id or update it
if self.is_base_event and start == self._get_ical_recurrence_id():
# We have a normal base event, so we just update it or create it
self._update_base_caldav_event(
calendar, base_event, ical_event_data
)
elif (
self.differs_from_base_event
or start != self._get_ical_recurrence_id()
):
# We have an event that differs from the base event in some way
# So we need to update or create its matching subcomponent, identified
# by its recurrence-id
if not base_event:
_logger.warning(
f"Failed to find base event for {self} on CalDAV server."
)
return
index = self._get_subcomponent_index_for_recurrence(base_event)
if index:
ical_event = base_event.icalendar_instance.subcomponents[index]
self._update_ical_event_values(ical_event, ical_event_data)
else:
base_event.icalendar_instance.add_component(
Event(**ical_event_data)
)
base_event.save()
else:
calendar.add_event(**ical_event_data)
self._update_base_caldav_event(calendar, base_event, ical_event_data)
def _update_base_caldav_event(
self,
calendar: icalendar.cal.Calendar,
event: caldav.Event,
ical_event_data: dict,
):
if event:
self._update_ical_event_values(event.icalendar_component, ical_event_data)
event.save()
else:
calendar.add_event(**ical_event_data)
@api.model
def _update_ical_event_values(
self, ical_event: icalendar.cal.Event, event_data: dict
):
for key, value in event_data.items():
ical_event[key] = value
def _get_caldav_base_event_by_uid(
self, calendar: caldav.Calendar, uid: str
) -> Optional[CalendarEvent]:
for event in calendar.events():
comp = event.icalendar_component
event_uid = self._extract_component_text(comp, "uid")
event_recurrence_id = self._extract_component_text(comp, "recurrence-id")
if event_uid == uid and not event_recurrence_id:
component = event.icalendar_component
event_uid = self._extract_component_text(component, "uid")
if event_uid == uid and not component.get("recurrence-id"):
return event
def unlink(self):
if not self.env.context.get("caldav_no_sync"):
for rec in self._to_sync():
try:
# TODO: fix the attempt to sync all events ... _to_sync is
# not working here
_logger.info(f"Removing event {rec.name} from CalDAV")
rec._sync_unlink_to_caldav()
except Exception as e:
_logger.error(f"Failed to delete event from CalDAV server: {e}")
return super(CalendarEvent, self).unlink()
def _get_subcomponent_index_for_recurrence(
self, caldav_event: caldav.Event
) -> Optional[int]:
ical_instance = caldav_event.icalendar_instance
for index, component in enumerate(ical_instance.subcomponents):
if component.get("name") == "VEVENT" and (
rec_id := component.get("recurrence-id")
and rec_id.dt == self._get_ical_recurrence_id()
):
return index
def _sync_unlink_to_caldav(self):
if self.caldav_uid:
for user in self.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
try:
_logger.info(
f"Removing CalDAV event {self.caldav_uid}, Odoo ID {self.id} starting at {self.start}"
)
caldav_event = self._find_in_caldav_calendar(
calendar, match_start=True
)
if caldav_event:
_logger.info(
f"Found event {caldav_event.icalendar_component.get("uid")}{caldav_event.icalendar_component.get("dtstart").dt}"
caldav_event = calendar.event_by_uid(self.caldav_uid)
if self.recurrence_id and not self.is_base_event:
index = self._get_subcomponent_index_for_recurrence(
caldav_event
)
caldav_event.delete()
if index:
caldav_event.icalendar_instance.subcomponents.pop(index)
caldav_event.save()
else:
# In some cases, like when events are detached from a recurrence,
# they can be base events but have an old caldav_uid from when
# they were recurring. That's why we make sure that the start
# of the event matches.
if self._matches_caldav_start(caldav_event):
caldav_event.delete()
except caldav.error.NotFoundError:
_logger.info(
f"CalDAV event {self.caldav_uid} starting at {self.start}"
f" not found on server during deletion."
)
# No worries - it just didn't exist so nothing to sync
pass
except Exception as e:
_logger.error(f"Failed to remove event from CalDAV server: {e}")
def _sync_recurrence_to_caldav(self):
for event in self._to_sync():
def _matches_caldav_start(self, caldav_event: caldav.Event) -> bool:
event_start = caldav_event.icalendar_component.get("dtstart").dt
tz = event_start.tzinfo
self_start = utc.localize(self.start).astimezone(tz)
return self_start == event_start
def _update_future_events(self, values, time_values, recurrence_values):
"""When self makes a change updating future events in the recurrence, we need
only to make sure that self is properly synchronized after the change. We also
need to make sure that the recurrence rule on the old base event is re-synced.
The superclass method already archives all the future events and re-creates
them, so we implicitly delete them from the CalDAV server before writing the
new base event (self).
We do, however, need to get a new caldav_uid and caldav_recurrence_id for all
the events in the new chain.
"""
old_base = self.recurrence_id.base_event_id
super()._update_future_events(values, time_values, recurrence_values)
events_to_refresh = self.recurrence_id._get_events_from(self.start)
events_to_refresh._recompute_caldav_ids()
self._sync_write_to_caldav()
old_base._sync_write_to_caldav()
def _break_recurrence(self, future=True):
recurrence = self.recurrence_id
detached_events = super()._break_recurrence(future) | self
# detached_events are already removed from the CalDAV server in
# calendar.recurrence._detach_events. We now need to reinitialize them.
for event in detached_events:
event._sync_write_to_caldav()
if future:
# When future=True, the base event needs to re-sync its recurrence values
recurrence.base_event_id._sync_write_to_caldav()
return detached_events - self
def _to_sync(self):
"""Determine which records in self we need to synchronize with the
CalDAV server. In essence, we only synchronize base events and those
differing from the base event in a recurring chain."""
return self.filtered(
lambda event: event._is_caldav_enabled()
lambda event: event.id
and event._is_caldav_enabled()
and (event.is_base_event or event.differs_from_base_event)
)
@ -428,51 +488,6 @@ class CalendarEvent(models.Model):
}
return mapping.get(state, "NEEDS-ACTION")
def _find_in_caldav_calendar(
self,
calendar: caldav.Calendar,
force_recurrence_id: bool = False,
match_start: bool = False,
) -> Optional[caldav.Event]:
"""Find this event in a given CalDAV Calendar. If the event is
recurring but is not a base event, it will be matched with its
caldav_uid and caldav_recurrence_id. Base events are matched by UID
only.
:param calendar: The CalDAV Calendar to find the event in.
:param force_recurrence_id: Match with the caldav_recurrence_id even
if self is a base event.
:return: The CalDAV Event found in the calendar (or None).
"""
# Since search by uid and recurrence-id is not yet supported,
# we do it manually
match_recurrence_id = (
force_recurrence_id or self.recurrence_id and not self.is_base_event
)
if not match_recurrence_id and not match_start:
calendar.event_by_uid(self.caldav_uid)
else:
events = calendar.events()
for event in events:
event_instance = event.icalendar_component
if (
event_instance.get("uid") == self.caldav_uid
and (
not match_recurrence_id
or (rec_id := event_instance.get("recurrence-id"))
and rec_id == self._get_ical_recurrence_id()
)
and (
not match_start
or event_instance.get("dtstart").dt
== utc.localize(self.start).astimezone(
event_instance.get("dtstart").dt.tzinfo
)
)
):
return event
return None
###################################################################
#### Methods for synchronizing changes from the server to Odoo ####
###################################################################
@ -510,7 +525,6 @@ class CalendarEvent(models.Model):
)
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()
@api.model
@ -550,7 +564,6 @@ class CalendarEvent(models.Model):
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
)
@ -561,20 +574,12 @@ class CalendarEvent(models.Model):
synced_events |= new_event
continue
elif outdated or not owned:
_logger.info(
f"Event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} "
f"{'outdated' if outdated else ''}"
f"{'not owned by user' + user.name if not owned else ''}."
f" Skipping."
)
pass
else:
changed_vals = existing_instance._get_recurrence_changes(
recurrency_vals
) | existing_instance._get_value_changes(values)
if changed_vals:
_logger.info(
f"Updating event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} with : {changed_vals}"
)
existing_instance.with_context(
caldav_no_sync=True,
).write(changed_vals)
@ -660,10 +665,11 @@ class CalendarEvent(models.Model):
if not rrule:
return {}
if rrule.get("until"):
rrule["until"] = rrule.get("until")[0].astimezone(utc)
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
"RRULE:" + rrule_str, component.decoded("dtstart")
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
)
vals = {
"recurrency": True,

View file

@ -8,19 +8,27 @@ _logger = logging.getLogger(__name__)
class RecurrenceRule(models.Model):
_inherit = "calendar.recurrence"
@api.model
def _default_uid(self):
return uuid.uuid4()
caldav_uid = fields.Char(
default=_default_uid,
readonly=True,
copy=False,
)
_sql_constraints = [
("caldav_uid_unique", "UNIQUE (caldav_uid)", "caldav_uid must be unique")
]
def _stop_at(self, event):
detached_events = super()._stop_at(event)
detached_events._recompute_caldav_uid()
detached_events._recompute_caldav_recurrence_id()
self.calendar_event_ids._sync_recurrence_to_caldav()
# detached_events._sync_recurrence_to_caldav()
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
vals["caldav_uid"] = str(uuid.uuid4())
return super().create(vals_list)
@api.model
def _detach_events(self, events):
"""When events are detached from a recurrence, their CalDAV UID and recurrence-id
are no longer going to be valid, so we remove them from the server. They may then
be re-written to the server with their new IDs later, but we don't care about
that here."""
detached_events = super()._detach_events(events)
for event in detached_events:
event._sync_unlink_to_caldav()
return detached_events