Significant improvements to the code around synchronizing events, especially recurring ones. Too much has changed to explain fully here, so read the changelog in README.rst or read the code. Squashed commit of the following: commite537a7f7f4Author: Marc Durepos <marc@bemade.org> Date: Fri Nov 8 15:47:39 2024 -0500 caldav_sync: getting ready for launch of 0.6.0 commit5797f8efcaAuthor: Marc Durepos <marc@bemade.org> Date: Fri Nov 8 12:41:13 2024 -0500 fixup! caldav_sync: all seems good. further testing to come before merging into 17.0 commit992778e353Author: Marc Durepos <marc@bemade.org> Date: Fri Nov 8 12:41:02 2024 -0500 caldav_sync: all seems good. further testing to come before merging into 17.0 commit90d05d75a2Author: Marc Durepos <marc@bemade.org> Date: Fri Nov 8 11:31:14 2024 -0500 caldav_sync: better sync with calls to _rewrite_recurrence commit5ca1d56e54Author: Marc Durepos <marc@bemade.org> Date: Thu Nov 7 16:13:31 2024 -0500 caldav_sync: recurrence working from Odoo to CalDAV. Need further testing for the other direction. commitce7b27cc65Author: Marc Durepos <marc@bemade.org> Date: Thu Nov 7 10:33:26 2024 -0500 caldav_sync: updating recurrences in the middle with future works, but it breaks singled out events before it (brings them back into the rrule chain) commit421d609abdAuthor: Marc Durepos <marc@bemade.org> Date: Thu Nov 7 08:08:35 2024 -0500 caldav_sync: still working on recurrence commit0e07d42825Author: Marc Durepos <marc@bemade.org> Date: Wed Nov 6 16:06:43 2024 -0500 caldav_sync: moving a single event in a recurrence is synchronizing well commit14a8b6acf0Author: Marc Durepos <marc@bemade.org> Date: Wed Nov 6 13:53:23 2024 -0500 caldav_sync: CRUD fully functional, recurring events working with exceptions around changes in mid-sequence with rrule updates commit068d1fb29fAuthor: Marc Durepos <marc@bemade.org> Date: Tue Nov 5 16:42:42 2024 -0500 caldav_sync: all CRUD operations working. Recurrences working to some degree but not fully. commit0104e3897cAuthor: Marc Durepos <marc@bemade.org> Date: Mon Nov 4 07:48:48 2024 -0500 caldav_sync: major rework underway. This commit is broken.
288 lines
12 KiB
Python
288 lines
12 KiB
Python
from collections.abc import Iterable
|
|
from odoo.tests import TransactionCase
|
|
from odoo import Command
|
|
from unittest.mock import patch, MagicMock, DEFAULT
|
|
import icalendar
|
|
from pathlib import Path
|
|
from .common import CaldavTestCommon
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, UTC, timedelta
|
|
import caldav
|
|
|
|
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,
|
|
):
|
|
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
|
|
|
|
def event_by_uid_side_effect(self, uid):
|
|
for event in self.events():
|
|
if str(event.icalendar_component.get("uid")) == uid:
|
|
return event
|
|
return DEFAULT
|
|
|
|
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))
|
|
if last_modified:
|
|
for event in ical_events:
|
|
for subcomponent in event.subcomponents:
|
|
if subcomponent.name == "VEVENT":
|
|
subcomponent["last-modified"] = icalendar.vDate(last_modified)
|
|
subcomponent["dtstamp"] = icalendar.vDate(last_modified)
|
|
|
|
base_events = [event for event in ical_events if not event.get("recurrence-id")]
|
|
for base_event in base_events:
|
|
child_events = [
|
|
event
|
|
for event in ical_events
|
|
if event.get("recurrence-id")
|
|
and event.get("uid") == base_event.get("uid")
|
|
]
|
|
for child_event in child_events:
|
|
base_event.add_component(child_event)
|
|
mock_calendar.add_event(base_event)
|
|
caldav_events = [caldav.Event(data=event) for event in base_events]
|
|
mock_calendar.events.return_value = caldav_events
|
|
mock_calendar.event_by_uid.side_effect = event_by_uid_side_effect
|
|
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),
|
|
}
|
|
)
|
|
)
|
|
|
|
@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
|