diff --git a/caldav_sync/models/calendar_event.py b/caldav_sync/models/calendar_event.py index 6067738..63e22a4 100644 --- a/caldav_sync/models/calendar_event.py +++ b/caldav_sync/models/calendar_event.py @@ -1,6 +1,5 @@ import uuid from odoo import models, api, fields, Command -from odoo.exceptions import UserError import caldav import logging from datetime import datetime @@ -8,7 +7,6 @@ from icalendar import Calendar, Event, vCalAddress, vText from bs4 import BeautifulSoup import re from pytz import timezone, utc -from caldav.elements.cdav import CompFilter _logger = logging.getLogger(__name__) @@ -42,102 +40,108 @@ def _parse_rrule_string(rrule_str): return params_dict +def _extract_vcal_email(vcal_address): + email_regex = re.compile(r"[a-z0-9.\-+_]+@[a-z0-9.\-+_]+\.[a-z]+") + res = email_regex.search(str(vcal_address)) + return res.group(0).lower().strip() if res else "" + + 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) + caldav_user_ids = fields.Many2many( + comodel_name="res.users", + compute="_compute_caldav_users", + ) + + @api.depends("user_id", "partner_ids", "partner_ids.user_id") + def _compute_caldav_users(self): + for rec in self: + rec.caldav_user_ids = (rec.user_id | rec.partner_ids.user_ids).filtered( + "is_caldav_enabled" + ) @api.model_create_multi def create(self, vals_list): - events = self.env['calendar.event'] for vals in vals_list: if not vals.get("caldav_uid"): 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._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: + 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() + rec._sync_update_to_caldav() except Exception as e: _logger.error(f"Failed to update event in CalDAV server: {e}") return res def unlink(self): if not self.env.context.get("caldav_no_sync"): - for event in self: + for rec in self.filtered(lambda event: event._is_caldav_enabled()): try: - _logger.debug(f"Removing event {event.name} from CalDAV") - event.sync_remove_from_caldav() + _logger.debug(f"Removing event {rec.name} from CalDAV") + rec._sync_remove_from_caldav() except Exception as e: _logger.error(f"Failed to delete event from CalDAV server: {e}") return super(CalendarEvent, self).unlink() def _is_caldav_enabled(self): - return self.env.user.is_caldav_enabled() + return self.env.user.is_caldav_enabled - def _get_caldav_client(self): - user = self.env.user - return caldav.DAVClient( - url=user.caldav_calendar_url, - username=user.caldav_username, - password=user.caldav_password, - ) - - def sync_create_to_caldav(self): - if not self._is_caldav_enabled(): - return - client = self._get_caldav_client() - calendar = client.calendar(url=self.env.user.caldav_calendar_url) + def _sync_create_to_caldav(self): for event in self: ical_event = event._get_icalendar() - 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} - ) - except Exception as e: - _logger.error(f"Failed to sync event to CalDAV server: {e}") - - def sync_update_to_caldav(self): - if not self._is_caldav_enabled(): - return - client = self._get_caldav_client() - calendar = client.calendar(url=self.env.user.caldav_calendar_url) - for event in self: - ical_event = event._get_icalendar() - 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): - if not self._is_caldav_enabled(): - return - client = self._get_caldav_client() - calendar = client.calendar(url=self.env.user.caldav_calendar_url) - for event in self: - if event.caldav_uid: + for user in event.caldav_user_ids: + client = user._get_caldav_client() + calendar = client.calendar(url=user.caldav_calendar_url) try: - _logger.debug(f"Removing CalDAV event {event.caldav_uid}") - event._get_icalendar().delete() - except caldav.error.NotFoundError: - _logger.warning( - f"CalDAV event {event.caldav_uid} not found on server." + _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} ) except Exception as e: - _logger.error(f"Failed to remove event from CalDAV server: {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: + 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() @@ -150,11 +154,16 @@ class CalendarEvent(models.Model): 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( - "dtstamp", utc.localize(event.write_date).astimezone(user_tz) + "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: @@ -174,7 +183,7 @@ class CalendarEvent(models.Model): attendee.params["partstat"] = vText( self._map_attendee_status(attendee_record.state) ) - ical_event.add("attendee", attendee, encode=0) + 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) @@ -194,55 +203,25 @@ class CalendarEvent(models.Model): @api.model def poll_caldav_server(self): - all_users = ( - self.env["res.users"].search([]).filtered(lambda u: u.is_caldav_enabled()) - ) + all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)]) for user in all_users: - self.with_user(user).poll_user_caldav_server() + self._poll_user_caldav_server(user) @api.model - def poll_user_caldav_server(self): - if not self._is_caldav_enabled(): - return - client = self._get_caldav_client() - try: - calendar = client.calendar(url=self.env.user.caldav_calendar_url) - events = calendar.events() - except Exception as e: - _logger.error(e) - try: - principal = client.principal() - msg = f"""Failed to connect to the calendar, but successfully connected to the - server at {client.url}. - You may need to select another calendar URL from those below. - - Available calendars: - - """ - for calendar in principal.calendars(): - msg += f"{calendar.name}: {calendar.url}\n" - raise UserError(msg) - except Exception as e: - _logger.error(e) - return + 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() - _logger.info(f"Polling CalDAV server for user {self.env.user.name}") - for caldav_event in events: ical_event = caldav_event.icalendar_instance - self.sync_event_from_ical(ical_event) - for component in ical_event.subcomponents: - if isinstance(component, Event): - uid = str(component.get("uid")) - recurrence_id = str(component.get("recurrence-id")) - if recurrence_id == "None": - recurrence_id = "" - caldav_uids.add(f"{uid}{recurrence_id}") + caldav_uids |= 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 "" @@ -250,9 +229,9 @@ class CalendarEvent(models.Model): if event_uid not in caldav_uids: _logger.info( f"Deleting orphan event {event.name} with UID {event.caldav_uid} " - f"and Recurrence ID {event.caldav_recurrence_id or ''}" + f"and Recurrence ID {recurrence_id}" ) - event.with_context(caldav_no_sync=True).unlink() + event.with_context(caldav_no_sync=True).with_user(user).unlink() @api.model def _get_existing_instance(self, uid, recurrence_id): @@ -270,7 +249,8 @@ 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""" - rrule = component.get("rrule") + 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 @@ -289,7 +269,7 @@ class CalendarEvent(models.Model): return {"recurrence_update": "all_events", "recurrency": False} rrule_str = rrule.to_ical().decode("utf-8") sequence = component.get("sequence") - if sequence and sequence != 0: + if sequence and sequence != 1: # This is not the base event so we can't change recurrence properties return {} @@ -321,109 +301,177 @@ class CalendarEvent(models.Model): return vals - def sync_event_from_ical(self, ical_event): - email_regex = re.compile(r"[a-z0-9.\-+_]+@[a-z0-9.\-+_]+\.[a-z]+") - current_user_email = self.env.user.email.lower() + def _sync_event_from_ical(self, ical_event, user): + current_user_email = user.email.lower() + caldav_uids = set() + event_components = [ + component for component in ical_event.walk() if component.name == "VEVENT" + ] - for component in ical_event.subcomponents: - if isinstance(component, Event): - uid = component.get("uid") - recurrence_id = component.get( - "recurrence_id" - ) # Unique identifier for a single event in a recurrence set - attendees = component.get("attendee", []) + for component in event_components: + uid = 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) - if isinstance(attendees, vCalAddress): - attendees = [attendees] - elif isinstance(attendees, str): - attendees = [vCalAddress(attendees)] - - attendees_emails = [ - email_regex.search(str(attendee)).group(0).lower().strip() - for attendee in attendees - if email_regex.search(str(attendee)) - ] - - _logger.info(f"Attendees emails: {attendees_emails}") - - # Add current user email to attendees if not already present - if current_user_email not in attendees_emails: - attendees_emails.append(current_user_email) - - attendee_ids = self.env["res.partner"].search( - [("email", "in", attendees_emails)] + existing_instance = self._get_existing_instance(uid, recurrence_id) + outdated = False + last_modified = 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 = ( + 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 ) - - existing_instance = self._get_existing_instance(uid, recurrence_id) - start = component.decoded("dtstart") - if isinstance(start, datetime): - start = start.astimezone(utc).replace(tzinfo=None) - end = component.decoded("dtend") - if isinstance(end, datetime): - end = end.astimezone(utc).replace(tzinfo=None) - values = { - "name": str(component.get("summary")), - "start": start, - "stop": end, - "description": self._extract_component_text( - component, "description" - ), - "location": self._extract_component_text(component, "location"), - "videocall_location": self._extract_component_text( - component, "conference" - ), - "caldav_uid": uid, - "partner_ids": [(6, 0, attendee_ids.ids)], - } - recurrency_vals = self._get_recurrency_values_from_ical_event(component) - if recurrency_vals: - values.update(recurrency_vals) - if not existing_instance: - _logger.info(f"Creating with vals: {values}") - self.with_context(caldav_no_sync=True).create(values) + ) + changed_vals = {} + if not existing_instance: + _logger.info(f"Creating with vals: {values}") + self.with_context(caldav_no_sync=True).create(values) + 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" Skipping." + ) + pass # 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.update( + attendee_ids=[Command.set(updated_attendees.ids)] + ) else: - _logger.info(f"Updating with vals: {values}") - changed_vals = {} - # Don't update partner_ids if no change - if attendee_ids - existing_instance.partner_ids: - changed_vals.update( - { - "partner_ids": - values.pop("partner_ids"), - } - ) + values.pop("partner_ids") # They break the equality check later - # Don't write values that haven't changed - changed_vals = { - key: val - for key, val in values.items() - if (cur_val := getattr(existing_instance, key)) - and isinstance(cur_val, type(val)) - and cur_val != val + # 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}) - } - if ( - recurrency_vals - and recurrency_vals.get("recurrency") - and ( - not existing_instance.recurrency - or not existing_instance.follow_recurrence - ) - ): - existing_instance.write( - { - "recurrency": True, - "follow_recurrence": True, - } - ) - existing_instance.with_context( - caldav_no_sync=True, - ).write(changed_vals) + 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 ( + recurrency_vals + and recurrency_vals.get("recurrency") + and ( + not existing_instance.recurrency + or not existing_instance.follow_recurrence + ) + ): + existing_instance.write( + { + "recurrency": True, + "follow_recurrence": True, + } + ) + + def _get_vals_recurrency_vals_from_ical_component( + self, attendee_ids, component, user + ): + start = component.decoded("dtstart") + if isinstance(start, datetime): + start = start.astimezone(utc).replace(tzinfo=None) + end = component.decoded("dtend") + if isinstance(end, datetime): + end = end.astimezone(utc).replace(tzinfo=None) + organizer = self._get_organizer_partner(component) + values = { + "name": str(component.get("summary")), + "start": start, + "stop": end, + "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"), + "partner_ids": [(6, 0, attendee_ids.ids)], + "partner_id": organizer.id if organizer else False, + "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 + + def _get_attendee_partners(self, component, current_user_email): + attendee_emails = self._get_ical_attendee_emails(component) + if current_user_email not in attendee_emails: + attendee_emails.append(current_user_email) + existing_partners = self.env["res.partner"].search( + [("email", "in", attendee_emails)] + ) + missing_emails = [ + email + for email in attendee_emails + if email not in [partner.email for partner in existing_partners] + ] + added_partners = self.env["res.partner"].create( + [ + { + "name": email, + "email": email, + } + for email in missing_emails + ] + ) + return existing_partners | added_partners + + def _get_organizer_partner(self, component): + organizer = component.get("organizer") + if organizer: + return self.env["res.partner"].search( + [("email", "=", _extract_vcal_email(organizer))] + ) + else: + return self.env["res.partner"] + + @staticmethod + def _get_ical_attendee_emails(component): + attendees = component.get("attendee", []) + if not isinstance(attendees, list): + attendees = [attendees] + attendee_emails = [_extract_vcal_email(attendee) for attendee in attendees] + return attendee_emails @staticmethod def _extract_component_text(component, subcomponent_name): - text = str(component.get(subcomponent_name)) - text = text if text != "None" else "" + val = component.get(subcomponent_name) + text = str(val) if val else "" + return text @staticmethod def _html_to_text(html): @@ -438,13 +486,3 @@ class CalendarEvent(models.Model): "tentative": "TENTATIVE", } return mapping.get(state, "NEEDS-ACTION") - - @staticmethod - def _map_ical_status(ical_status): - mapping = { - "NEEDS-ACTION": "needsAction", - "ACCEPTED": "accepted", - "DECLINED": "declined", - "TENTATIVE": "tentative", - } - return mapping.get(ical_status, "needsAction") diff --git a/caldav_sync/models/res_users.py b/caldav_sync/models/res_users.py index 904c59e..8c661a6 100644 --- a/caldav_sync/models/res_users.py +++ b/caldav_sync/models/res_users.py @@ -1,12 +1,60 @@ -from odoo import models, fields +from odoo import models, fields, api +from odoo.exceptions import UserError +from odoo.tools import mute_logger +import caldav +import logging + +_logger = logging.getLogger(__name__) + class ResUsers(models.Model): - _inherit = 'res.users' + _inherit = "res.users" - caldav_calendar_url = fields.Char(string='CalDAV Calendar URL') - caldav_username = fields.Char(string='CalDAV Username') - caldav_password = fields.Char(string='CalDAV Password') + caldav_calendar_url = fields.Char(string="CalDAV Calendar URL") + caldav_username = fields.Char(string="CalDAV Username") + caldav_password = fields.Char(string="CalDAV Password") + is_caldav_enabled = fields.Boolean(compute="_compute_is_caldav_enabled", store=True) - def is_caldav_enabled(self): + @api.depends("caldav_username", "caldav_password", "caldav_calendar_url") + def _compute_is_caldav_enabled(self): + """This is a bit of an odd way of computing the field, but it works since any + failed attempt to get the events from the server should mark the user as + CalDAV being disabled. We just make sure to mute the logger in the case that + an exception is raised because we are just computing the field, not actually + attempting to synchronize anything.""" + for rec in self: + with mute_logger("odoo.addons.caldav_sync.models.res_users"): + rec._get_caldav_events() + + def _get_caldav_client(self): self.ensure_one() - return bool(self.caldav_calendar_url and self.caldav_username and self.caldav_password) \ No newline at end of file + return caldav.DAVClient( + url=self.caldav_calendar_url, + username=self.caldav_username, + password=self.caldav_password, + ) + + def _get_caldav_events(self): + self.ensure_one() + client = self._get_caldav_client() + try: + calendar = client.calendar(url=self.caldav_calendar_url) + events = calendar.events() + self.is_caldav_enabled = True + except Exception as e: + self.is_caldav_enabled = False + _logger.error(e) + try: + principal = client.principal() + msg = ( + f"Failed to connect to the calendar, but successfully connected " + f"to the server at {client.url}. You may need to select another " + f"calendar URL from those below.\n\n" + ) + for calendar in principal.calendars(): + msg += f"{calendar.name}: {calendar.url}\n" + raise UserError(msg) + except Exception as e: + _logger.error(e) + return + return events diff --git a/caldav_sync/tests/__init__.py b/caldav_sync/tests/__init__.py new file mode 100644 index 0000000..3908f85 --- /dev/null +++ b/caldav_sync/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_res_users +from . import test_calendar diff --git a/caldav_sync/tests/common.py b/caldav_sync/tests/common.py new file mode 100644 index 0000000..6c9957e --- /dev/null +++ b/caldav_sync/tests/common.py @@ -0,0 +1,26 @@ +from odoo import Command + + +class CaldavTestCommon: + @classmethod + def _generate_user( + cls, name, caldav_username=None, caldav_password=None, caldav_url=None + ): + groups_ids = cls.env.ref("base.group_user") | cls.env.ref( + "base.group_partner_manager" + ) + vals = { + "name": name, + "login": name, + "password": name, + "email": name + "@example.com", + "groups_id": [Command.set(groups_ids.ids)], + } + if caldav_username: + vals.update(caldav_username=caldav_username) + if caldav_password: + vals.update(caldav_password=caldav_password) + if caldav_url: + vals.update(caldav_calendar_url=caldav_url) + user = cls.env["res.users"].create(vals) + return user diff --git a/caldav_sync/tests/data/basic.ics b/caldav_sync/tests/data/basic.ics new file mode 100644 index 0000000..f258e10 --- /dev/null +++ b/caldav_sync/tests/data/basic.ics @@ -0,0 +1,37 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +VERSION:2.0 +X-WR-CALNAME:Test +METHOD:PUBLISH +PRODID:-//Apple Inc.//macOS 14.5//EN +BEGIN:VTIMEZONE +TZID:Europe/Brussels +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:GMT+2 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:GMT+1 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20241005T105747Z +DTEND;TZID=Europe/Brussels:20241007T090000 +TRANSP:OPAQUE +UID:5C753EFA-DA9C-479A-81CE-5A5421518EAE +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +SUMMARY:Test +DTSTART;TZID=Europe/Brussels:20241007T080000 +DTSTAMP:20241005T105752Z +LAST-MODIFIED:20241005T105747Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR diff --git a/caldav_sync/tests/data/basic_updated.ics b/caldav_sync/tests/data/basic_updated.ics new file mode 100644 index 0000000..24e329c --- /dev/null +++ b/caldav_sync/tests/data/basic_updated.ics @@ -0,0 +1,39 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +VERSION:2.0 +X-WR-CALNAME:Test Updated +METHOD:PUBLISH +PRODID:-//Apple Inc.//macOS 14.5//EN +BEGIN:VTIMEZONE +TZID:Europe/Brussels +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:GMT+2 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:GMT+1 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +TRANSP:OPAQUE +DTEND;TZID=Europe/Brussels:20241007T100000 +UID:5C753EFA-DA9C-479A-81CE-5A5421518EAE +DTSTAMP:20241005T133547Z +LOCATION:https://www.test.com/videocall +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +DESCRIPTION:Some note ... +SEQUENCE:1 +SUMMARY:Test Updated +DTSTART;TZID=Europe/Brussels:20241007T090000 +LAST-MODIFIED:20241005T133542Z +CREATED:20241005T105747Z +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +END:VCALENDAR diff --git a/caldav_sync/tests/data/multi_event.ics b/caldav_sync/tests/data/multi_event.ics new file mode 100644 index 0000000..e69de29 diff --git a/caldav_sync/tests/data/test_multi_attendee.ics b/caldav_sync/tests/data/test_multi_attendee.ics new file mode 100644 index 0000000..df306be --- /dev/null +++ b/caldav_sync/tests/data/test_multi_attendee.ics @@ -0,0 +1,192 @@ +BEGIN:VCALENDAR +PRODID:-//caldav.icloud.com//CALDAVJ 2425B663//EN +METHOD:REQUEST +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;CN=Partner 1;CUTYPE=INDIVIDUAL;EMAIL=partner1@example.com:mailto + :partner1@example.com +ATTENDEE;CN=Partner 2;CUTYPE=INDIVIDUAL;EMAIL=partner2@example.com:mail + to:partner2@example.com +ATTENDEE;CN=test1;CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;EMAIL=test1@example.com; +ROLE=CHAIR:mailto:test1@example.com +CREATED:20241005T152408Z +DESCRIPTION:Some note +DTEND;TZID=Europe/Brussels:20241007T110000 +DTSTART;TZID=Europe/Brussels:20241007T100000 +LAST-MODIFIED:20241006T214319Z +ORGANIZER;CN=test1;EMAIL=test1@example.com:mailto:test1@example.com +SEQUENCE:1 +SUMMARY:Test multi-attendee +UID:2495546B-5C9A-4632-AAD3-A179EF83CF20 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +DTSTAMP:20241006T214320Z +END:VEVENT +BEGIN:VTIMEZONE +TZID:Europe/Brussels +X-LIC-LOCATION:Europe/Brussels +BEGIN:STANDARD +DTSTART:18800101T000000 +RDATE:18800101T000000 +TZNAME:BMT +TZOFFSETFROM:+001730 +TZOFFSETTO:+001730 +END:STANDARD +BEGIN:STANDARD +DTSTART:18920501T120000 +RDATE:18920501T120000 +TZNAME:WET +TZOFFSETFROM:+001730 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19141108T000000 +RDATE:19141108T000000 +TZNAME:CET +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160501T000000 +RDATE:19160501T000000 +RDATE:19400520T030000 +RDATE:19430329T020000 +RDATE:19440403T020000 +RDATE:19450402T020000 +RDATE:19460519T020000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19161001T010000 +RDATE:19161001T010000 +RDATE:19421102T030000 +RDATE:19431004T030000 +RDATE:19440917T030000 +RDATE:19450916T030000 +RDATE:19461007T030000 +RDATE:19770925T030000 +RDATE:19781001T030000 +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19170416T020000 +RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19170917T030000 +RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181111T120000 +RRULE:FREQ=YEARLY;UNTIL=19191004T230000Z;BYMONTH=10;BYDAY=1MO +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19190301T230000 +RDATE:19190301T230000 +RDATE:19200214T230000 +RDATE:19210314T230000 +RDATE:19220325T230000 +RDATE:19230421T230000 +RDATE:19240329T230000 +RDATE:19250404T230000 +RDATE:19260417T230000 +RDATE:19270409T230000 +RDATE:19280414T230000 +RDATE:19290421T020000 +RDATE:19300413T020000 +RDATE:19310419T020000 +RDATE:19320403T020000 +RDATE:19330326T020000 +RDATE:19340408T020000 +RDATE:19350331T020000 +RDATE:19360419T020000 +RDATE:19370404T020000 +RDATE:19380327T020000 +RDATE:19390416T020000 +RDATE:19400225T020000 +TZNAME:WEST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19201024T000000 +RDATE:19201024T000000 +RDATE:19211026T000000 +RDATE:19391119T030000 +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19221008T000000 +RRULE:FREQ=YEARLY;UNTIL=19271001T230000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19281007T030000 +RRULE:FREQ=YEARLY;UNTIL=19381002T020000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19440903T000000 +RDATE:19440903T000000 +TZNAME:CEST +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19770101T000000 +RDATE:19770101T000000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19770403T020000 +RRULE:FREQ=YEARLY;UNTIL=19800406T010000Z;BYMONTH=4;BYDAY=1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19790930T030000 +RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +END:VCALENDAR diff --git a/caldav_sync/tests/data/test_multi_attendee_update.ics b/caldav_sync/tests/data/test_multi_attendee_update.ics new file mode 100644 index 0000000..f905b07 --- /dev/null +++ b/caldav_sync/tests/data/test_multi_attendee_update.ics @@ -0,0 +1,190 @@ +BEGIN:VCALENDAR +PRODID:-//caldav.icloud.com//CALDAVJ 2425B663//EN +METHOD:REQUEST +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;CN=Partner 2;CUTYPE=INDIVIDUAL;EMAIL=partner2@example.com:mail + to:partner2@example.com +ATTENDEE;CN=test1;CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;EMAIL=test1@example.com; +ROLE=CHAIR:mailto:test1@example.com +CREATED:20241005T152408Z +DESCRIPTION:Some note +DTEND;TZID=Europe/Brussels:20241007T110000 +DTSTART;TZID=Europe/Brussels:20241007T100000 +LAST-MODIFIED:20241006T214319Z +ORGANIZER;CN=test1;EMAIL=test1@example.com:mailto:test1@example.com +SEQUENCE:1 +SUMMARY:Test multi-attendee +UID:2495546B-5C9A-4632-AAD3-A179EF83CF20 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +DTSTAMP:20241006T214320Z +END:VEVENT +BEGIN:VTIMEZONE +TZID:Europe/Brussels +X-LIC-LOCATION:Europe/Brussels +BEGIN:STANDARD +DTSTART:18800101T000000 +RDATE:18800101T000000 +TZNAME:BMT +TZOFFSETFROM:+001730 +TZOFFSETTO:+001730 +END:STANDARD +BEGIN:STANDARD +DTSTART:18920501T120000 +RDATE:18920501T120000 +TZNAME:WET +TZOFFSETFROM:+001730 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19141108T000000 +RDATE:19141108T000000 +TZNAME:CET +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160501T000000 +RDATE:19160501T000000 +RDATE:19400520T030000 +RDATE:19430329T020000 +RDATE:19440403T020000 +RDATE:19450402T020000 +RDATE:19460519T020000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19161001T010000 +RDATE:19161001T010000 +RDATE:19421102T030000 +RDATE:19431004T030000 +RDATE:19440917T030000 +RDATE:19450916T030000 +RDATE:19461007T030000 +RDATE:19770925T030000 +RDATE:19781001T030000 +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19170416T020000 +RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19170917T030000 +RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181111T120000 +RRULE:FREQ=YEARLY;UNTIL=19191004T230000Z;BYMONTH=10;BYDAY=1MO +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19190301T230000 +RDATE:19190301T230000 +RDATE:19200214T230000 +RDATE:19210314T230000 +RDATE:19220325T230000 +RDATE:19230421T230000 +RDATE:19240329T230000 +RDATE:19250404T230000 +RDATE:19260417T230000 +RDATE:19270409T230000 +RDATE:19280414T230000 +RDATE:19290421T020000 +RDATE:19300413T020000 +RDATE:19310419T020000 +RDATE:19320403T020000 +RDATE:19330326T020000 +RDATE:19340408T020000 +RDATE:19350331T020000 +RDATE:19360419T020000 +RDATE:19370404T020000 +RDATE:19380327T020000 +RDATE:19390416T020000 +RDATE:19400225T020000 +TZNAME:WEST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19201024T000000 +RDATE:19201024T000000 +RDATE:19211026T000000 +RDATE:19391119T030000 +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19221008T000000 +RRULE:FREQ=YEARLY;UNTIL=19271001T230000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19281007T030000 +RRULE:FREQ=YEARLY;UNTIL=19381002T020000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19440903T000000 +RDATE:19440903T000000 +TZNAME:CEST +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19770101T000000 +RDATE:19770101T000000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19770403T020000 +RRULE:FREQ=YEARLY;UNTIL=19800406T010000Z;BYMONTH=4;BYDAY=1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19790930T030000 +RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +END:VCALENDAR diff --git a/caldav_sync/tests/data/test_multi_user.ics b/caldav_sync/tests/data/test_multi_user.ics new file mode 100644 index 0000000..08ca584 --- /dev/null +++ b/caldav_sync/tests/data/test_multi_user.ics @@ -0,0 +1,192 @@ +BEGIN:VCALENDAR +PRODID:-//caldav.icloud.com//CALDAVJ 2425B663//EN +METHOD:REQUEST +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;CN=test3;CUTYPE=INDIVIDUAL;EMAIL=test3@example.com:mailto + :test3@example.com +ATTENDEE;CN=test2;CUTYPE=INDIVIDUAL;EMAIL=test2@example.com:mail + to:test2@example.com +ATTENDEE;CN=test1;CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;EMAIL=test1@example.com; +ROLE=CHAIR:mailto:test1@example.com +CREATED:20241005T152408Z +DESCRIPTION:Some note +DTEND;TZID=Europe/Brussels:20241007T110000 +DTSTART;TZID=Europe/Brussels:20241007T100000 +LAST-MODIFIED:20241006T214319Z +ORGANIZER;CN=test1;EMAIL=test1@example.com:mailto:test1@example.com +SEQUENCE:1 +SUMMARY:Test multi-attendee +UID:2495546B-5C9A-4632-AAD3-A179EF83CF20 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +DTSTAMP:20241006T214320Z +END:VEVENT +BEGIN:VTIMEZONE +TZID:Europe/Brussels +X-LIC-LOCATION:Europe/Brussels +BEGIN:STANDARD +DTSTART:18800101T000000 +RDATE:18800101T000000 +TZNAME:BMT +TZOFFSETFROM:+001730 +TZOFFSETTO:+001730 +END:STANDARD +BEGIN:STANDARD +DTSTART:18920501T120000 +RDATE:18920501T120000 +TZNAME:WET +TZOFFSETFROM:+001730 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19141108T000000 +RDATE:19141108T000000 +TZNAME:CET +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160501T000000 +RDATE:19160501T000000 +RDATE:19400520T030000 +RDATE:19430329T020000 +RDATE:19440403T020000 +RDATE:19450402T020000 +RDATE:19460519T020000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19161001T010000 +RDATE:19161001T010000 +RDATE:19421102T030000 +RDATE:19431004T030000 +RDATE:19440917T030000 +RDATE:19450916T030000 +RDATE:19461007T030000 +RDATE:19770925T030000 +RDATE:19781001T030000 +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19170416T020000 +RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19170917T030000 +RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181111T120000 +RRULE:FREQ=YEARLY;UNTIL=19191004T230000Z;BYMONTH=10;BYDAY=1MO +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19190301T230000 +RDATE:19190301T230000 +RDATE:19200214T230000 +RDATE:19210314T230000 +RDATE:19220325T230000 +RDATE:19230421T230000 +RDATE:19240329T230000 +RDATE:19250404T230000 +RDATE:19260417T230000 +RDATE:19270409T230000 +RDATE:19280414T230000 +RDATE:19290421T020000 +RDATE:19300413T020000 +RDATE:19310419T020000 +RDATE:19320403T020000 +RDATE:19330326T020000 +RDATE:19340408T020000 +RDATE:19350331T020000 +RDATE:19360419T020000 +RDATE:19370404T020000 +RDATE:19380327T020000 +RDATE:19390416T020000 +RDATE:19400225T020000 +TZNAME:WEST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19201024T000000 +RDATE:19201024T000000 +RDATE:19211026T000000 +RDATE:19391119T030000 +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19221008T000000 +RRULE:FREQ=YEARLY;UNTIL=19271001T230000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19281007T030000 +RRULE:FREQ=YEARLY;UNTIL=19381002T020000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19440903T000000 +RDATE:19440903T000000 +TZNAME:CEST +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19770101T000000 +RDATE:19770101T000000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19770403T020000 +RRULE:FREQ=YEARLY;UNTIL=19800406T010000Z;BYMONTH=4;BYDAY=1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19790930T030000 +RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +END:VCALENDAR diff --git a/caldav_sync/tests/data/test_multi_user_update.ics b/caldav_sync/tests/data/test_multi_user_update.ics new file mode 100644 index 0000000..41fd284 --- /dev/null +++ b/caldav_sync/tests/data/test_multi_user_update.ics @@ -0,0 +1,190 @@ +BEGIN:VCALENDAR +PRODID:-//caldav.icloud.com//CALDAVJ 2425B663//EN +METHOD:REQUEST +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;CN=test3;CUTYPE=INDIVIDUAL;EMAIL=test3@example.com:mail + to:test3@example.com +ATTENDEE;CN=test1;CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;EMAIL=test1@example.com; +ROLE=CHAIR:mailto:test1@example.com +CREATED:20241005T152408Z +DESCRIPTION:Some note +DTEND;TZID=Europe/Brussels:20241007T110000 +DTSTART;TZID=Europe/Brussels:20241007T100000 +LAST-MODIFIED:20241006T214319Z +ORGANIZER;CN=test1;EMAIL=test1@example.com:mailto:test1@example.com +SEQUENCE:1 +SUMMARY:Test multi-attendee +UID:2495546B-5C9A-4632-AAD3-A179EF83CF20 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +DTSTAMP:20241006T214320Z +END:VEVENT +BEGIN:VTIMEZONE +TZID:Europe/Brussels +X-LIC-LOCATION:Europe/Brussels +BEGIN:STANDARD +DTSTART:18800101T000000 +RDATE:18800101T000000 +TZNAME:BMT +TZOFFSETFROM:+001730 +TZOFFSETTO:+001730 +END:STANDARD +BEGIN:STANDARD +DTSTART:18920501T120000 +RDATE:18920501T120000 +TZNAME:WET +TZOFFSETFROM:+001730 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19141108T000000 +RDATE:19141108T000000 +TZNAME:CET +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160501T000000 +RDATE:19160501T000000 +RDATE:19400520T030000 +RDATE:19430329T020000 +RDATE:19440403T020000 +RDATE:19450402T020000 +RDATE:19460519T020000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19161001T010000 +RDATE:19161001T010000 +RDATE:19421102T030000 +RDATE:19431004T030000 +RDATE:19440917T030000 +RDATE:19450916T030000 +RDATE:19461007T030000 +RDATE:19770925T030000 +RDATE:19781001T030000 +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19170416T020000 +RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19170917T030000 +RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181111T120000 +RRULE:FREQ=YEARLY;UNTIL=19191004T230000Z;BYMONTH=10;BYDAY=1MO +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19190301T230000 +RDATE:19190301T230000 +RDATE:19200214T230000 +RDATE:19210314T230000 +RDATE:19220325T230000 +RDATE:19230421T230000 +RDATE:19240329T230000 +RDATE:19250404T230000 +RDATE:19260417T230000 +RDATE:19270409T230000 +RDATE:19280414T230000 +RDATE:19290421T020000 +RDATE:19300413T020000 +RDATE:19310419T020000 +RDATE:19320403T020000 +RDATE:19330326T020000 +RDATE:19340408T020000 +RDATE:19350331T020000 +RDATE:19360419T020000 +RDATE:19370404T020000 +RDATE:19380327T020000 +RDATE:19390416T020000 +RDATE:19400225T020000 +TZNAME:WEST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19201024T000000 +RDATE:19201024T000000 +RDATE:19211026T000000 +RDATE:19391119T030000 +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19221008T000000 +RRULE:FREQ=YEARLY;UNTIL=19271001T230000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19281007T030000 +RRULE:FREQ=YEARLY;UNTIL=19381002T020000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6, + 7,8;BYDAY=SU +TZNAME:WET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19440903T000000 +RDATE:19440903T000000 +TZNAME:CEST +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19770101T000000 +RDATE:19770101T000000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19770403T020000 +RRULE:FREQ=YEARLY;UNTIL=19800406T010000Z;BYMONTH=4;BYDAY=1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19790930T030000 +RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +END:VCALENDAR diff --git a/caldav_sync/tests/data/test_recurring.ics b/caldav_sync/tests/data/test_recurring.ics new file mode 100644 index 0000000..fa5400c --- /dev/null +++ b/caldav_sync/tests/data/test_recurring.ics @@ -0,0 +1,38 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +VERSION:2.0 +X-WR-CALNAME:test recurring +METHOD:PUBLISH +PRODID:-//Apple Inc.//macOS 14.5//EN +BEGIN:VTIMEZONE +TZID:America/Toronto +BEGIN:DAYLIGHT +TZOFFSETFROM:-0500 +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +TZNAME:EDT +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0400 +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +TZNAME:EST +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +TRANSP:OPAQUE +DTEND;TZID=America/Toronto:20241008T040000 +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +UID:F777C16F-2F14-4326-9681-69C6043DB8C0 +DTSTAMP:20241007T195255Z +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +SEQUENCE:1 +SUMMARY:test recurring +DTSTART;TZID=America/Toronto:20241008T030000 +LAST-MODIFIED:20241007T195252Z +CREATED:20241007T195223Z +RRULE:FREQ=WEEKLY;COUNT=10 +END:VEVENT +END:VCALENDAR diff --git a/caldav_sync/tests/test_calendar.py b/caldav_sync/tests/test_calendar.py new file mode 100644 index 0000000..3bc2706 --- /dev/null +++ b/caldav_sync/tests/test_calendar.py @@ -0,0 +1,301 @@ +from collections.abc import Iterable +from odoo.tests import TransactionCase +from odoo import Command +from unittest.mock import patch, MagicMock, PropertyMock +import icalendar +from pathlib import Path +from .common import CaldavTestCommon +from contextlib import contextmanager +from datetime import datetime, UTC, timedelta + +WEEKDAY_MAP = { + 0: "SUN", + 1: "MON", + 2: "TUE", + 3: "WED", + 4: "THU", + 5: "FRI", + 6: "SAT", +} + + +def _get_ics_path(filename): + return Path(__file__).parent / "data" / filename + + +@contextmanager +def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None): + with ( + patch("caldav.DAVClient") as MockDAVClient, + patch("caldav.Calendar") as MockCalendar, + patch("caldav.Event") as MockEvent, + ): + mock_client = MockDAVClient.return_value + mock_calendar = MockCalendar.return_value + mock_client.calendar = mock_calendar + mock_calendars = {} + + def calendar_side_effect(url): + if url not in mock_calendars: + mock_calendars[url] = MockCalendar() + if url == user.caldav_calendar_url: + return mock_calendars[url] + raise Exception("Calendar does not exist.") + + mock_calendar.side_effect = calendar_side_effect + ical_events = [] + if ics_paths: + if not isinstance(ics_paths, Iterable): + ics_paths = [ics_paths] if ics_paths else [] + for ics_path in ics_paths: + with ics_path.open("rb") as file: + ical_content = file.read() + ical_events.append(icalendar.Calendar.from_ical(ical_content)) + mock_caldav_events = [] + for ical_event in ical_events: + mock_event = MockEvent() + mock_event.icalendar_instance = ical_event + if last_modified: + for component in ical_event.walk(): + if component.name == "VEVENT": + component["last-modified"] = last_modified.strftime( + "%Y%m%dT%H%M%SZ" + ) + mock_caldav_events.append(mock_event) + mock_calendar.events.return_value = mock_caldav_events + user._compute_is_caldav_enabled() + yield + + +class TestCalendarEvent(TransactionCase, CaldavTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.users"].search([])._compute_is_caldav_enabled() + cls.user_1_url = "https://mycaldav.test.com/test1calendar" + cls.user_1 = cls._generate_user("test1", "test1", cls.user_1_url) + cls.user_2_url = "https://mycaldav.test.com/test2calendar" + cls.user_2 = cls._generate_user("test2", "test2", cls.user_2_url) + cls.user_3_url = "https://mycaldav.test.com/test3calendar" + cls.user_3 = cls._generate_user("test3", "test3", cls.user_3_url) + + def test_basic_event_from_server_create(self): + user = self.user_1 + ics_path = _get_ics_path("basic.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + current_events = self.env["calendar.event"].search([]) + self.env["calendar.event"].poll_caldav_server() + events_after_sync = self.env["calendar.event"].search([]) + new_events = events_after_sync - current_events + self.assertEqual(len(new_events), 1) + + def test_basic_event_from_server_update(self): + user = self.user_1 + ics_path = _get_ics_path("basic.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search([("user_id", "=", user.id)]) + orig_start = event.start + orig_stop = event.stop + ics_path = _get_ics_path("basic_updated.ics") + with _patch_caldav_with_events_from_ics( + ics_path, + user, + last_modified=(datetime.now(UTC)), + ): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search([("user_id", "=", user.id)]) + self.assertEqual(event.name, "Test Updated") + # This next one is just lazy avoiding the HTML stripping + self.assertIn("Some note ...", event.description) + self.assertGreater(event.start, orig_start) + self.assertGreater(event.stop, orig_stop) + + def test_basic_event_from_server_delete(self): + user = self.user_1 + ics_path = _get_ics_path("basic.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + self.env["calendar.event"].poll_caldav_server() + # Passing None to ics_path means no events returned from server + with _patch_caldav_with_events_from_ics(None, user): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search([("user_id", "=", user.id)]) + self.assertFalse(event) + + def test_recurring_from_server_create(self): + user = self.user_1 + ics_path = _get_ics_path("test_recurring.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + self.env["calendar.event"].poll_caldav_server() + events = self.env["calendar.event"].search( + [("partner_id", "=", user.partner_id.id)] + ) + self.assertEqual(len(events), 10) + + def test_multiple_attendees_event_from_server_create(self): + user = self.user_1 + ics_path = _get_ics_path("test_multi_attendee.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search([("user_id", "=", user.id)]) + self.assertEqual(len(event.attendee_ids), 3) + self.assertIn(user.partner_id, event.attendee_ids.partner_id) + + def test_multiple_attendees_event_from_server_update(self): + user = self.user_1 + ics_path = _get_ics_path("test_multi_attendee.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search([("user_id", "=", user.id)]) + ics_path = _get_ics_path("test_multi_attendee_update.ics") + with _patch_caldav_with_events_from_ics( + ics_path, user, last_modified=datetime.now(UTC) + ): + self.env["calendar.event"].poll_caldav_server() + self.assertEqual(len(event.attendee_ids), 2) + self.assertIn(user.partner_id, event.attendee_ids.partner_id) + + def test_multiple_attendees_event_from_server_delete(self): + user = self.user_1 + ics_path = _get_ics_path("test_multi_attendee.ics") + with _patch_caldav_with_events_from_ics(ics_path, user): + self.env["calendar.event"].poll_caldav_server() + # Passing None as ics_path means no events returned from server + with _patch_caldav_with_events_from_ics(None, user): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search([("user_id", "=", user.id)]) + self.assertFalse(event) + + def test_multiple_user_attendees_event_from_server_create(self): + """Test event has: + Organizer: user1 (test1@example.com) + Attendees: user2 and user3 (test2@example.com, test3@example.com) + """ + user1 = self.user_1 + user2 = self.user_2 + user3 = self.user_3 + ics_path = _get_ics_path("test_multi_user.ics") + with _patch_caldav_with_events_from_ics(ics_path, user1): + self.env["calendar.event"].poll_caldav_server() + with _patch_caldav_with_events_from_ics(ics_path, user2): + self.env["calendar.event"].poll_caldav_server() + with _patch_caldav_with_events_from_ics(ics_path, user3): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search( + [("caldav_uid", "=", "2495546B-5C9A-4632-AAD3-A179EF83CF20")] + ) + self.assertEqual(len(event), 1) + # Make sure the event wasn't duplicated all over the place + other_user_events = self.env["calendar.event"].search( + [("user_id", "in", [user2.id, user3.id])] + ) + self.assertFalse(other_user_events) + self.assertIn(user2.partner_id, event.partner_ids) + self.assertIn(user3.partner_id, event.partner_ids) + + def test_multiple_user_attendees_event_from_server_update(self): + """Test event has (as in above test): + Organizer: user1 (test1@example.com) + Attendees: user2 and user3 (test2@example.com, test3@example.com) + """ + user1 = self.user_1 + user2 = self.user_2 + user3 = self.user_3 + ics_path = _get_ics_path("test_multi_user.ics") + with _patch_caldav_with_events_from_ics(ics_path, user1): + self.env["calendar.event"].poll_caldav_server() + with _patch_caldav_with_events_from_ics(ics_path, user2): + self.env["calendar.event"].poll_caldav_server() + with _patch_caldav_with_events_from_ics(ics_path, user3): + self.env["calendar.event"].poll_caldav_server() + # Now update it to remove one attendee + # Shuffle the user polling order just to test more robustly + ics_path = _get_ics_path("test_multi_user_update.ics") + with _patch_caldav_with_events_from_ics( + ics_path, user2, last_modified=datetime.now(UTC) + ): + self.env["calendar.event"].poll_caldav_server() + with _patch_caldav_with_events_from_ics( + ics_path, user3, last_modified=datetime.now(UTC) + ): + self.env["calendar.event"].poll_caldav_server() + with _patch_caldav_with_events_from_ics( + ics_path, user1, last_modified=datetime.now(UTC) + ): + self.env["calendar.event"].poll_caldav_server() + event = self.env["calendar.event"].search( + [("caldav_uid", "=", "2495546B-5C9A-4632-AAD3-A179EF83CF20")] + ) + self.assertIn(user3.partner_id, event.partner_ids) + self.assertNotIn(user2.partner_id, event.partner_ids) + self.assertEqual(len(event.attendee_ids), 2) + + def _create_multi_user_test_event(self): + return ( + self.env["calendar.event"] + .with_user(self.user_1) + .create( + { + "name": "Test event", + "partner_ids": [ + Command.set( + [ + self.user_2.partner_id.id, + self.user_3.partner_id.id, + self.user_1.partner_id.id, + ] + ), + Command.create( + { + "name": "Test partner", + "email": "testpartner@example.com", + } + ), + ], + "start": datetime.now() + timedelta(days=2), + "stop": datetime.now() + timedelta(days=2, hours=1), + } + ) + ) + + def test_multiple_user_attendees_event_to_server_create(self): + with self._patch_all_3_users_davclients() as (_, mock_calendar): + self._create_multi_user_test_event() + self.assertEqual(mock_calendar.add_event.call_count, 3) + + def test_event_to_server_delete(self): + with self._patch_all_3_users_davclients() as (_, mock_calendar): + self._create_multi_user_test_event().unlink() + self.assertEqual( + mock_calendar.event_by_uid.return_value.delete.call_count, 3 + ) + + def test_event_to_server_update(self): + with self._patch_all_3_users_davclients() as (_, mock_calendar): + self._create_multi_user_test_event().write( + {"start": datetime.now() + timedelta(days=14)} + ) + self.assertEqual(mock_calendar.save_event.call_count, 3) + + def test_recurrent_event_to_server(self): + with self._patch_all_3_users_davclients() as (_, mock_calendar): + self._create_multi_user_test_event().write( + { + "recurrency": True, + } + ) + args = mock_calendar.save_event.call_args + self.assertEqual(mock_calendar.save_event.call_count, 3) + + @contextmanager + def _patch_all_3_users_davclients(self): + with patch("caldav.DAVClient") as MockDAVClient: + (self.user_1 | self.user_2 | self.user_3)._compute_is_caldav_enabled() + + mock_client = MockDAVClient.return_value + mock_calendar = MagicMock() + mock_event_by_uid = MagicMock() + mock_client.calendar.return_value = mock_calendar + mock_calendar.events.return_value = [] + mock_calendar.event_by_uid.return_value = mock_event_by_uid + yield mock_client, mock_calendar diff --git a/caldav_sync/tests/test_res_users.py b/caldav_sync/tests/test_res_users.py new file mode 100644 index 0000000..f00c58d --- /dev/null +++ b/caldav_sync/tests/test_res_users.py @@ -0,0 +1,32 @@ +from odoo.tests import TransactionCase +from unittest.mock import patch, MagicMock +from .common import CaldavTestCommon + + +class TestUsers(TransactionCase, CaldavTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_caldav_enabled_false_without_url(self): + user = self._generate_user("test", "test") + self.assertFalse(user.is_caldav_enabled) + + @patch("caldav.DAVClient") + def test_caldav_connection_succeeds_but_not_calendar(self, MockDAVClient): + user = self._generate_user("test", "test", "https://example.com/abc123") + # Create a mock client and mock calendar + mock_client = MockDAVClient.return_value + mock_calendar = MagicMock() + mock_client.calendar.return_value = mock_calendar + + # Mock the events method to raise an exception + mock_calendar.events.side_effect = Exception("Failed to get events") + + # An exception should not be raised because we need to continue to sync other + # users' calendars. Instead the exception message is simply logged to error. + with self.assertLogs("odoo.addons.caldav_sync.models.res_users", "ERROR"): + user._get_caldav_events() + + # Ensure the is_caldav_enabled is set to False + self.assertFalse(user.is_caldav_enabled)