caldav_sync: major rework.

- Refactoring
- New test cases (lots of them)
- A bunch of bug fixes based on test case executions
This commit is contained in:
Marc Durepos 2024-10-07 17:14:18 -04:00
parent 786b501c58
commit c2e3a34d4f
14 changed files with 1540 additions and 215 deletions

View file

@ -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")

View file

@ -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)
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

View file

@ -0,0 +1,2 @@
from . import test_res_users
from . import test_calendar

View file

@ -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

View file

@ -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

View file

@ -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

View file

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)