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 from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
import caldav import caldav
import logging import logging
from datetime import datetime, date from datetime import datetime
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event, vInt from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
import re import re
from pytz import timezone, utc from pytz import timezone, utc
from typing import List, Dict, TypeVar, Optional from typing import List, Dict, TypeVar, Optional
@ -87,22 +87,10 @@ class CalendarEvent(models.Model):
#### Field Computation Methods #### #### Field Computation Methods ####
################################### ###################################
def _recompute_caldav_uid(self): def _recompute_caldav_ids(self):
for event in self: for event in self:
_logger.info(f"Recomputing CalDAV UID from {event.caldav_uid}")
if event.recurrence_id: if event.recurrence_id:
event.caldav_uid = event.recurrence_id.caldav_uid 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() time = event.recurrence_id.dtstart.time()
date = event.start.date() date = event.start.date()
event.caldav_recurrence_id = datetime( event.caldav_recurrence_id = datetime(
@ -114,6 +102,7 @@ class CalendarEvent(models.Model):
time.second, time.second,
) )
else: else:
event.caldav_uid = uuid.uuid4()
event.caldav_recurrence_id = False event.caldav_recurrence_id = False
@api.depends("name", "description", "partner_ids", "location", "videocall_location") @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}) CalendarEvent, self.with_context({"caldav_no_sync": True})
).create(vals_list) ).create(vals_list)
ctx = {"caldav_no_sync": True} ctx = {"caldav_no_sync": True}
events.with_context(ctx)._recompute_caldav_uid() events.with_context(ctx)._recompute_caldav_ids()
events.with_context(ctx)._recompute_caldav_recurrence_id()
if not self.env.context.get("caldav_no_sync"): if not self.env.context.get("caldav_no_sync"):
events._to_sync()._sync_create_to_caldav() events._to_sync()._sync_create_to_caldav()
return events return events
@ -203,17 +191,15 @@ class CalendarEvent(models.Model):
for user in event.caldav_user_ids: for user in event.caldav_user_ids:
client = user._get_caldav_client() client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url) calendar = client.calendar(url=user.caldav_calendar_url)
# try: try:
_logger.info(f"Creating new CalDAV event for {event.name}") caldav_events = event._create_in_icalendar(calendar)
caldav_events = event._create_in_icalendar(calendar) for caldav_event in caldav_events:
for caldav_event in caldav_events: caldav_uid = caldav_event.vobject_instance.vevent.uid.value
caldav_uid = caldav_event.vobject_instance.vevent.uid.value event.with_context(caldav_no_sync=True).write(
_logger.info(f"New CalDAV UID: {caldav_uid}") {"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}")
# except Exception as e:
# _logger.error(f"Failed to sync event to CalDAV server: {e}")
def write(self, vals): def write(self, vals):
res = super(CalendarEvent, self.with_context({"caldav_no_sync": True})).write( res = super(CalendarEvent, self.with_context({"caldav_no_sync": True})).write(
@ -228,7 +214,6 @@ class CalendarEvent(models.Model):
to_sync = self._to_sync() to_sync = self._to_sync()
if to_sync and not self.env.context.get("caldav_no_sync"): if to_sync and not self.env.context.get("caldav_no_sync"):
for rec in to_sync: for rec in to_sync:
_logger.info(f"Updating event {rec.name} at {rec.start} in " f"CalDAV.")
rec._sync_write_to_caldav() rec._sync_write_to_caldav()
return res return res
@ -238,84 +223,159 @@ class CalendarEvent(models.Model):
client = user._get_caldav_client() client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url) calendar = client.calendar(url=user.caldav_calendar_url)
event = None base_event = self._get_caldav_base_event_by_uid(calendar, self.caldav_uid)
try: if self.recurrence_id:
event = self._find_in_caldav_calendar(calendar) tz = timezone(self.event_tz or self.env.user.tz)
except caldav.error.NotFoundError: start = utc.localize(self.start).astimezone(tz)
_logger.info( # We have an event in the recurrence chain that differs from the base
f"CalDAV event {self.caldav_uid} not found on server during update." # We need to add an iCalendar VEVENT with recurrence-id or update it
f" Creating it instead." 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
if event: self._update_base_caldav_event(
event.save() calendar, base_event, ical_event_data
elif self.recurrence_id and not self.is_base_event: )
base_event = self._get_caldav_base_event_by_uid( elif (
calendar, self.caldav_uid self.differs_from_base_event
) or start != self._get_ical_recurrence_id()
base_event.icalendar_instance.add_component(Event(**ical_event_data)) ):
if self.caldav_recurrence_id != self.start: # We have an event that differs from the base event in some way
tz = timezone(self.event_tz) # So we need to update or create its matching subcomponent, identified
base_event.save() # 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: 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( def _get_caldav_base_event_by_uid(
self, calendar: caldav.Calendar, uid: str self, calendar: caldav.Calendar, uid: str
) -> Optional[CalendarEvent]: ) -> Optional[CalendarEvent]:
for event in calendar.events(): for event in calendar.events():
comp = event.icalendar_component component = event.icalendar_component
event_uid = self._extract_component_text(comp, "uid") event_uid = self._extract_component_text(component, "uid")
event_recurrence_id = self._extract_component_text(comp, "recurrence-id") if event_uid == uid and not component.get("recurrence-id"):
if event_uid == uid and not event_recurrence_id:
return event return event
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 rec in self._to_sync(): for rec in self._to_sync():
try: 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() rec._sync_unlink_to_caldav()
except Exception as e: except Exception as e:
_logger.error(f"Failed to delete event from CalDAV server: {e}") _logger.error(f"Failed to delete event from CalDAV server: {e}")
return super(CalendarEvent, self).unlink() 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): def _sync_unlink_to_caldav(self):
if self.caldav_uid: if self.caldav_uid:
for user in self.caldav_user_ids: for user in self.caldav_user_ids:
client = user._get_caldav_client() client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url) calendar = client.calendar(url=user.caldav_calendar_url)
try: try:
_logger.info( caldav_event = calendar.event_by_uid(self.caldav_uid)
f"Removing CalDAV event {self.caldav_uid}, Odoo ID {self.id} starting at {self.start}" if self.recurrence_id and not self.is_base_event:
) index = self._get_subcomponent_index_for_recurrence(
caldav_event = self._find_in_caldav_calendar( caldav_event
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.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: except caldav.error.NotFoundError:
_logger.info( # No worries - it just didn't exist so nothing to sync
f"CalDAV event {self.caldav_uid} starting at {self.start}" pass
f" not found on server during deletion."
)
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 _sync_recurrence_to_caldav(self): def _matches_caldav_start(self, caldav_event: caldav.Event) -> bool:
for event in self._to_sync(): 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() 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): def _to_sync(self):
"""Determine which records in self we need to synchronize with the """Determine which records in self we need to synchronize with the
CalDAV server. In essence, we only synchronize base events and those CalDAV server. In essence, we only synchronize base events and those
differing from the base event in a recurring chain.""" differing from the base event in a recurring chain."""
return self.filtered( 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) 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") 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 #### #### Methods for synchronizing changes from the server to Odoo ####
################################################################### ###################################################################
@ -510,7 +525,6 @@ class CalendarEvent(models.Model):
) )
orphaned_events = orphaned_events._to_sync() orphaned_events = orphaned_events._to_sync()
if orphaned_events: if orphaned_events:
_logger.info(f"Deleting orphaned events {orphaned_events.ids}")
orphaned_events.with_context(caldav_no_sync=True).with_user(user).unlink() orphaned_events.with_context(caldav_no_sync=True).with_user(user).unlink()
@api.model @api.model
@ -550,7 +564,6 @@ class CalendarEvent(models.Model):
if not recurrency_vals.get("follow_recurrence"): if not recurrency_vals.get("follow_recurrence"):
recurrency_vals = {} recurrency_vals = {}
_logger.info(f"Creating with vals: {values | recurrency_vals}")
new_event = self.with_context(caldav_no_sync=True).create( new_event = self.with_context(caldav_no_sync=True).create(
values | recurrency_vals values | recurrency_vals
) )
@ -561,20 +574,12 @@ class CalendarEvent(models.Model):
synced_events |= new_event synced_events |= new_event
continue continue
elif outdated or not owned: elif outdated or not owned:
_logger.info( pass
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."
)
else: else:
changed_vals = existing_instance._get_recurrence_changes( changed_vals = existing_instance._get_recurrence_changes(
recurrency_vals recurrency_vals
) | existing_instance._get_value_changes(values) ) | existing_instance._get_value_changes(values)
if changed_vals: if changed_vals:
_logger.info(
f"Updating event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} with : {changed_vals}"
)
existing_instance.with_context( existing_instance.with_context(
caldav_no_sync=True, caldav_no_sync=True,
).write(changed_vals) ).write(changed_vals)
@ -660,10 +665,11 @@ class CalendarEvent(models.Model):
if not rrule: if not rrule:
return {} 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_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
rrule_params = self.env["calendar.recurrence"]._rrule_parse( rrule_params = self.env["calendar.recurrence"]._rrule_parse(
"RRULE:" + rrule_str, component.decoded("dtstart") "RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
) )
vals = { vals = {
"recurrency": True, "recurrency": True,

View file

@ -8,19 +8,27 @@ _logger = logging.getLogger(__name__)
class RecurrenceRule(models.Model): class RecurrenceRule(models.Model):
_inherit = "calendar.recurrence" _inherit = "calendar.recurrence"
@api.model
def _default_uid(self):
return uuid.uuid4()
caldav_uid = fields.Char( caldav_uid = fields.Char(
default=_default_uid,
readonly=True, readonly=True,
copy=False,
) )
_sql_constraints = [
("caldav_uid_unique", "UNIQUE (caldav_uid)", "caldav_uid must be unique")
]
def _stop_at(self, event): @api.model_create_multi
detached_events = super()._stop_at(event) def create(self, vals_list):
detached_events._recompute_caldav_uid() for vals in vals_list:
detached_events._recompute_caldav_recurrence_id() vals["caldav_uid"] = str(uuid.uuid4())
self.calendar_event_ids._sync_recurrence_to_caldav() return super().create(vals_list)
# detached_events._sync_recurrence_to_caldav()
@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 return detached_events