caldav_sync: major rework underway. This commit is broken.

This commit is contained in:
Marc Durepos 2024-11-04 07:48:48 -05:00
parent 6b3b76e9e7
commit 0104e3897c
3 changed files with 406 additions and 242 deletions

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import calendar_event from . import calendar_event
from . import res_users from . import res_users
from . import calendar_recurrence

View file

@ -1,8 +1,11 @@
import uuid import uuid
from wsgiref.util import request_uri
from odoo import models, api, fields, Command from odoo import models, api, fields, Command
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
import caldav import caldav
import logging import logging
from datetime import datetime from datetime import datetime, date
from icalendar import Calendar, Event, vCalAddress, vText from icalendar import Calendar, Event, vCalAddress, vText
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import re import re
@ -50,11 +53,60 @@ 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) # Recurrence ID in iCalendar is the date or datetime the event would have
# been at if it followed the sequence. It is set by calendar.recurrence
# when applying a recurrence.
caldav_recurrence_id = fields.Char(
string="CalDAV Recurrence ID",
)
caldav_user_ids = fields.Many2many( caldav_user_ids = fields.Many2many(
comodel_name="res.users", comodel_name="res.users",
compute="_compute_caldav_users", compute="_compute_caldav_users",
) )
is_base_event = fields.Boolean(compute="_compute_is_base_event")
differs_from_base_event = fields.Boolean(compute="_compute_differs_from_base_event")
@api.depends("name", "description", "partner_ids", "location", "videocall_location")
def _compute_differs_from_base_event(self):
base_events = self.filtered("is_base_event")
non_base_events = self - base_events
for event in base_events:
event.differs_from_base_event = False
fields_to_check = [
"name",
"description",
"partner_ids",
"location",
"videocall_location",
]
for event in non_base_events:
base_event = event.recurrence_id.base_event_id
event.differs_from_base_event = any(
[
getattr(event, field) != getattr(base_event, field)
for field in fields_to_check
]
)
@api.depends("is_base_event")
def _compute_update_all_recurrence(self):
for rec in self:
rec.update_all_recurrence = (
rec.recurrency
and rec.is_base_event
and (
rec.recurrence_update == "all_events"
or not rec.recurrence_update
or rec.recurrence_id.calendar_event_ids == rec
)
)
@api.depends("recurrency", "recurrence_id.base_event_id")
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
)
@api.depends("user_id", "partner_ids", "partner_ids.user_id") @api.depends("user_id", "partner_ids", "partner_ids.user_id")
def _compute_caldav_users(self): def _compute_caldav_users(self):
@ -63,6 +115,12 @@ class CalendarEvent(models.Model):
"is_caldav_enabled" "is_caldav_enabled"
) )
def _to_sync(self):
return self.filtered(
lambda event: event._is_caldav_enabled()
and (event.is_base_event or event.differs_from_base_event)
)
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
for vals in vals_list: for vals in vals_list:
@ -70,23 +128,23 @@ class CalendarEvent(models.Model):
vals["caldav_uid"] = str(uuid.uuid4()) vals["caldav_uid"] = str(uuid.uuid4())
events = super(CalendarEvent, self).create(vals_list) events = super(CalendarEvent, self).create(vals_list)
if not self.env.context.get("caldav_no_sync"): if not self.env.context.get("caldav_no_sync"):
events._sync_create_to_caldav() events._to_sync()._sync_create_to_caldav()
return events return events
def write(self, vals): def write(self, vals):
res = super(CalendarEvent, self).write(vals) res = super().write(vals)
if not self.env.context.get("caldav_no_sync") and self.ids: to_sync = self._to_sync()
for rec in self.filtered(lambda event: event._is_caldav_enabled()): if to_sync and self.env.context.get("caldav_no_sync"):
try: for rec in to_sync:
_logger.debug(f"Updating event {self.name} in CalDAV") _logger.debug(
rec._sync_update_to_caldav() f"Updating event {rec.name} in CalDAV. " f"{rec.fields_get()}"
except Exception as e: )
_logger.error(f"Failed to update event in CalDAV server: {e}") rec._sync_update_to_caldav()
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 rec in self.filtered(lambda event: event._is_caldav_enabled()): for rec in self._to_sync():
try: try:
_logger.debug(f"Removing event {rec.name} from CalDAV") _logger.debug(f"Removing event {rec.name} from CalDAV")
rec._sync_remove_from_caldav() rec._sync_remove_from_caldav()
@ -99,107 +157,146 @@ class CalendarEvent(models.Model):
def _sync_create_to_caldav(self): def _sync_create_to_caldav(self):
for event in self: for event in self:
ical_event = event._get_icalendar()
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.debug(f"Creating new CalDAV event for {event.name}") _logger.debug(f"Creating new CalDAV event for {event.name}")
caldav_event = calendar.add_event(ical_event) caldav_events = event._create_in_icalendar(calendar)
caldav_uid = caldav_event.vobject_instance.vevent.uid.value for caldav_event in caldav_events:
_logger.debug(f"New CalDAV UID: {caldav_uid}") caldav_uid = caldav_event.vobject_instance.vevent.uid.value
event.with_context(caldav_no_sync=True).write( _logger.debug(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: 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}")
def _sync_update_to_caldav(self): def _sync_update_to_caldav(self):
for event in self: ical_event_data = self._create_event_data()
ical_event = event._get_icalendar() for user in self.caldav_user_ids:
for user in event.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)
if event:
event.save(**ical_event_data)
else:
calendar.save_event(**ical_event_data)
def _sync_remove_from_caldav(self):
if self.caldav_uid:
for user in self.caldav_user_ids:
client = user._get_caldav_client() client = user._get_caldav_client()
calendar = client.calendar(url=self.env.user.caldav_calendar_url) calendar = client.calendar(url=self.env.user.caldav_calendar_url)
try: try:
_logger.debug(f"Updating existing CalDAV event {event.caldav_uid}") _logger.debug(f"Removing CalDAV event {self.caldav_uid}")
calendar.save_event(ical=ical_event) ical_event = self._find_in_icalendar(calendar, user)
except Exception as e: if ical_event:
_logger.error(f"Failed to sync event to CalDAV server: {e}") ical_event.delete()
except caldav.error.NotFoundError:
def _sync_remove_from_caldav(self): _logger.warning(
for event in self: f"CalDAV event {self.caldav_uid} not found on server during deletion."
if event.caldav_uid:
for user in event.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
try:
_logger.debug(f"Removing CalDAV event {event.caldav_uid}")
calendar.event_by_uid(event.caldav_uid).delete()
# event._get_icalendar().delete()
except caldav.error.NotFoundError:
_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")
for event in self:
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(datetime.now()).astimezone(user_tz))
ical_event.add(
"last-modified", utc.localize(self.write_date).astimezone(user_tz)
)
ical_event.add(
"created", utc.localize(self.create_date).astimezone(user_tz)
)
if event.name:
ical_event.add("summary", event.name)
# TODO: Consider using X-ALT-DESC to stick HTML into the iCal event desc.
if event.description and 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)
if 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,
)
if attendee_record:
attendee.params["partstat"] = vText(
self._map_attendee_status(attendee_record.state)
) )
ical_event.add(name="attendee", value=attendee, encode=False) except Exception as e:
organizer = vCalAddress(f"MAILTO:{event.user_id.email}") _logger.error(f"Failed to remove event from CalDAV server: {e}")
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)
# Add DTSTART and DTEND def _find_in_icalendar(self, calendar, user):
ical_event.add("dtstart", utc.localize(event.start).astimezone(user_tz)) # Since search by uid and recurrence-id is not yet supported,
ical_event.add("dtend", utc.localize(event.stop).astimezone(user_tz)) # we do it manually
match_recurrence_id = self.recurrency 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
if (
event.get("uid") == self.caldav_uid
and event.get("recurrence-id") == self.caldav_recurrence_id
):
return event
return None
calendar.add_component(ical_event) 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:
ical_event_data = self._create_event_data()
second_caldav_event = calendar.save_event(**ical_event_data)
return [caldav_event, second_caldav_event]
return [caldav_event]
return calendar.to_ical() def _create_event_data(self):
event_data = {}
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:
self._add_event_recurrence(event_data)
elif self.recurrency:
self._add_event_recurrence_id(event_data)
return event_data
def _add_event_header_info(self, event_data):
event_data["uid"] = self.caldav_uid
if self.name:
event_data["summary"] = 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)
if self.location:
event_data["location"] = self.location
if 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)
return event_data
def _add_event_recurrence_id(self, event_data):
if self.recurrency:
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
def _add_event_attendees(self, event_data):
attendee_lines = []
for partner in self.partner_ids:
if partner == self.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", "=", self.id), ("partner_id", "=", partner.id)],
limit=1,
)
if attendee_record:
attendee.params["partstat"] = vText(
self._map_attendee_status(attendee_record.state)
)
attendee_lines.append(attendee)
organizer = vCalAddress(f"MAILTO:{self.user_id.email}")
organizer.params["cn"] = self.user_id.name
event_data["organizer"] = organizer
event_data["attendee"] = attendee_lines
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)
return recurrence_id
@api.model @api.model
def poll_caldav_server(self): def poll_caldav_server(self):
@ -211,33 +308,59 @@ class CalendarEvent(models.Model):
def _poll_user_caldav_server(self, user): def _poll_user_caldav_server(self, user):
_logger.info(f"Polling CalDAV server for user {user.name}") _logger.info(f"Polling CalDAV server for user {user.name}")
events = user._get_caldav_events() events = user._get_caldav_events()
caldav_uids = set() synced_events = self.env["calendar.event"]
for caldav_event in events: for caldav_event in events:
ical_event = caldav_event.icalendar_instance ical_event = caldav_event.icalendar_instance
caldav_uids |= self._sync_event_from_ical(ical_event, user) synced_events |= self._sync_event_from_ical(ical_event, user)
_logger.info(f"CalDAV UIDs fetched: {caldav_uids}")
# Remove Odoo events that no longer exist on the CalDAV server
# TODO: check if this fails when the user is deleting someone else's event # TODO: check if this fails when the user is deleting someone else's event
# TODO: check if we should send updates to invitees # TODO: check if we should send updates to invitees
odoo_events = self.search([("caldav_uid", "!=", False)]) orphaned_events = self.search(
for event in odoo_events: [
recurrence_id = event.caldav_recurrence_id or "" ("caldav_uid", "!=", False),
event_uid = f"{event.caldav_uid}{event.caldav_recurrence_id or ''}" ("id", "not " "in", synced_events.ids),
if event_uid not in caldav_uids: ("user_id", "=", user.id),
_logger.info( ]
f"Deleting orphan event {event.name} with UID {event.caldav_uid} " )._to_sync()
f"and Recurrence ID {recurrence_id}" if orphaned_events:
) _logger.info(f"Deleting orphaned events {orphaned_events.ids}")
event.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
def _get_existing_instance(self, uid, recurrence_id): def _get_existing_instance(self, uid, recurrence_id):
instance = self.env["calendar.event"].search( if recurrence_id:
[("caldav_uid", "=", uid), ("recurrence_id", "=", recurrence_id)] recurrence_id = recurrence_id.dt.strftime("%Y%m%dT%H%M%S")
) instance = self.env["calendar.event"].search(
[
("caldav_uid", "=", uid),
("caldav_recurrence_id", "=", recurrence_id),
]
)
else:
instance = self.env["calendar.event"].search(
[
("caldav_uid", "=", uid),
("recurrency", "=", False),
]
)
if instance:
return instance
else:
return (
self.env["calendar.recurrence"]
.search(
[
("base_event_id.caldav_uid", "=", uid),
]
)
.base_event_id
)
if len(instance) == 1:
return instance
if len(instance) > 1:
instance = instance.recurrence_id.base_event_id
return instance or self.env["calendar.event"].search( return instance or self.env["calendar.event"].search(
[ [
("caldav_uid", "=", "uid"), ("caldav_uid", "=", "uid"),
@ -249,169 +372,178 @@ class CalendarEvent(models.Model):
"""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"""
if component.get("recurrence-id"):
# When a component has "recurrence-id" set, it is a single event
# in a series of events. Recurrence-id is the time the event should
# normally occur at if it follows the recurrence of the series.
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",
}
rrule = [item[1] for item in component.property_items() if item[0] == "RRULE"] rrule = [item[1] for item in component.property_items() if item[0] == "RRULE"]
rrule = rrule[0] if rrule else None rrule = rrule[0] if rrule else None
if not rrule: if not rrule:
if not self.recurrency:
# No change, this was already not a recurring event
return {}
else:
# 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,
}
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() and rrule.to_ical().decode("utf-8")
sequence = component.get("sequence")
if sequence and sequence != 1:
# This is not the base event so we can't change recurrence properties
return {} return {}
caldav_recurrence_id = component.get("recurrence-id") 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_str, component.decoded("dtstart") rrule_str, component.decoded("dtstart")
) )
vals = { vals = {
"recurrency": True, "recurrency": True,
"follow_recurrence": True, "follow_recurrence": True,
"caldav_recurrence_id": caldav_recurrence_id,
"recurrence_update": "all_events", "recurrence_update": "all_events",
"rrule_type": rrule_params.get("rrule_type"), **rrule_params,
"end_type": rrule_params.get("end_type"), }
"interval": rrule_params.get("interval"), # Convert None to False since fields from Odoo that are not filled come back False
"count": rrule_params.get("count"), vals = {
"month_by": rrule_params.get("monty_by"), key: value if value is not None else False for key, value in vals.items()
"day": rrule_params.get("day"),
"byday": rrule_params.get("byday"),
"until": rrule_params.get("until"),
} }
if rrule_params.get("weekday"): # Forever doesn't exist in Odoo. The calendar.recurrence model changes 'forever'
vals.update(rrule_params.get("weekday")) # into 'count' with MAX_RECURRENT_EVENT as the 'count' parameter
day_list = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] if vals.get("end_type") == "forever":
vals.update( vals.update(end_type="count")
{day: rrule_params.get(day) for day in day_list if day in rrule_params} if not vals.get("count"):
) vals.update(count=MAX_RECURRENT_EVENT)
return vals return vals
def _get_recurrence_changes(self, recurrency_vals):
if not recurrency_vals and not self.recurrency:
return {}
if not recurrency_vals and self.recurrency:
return {"recurrence_update": "all_events", "recurrency": False}
if recurrency_vals and not self.recurrency:
return recurrency_vals
changed_fields = {
key: recurrency_vals[key]
for key in recurrency_vals.keys()
if hasattr(self, key) and recurrency_vals[key] != getattr(self, key)
}
if (
len(changed_fields) == 1
and changed_fields.get("recurrence_update") == "all_events"
):
return {}
return changed_fields
def _get_value_changes(self, values):
changed_vals = {}
# Don't update partner_ids if no change
if "partner_ids" in values:
partner_ids = values["partner_ids"][0][2] # this is a SET command
added_partner_ids = set(
[id for id in partner_ids if id not in self.partner_ids.ids]
)
removed_partner_ids = set(
[id for id in self.partner_ids.ids if id not in partner_ids]
)
if not (added_partner_ids or removed_partner_ids):
values.pop("partner_ids") # They break the equality check later
else:
changed_vals["partner_ids"] = values["partner_ids"]
# Get just the list of values that have changed, leave the others alone
for key, val in values.items():
curr_val = getattr(self, key)
# Can't deal with x2many fields, need ID from a record
if isinstance(val, list):
continue
if curr_val and isinstance(curr_val, models.Model):
if len(curr_val) > 1:
continue
curr_val = curr_val.id
if curr_val != val:
changed_vals.update({key: val})
return changed_vals
def _sync_event_from_ical(self, ical_event, user): def _sync_event_from_ical(self, ical_event, user):
current_user_email = user.email.lower() synced_events = self.env["calendar.event"]
caldav_uids = set()
event_components = [ event_components = [
component for component in ical_event.walk() if component.name == "VEVENT" component for component in ical_event.walk() if component.name == "VEVENT"
] ]
for component in event_components: for component in event_components:
uid = component.get("uid") uid = str(component.get("uid"))
recurrence_id = component.get( recurrence_id = component.get(
"recurrence_id" "recurrence-id"
) # Unique identifier for a single event in a recurrence set ) # Date & time a single event would normally be at in the series
partner_ids = self._get_attendee_partners(component, current_user_email)
existing_instance = self._get_existing_instance(uid, recurrence_id) existing_instance = self._get_existing_instance(uid, recurrence_id)
outdated = False outdated = self._get_outdated(component, existing_instance, synced_events)
last_modified = component.get("last-modified") and component.decoded(
"last-modified"
)
if existing_instance and last_modified:
last_modified = last_modified.astimezone(utc).replace(tzinfo=None)
if last_modified < existing_instance.write_date:
# _logger.info(
# f"Last modified date {last_modified} is before most recent "
# f"write date {existing_instance.write_date}. Skipping."
# )
outdated = True
owned = ( owned = (
existing_instance and existing_instance.partner_id == user.partner_id existing_instance and existing_instance.partner_id == user.partner_id
) )
values, recurrency_vals = ( values = self._get_values_from_ical_component(component, user)
self._get_vals_recurrency_vals_from_ical_component( recurrency_vals = existing_instance._get_recurrency_values_from_ical_event(
partner_ids, component, user component
)
) )
changed_vals = {}
if not existing_instance: if not existing_instance:
_logger.info(f"Creating with vals: {values}") _logger.info(f"Creating with vals: {values | recurrency_vals}")
self.with_context(caldav_no_sync=True).create(values) new_event = self.with_context(caldav_no_sync=True).create(
values | recurrency_vals
)
self.env["calendar.event"].flush_model()
if new_event.recurrency:
synced_events |= new_event.recurrence_id.calendar_event_ids
else:
synced_events |= new_event
continue
elif outdated or not owned: elif outdated or not owned:
_logger.info( _logger.info(
f"Event {existing_instance.caldav_uid} " f"Event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} "
f"{'outdated ' if outdated else ''}" f"{'outdated' if outdated else ''}"
f"{'not owned by user ' + user.name if not owned else ''}." f"{'not owned by user' + user.name if not owned else ''}."
f" Skipping." f" Skipping."
) )
pass # Do nothing, it's not this user's event to modify or it's outdated # Do nothing, it's not this user's event to modify or it's outdated
else: else:
# Don't update partner_ids if no change changed_vals = existing_instance._get_recurrence_changes(
if partner_ids != existing_instance.partner_ids: recurrency_vals
changed_vals.update(partner_ids=values.pop("partner_ids")) ) | existing_instance._get_value_changes(values)
updated_attendees = existing_instance.attendee_ids.filtered( if changed_vals:
lambda rec: rec.partner_id in partner_ids _logger.info(
f"Updating event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} with : {changed_vals}"
) )
changed_vals.update( existing_instance.with_context(
attendee_ids=[Command.set(updated_attendees.ids)] caldav_no_sync=True,
) ).write(changed_vals)
else: if existing_instance.recurrency and existing_instance.is_base_event:
values.pop("partner_ids") # They break the equality check later synced_events |= existing_instance.recurrence_id.calendar_event_ids
else:
synced_events |= existing_instance
return synced_events
# Get just the list of values that have changed, leave the others alone def _get_outdated(self, component, existing_instance, synced_events):
for key, val in values.items(): outdated = False
curr_val = getattr(existing_instance, key) last_modified = component.get("last-modified")
# Can't deal with x2many fields, need ID from a record
if isinstance(val, list):
continue
if curr_val and isinstance(curr_val, models.Model):
if len(curr_val) > 1:
continue
curr_val = curr_val.id
if curr_val != val:
changed_vals.update({key: val})
self._update_event_recurrence(existing_instance, recurrency_vals)
_logger.info(f"Updating with : {changed_vals}")
existing_instance.with_context(
caldav_no_sync=True,
).write(changed_vals)
recurrence_id = str(component.get("recurrence-id"))
if recurrence_id == "None":
recurrence_id = ""
caldav_uids.add(f"{uid}{recurrence_id}")
return caldav_uids
@staticmethod
def _update_event_recurrence(existing_instance, recurrency_vals):
if ( if (
recurrency_vals existing_instance
and recurrency_vals.get("recurrency") and last_modified
and ( and existing_instance not in synced_events
not existing_instance.recurrency
or not existing_instance.follow_recurrence
)
): ):
existing_instance.write( last_modified = last_modified.dt.astimezone(utc).replace(tzinfo=None)
{ if last_modified < existing_instance.write_date:
"recurrency": True, outdated = True
"follow_recurrence": True, return outdated
}
)
def _get_vals_recurrency_vals_from_ical_component( def _get_values_from_ical_component(self, component, user):
self, attendee_ids, component, user start = component.get("dtstart") and 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.get("dtend") and 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)
organizer = self._get_organizer_partner(component) organizer = self._get_organizer_partner(component)
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
values = { values = {
"name": str(component.get("summary")), "name": str(component.get("summary")),
"start": start, "start": start,
@ -419,15 +551,12 @@ class CalendarEvent(models.Model):
"description": self._extract_component_text(component, "description"), "description": self._extract_component_text(component, "description"),
"location": self._extract_component_text(component, "location"), "location": self._extract_component_text(component, "location"),
"videocall_location": self._extract_component_text(component, "conference"), "videocall_location": self._extract_component_text(component, "conference"),
"caldav_uid": component.get("uid"), "caldav_uid": str(component.get("uid")),
"partner_ids": [(6, 0, attendee_ids.ids)], "partner_ids": [(6, 0, attendee_ids.ids)],
"partner_id": organizer.id if organizer else False, "partner_id": organizer.id if organizer else user.partner_id.id,
"user_id": user.id, "user_id": user.id,
} }
recurrency_vals = self._get_recurrency_values_from_ical_event(component) return values
if recurrency_vals:
values.update(recurrency_vals)
return values, recurrency_vals
def _get_attendee_partners(self, component, current_user_email): def _get_attendee_partners(self, component, current_user_email):
attendee_emails = self._get_ical_attendee_emails(component) attendee_emails = self._get_ical_attendee_emails(component)
@ -450,7 +579,23 @@ class CalendarEvent(models.Model):
for email in missing_emails for email in missing_emails
] ]
) )
return existing_partners | added_partners final_attendees = {}
all_partners = existing_partners | added_partners
# We do this because partners may have identical emails and we only want one
# attending partner per email. Otherwise invitations get sent out multiple times
# and nobody likes that.
# Prioritize users as attendees
for partner in all_partners.filtered(lambda partner: bool(partner.user_id)):
if partner.email not in final_attendees:
final_attendees[partner.email] = partner.id
for partner in all_partners.filtered(lambda partner: not partner.user_id):
if partner.email not in final_attendees:
final_attendees[partner.email] = partner.id
return all_partners.filtered(
lambda partner: partner.id in final_attendees.values()
)
def _get_organizer_partner(self, component): def _get_organizer_partner(self, component):
organizer = component.get("organizer") organizer = component.get("organizer")

View file

@ -0,0 +1,18 @@
from odoo import models, fields, api
from pytz import timezone
class RecurrenceRule(models.Model):
_inherit = "calendar.recurrence"
def _apply_recurrence(self, **kwargs):
detached_events = super()._apply_recurrence(**kwargs)
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)
for event in self.calendar_event_ids:
event.caldav_recurrence_id = _get_recurrence_id(event)
return detached_events