diff --git a/caldav_sync/models/__init__.py b/caldav_sync/models/__init__.py index 1ac5a2d..4d0ff4c 100644 --- a/caldav_sync/models/__init__.py +++ b/caldav_sync/models/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import calendar_event from . import res_users +from . import calendar_recurrence diff --git a/caldav_sync/models/calendar_event.py b/caldav_sync/models/calendar_event.py index a3a5ae8..3787af4 100644 --- a/caldav_sync/models/calendar_event.py +++ b/caldav_sync/models/calendar_event.py @@ -1,8 +1,11 @@ import uuid +from wsgiref.util import request_uri + from odoo import models, api, fields, Command +from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT import caldav import logging -from datetime import datetime +from datetime import datetime, date from icalendar import Calendar, Event, vCalAddress, vText from bs4 import BeautifulSoup import re @@ -50,11 +53,60 @@ class CalendarEvent(models.Model): _inherit = "calendar.event" 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( comodel_name="res.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") def _compute_caldav_users(self): @@ -63,6 +115,12 @@ class CalendarEvent(models.Model): "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 def create(self, vals_list): for vals in vals_list: @@ -70,23 +128,23 @@ class CalendarEvent(models.Model): vals["caldav_uid"] = str(uuid.uuid4()) events = super(CalendarEvent, self).create(vals_list) if not self.env.context.get("caldav_no_sync"): - events._sync_create_to_caldav() + events._to_sync()._sync_create_to_caldav() return events def write(self, vals): - res = super(CalendarEvent, self).write(vals) - if not self.env.context.get("caldav_no_sync") and self.ids: - for rec in self.filtered(lambda event: event._is_caldav_enabled()): - try: - _logger.debug(f"Updating event {self.name} in CalDAV") - rec._sync_update_to_caldav() - except Exception as e: - _logger.error(f"Failed to update event in CalDAV server: {e}") + res = super().write(vals) + to_sync = self._to_sync() + if to_sync and 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()}" + ) + rec._sync_update_to_caldav() return res def unlink(self): 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: _logger.debug(f"Removing event {rec.name} from CalDAV") rec._sync_remove_from_caldav() @@ -99,107 +157,146 @@ class CalendarEvent(models.Model): def _sync_create_to_caldav(self): for event in self: - ical_event = event._get_icalendar() 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_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} - ) + 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): - for event in self: - ical_event = event._get_icalendar() - for user in event.caldav_user_ids: + 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) + 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() calendar = client.calendar(url=self.env.user.caldav_calendar_url) try: - _logger.debug(f"Updating existing CalDAV event {event.caldav_uid}") - calendar.save_event(ical=ical_event) - except Exception as e: - _logger.error(f"Failed to sync event to CalDAV server: {e}") - - def _sync_remove_from_caldav(self): - for event in self: - 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) + _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( + f"CalDAV event {self.caldav_uid} not found on server during deletion." ) - ical_event.add(name="attendee", value=attendee, encode=False) - organizer = vCalAddress(f"MAILTO:{event.user_id.email}") - 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) + except Exception as e: + _logger.error(f"Failed to remove event from CalDAV server: {e}") - # 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)) + def _find_in_icalendar(self, calendar, user): + # 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 + 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 def poll_caldav_server(self): @@ -211,33 +308,59 @@ class CalendarEvent(models.Model): def _poll_user_caldav_server(self, user): _logger.info(f"Polling CalDAV server for user {user.name}") events = user._get_caldav_events() - caldav_uids = set() - + synced_events = self.env["calendar.event"] for caldav_event in events: 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 we should send updates to invitees - odoo_events = self.search([("caldav_uid", "!=", False)]) - for event in odoo_events: - 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 {recurrence_id}" - ) - event.with_context(caldav_no_sync=True).with_user(user).unlink() + orphaned_events = self.search( + [ + ("caldav_uid", "!=", False), + ("id", "not " "in", synced_events.ids), + ("user_id", "=", user.id), + ] + )._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 def _get_existing_instance(self, uid, recurrence_id): - instance = self.env["calendar.event"].search( - [("caldav_uid", "=", uid), ("recurrence_id", "=", recurrence_id)] - ) + if 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( [ ("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 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 = rrule[0] if rrule else None + 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 {} - 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_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"), + **rrule_params, + } + # Convert None to False since fields from Odoo that are not filled come back False + vals = { + key: value if value is not None else False for key, value in vals.items() } - 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} - ) - + # Forever doesn't exist in Odoo. The calendar.recurrence model changes 'forever' + # into 'count' with MAX_RECURRENT_EVENT as the 'count' parameter + if vals.get("end_type") == "forever": + vals.update(end_type="count") + if not vals.get("count"): + vals.update(count=MAX_RECURRENT_EVENT) 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): - current_user_email = user.email.lower() - caldav_uids = set() + synced_events = self.env["calendar.event"] event_components = [ component for component in ical_event.walk() if component.name == "VEVENT" ] - for component in event_components: - uid = component.get("uid") + uid = str(component.get("uid")) recurrence_id = component.get( - "recurrence_id" - ) # Unique identifier for a single event in a recurrence set - partner_ids = self._get_attendee_partners(component, current_user_email) + "recurrence-id" + ) # Date & time a single event would normally be at in the series existing_instance = self._get_existing_instance(uid, recurrence_id) - outdated = False - 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 + outdated = self._get_outdated(component, existing_instance, synced_events) owned = ( existing_instance and existing_instance.partner_id == user.partner_id ) - values, recurrency_vals = ( - self._get_vals_recurrency_vals_from_ical_component( - partner_ids, component, user - ) + values = self._get_values_from_ical_component(component, user) + recurrency_vals = existing_instance._get_recurrency_values_from_ical_event( + component ) - changed_vals = {} if not existing_instance: - _logger.info(f"Creating with vals: {values}") - self.with_context(caldav_no_sync=True).create(values) + _logger.info(f"Creating with vals: {values | recurrency_vals}") + 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: _logger.info( - f"Event {existing_instance.caldav_uid} " - f"{'outdated ' if outdated else ''}" - f"{'not owned by user ' + user.name if not owned else ''}." + 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 # 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: - # Don't update partner_ids if no change - if partner_ids != existing_instance.partner_ids: - changed_vals.update(partner_ids=values.pop("partner_ids")) - updated_attendees = existing_instance.attendee_ids.filtered( - lambda rec: rec.partner_id in partner_ids + 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}" ) - changed_vals.update( - attendee_ids=[Command.set(updated_attendees.ids)] - ) - else: - values.pop("partner_ids") # They break the equality check later + 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: + synced_events |= existing_instance + return synced_events - # Get just the list of values that have changed, leave the others alone - for key, val in values.items(): - curr_val = getattr(existing_instance, 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}) - - 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): + def _get_outdated(self, component, existing_instance, synced_events): + outdated = False + last_modified = component.get("last-modified") if ( - recurrency_vals - and recurrency_vals.get("recurrency") - and ( - not existing_instance.recurrency - or not existing_instance.follow_recurrence - ) + existing_instance + and last_modified + and existing_instance not in synced_events ): - existing_instance.write( - { - "recurrency": True, - "follow_recurrence": True, - } - ) + last_modified = last_modified.dt.astimezone(utc).replace(tzinfo=None) + if last_modified < existing_instance.write_date: + outdated = True + return outdated - def _get_vals_recurrency_vals_from_ical_component( - self, attendee_ids, component, user - ): - start = component.decoded("dtstart") + def _get_values_from_ical_component(self, component, user): + start = component.get("dtstart") and component.decoded("dtstart") if isinstance(start, datetime): start = start.astimezone(utc).replace(tzinfo=None) - end = component.decoded("dtend") + end = component.get("dtend") and component.decoded("dtend") if isinstance(end, datetime): end = end.astimezone(utc).replace(tzinfo=None) organizer = self._get_organizer_partner(component) + attendee_ids = self._get_attendee_partners(component, user.partner_id.email) values = { "name": str(component.get("summary")), "start": start, @@ -419,15 +551,12 @@ class CalendarEvent(models.Model): "description": self._extract_component_text(component, "description"), "location": self._extract_component_text(component, "location"), "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_id": organizer.id if organizer else False, + "partner_id": organizer.id if organizer else user.partner_id.id, "user_id": user.id, } - recurrency_vals = self._get_recurrency_values_from_ical_event(component) - if recurrency_vals: - values.update(recurrency_vals) - return values, recurrency_vals + return values def _get_attendee_partners(self, component, current_user_email): attendee_emails = self._get_ical_attendee_emails(component) @@ -450,7 +579,23 @@ class CalendarEvent(models.Model): 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): organizer = component.get("organizer") diff --git a/caldav_sync/models/calendar_recurrence.py b/caldav_sync/models/calendar_recurrence.py new file mode 100644 index 0000000..a184c24 --- /dev/null +++ b/caldav_sync/models/calendar_recurrence.py @@ -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