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:
parent
786b501c58
commit
c2e3a34d4f
14 changed files with 1540 additions and 215 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
caldav_sync/tests/__init__.py
Normal file
2
caldav_sync/tests/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import test_res_users
|
||||
from . import test_calendar
|
||||
26
caldav_sync/tests/common.py
Normal file
26
caldav_sync/tests/common.py
Normal 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
|
||||
37
caldav_sync/tests/data/basic.ics
Normal file
37
caldav_sync/tests/data/basic.ics
Normal 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
|
||||
39
caldav_sync/tests/data/basic_updated.ics
Normal file
39
caldav_sync/tests/data/basic_updated.ics
Normal 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
|
||||
0
caldav_sync/tests/data/multi_event.ics
Normal file
0
caldav_sync/tests/data/multi_event.ics
Normal file
192
caldav_sync/tests/data/test_multi_attendee.ics
Normal file
192
caldav_sync/tests/data/test_multi_attendee.ics
Normal 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
|
||||
190
caldav_sync/tests/data/test_multi_attendee_update.ics
Normal file
190
caldav_sync/tests/data/test_multi_attendee_update.ics
Normal 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
|
||||
192
caldav_sync/tests/data/test_multi_user.ics
Normal file
192
caldav_sync/tests/data/test_multi_user.ics
Normal 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
|
||||
190
caldav_sync/tests/data/test_multi_user_update.ics
Normal file
190
caldav_sync/tests/data/test_multi_user_update.ics
Normal 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
|
||||
38
caldav_sync/tests/data/test_recurring.ics
Normal file
38
caldav_sync/tests/data/test_recurring.ics
Normal 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
|
||||
301
caldav_sync/tests/test_calendar.py
Normal file
301
caldav_sync/tests/test_calendar.py
Normal 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
|
||||
32
caldav_sync/tests/test_res_users.py
Normal file
32
caldav_sync/tests/test_res_users.py
Normal 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)
|
||||
Loading…
Reference in a new issue