caldav_sync: CRUD fully functional, recurring events working with exceptions around changes in mid-sequence with rrule updates
This commit is contained in:
parent
068d1fb29f
commit
14a8b6acf0
4 changed files with 476 additions and 285 deletions
|
|
@ -16,7 +16,7 @@
|
||||||
"website": "https://www.bemade.org",
|
"website": "https://www.bemade.org",
|
||||||
"depends": ["base", "calendar"],
|
"depends": ["base", "calendar"],
|
||||||
"external_dependencies": {
|
"external_dependencies": {
|
||||||
"python": ["caldav", "icalendar", "bs4"],
|
"python": ["caldav", "icalendar", "markdownify", "markdown2"],
|
||||||
},
|
},
|
||||||
"images": ["static/description/images/main_screenshot.png"],
|
"images": ["static/description/images/main_screenshot.png"],
|
||||||
"data": [
|
"data": [
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import icalendar.cal
|
||||||
|
|
||||||
from odoo import models, api, fields
|
from odoo import models, api, fields
|
||||||
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
|
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
|
||||||
import caldav
|
import caldav
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from icalendar import vCalAddress, vText, vDatetime, vRecur
|
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import re
|
import re
|
||||||
from pytz import timezone, utc
|
from pytz import timezone, utc
|
||||||
|
from typing import List, Dict, TypeVar, Optional
|
||||||
|
from markdownify import markdownify as md
|
||||||
|
import markdown2 as md2
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -22,6 +27,10 @@ WEEKDAY_MAP = {
|
||||||
6: "SU",
|
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):
|
def _parse_rrule_string(rrule_str):
|
||||||
def try_to_int(part):
|
def try_to_int(part):
|
||||||
|
|
@ -38,6 +47,11 @@ def _parse_rrule_string(rrule_str):
|
||||||
params_dict = {}
|
params_dict = {}
|
||||||
for param in params:
|
for param in params:
|
||||||
parts = param.split("=")
|
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")
|
||||||
|
else:
|
||||||
|
parts[1] = datetime.strptime(parts[1], "%Y%m%d")
|
||||||
params_dict.update({parts[0]: try_to_int(parts[1])})
|
params_dict.update({parts[0]: try_to_int(parts[1])})
|
||||||
return params_dict
|
return params_dict
|
||||||
|
|
||||||
|
|
@ -55,7 +69,7 @@ class CalendarEvent(models.Model):
|
||||||
# Recurrence ID in iCalendar is the date or datetime the event would have
|
# Recurrence ID in iCalendar is the date or datetime the event would have
|
||||||
# been at if it followed the sequence. It is set by calendar.recurrence
|
# been at if it followed the sequence. It is set by calendar.recurrence
|
||||||
# when applying a recurrence.
|
# when applying a recurrence.
|
||||||
caldav_recurrence_id = fields.Char(
|
caldav_recurrence_id = fields.Datetime(
|
||||||
string="CalDAV Recurrence ID",
|
string="CalDAV Recurrence ID",
|
||||||
)
|
)
|
||||||
caldav_user_ids = fields.Many2many(
|
caldav_user_ids = fields.Many2many(
|
||||||
|
|
@ -65,6 +79,10 @@ class CalendarEvent(models.Model):
|
||||||
is_base_event = fields.Boolean(compute="_compute_is_base_event")
|
is_base_event = fields.Boolean(compute="_compute_is_base_event")
|
||||||
differs_from_base_event = fields.Boolean(compute="_compute_differs_from_base_event")
|
differs_from_base_event = fields.Boolean(compute="_compute_differs_from_base_event")
|
||||||
|
|
||||||
|
###################################
|
||||||
|
#### Field Computation Methods ####
|
||||||
|
###################################
|
||||||
|
|
||||||
@api.depends("name", "description", "partner_ids", "location", "videocall_location")
|
@api.depends("name", "description", "partner_ids", "location", "videocall_location")
|
||||||
def _compute_differs_from_base_event(self):
|
def _compute_differs_from_base_event(self):
|
||||||
base_events = self.filtered("is_base_event")
|
base_events = self.filtered("is_base_event")
|
||||||
|
|
@ -80,11 +98,14 @@ class CalendarEvent(models.Model):
|
||||||
]
|
]
|
||||||
for event in non_base_events:
|
for event in non_base_events:
|
||||||
base_event = event.recurrence_id.base_event_id
|
base_event = event.recurrence_id.base_event_id
|
||||||
event.differs_from_base_event = any(
|
event.differs_from_base_event = (
|
||||||
[
|
any(
|
||||||
getattr(event, field) != getattr(base_event, field)
|
[
|
||||||
for field in fields_to_check
|
getattr(event, field) != getattr(base_event, field)
|
||||||
]
|
for field in fields_to_check
|
||||||
|
]
|
||||||
|
)
|
||||||
|
or not event.follow_recurrence
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends("is_base_event")
|
@api.depends("is_base_event")
|
||||||
|
|
@ -114,11 +135,23 @@ class CalendarEvent(models.Model):
|
||||||
"is_caldav_enabled"
|
"is_caldav_enabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _to_sync(self):
|
def _is_caldav_enabled(self):
|
||||||
return self.filtered(
|
return self.user_id.is_caldav_enabled
|
||||||
lambda event: event._is_caldav_enabled()
|
|
||||||
and (event.is_base_event or event.differs_from_base_event)
|
def _get_ical_recurrence_id(self) -> datetime:
|
||||||
)
|
"""Get the recurrence-id to use for identifying this event
|
||||||
|
specifically in an iCalendar instance.
|
||||||
|
|
||||||
|
:return: The timezone-aware datetime that uniquely identifies this
|
||||||
|
event in the recurrence chain.
|
||||||
|
"""
|
||||||
|
user_tz = timezone(self.user_id.tz)
|
||||||
|
recurrence_id = utc.localize(self.caldav_recurrence_id).astimezone(user_tz)
|
||||||
|
return recurrence_id
|
||||||
|
|
||||||
|
#################################################################
|
||||||
|
#### Local Change Methods - Sending Updates to CalDAV Server ####
|
||||||
|
#################################################################
|
||||||
|
|
||||||
@api.model_create_multi
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
|
|
@ -132,38 +165,6 @@ class CalendarEvent(models.Model):
|
||||||
events._to_sync()._sync_create_to_caldav()
|
events._to_sync()._sync_create_to_caldav()
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def write(self, vals):
|
|
||||||
res = super(CalendarEvent, self.with_context({"caldav_no_sync": True})).write(
|
|
||||||
vals
|
|
||||||
)
|
|
||||||
# Events sometimes get archived in Odoo in the process of updating recurrence
|
|
||||||
# In that case, we will delete them from the CalDAV server before recreating
|
|
||||||
if "active" in vals and not vals.get("active"):
|
|
||||||
for event in self:
|
|
||||||
event._sync_remove_from_caldav()
|
|
||||||
return res
|
|
||||||
to_sync = self._to_sync()
|
|
||||||
if to_sync and not self.env.context.get("caldav_no_sync"):
|
|
||||||
for rec in to_sync:
|
|
||||||
_logger.debug(
|
|
||||||
f"Updating event {rec.name} in CalDAV. " f"{rec.fields_get()}"
|
|
||||||
)
|
|
||||||
rec._sync_update_to_caldav()
|
|
||||||
return res
|
|
||||||
|
|
||||||
def unlink(self):
|
|
||||||
if not self.env.context.get("caldav_no_sync"):
|
|
||||||
for rec in self._to_sync():
|
|
||||||
try:
|
|
||||||
_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.user_id.is_caldav_enabled
|
|
||||||
|
|
||||||
def _sync_create_to_caldav(self):
|
def _sync_create_to_caldav(self):
|
||||||
for event in self:
|
for event in self:
|
||||||
for user in event.caldav_user_ids:
|
for user in event.caldav_user_ids:
|
||||||
|
|
@ -181,7 +182,26 @@ class CalendarEvent(models.Model):
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# _logger.error(f"Failed to sync event to CalDAV server: {e}")
|
# _logger.error(f"Failed to sync event to CalDAV server: {e}")
|
||||||
|
|
||||||
def _sync_update_to_caldav(self):
|
def write(self, vals):
|
||||||
|
res = super(CalendarEvent, self.with_context({"caldav_no_sync": True})).write(
|
||||||
|
vals
|
||||||
|
)
|
||||||
|
# Events sometimes get archived in Odoo in the process of updating recurrence
|
||||||
|
# In that case, we will delete them from the CalDAV server before recreating
|
||||||
|
if "active" in vals and not vals.get("active"):
|
||||||
|
for event in self:
|
||||||
|
event._sync_unlink_to_caldav()
|
||||||
|
return res
|
||||||
|
to_sync = self._to_sync()
|
||||||
|
if to_sync and not self.env.context.get("caldav_no_sync"):
|
||||||
|
for rec in to_sync:
|
||||||
|
_logger.debug(
|
||||||
|
f"Updating event {rec.name} in CalDAV. " f"{rec.fields_get()}"
|
||||||
|
)
|
||||||
|
rec._sync_write_to_caldav()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _sync_write_to_caldav(self):
|
||||||
ical_event_data = self._create_event_data()
|
ical_event_data = self._create_event_data()
|
||||||
for user in self.caldav_user_ids:
|
for user in self.caldav_user_ids:
|
||||||
client = user._get_caldav_client()
|
client = user._get_caldav_client()
|
||||||
|
|
@ -189,27 +209,42 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
event = None
|
event = None
|
||||||
try:
|
try:
|
||||||
event = self._find_in_icalendar(calendar, user)
|
event = self._find_in_caldav_calendar(calendar)
|
||||||
except caldav.error.NotFoundError:
|
except caldav.error.NotFoundError:
|
||||||
_logger.info(
|
_logger.debug(
|
||||||
f"CalDAV event {self.caldav_uid} not found on server during update."
|
f"CalDAV event {self.caldav_uid} not found on server during update."
|
||||||
f" Creating it instead."
|
f" Creating it instead."
|
||||||
)
|
)
|
||||||
|
|
||||||
if event:
|
if event:
|
||||||
event.icalendar_component.update(ical_event_data)
|
event.icalendar_component.update(ical_event_data)
|
||||||
event.save()
|
event.save()
|
||||||
|
elif self.recurrence_id and not self.follow_recurrence:
|
||||||
|
base_event = calendar.event_by_uid(self.caldav_uid)
|
||||||
|
base_event.icalendar_instance.add_component(Event(**ical_event_data))
|
||||||
|
base_event.save()
|
||||||
else:
|
else:
|
||||||
calendar.save_event(**ical_event_data)
|
calendar.save_event(**ical_event_data)
|
||||||
|
|
||||||
def _sync_remove_from_caldav(self):
|
def unlink(self):
|
||||||
|
if not self.env.context.get("caldav_no_sync"):
|
||||||
|
for rec in self._to_sync():
|
||||||
|
try:
|
||||||
|
# TODO: fix the attempt to sync all events ... _to_sync is
|
||||||
|
# not working here
|
||||||
|
_logger.debug(f"Removing event {rec.name} from CalDAV")
|
||||||
|
rec._sync_unlink_to_caldav()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to delete event from CalDAV server: {e}")
|
||||||
|
return super(CalendarEvent, self).unlink()
|
||||||
|
|
||||||
|
def _sync_unlink_to_caldav(self):
|
||||||
if self.caldav_uid:
|
if self.caldav_uid:
|
||||||
for user in self.caldav_user_ids:
|
for user in self.caldav_user_ids:
|
||||||
client = user._get_caldav_client()
|
client = user._get_caldav_client()
|
||||||
calendar = client.calendar(url=user.caldav_calendar_url)
|
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||||
try:
|
try:
|
||||||
_logger.debug(f"Removing CalDAV event {self.caldav_uid}")
|
_logger.debug(f"Removing CalDAV event {self.caldav_uid}")
|
||||||
ical_event = self._find_in_icalendar(calendar, user)
|
ical_event = self._find_in_caldav_calendar(calendar)
|
||||||
if ical_event:
|
if ical_event:
|
||||||
ical_event.delete()
|
ical_event.delete()
|
||||||
except caldav.error.NotFoundError:
|
except caldav.error.NotFoundError:
|
||||||
|
|
@ -219,26 +254,21 @@ class CalendarEvent(models.Model):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"Failed to remove event from CalDAV server: {e}")
|
_logger.error(f"Failed to remove event from CalDAV server: {e}")
|
||||||
|
|
||||||
def _find_in_icalendar(self, calendar, user, force_recurrence_id=False):
|
def _to_sync(self):
|
||||||
# Since search by uid and recurrence-id is not yet supported,
|
"""Determine which records in self we need to synchronize with the
|
||||||
# we do it manually
|
CalDAV server. In essence, we only synchronize base events and those
|
||||||
match_recurrence_id = (
|
differing from the base event in a recurring chain."""
|
||||||
force_recurrence_id or self.recurrence_id and not self.is_base_event
|
return self.filtered(
|
||||||
|
lambda event: event._is_caldav_enabled()
|
||||||
|
and (event.is_base_event or event.differs_from_base_event)
|
||||||
)
|
)
|
||||||
if not match_recurrence_id:
|
|
||||||
return calendar.event_by_uid(self.caldav_uid)
|
|
||||||
else:
|
|
||||||
events = user._get_caldav_events()
|
|
||||||
for event in events:
|
|
||||||
event_instance = event.icalendar_instance
|
|
||||||
if (
|
|
||||||
event_instance.get("uid") == self.caldav_uid
|
|
||||||
and event_instance.get("recurrence-id") == self.caldav_recurrence_id
|
|
||||||
):
|
|
||||||
return event
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _create_in_icalendar(self, calendar):
|
def _create_in_icalendar(self, calendar: caldav.Calendar) -> List[caldav.Event]:
|
||||||
|
"""Create an event matching self in the provided calendar.
|
||||||
|
|
||||||
|
:param calendar: The calendar in which to create the event.
|
||||||
|
:return: The list of created events, usually one.
|
||||||
|
"""
|
||||||
ical_event_data = self._create_event_data()
|
ical_event_data = self._create_event_data()
|
||||||
caldav_event = calendar.save_event(**ical_event_data)
|
caldav_event = calendar.save_event(**ical_event_data)
|
||||||
if self.recurrence_id and self.is_base_event and not self.follow_recurrence:
|
if self.recurrence_id and self.is_base_event and not self.follow_recurrence:
|
||||||
|
|
@ -247,7 +277,8 @@ class CalendarEvent(models.Model):
|
||||||
return [caldav_event, second_caldav_event]
|
return [caldav_event, second_caldav_event]
|
||||||
return [caldav_event]
|
return [caldav_event]
|
||||||
|
|
||||||
def _create_event_data(self):
|
def _create_event_data(self) -> Dict:
|
||||||
|
"""Create a dictionary of iCalendar compatible event data."""
|
||||||
event_data = {}
|
event_data = {}
|
||||||
self._add_event_dates(event_data)
|
self._add_event_dates(event_data)
|
||||||
self._add_event_header_info(event_data)
|
self._add_event_header_info(event_data)
|
||||||
|
|
@ -258,7 +289,8 @@ class CalendarEvent(models.Model):
|
||||||
self._add_event_recurrence_id(event_data)
|
self._add_event_recurrence_id(event_data)
|
||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
def _add_event_header_info(self, event_data):
|
def _add_event_header_info(self, event_data: Dict) -> None:
|
||||||
|
"""Add event header info to a dict containing event data."""
|
||||||
event_data["uid"] = vText(self.caldav_uid)
|
event_data["uid"] = vText(self.caldav_uid)
|
||||||
if self.name:
|
if self.name:
|
||||||
event_data["summary"] = vText(self.name)
|
event_data["summary"] = vText(self.name)
|
||||||
|
|
@ -270,7 +302,8 @@ class CalendarEvent(models.Model):
|
||||||
if self.videocall_location:
|
if self.videocall_location:
|
||||||
event_data["conference"] = self.videocall_location
|
event_data["conference"] = self.videocall_location
|
||||||
|
|
||||||
def _add_event_dates(self, event_data):
|
def _add_event_dates(self, event_data: Dict) -> None:
|
||||||
|
"""Add pertinent dates to event data, based on self."""
|
||||||
user_tz = timezone("UTC")
|
user_tz = timezone("UTC")
|
||||||
if self.user_id.tz:
|
if self.user_id.tz:
|
||||||
user_tz = timezone(self.user_id.tz)
|
user_tz = timezone(self.user_id.tz)
|
||||||
|
|
@ -284,18 +317,23 @@ class CalendarEvent(models.Model):
|
||||||
event_data["dtend"] = vDatetime(utc.localize(self.stop).astimezone(user_tz))
|
event_data["dtend"] = vDatetime(utc.localize(self.stop).astimezone(user_tz))
|
||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
def _add_event_recurrence_id(self, event_data):
|
def _add_event_recurrence_id(self, event_data: Dict) -> None:
|
||||||
|
"""Add the recurrence-id parameter to event data if self is linked
|
||||||
|
to a calendar.recurrence record."""
|
||||||
if self.recurrence_id:
|
if self.recurrence_id:
|
||||||
event_data["recurrence-id"] = self._get_ical_recurrence_id()
|
event_data["recurrence-id"] = vDatetime(self._get_ical_recurrence_id())
|
||||||
|
|
||||||
def _add_event_recurrence(self, event_data):
|
def _add_event_recurrence(self, event_data: Dict) -> None:
|
||||||
# Add RRULE if the event is recurrent
|
"""Add the recurrence rule (rrule) to event data if self is
|
||||||
|
recurrent. This should only be called for base events."""
|
||||||
if self.recurrence_id:
|
if self.recurrence_id:
|
||||||
rrule = str(self.recurrence_id._get_rrule())
|
rrule = str(self.recurrence_id._get_rrule())
|
||||||
rrule_dict = _parse_rrule_string(rrule)
|
rrule_dict = _parse_rrule_string(rrule)
|
||||||
event_data["rrule"] = vRecur(**rrule_dict)
|
event_data["rrule"] = vRecur(**rrule_dict)
|
||||||
|
|
||||||
def _add_event_attendees(self, event_data):
|
def _add_event_attendees(self, event_data: Dict) -> None:
|
||||||
|
"""Add the attendee information to the "organizer" and "attendee"
|
||||||
|
keys of the event data."""
|
||||||
attendee_lines = []
|
attendee_lines = []
|
||||||
for partner in self.partner_ids:
|
for partner in self.partner_ids:
|
||||||
if partner == self.user_id.partner_id:
|
if partner == self.user_id.partner_id:
|
||||||
|
|
@ -316,23 +354,117 @@ class CalendarEvent(models.Model):
|
||||||
event_data["organizer"] = organizer
|
event_data["organizer"] = organizer
|
||||||
event_data["attendee"] = attendee_lines
|
event_data["attendee"] = attendee_lines
|
||||||
|
|
||||||
def _get_ical_recurrence_id(self):
|
@api.model
|
||||||
user_tz = timezone(self.user_id.tz)
|
def _html_to_text(self, html):
|
||||||
recurrence_id = (
|
return md(html, heading_style="ATX")
|
||||||
utc.localize(datetime.strptime(self.caldav_recurrence_id, "%Y%m%dT%H%M%S"))
|
|
||||||
.astimezone(user_tz)
|
|
||||||
.strftime("%Y%m%dT%H%M%S")
|
|
||||||
)
|
|
||||||
return recurrence_id
|
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def poll_caldav_server(self):
|
def _map_attendee_status(self, state: str) -> str:
|
||||||
|
"""Map the state of an Odoo event attendee to its iCalendar
|
||||||
|
equivalent.
|
||||||
|
|
||||||
|
:param state: The state of the Odoo event attendee.
|
||||||
|
:return: The equivalent iCalendar attendee state."""
|
||||||
|
mapping = {
|
||||||
|
"needsAction": "NEEDS-ACTION",
|
||||||
|
"accepted": "ACCEPTED",
|
||||||
|
"declined": "DECLINED",
|
||||||
|
"tentative": "TENTATIVE",
|
||||||
|
}
|
||||||
|
return mapping.get(state, "NEEDS-ACTION")
|
||||||
|
|
||||||
|
def _post_recurrence_detach(self):
|
||||||
|
"""After events are detached from a recurrence, we need to split them up on the
|
||||||
|
CalDAV server. The caldav_uid that unites events of a series needs to be
|
||||||
|
separated, making a new uid for each base event (and its recurrences)."""
|
||||||
|
# Start by deleting the old calendar events since we will need to remake them
|
||||||
|
_logger.debug(
|
||||||
|
f"Events {self} with CalDAV UIDs "
|
||||||
|
f"{self.mapped('caldav_uid')} are being "
|
||||||
|
f"detached."
|
||||||
|
)
|
||||||
|
for event in self:
|
||||||
|
for user in event.caldav_user_ids:
|
||||||
|
calendar = user._get_caldav_client().calendar(
|
||||||
|
url=user.caldav_calendar_url
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
caldav_event = event._find_in_caldav_calendar(
|
||||||
|
calendar, force_recurrence_id=True
|
||||||
|
)
|
||||||
|
if caldav_event:
|
||||||
|
caldav_event.delete()
|
||||||
|
except caldav.error.NotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
base_events = self.filtered("is_base_event")
|
||||||
|
# Assign a new UID for each base event and its recurrences
|
||||||
|
for event in base_events:
|
||||||
|
recurrence_events = (
|
||||||
|
event.recurrence_id and event.recurrence_id.calendar_event_ids | event
|
||||||
|
)
|
||||||
|
new_uid = str(uuid.uuid4())
|
||||||
|
recurrence_events.write({"caldav_uid": new_uid})
|
||||||
|
|
||||||
|
def _break_recurrence(self, future=True):
|
||||||
|
_logger.debug(
|
||||||
|
f"Recurrence breaking for events {self} with CalDAV "
|
||||||
|
f"UIDs {self.mapped('caldav_uid')}."
|
||||||
|
)
|
||||||
|
super()._break_recurrence(future)
|
||||||
|
self._post_recurrence_detach()
|
||||||
|
|
||||||
|
def _find_in_caldav_calendar(
|
||||||
|
self, calendar: caldav.Calendar, force_recurrence_id: bool = False
|
||||||
|
) -> Optional[caldav.Event]:
|
||||||
|
"""Find this event in a given CalDAV Calendar. If the event is
|
||||||
|
recurring but is not a base event, it will be matched with its
|
||||||
|
caldav_uid and caldav_recurrence_id. Base events are matched by UID
|
||||||
|
only.
|
||||||
|
|
||||||
|
:param calendar: The CalDAV Calendar to find the event in.
|
||||||
|
:param force_recurrence_id: Match with the caldav_recurrence_id even
|
||||||
|
if self is a base event.
|
||||||
|
:return: The CalDAV Event found in the calendar (or None).
|
||||||
|
"""
|
||||||
|
# Since search by uid and recurrence-id is not yet supported,
|
||||||
|
# we do it manually
|
||||||
|
match_recurrence_id = (
|
||||||
|
force_recurrence_id or self.recurrence_id and not self.is_base_event
|
||||||
|
)
|
||||||
|
if not match_recurrence_id:
|
||||||
|
return calendar.event_by_uid(self.caldav_uid)
|
||||||
|
else:
|
||||||
|
events = calendar.events()
|
||||||
|
for event in events:
|
||||||
|
event_instance = event.icalendar_instance
|
||||||
|
if (
|
||||||
|
event_instance.get("uid") == self.caldav_uid
|
||||||
|
and event_instance.get("recurrence-id").dt
|
||||||
|
== self._get_ical_recurrence_id()
|
||||||
|
):
|
||||||
|
return event
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
#### Methods for synchronizing changes from the server to Odoo ####
|
||||||
|
###################################################################
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def poll_caldav_server(self) -> None:
|
||||||
|
"""Poll each user's CalDAV calendar server for changes and
|
||||||
|
synchronize them with their Odoo calendar."""
|
||||||
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
|
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
|
||||||
for user in all_users:
|
for user in all_users:
|
||||||
self._poll_user_caldav_server(user)
|
self._poll_user_caldav_server(user)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _poll_user_caldav_server(self, user):
|
def _poll_user_caldav_server(self, user) -> None:
|
||||||
|
"""Poll a single user's CalDAV calendar on the server for changes
|
||||||
|
and synchronize them to Odoo.
|
||||||
|
|
||||||
|
:param user: The res.user record for whom to synchronize events.
|
||||||
|
"""
|
||||||
_logger.info(f"Polling CalDAV server for user {user.name}")
|
_logger.info(f"Polling CalDAV server for user {user.name}")
|
||||||
events = user._get_caldav_events()
|
events = user._get_caldav_events()
|
||||||
synced_events = self.env["calendar.event"]
|
synced_events = self.env["calendar.event"]
|
||||||
|
|
@ -355,150 +487,28 @@ class CalendarEvent(models.Model):
|
||||||
orphaned_events.with_context(caldav_no_sync=True).with_user(user).unlink()
|
orphaned_events.with_context(caldav_no_sync=True).with_user(user).unlink()
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _get_existing_instance(self, uid, recurrence_id):
|
def _sync_event_from_ical(
|
||||||
if recurrence_id:
|
self, ical_event: icalendar.cal.Event, user: User
|
||||||
recurrence_id = recurrence_id.dt.strftime("%Y%m%dT%H%M%S")
|
) -> CalendarEvent:
|
||||||
instance = self.env["calendar.event"].search(
|
"""Given an iCalendar event, compare the event with any existing
|
||||||
[
|
Odoo event that it matches and synchronize the changes. If no event
|
||||||
("caldav_uid", "=", uid),
|
exists, create one.
|
||||||
("caldav_recurrence_id", "=", recurrence_id),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
instance = self.env["calendar.event"].search(
|
|
||||||
[
|
|
||||||
("caldav_uid", "=", uid),
|
|
||||||
("recurrency", "=", False),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if instance:
|
|
||||||
return instance
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
self.env["calendar.recurrence"]
|
|
||||||
.search(
|
|
||||||
[
|
|
||||||
("base_event_id.caldav_uid", "=", uid),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
.base_event_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(instance) == 1:
|
:param ical_event: The iCalendar event to synchronize with Odoo.
|
||||||
return instance
|
:param user: The res.user record for whom to synchronize the event.
|
||||||
if len(instance) > 1:
|
:return: The calendar.event records that were synchronized.
|
||||||
instance = instance.recurrence_id.base_event_id
|
"""
|
||||||
|
|
||||||
return instance or self.env["calendar.event"].search(
|
|
||||||
[
|
|
||||||
("caldav_uid", "=", "uid"),
|
|
||||||
("recurrence_id", "=", False),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def _get_recurrency_values_from_ical_event(self, component):
|
|
||||||
"""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"""
|
|
||||||
|
|
||||||
if component.get("recurrence-id"):
|
|
||||||
# When a component has "recurrence-id" set, it is a single event
|
|
||||||
# in a series of events. Recurrence-id is the time the event should
|
|
||||||
# normally occur at if it follows the recurrence of the series.
|
|
||||||
follow_recurrence = (
|
|
||||||
component.get("recurrence-id").dt == component.get("dtstart").dt
|
|
||||||
)
|
|
||||||
if not self.follow_recurrence:
|
|
||||||
return {"recurrency": False}
|
|
||||||
|
|
||||||
rrule = [item[1] for item in component.property_items() if item[0] == "RRULE"]
|
|
||||||
rrule = rrule[0] if rrule else None
|
|
||||||
|
|
||||||
if not rrule:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
|
|
||||||
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
|
|
||||||
"RRULE:" + rrule_str, component.decoded("dtstart")
|
|
||||||
)
|
|
||||||
vals = {
|
|
||||||
"recurrency": True,
|
|
||||||
"follow_recurrence": True,
|
|
||||||
"recurrence_update": "all_events",
|
|
||||||
**rrule_params,
|
|
||||||
}
|
|
||||||
# Convert None to False since fields from Odoo that are not filled come back False
|
|
||||||
vals = {
|
|
||||||
key: value if value is not None else False for key, value in vals.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Forever doesn't exist in Odoo. The calendar.recurrence model changes 'forever'
|
|
||||||
# into 'count' with MAX_RECURRENT_EVENT as the 'count' parameter
|
|
||||||
if vals.get("end_type") == "forever":
|
|
||||||
vals.update(end_type="count")
|
|
||||||
if not vals.get("count"):
|
|
||||||
vals.update(count=MAX_RECURRENT_EVENT)
|
|
||||||
return vals
|
|
||||||
|
|
||||||
def _get_recurrence_changes(self, recurrency_vals):
|
|
||||||
if not recurrency_vals and not self.recurrence_id:
|
|
||||||
return {}
|
|
||||||
if not recurrency_vals and self.recurrence_id:
|
|
||||||
return {"recurrence_update": "all_events", "recurrency": False}
|
|
||||||
if recurrency_vals and not self.recurrence_id:
|
|
||||||
return recurrency_vals
|
|
||||||
changed_fields = {
|
|
||||||
key: recurrency_vals[key]
|
|
||||||
for key in recurrency_vals.keys()
|
|
||||||
if hasattr(self, key) and recurrency_vals[key] != getattr(self, key)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
len(changed_fields) == 1
|
|
||||||
and changed_fields.get("recurrence_update") == "all_events"
|
|
||||||
):
|
|
||||||
return {}
|
|
||||||
return changed_fields
|
|
||||||
|
|
||||||
def _get_value_changes(self, values):
|
|
||||||
changed_vals = {}
|
|
||||||
# Don't update partner_ids if no change
|
|
||||||
if "partner_ids" in values:
|
|
||||||
partner_ids = values["partner_ids"][0][2] # this is a SET command
|
|
||||||
added_partner_ids = set(
|
|
||||||
[id for id in partner_ids if id not in self.partner_ids.ids]
|
|
||||||
)
|
|
||||||
removed_partner_ids = set(
|
|
||||||
[id for id in self.partner_ids.ids if id not in partner_ids]
|
|
||||||
)
|
|
||||||
if not (added_partner_ids or removed_partner_ids):
|
|
||||||
values.pop("partner_ids") # They break the equality check later
|
|
||||||
else:
|
|
||||||
changed_vals["partner_ids"] = values["partner_ids"]
|
|
||||||
|
|
||||||
# Get just the list of values that have changed, leave the others alone
|
|
||||||
for key, val in values.items():
|
|
||||||
curr_val = getattr(self, 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})
|
|
||||||
return changed_vals
|
|
||||||
|
|
||||||
def _sync_event_from_ical(self, ical_event, user):
|
|
||||||
synced_events = self.env["calendar.event"]
|
synced_events = self.env["calendar.event"]
|
||||||
event_components = [
|
event_components = [
|
||||||
component for component in ical_event.walk() if component.name == "VEVENT"
|
component for component in ical_event.walk() if component.name == "VEVENT"
|
||||||
]
|
]
|
||||||
for component in event_components:
|
for component in event_components:
|
||||||
uid = str(component.get("uid"))
|
uid = str(component.get("uid"))
|
||||||
recurrence_id = component.get(
|
recurrence_id = (
|
||||||
"recurrence-id"
|
component.get("recurrence-id").dt
|
||||||
) # Date & time a single event would normally be at in the series
|
if "recurrence-id" in component
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
existing_instance = self._get_existing_instance(uid, recurrence_id)
|
existing_instance = self._get_existing_instance(uid, recurrence_id)
|
||||||
outdated = self._get_outdated(component, existing_instance, synced_events)
|
outdated = self._get_outdated(component, existing_instance, synced_events)
|
||||||
|
|
@ -548,7 +558,187 @@ class CalendarEvent(models.Model):
|
||||||
synced_events |= existing_instance
|
synced_events |= existing_instance
|
||||||
return synced_events
|
return synced_events
|
||||||
|
|
||||||
def _get_outdated(self, component, existing_instance, synced_events):
|
@api.model
|
||||||
|
def _get_existing_instance(self, uid, recurrence_id: datetime) -> CalendarEvent:
|
||||||
|
"""Find the Odoo calendar.event record matching uid and,
|
||||||
|
if set, recurrence_id.
|
||||||
|
"""
|
||||||
|
if recurrence_id:
|
||||||
|
recurrence_id = recurrence_id.astimezone(utc).replace(tzinfo=None)
|
||||||
|
instance = self.env["calendar.event"].search(
|
||||||
|
[
|
||||||
|
("caldav_uid", "=", uid),
|
||||||
|
("caldav_recurrence_id", "=", recurrence_id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instance = self.env["calendar.event"].search(
|
||||||
|
[
|
||||||
|
("caldav_uid", "=", uid),
|
||||||
|
("recurrency", "=", False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if instance:
|
||||||
|
return instance
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
self.env["calendar.recurrence"]
|
||||||
|
.search(
|
||||||
|
[
|
||||||
|
("base_event_id.caldav_uid", "=", uid),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.base_event_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(instance) == 1:
|
||||||
|
return instance
|
||||||
|
if len(instance) > 1:
|
||||||
|
instance = instance.recurrence_id.base_event_id
|
||||||
|
|
||||||
|
return instance or self.env["calendar.event"].search(
|
||||||
|
[
|
||||||
|
("caldav_uid", "=", "uid"),
|
||||||
|
("recurrence_id", "=", False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_recurrency_values_from_ical_event(
|
||||||
|
self, component: icalendar.cal.Component
|
||||||
|
) -> Dict:
|
||||||
|
"""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 = [item[1] for item in component.property_items() if item[0] == "RRULE"]
|
||||||
|
rrule = rrule[0] if rrule else None
|
||||||
|
|
||||||
|
if component.get("recurrence-id"):
|
||||||
|
# When a component has "recurrence-id" set, it is a single event
|
||||||
|
# in a series of events. Recurrence-id is the time the event should
|
||||||
|
# normally occur at if it follows the recurrence of the series.
|
||||||
|
follows_recurrence = (
|
||||||
|
component.get("recurrence-id").dt == component.get("dtstart").dt
|
||||||
|
)
|
||||||
|
if not follows_recurrence:
|
||||||
|
return {
|
||||||
|
"follow_recurrence": False,
|
||||||
|
"recurrence_update": "self_only",
|
||||||
|
}
|
||||||
|
if not rrule:
|
||||||
|
# This event is following the base event's recurrence rule
|
||||||
|
return {
|
||||||
|
"follow_recurrence": True,
|
||||||
|
"recurrence_update": "self_only",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not rrule:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
|
||||||
|
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
|
||||||
|
"RRULE:" + rrule_str, component.decoded("dtstart")
|
||||||
|
)
|
||||||
|
vals = {
|
||||||
|
"recurrency": True,
|
||||||
|
"follow_recurrence": True,
|
||||||
|
"recurrence_update": "future_events",
|
||||||
|
**rrule_params,
|
||||||
|
}
|
||||||
|
# Convert None to False since fields from Odoo that are not filled come back False
|
||||||
|
vals = {
|
||||||
|
key: value if value is not None else False for key, value in vals.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forever doesn't exist in Odoo. The calendar.recurrence model changes 'forever'
|
||||||
|
# into 'count' with MAX_RECURRENT_EVENT as the 'count' parameter
|
||||||
|
if vals.get("end_type") == "forever":
|
||||||
|
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()
|
||||||
|
vals.update(until=until_day)
|
||||||
|
vals.pop("count", None)
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def _get_recurrence_changes(self, recurrency_vals: Dict) -> Dict:
|
||||||
|
"""Compare a set of recurrency values with those already present on
|
||||||
|
this event.
|
||||||
|
|
||||||
|
:param recurrency_vals: The recurrency values from an iCalendar event
|
||||||
|
to compare to self.
|
||||||
|
|
||||||
|
:return: A dictionary containing only the values that are different
|
||||||
|
from those in self."""
|
||||||
|
if not recurrency_vals and not self.recurrence_id:
|
||||||
|
return {}
|
||||||
|
if not recurrency_vals and self.recurrence_id:
|
||||||
|
return {"recurrence_update": "all_events", "recurrency": False}
|
||||||
|
if recurrency_vals and not self.recurrence_id:
|
||||||
|
return recurrency_vals
|
||||||
|
changed_fields = {
|
||||||
|
key: recurrency_vals[key]
|
||||||
|
for key in recurrency_vals.keys()
|
||||||
|
if hasattr(self, key) and recurrency_vals[key] != getattr(self, key)
|
||||||
|
}
|
||||||
|
if len(changed_fields) == 1 and "recurrence_update" in changed_fields:
|
||||||
|
return {}
|
||||||
|
return changed_fields
|
||||||
|
|
||||||
|
def _get_value_changes(self, values: Dict) -> Dict:
|
||||||
|
"""Compare the values from an iCalendar event to those already
|
||||||
|
present on self.
|
||||||
|
|
||||||
|
:param values: The values from an iCalendar event
|
||||||
|
:return: A dictionary containing only the values that are different
|
||||||
|
from those in self."""
|
||||||
|
changed_vals = {}
|
||||||
|
# Don't update partner_ids if no change
|
||||||
|
if "partner_ids" in values:
|
||||||
|
partner_ids = values["partner_ids"][0][2] # this is a SET command
|
||||||
|
added_partner_ids = set(
|
||||||
|
[id for id in partner_ids if id not in self.partner_ids.ids]
|
||||||
|
)
|
||||||
|
removed_partner_ids = set(
|
||||||
|
[id for id in self.partner_ids.ids if id not in partner_ids]
|
||||||
|
)
|
||||||
|
if not (added_partner_ids or removed_partner_ids):
|
||||||
|
values.pop("partner_ids") # They break the equality check later
|
||||||
|
else:
|
||||||
|
changed_vals["partner_ids"] = values["partner_ids"]
|
||||||
|
|
||||||
|
# Get just the list of values that have changed, leave the others alone
|
||||||
|
for key, val in values.items():
|
||||||
|
curr_val = getattr(self, 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})
|
||||||
|
return changed_vals
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_outdated(
|
||||||
|
self,
|
||||||
|
component: icalendar.cal.Component,
|
||||||
|
existing_instance: CalendarEvent,
|
||||||
|
synced_events: CalendarEvent,
|
||||||
|
) -> bool:
|
||||||
|
"""Check whether a component from the CalDAV server (typically an
|
||||||
|
event) is outdated when compared to its existing Odoo calendar.event
|
||||||
|
instance.
|
||||||
|
|
||||||
|
:param component: The iCalendar component to check.
|
||||||
|
:param existing_instance: The calendar.event record to
|
||||||
|
compare with.
|
||||||
|
:param synced_events: The calendar.event record(s) that have already
|
||||||
|
been synchronized.
|
||||||
|
:return: True if the iCalendar component is outdated, False otherwise.
|
||||||
|
"""
|
||||||
outdated = False
|
outdated = False
|
||||||
last_modified = component.get("last-modified")
|
last_modified = component.get("last-modified")
|
||||||
if (
|
if (
|
||||||
|
|
@ -561,7 +751,17 @@ class CalendarEvent(models.Model):
|
||||||
outdated = True
|
outdated = True
|
||||||
return outdated
|
return outdated
|
||||||
|
|
||||||
def _get_values_from_ical_component(self, component, user):
|
@api.model
|
||||||
|
def _get_values_from_ical_component(
|
||||||
|
self, component: icalendar.cal.Component, user: User
|
||||||
|
) -> Dict:
|
||||||
|
"""Get the dictionary representing calendar.event field values from
|
||||||
|
an iCalendar event.
|
||||||
|
|
||||||
|
:param component: The iCalendar component from which to extract
|
||||||
|
values.
|
||||||
|
:param user: The res.users record the event will belong to.
|
||||||
|
:return: The dictionary of values to construct a calendar.event."""
|
||||||
start = component.get("dtstart") and component.decoded("dtstart")
|
start = component.get("dtstart") and component.decoded("dtstart")
|
||||||
if isinstance(start, datetime):
|
if isinstance(start, datetime):
|
||||||
start = start.astimezone(utc).replace(tzinfo=None)
|
start = start.astimezone(utc).replace(tzinfo=None)
|
||||||
|
|
@ -574,7 +774,9 @@ class CalendarEvent(models.Model):
|
||||||
"name": str(component.get("summary")),
|
"name": str(component.get("summary")),
|
||||||
"start": start,
|
"start": start,
|
||||||
"stop": end,
|
"stop": end,
|
||||||
"description": self._extract_component_text(component, "description"),
|
"description": self._text_to_html(
|
||||||
|
self._extract_component_text(component, "description")
|
||||||
|
),
|
||||||
"location": self._extract_component_text(component, "location"),
|
"location": self._extract_component_text(component, "location"),
|
||||||
"videocall_location": self._extract_component_text(component, "conference"),
|
"videocall_location": self._extract_component_text(component, "conference"),
|
||||||
"caldav_uid": str(component.get("uid")),
|
"caldav_uid": str(component.get("uid")),
|
||||||
|
|
@ -584,7 +786,18 @@ class CalendarEvent(models.Model):
|
||||||
}
|
}
|
||||||
return values
|
return values
|
||||||
|
|
||||||
def _get_attendee_partners(self, component, current_user_email):
|
@api.model
|
||||||
|
def _get_attendee_partners(
|
||||||
|
self, component: icalendar.cal.Component, current_user_email: str
|
||||||
|
) -> Partner:
|
||||||
|
"""Get the res.partner records who are attendees for a given
|
||||||
|
iCalendar event.
|
||||||
|
|
||||||
|
:param component: The iCalendar component from which to extract
|
||||||
|
attendees.
|
||||||
|
:param current_user_email: The email for the res.users record the
|
||||||
|
matching Odoo event belongs to.
|
||||||
|
:return: The res.partner records who are attendees for the event."""
|
||||||
attendee_emails = self._get_ical_attendee_emails(component)
|
attendee_emails = self._get_ical_attendee_emails(component)
|
||||||
if current_user_email not in attendee_emails:
|
if current_user_email not in attendee_emails:
|
||||||
attendee_emails.append(current_user_email)
|
attendee_emails.append(current_user_email)
|
||||||
|
|
@ -623,7 +836,14 @@ class CalendarEvent(models.Model):
|
||||||
lambda partner: partner.id in final_attendees.values()
|
lambda partner: partner.id in final_attendees.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_organizer_partner(self, component):
|
@api.model
|
||||||
|
def _get_organizer_partner(self, component: icalendar.cal.Component) -> Partner:
|
||||||
|
"""Get the partner matching the organizer on an iCalendar event.
|
||||||
|
|
||||||
|
:param component: The iCalendar component from which to extract the
|
||||||
|
organizer.
|
||||||
|
:return: The res.partner record matching the event organizer."""
|
||||||
|
|
||||||
organizer = component.get("organizer")
|
organizer = component.get("organizer")
|
||||||
if organizer:
|
if organizer:
|
||||||
partner = self.env["res.partner"].search(
|
partner = self.env["res.partner"].search(
|
||||||
|
|
@ -634,63 +854,36 @@ class CalendarEvent(models.Model):
|
||||||
else:
|
else:
|
||||||
return self.env["res.partner"]
|
return self.env["res.partner"]
|
||||||
|
|
||||||
@staticmethod
|
@api.model
|
||||||
def _get_ical_attendee_emails(component):
|
def _get_ical_attendee_emails(
|
||||||
|
self, component: icalendar.cal.Component
|
||||||
|
) -> List[str]:
|
||||||
|
"""Get the email addresses for attendees from an iCalendar event.
|
||||||
|
|
||||||
|
:param component: The iCalendar event from which to extract emails.
|
||||||
|
:return: A list of attendee emails
|
||||||
|
"""
|
||||||
attendees = component.get("attendee", [])
|
attendees = component.get("attendee", [])
|
||||||
if not isinstance(attendees, list):
|
if not isinstance(attendees, list):
|
||||||
attendees = [attendees]
|
attendees = [attendees]
|
||||||
attendee_emails = [_extract_vcal_email(attendee) for attendee in attendees]
|
attendee_emails = [_extract_vcal_email(attendee) for attendee in attendees]
|
||||||
return attendee_emails
|
return attendee_emails
|
||||||
|
|
||||||
@staticmethod
|
@api.model
|
||||||
def _extract_component_text(component, subcomponent_name):
|
def _extract_component_text(
|
||||||
|
self, component: icalendar.cal.Component, subcomponent_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Extract the text from an iCalendar subcomponent. Convenience
|
||||||
|
method to deal with empty subcomponents.
|
||||||
|
|
||||||
|
:param component: The iCalendar component from which to extract text.
|
||||||
|
:param subcomponent_name: The name of the subcomponent to extract.
|
||||||
|
:return: The extracted subcomponent text or an empty string.
|
||||||
|
"""
|
||||||
val = component.get(subcomponent_name)
|
val = component.get(subcomponent_name)
|
||||||
text = str(val) if val else ""
|
text = str(val) if val else ""
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@staticmethod
|
@api.model
|
||||||
def _html_to_text(html):
|
def _text_to_html(self, text):
|
||||||
return BeautifulSoup(html, "html.parser").getText()
|
return md2.markdown(text)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _map_attendee_status(state):
|
|
||||||
mapping = {
|
|
||||||
"needsAction": "NEEDS-ACTION",
|
|
||||||
"accepted": "ACCEPTED",
|
|
||||||
"declined": "DECLINED",
|
|
||||||
"tentative": "TENTATIVE",
|
|
||||||
}
|
|
||||||
return mapping.get(state, "NEEDS-ACTION")
|
|
||||||
|
|
||||||
def _post_recurrence_detach(self):
|
|
||||||
"""After we detach events from a recurrence, we need to split them up on the
|
|
||||||
CalDAV server. The caldav_uid that unites events of a series needs to be
|
|
||||||
separated, making a new uid for each base event (and its recurrences)."""
|
|
||||||
caldav_events = []
|
|
||||||
# Start by deleting the old calendar events since we will need to remake them
|
|
||||||
for event in self:
|
|
||||||
for user in event.caldav_user_ids:
|
|
||||||
calendar = user._get_caldav_client().calendar(
|
|
||||||
url=user.caldav_calendar_url
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
caldav_event = event._find_in_icalendar(
|
|
||||||
calendar, user, force_recurrence_id=True
|
|
||||||
)
|
|
||||||
if caldav_event:
|
|
||||||
caldav_event.delete()
|
|
||||||
except caldav.error.NotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
base_events = self.filtered("is_base_event")
|
|
||||||
# Assign a new UID for each base event and its recurrences
|
|
||||||
for event in base_events:
|
|
||||||
recurrence_events = (
|
|
||||||
event.recurrence_id and event.recurrence_id.calendar_event_ids | event
|
|
||||||
)
|
|
||||||
new_uid = str(uuid.uuid4())
|
|
||||||
recurrence_events.write({"caldav_uid": new_uid})
|
|
||||||
|
|
||||||
def _break_recurrence(self, future=True):
|
|
||||||
super()._break_recurrence(future)
|
|
||||||
self._post_recurrence_detach()
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from odoo import models, fields, api
|
from odoo import models, fields, api
|
||||||
from pytz import timezone
|
from pytz import timezone, utc
|
||||||
|
|
||||||
|
|
||||||
class RecurrenceRule(models.Model):
|
class RecurrenceRule(models.Model):
|
||||||
|
|
@ -7,10 +7,7 @@ class RecurrenceRule(models.Model):
|
||||||
|
|
||||||
def _recompute_event_caldav_recurrence_ids(self):
|
def _recompute_event_caldav_recurrence_ids(self):
|
||||||
def _get_recurrence_id(event):
|
def _get_recurrence_id(event):
|
||||||
date_format = "%Y%m%d" if event.allday else "%Y%m%dT%H%M%S"
|
return event.start.astimezone(utc).replace(tzinfo=None)
|
||||||
return event.start.astimezone(timezone(event.user_id.tz)).strftime(
|
|
||||||
date_format
|
|
||||||
)
|
|
||||||
|
|
||||||
for event in self.calendar_event_ids:
|
for event in self.calendar_event_ids:
|
||||||
event.caldav_recurrence_id = _get_recurrence_id(event)
|
event.caldav_recurrence_id = _get_recurrence_id(event)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
caldav==1.3.9
|
caldav==1.3.9
|
||||||
icalendar==5.0.13
|
icalendar==5.0.13
|
||||||
bs4==0.0.2
|
markdownify==0.13.1
|
||||||
|
markdown2==2.5.1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue