caldav_sync: v0.8.0 - disable notifications when polling server

- Disable sending of notification emails when events are created or updated
  in Odoo during a CalDAV server synchronization.
- General code cleanup with improved type hints.
This commit is contained in:
Marc Durepos 2025-06-10 10:07:47 -04:00
parent 4b2b53caa7
commit df5fc408de
6 changed files with 196 additions and 97 deletions

View file

@ -54,8 +54,22 @@ Technical Details
Change Log
----------
17.0.0.6.0
^^^^^^^^^^
0.8.0
^^^^^
* Disable sending of notification emails when events are created or updated
in Odoo during a CalDAV server synchronization.
* General code cleanup with improved type hints.
0.7.0
^^^^^
* Stopped the import of past events when synchronizing from the CalDAV server.
This should help with performance, timeouts and avoid importing events that
are not relevant to the user.
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.

View file

@ -8,7 +8,7 @@
{
"name": "CalDAV Synchronization",
"version": "18.0.0.7.0",
"version": "18.0.0.8.0",
"license": "LGPL-3",
"category": "Productivity",
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",

View file

@ -2,3 +2,4 @@
from . import calendar_event
from . import res_users
from . import calendar_recurrence
from . import calendar_attendee

View file

@ -0,0 +1,24 @@
from odoo import models, api
import logging
_logger = logging.getLogger(__name__)
class CalendarAttendee(models.Model):
_inherit = "calendar.attendee"
def _send_mail_to_attendees(self, mail_template, force_send=False):
"""Override to prevent sending emails when dont_notify context is set.
:param mail_template: a mail.template record
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
:return: Result of super or False if notification is skipped
"""
# Check for dont_notify in context
if self.env.context.get("dont_notify"):
_logger.info("Email notifications skipped due to dont_notify context")
return False
return super(CalendarAttendee, self)._send_mail_to_attendees(
mail_template, force_send
)

View file

@ -3,20 +3,29 @@ import uuid
import icalendar.cal
from odoo import models, api, fields, _
from odoo.tools.misc import _logger
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
import caldav
from caldav.lib.error import NotFoundError
import logging
from datetime import datetime
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event, vDate
import re
from pytz import timezone, utc
from typing import List, Dict, TypeVar, Optional
from typing import List, Dict, Optional, Any, TYPE_CHECKING
from markdownify import markdownify as md
import markdown2 as md2
_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from odoo.addons.base.models.res_users import Users as User
from odoo.addons.base.models.res_partner import Partner
from odoo.addons.calendar.models.calendar_event import Meeting as OdooCalendarEvent
else:
User = models.Model
Partner = models.Model
OdooCalendarEvent = models.Model
WEEKDAY_MAP = {
0: "MO",
1: "TU",
@ -27,35 +36,59 @@ WEEKDAY_MAP = {
6: "SU",
}
CalendarEvent = TypeVar("calendar.event", bound=models.Model)
User = TypeVar("res.users", bound=models.Model)
Partner = TypeVar("res.partner", bound=models.Model)
def _parse_rrule_string(rrule_str: str) -> Dict[str, Any]:
"""Parse a string representing an RRULE into a dictionary of its parts.
def _parse_rrule_string(rrule_str):
def try_to_int(part):
try:
return int(part)
except Exception:
return part
Takes a string like "RRULE:FREQ=WEEKLY;UNTIL=20221231T000000Z;BYDAY=MO"
and returns a dictionary with proper types for vRecur.
"""
from icalendar import vDDDTypes, vWeekday, vFrequency
regex_str = "RRULE:(.*)$"
regex = re.compile(regex_str)
params_match = regex.search(rrule_str)
params_part = params_match.groups()[0]
params = params_part.split(";")
params_dict = {}
for param in params:
parts = param.split("=")
if parts[0].upper() == "UNTIL":
if "T" in parts[1]:
parts[1] = datetime.strptime(parts[1], "%Y%m%dT%H%M%S")
def parse_value(key: str, value: str) -> Any:
if key == "UNTIL":
# Convert to datetime and wrap in vDDDTypes
if "T" in value:
dt = datetime.strptime(value, "%Y%m%dT%H%M%S")
else:
parts[1] = datetime.strptime(parts[1], "%Y%m%d")
if parts[0].upper() == "BYDAY":
parts[1] = [part for part in parts[1].split(",")]
params_dict.update({parts[0]: try_to_int(parts[1])})
return params_dict
dt = datetime.strptime(value, "%Y%m%d")
return vDDDTypes(dt)
elif key in ("WKST", "BYDAY", "BYWEEKDAY"):
# Convert to vWeekday
return [vWeekday(day) for day in value.split(",")]
elif key == "FREQ":
# vFrequency will handle the conversion
return vFrequency(value)
elif key in (
"COUNT",
"INTERVAL",
"BYSECOND",
"BYMINUTE",
"BYHOUR",
"BYWEEKNO",
"BYMONTHDAY",
"BYYEARDAY",
"BYMONTH",
"BYSETPOS",
):
# Convert to int or list of ints
if "," in value:
return [int(v) for v in value.split(",")]
return int(value)
return value
if not rrule_str.startswith("RRULE:"):
return {}
params = rrule_str[6:] # Remove 'RRULE:'
result = {}
for param in params.split(";"):
if "=" in param:
key, value = param.split("=", 1)
key = key.upper()
result[key] = parse_value(key, value)
return result
def _extract_vcal_email(vcal_address):
@ -134,19 +167,6 @@ class CalendarEvent(models.Model):
or not event.follow_recurrence
)
@api.depends("is_base_event")
def _compute_update_all_recurrence(self):
for rec in self:
rec.update_all_recurrence = (
rec.recurrency
and rec.is_base_event
and (
rec.recurrence_update == "all_events"
or not rec.recurrence_update
or rec.recurrence_id.calendar_event_ids == rec
)
)
@api.depends("recurrency", "recurrence_id", "recurrence_id.base_event_id")
def _compute_is_base_event(self):
for rec in self:
@ -196,7 +216,9 @@ class CalendarEvent(models.Model):
try:
caldav_events = event._create_in_icalendar(calendar)
for caldav_event in caldav_events:
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
caldav_uid = (
caldav_event.vobject_instance.vevent.uid.value
) # pyright: ignore[reportAttributeAccessIssue]
event.with_context(caldav_no_sync=True).write(
{"caldav_uid": caldav_uid}
)
@ -224,6 +246,11 @@ class CalendarEvent(models.Model):
calendar = client.calendar(url=user.caldav_calendar_url)
base_event = self._get_caldav_base_event_by_uid(calendar, self.caldav_uid)
if not base_event:
_logger.warning(
f"Failed to find base event for {self} on CalDAV server."
)
return
if self.recurrence_id:
tz = timezone(self.event_tz or self.env.user.tz)
start = utc.localize(self.start).astimezone(tz)
@ -260,12 +287,14 @@ class CalendarEvent(models.Model):
def _update_base_caldav_event(
self,
calendar: icalendar.cal.Calendar,
calendar: caldav.Calendar,
event: caldav.Event,
ical_event_data: dict,
):
if event:
self._update_ical_event_values(event.icalendar_component, ical_event_data)
self._update_ical_event_values(
event.icalendar_component, ical_event_data
) # pyright: ignore[reportAttributeAccessIssue]
event.save()
else:
calendar.add_event(**ical_event_data)
@ -281,9 +310,11 @@ class CalendarEvent(models.Model):
def _get_caldav_base_event_by_uid(
self, calendar: caldav.Calendar, uid: str
) -> Optional[CalendarEvent]:
) -> Optional[caldav.Event]:
for event in calendar.events():
component = event.icalendar_component
component = (
event.icalendar_component
) # pyright: ignore[reportAttributeAccessIssue]
event_uid = self._extract_component_text(component, "uid")
if event_uid == uid and not component.get("recurrence-id"):
return event
@ -316,6 +347,7 @@ class CalendarEvent(models.Model):
calendar = client.calendar(url=user.caldav_calendar_url)
try:
caldav_event = calendar.event_by_uid(self.caldav_uid)
assert isinstance(caldav_event, caldav.Event)
if not delete_all and self.recurrence_id and not self.is_base_event:
index = self._get_subcomponent_index_for_recurrence(
caldav_event
@ -330,14 +362,16 @@ class CalendarEvent(models.Model):
# of the event matches.
if delete_all or self._matches_caldav_start(caldav_event):
caldav_event.delete()
except caldav.error.NotFoundError:
except NotFoundError:
# No worries - it just didn't exist so nothing to sync
pass
except Exception as e:
_logger.error(f"Failed to remove event from CalDAV server: {e}")
def _matches_caldav_start(self, caldav_event: caldav.Event) -> bool:
event_start = caldav_event.icalendar_component.get("dtstart").dt
event_start = caldav_event.icalendar_component.get(
"dtstart"
).dt # pyright: ignore[reportAttributeAccessIssue]
tz = event_start.tzinfo
self_start = utc.localize(self.start).astimezone(tz)
return self_start == event_start
@ -464,7 +498,6 @@ class CalendarEvent(models.Model):
)
event_data["dtstart"] = vDatetime(utc.localize(self.start).astimezone(event_tz))
event_data["dtend"] = vDatetime(utc.localize(self.stop).astimezone(event_tz))
return event_data
def _add_event_recurrence_id(self, event_data: Dict) -> None:
"""Add the recurrence-id parameter to event data if self is linked
@ -532,7 +565,8 @@ class CalendarEvent(models.Model):
synchronize them with their Odoo calendar."""
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
for user in all_users:
self._poll_user_caldav_server(user)
self.with_context(dont_notify=True)._poll_user_caldav_server(user)
# self._poll_user_caldav_server(user)
@api.model
def _poll_user_caldav_server(self, user) -> None:
@ -569,7 +603,7 @@ class CalendarEvent(models.Model):
# There are some events remaining in this recurrence series,
# so we have synchronized them individually.
pass
except caldav.error.NotFoundError:
except NotFoundError:
# There are no more events with this UID, so we need to clear
# out the whole recurrence chain from the Odoo side.
ctx = {"caldav_no_sync": True}
@ -583,9 +617,8 @@ class CalendarEvent(models.Model):
).with_user(user).unlink()
@api.model
def _sync_event_from_ical(
self, ical_event: icalendar.cal.Event, user: User
) -> CalendarEvent:
@api.returns("calendar.event")
def _sync_event_from_ical(self, ical_event: icalendar.cal.Event, user: User):
"""Given an iCalendar event, compare the event with any existing
Odoo event that it matches and synchronize the changes. If no event
exists, create one iff the event is in the future.
@ -609,14 +642,17 @@ class CalendarEvent(models.Model):
existing_instance = self._get_existing_instance(uid, recurrence_id)
outdated = self._get_outdated(component, existing_instance, synced_events)
owned = (
existing_instance and existing_instance.partner_id == user.partner_id
existing_instance and existing_instance.partner_id == user.partner_id
)
# Pass for_creation=True only when creating a new event
values = self._get_values_from_ical_component(component, user, for_creation=not existing_instance)
values = self._get_values_from_ical_component(
component, user, for_creation=not existing_instance
)
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
if not existing_instance:
# If the event is in the past, we just ignore it.
if values.get("stop") < datetime.now(tz=None):
stop = values.get("stop")
if stop and stop < datetime.now(tz=None):
continue
# If we're creating an instance and it doesn't follow the recurrence,
# just scrap the recurrency vals, they're not useful
@ -651,7 +687,8 @@ class CalendarEvent(models.Model):
return synced_events
@api.model
def _get_existing_instance(self, uid, recurrence_id: Optional[datetime]) -> CalendarEvent:
@api.returns("calendar.event")
def _get_existing_instance(self, uid, recurrence_id: Optional[datetime]):
"""Find the Odoo calendar.event record matching uid and,
if set, recurrence_id.
"""
@ -723,14 +760,20 @@ class CalendarEvent(models.Model):
"recurrence_update": "self_only",
}
if not rrule:
if not rrule or not isinstance(rrule, vRecur):
return {}
if rrule.get("until"):
rrule["until"] = rrule.get("until")[0].astimezone(utc)
until = rrule.get("until")
if until and isinstance(until, list):
until = until[0].astimezone(utc)
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
)
if rrule_str:
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
)
else:
_logger.warning(f"Could not convert RRULE to string: {rrule}")
return {}
vals = {
"recurrency": True,
"follow_recurrence": True,
@ -748,8 +791,9 @@ class CalendarEvent(models.Model):
vals.update(end_type="count")
if not vals.get("count"):
vals.update(count=MAX_RECURRENT_EVENT)
if vals.get("until"):
until_day = vals.get("until").date()
until = vals.get("until")
if until and (isinstance(until, vDatetime) or isinstance(until, vDate)):
until_day = until.dt if isinstance(until, vDatetime) else until.dt
vals.update(until=until_day)
vals.pop("count", None)
return vals
@ -818,8 +862,8 @@ class CalendarEvent(models.Model):
def _get_outdated(
self,
component: icalendar.cal.Component,
existing_instance: CalendarEvent,
synced_events: CalendarEvent,
existing_instance: OdooCalendarEvent,
synced_events: OdooCalendarEvent,
) -> bool:
"""Check whether a component from the CalDAV server (typically an
event) is outdated when compared to its existing Odoo calendar.event
@ -861,10 +905,10 @@ class CalendarEvent(models.Model):
end = component.get("dtend") and component.decoded("dtend")
if isinstance(end, datetime):
end = end.astimezone(utc).replace(tzinfo=None)
# Get attendees regardless of creation/update
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
# Basic values that apply to both creation and updates
values = {
"name": str(component.get("summary")),
@ -878,24 +922,32 @@ class CalendarEvent(models.Model):
"caldav_uid": str(component.get("uid")),
"partner_ids": [(6, 0, attendee_ids.ids)],
}
# Only set user_id and partner_id during creation
if for_creation:
organizer_partner = self._get_organizer_partner(component)
if organizer_partner:
# Get the Odoo user ID associated with the organizer partner
organizer = organizer_partner.user_ids[0].id if organizer_partner.user_ids else False
values.update({
"partner_id": organizer_partner.id,
"user_id": organizer,
})
organizer = (
organizer_partner.user_ids[0].id
if organizer_partner.user_ids
else False
)
values.update(
{
"partner_id": organizer_partner.id,
"user_id": organizer,
}
)
else:
# For new events without an organizer, use the current user
values.update({
"partner_id": user.partner_id.id,
"user_id": user.id,
})
values.update(
{
"partner_id": user.partner_id.id,
"user_id": user.id,
}
)
return values
@api.model

View file

@ -25,11 +25,10 @@ def _get_ics_path(filename):
@contextmanager
def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None, futurize=True):
with (
patch("caldav.DAVClient") as MockDAVClient,
patch("caldav.Calendar") as MockCalendar,
):
def _patch_caldav_with_events_from_ics(
ics_paths, user, last_modified=None, futurize=True
):
with patch("caldav.DAVClient") as MockDAVClient:
mock_client = MockDAVClient.return_value
mock_calendars = {}
@ -70,14 +69,20 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None, futu
for event in ical_events:
for subcomponent in event.subcomponents:
if subcomponent.name == "VEVENT":
start = subcomponent.get("dtstart") and subcomponent.decoded("dtstart")
end = subcomponent.get("dtend") and subcomponent.decoded("dtend")
start = subcomponent.get("dtstart") and subcomponent.decoded(
"dtstart"
)
end = subcomponent.get("dtend") and subcomponent.decoded(
"dtend"
)
if isinstance(start, datetime) and isinstance(end, datetime):
duration = end - start
else:
duration = timedelta(hours=1)
subcomponent["dtstart"] = icalendar.vDDDTypes(datetime.now())
subcomponent["dtend"] = icalendar.vDDDTypes(datetime.now() + duration)
subcomponent["dtend"] = icalendar.vDDDTypes(
datetime.now() + duration
)
base_events = [event for event in ical_events if not event.get("recurrence-id")]
for base_event in base_events:
@ -97,7 +102,7 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None, futu
yield
@tagged('post_install', '-at_install')
@tagged("post_install", "-at_install")
class TestCalendarEvent(TransactionCase, CaldavTestCommon):
@classmethod
def setUpClass(cls):
@ -261,7 +266,6 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
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)
@ -277,21 +281,25 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
self.env["calendar.event"].poll_caldav_server()
with _patch_caldav_with_events_from_ics(ics_path, user3):
self.env["calendar.event"].poll_caldav_server()
notification_method = "odoo.addons.calendar.models.calendar_attendee.Attendee._send_mail_to_attendees"
# 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)
):
), patch(notification_method) as mock_notification_method:
self.env["calendar.event"].poll_caldav_server()
mock_notification_method.assert_not_called()
with _patch_caldav_with_events_from_ics(
ics_path, user3, last_modified=datetime.now(UTC)
):
), patch(notification_method) as mock_notification_method:
self.env["calendar.event"].poll_caldav_server()
mock_notification_method.assert_not_called()
with _patch_caldav_with_events_from_ics(
ics_path, user1, last_modified=datetime.now(UTC)
):
), patch(notification_method) as mock_notification_method:
self.env["calendar.event"].poll_caldav_server()
mock_notification_method.assert_not_called()
event = self.env["calendar.event"].search(
[("caldav_uid", "=", "2495546B-5C9A-4632-AAD3-A179EF83CF20")]
)