Compare commits

...

11 commits

8 changed files with 922 additions and 363 deletions

View file

@ -3,7 +3,7 @@ CalDAV Synchronization
Bemade Inc.
Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
Copyright (C) 2023-June Bemade Inc. (https://www.bemade.org).
Author: Marc Durepos (Contact : marc@bemade.org)
This program is under the terms of the GNU Lesser General Public License (LGPL-3)
@ -44,13 +44,44 @@ Usage
Technical Details
-----------------
- The module extends the `calendar.event` model to add CalDAV synchronization
* The module extends the `calendar.event` model to add CalDAV synchronization
functionality.
- It uses the `icalendar` library to format events and the `caldav` library to
* It uses the `icalendar` library to format events and the `caldav` library to
interact with CalDAV servers.
- Polling for changes on the CalDAV server can be triggered manually by
* Polling for changes on the CalDAV server can be triggered manually by
triggering the scheduled action in Odoo.
Change Log
----------
17.0.0.6.0
^^^^^^^^^^
* Fixed an issue where synchronizing events created duplicate events on every sync.
* Completely revamped and synchronization of recurring events in both directions.
* Making a recurring event in Odoo correctly creates the recurring event on the server.
* Modifying the base event of a recurrence with "all events" or "future events" in
Odoo reflects correctly on the server.
* Modifying a non-base event correctly updates on the server in all 3 modes (this
event only, all events, future events).
* Modifying a base recurring event on the CalDAV server correctly updates the events
on Odoo after a synchronization.
* Deleting a whole recurring sequence from Odoo correctly deletes the sequence from
the CalDAV server.
* Deleting a single event or a whole recurring sequence on the CalDAV server
correctly synchronizes to Odoo after a synchronization.
* CalDAV (iCalendar) UIDs are now correctly shared among events of a same recurrence in
Odoo. This corrects a number of issues around updating and deleting events from both
the Odoo and CalDAV server side.
Issues & Requests
-----------------
Please submit issues on Bemade's Gitlab at https://git.bemade.org/bemade/bemade-addons
or via our website at https://www.bemade.org.
License
-------

View file

@ -8,7 +8,7 @@
{
"name": "CalDAV Synchronization",
"version": "17.0.0.5.9",
"version": "17.0.0.6.0",
"license": "LGPL-3",
"category": "Productivity",
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
@ -16,7 +16,7 @@
"website": "https://www.bemade.org",
"depends": ["base", "calendar"],
"external_dependencies": {
"python": ["caldav", "icalendar", "bs4"],
"python": ["caldav", "icalendar", "markdownify", "markdown2"],
},
"images": ["static/description/images/main_screenshot.png"],
"data": [

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import calendar_event
from . import res_users
from . import calendar_recurrence

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
from odoo import models, fields, api
import uuid
import logging
_logger = logging.getLogger(__name__)
class RecurrenceRule(models.Model):
_inherit = "calendar.recurrence"
caldav_uid = fields.Char(
readonly=True,
copy=False,
)
_sql_constraints = [
("caldav_uid_unique", "UNIQUE (caldav_uid)", "caldav_uid must be unique")
]
@api.model_create_multi
def create(self, vals_list):
if not self._context.get("caldav_keep_ids"):
for vals in vals_list:
vals["caldav_uid"] = str(uuid.uuid4())
else:
for vals in vals_list:
base_event = self.env["calendar.event"].browse(vals["base_event_id"])
vals.update(caldav_uid=base_event.caldav_uid)
return super().create(vals_list)
@api.model
def _detach_events(self, events):
"""When events are detached from a recurrence, their CalDAV UID and recurrence-id
are no longer going to be valid, so we remove them from the server. They may then
be re-written to the server with their new IDs later, but we don't care about
that here."""
detached_events = super()._detach_events(events)
for event in detached_events:
event._sync_unlink_to_caldav()
return detached_events

View file

@ -1,3 +1,4 @@
caldav==1.3.9
icalendar==5.0.13
bs4==0.0.2
markdownify==0.13.1
markdown2==2.5.1

View file

@ -32,6 +32,11 @@
<p><strong>License:</strong> GNU Lesser General Public License (LGPL-3)</p>
<h2>Overview</h2>
<p>The CalDAV Synchronization module for Odoo allows users to synchronize their calendar events with CalDAV servers. This enables seamless integration of Odoo calendar with external applications like Apple Calendar or Thunderbird.</p>
<h2>Warning</h2>
<p>This module is in early development stages and should not be considered stable.
Please test it on an unimportant calendar before using it with your important data.
We are working hard to make improvements based on user feedback, so please reach
out if you run into any issues or need a specific feature.</p>
<h2>Features</h2>
<ul>
<li>Synchronize Odoo calendar events with CalDAV servers.</li>
@ -64,6 +69,17 @@
</ol>
<h2>Technical Details</h2>
<p>The module extends the <code>calendar.event</code> model to add CalDAV synchronization functionality. It uses the <code>icalendar</code> library to format events and the <code>caldav</code> library to interact with CalDAV servers. Polling for changes on the CalDAV server can be triggered manually by triggering the scheduled action in Odoo.</p>
<h2>Support</h2>
<p>If you have any issues with the module or feature requests, please reach out
to us by creating an issue on our Gitlab at
<a href="https://git.bemade.org/bemade/bemade-addons">
https://git.bemade.org/bemade/bemade-addons
</a>
or by submitting a request on our website at
<a href="https://www.bemade.org">
https://www.bemade.org</a>
</a>
</p>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>

View file

@ -1,12 +1,13 @@
from collections.abc import Iterable
from odoo.tests import TransactionCase
from odoo import Command
from unittest.mock import patch, MagicMock, PropertyMock
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",
@ -28,7 +29,6 @@ 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
@ -43,6 +43,13 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
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):
@ -51,18 +58,27 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
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
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
@ -258,35 +274,6 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
)
)
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: