bemade-addons/caldav_sync/models/calendar_event.py

917 lines
37 KiB
Python
Raw Normal View History

import uuid
import icalendar.cal
from odoo import models, api, fields
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
2024-05-23 08:50:09 -04:00
import caldav
import logging
from datetime import datetime, date
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event, vInt
import re
2024-05-28 11:15:49 -04:00
from pytz import timezone, utc
from typing import List, Dict, TypeVar, Optional
from markdownify import markdownify as md
import markdown2 as md2
2024-05-23 08:50:09 -04:00
_logger = logging.getLogger(__name__)
2024-05-24 20:59:50 -04:00
WEEKDAY_MAP = {
0: "MO",
1: "TU",
2: "WE",
3: "TH",
4: "FR",
5: "SA",
6: "SU",
}
CalendarEvent = TypeVar("calendar.event", bound=models.Model)
User = TypeVar("res.users", bound=models.Model)
Partner = TypeVar("res.partner", bound=models.Model)
2024-05-24 12:00:13 -04:00
def _parse_rrule_string(rrule_str):
def try_to_int(part):
try:
return int(part)
except Exception:
return part
regex_str = "RRULE:(.*)$"
regex = re.compile(regex_str)
params_match = regex.search(rrule_str)
params_part = params_match.groups()[0]
params = params_part.split(";")
2024-05-24 12:00:13 -04:00
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")
else:
parts[1] = datetime.strptime(parts[1], "%Y%m%d")
2024-05-24 12:00:13 -04:00
params_dict.update({parts[0]: try_to_int(parts[1])})
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 ""
2024-05-23 08:50:09 -04:00
class CalendarEvent(models.Model):
_inherit = "calendar.event"
2024-05-23 08:50:09 -04:00
# CalDAV UID is either unique per event or the same for all events in a recurring
# sequence. This field and caldav_recurrence_id are not calculated fields since
# the recalculation timing is hard to control and we want to make sure we sync
# some modifications before the UID changes with recurrence_id and other changes on
# the record.
caldav_uid = fields.Char(string="CalDAV UID", store=True)
# 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
# when applying a recurrence.
caldav_recurrence_id = fields.Datetime(
string="CalDAV Recurrence ID",
)
caldav_user_ids = fields.Many2many(
comodel_name="res.users",
compute="_compute_caldav_users",
)
is_base_event = fields.Boolean(compute="_compute_is_base_event")
differs_from_base_event = fields.Boolean(compute="_compute_differs_from_base_event")
###################################
#### Field Computation Methods ####
###################################
def _recompute_caldav_uid(self):
for event in self:
_logger.info(f"Recomputing CalDAV UID from {event.caldav_uid}")
if event.recurrence_id:
event.caldav_uid = event.recurrence_id.caldav_uid
else:
event.caldav_uid = uuid.uuid4()
_logger.info(f"New CalDAV UID: {event.caldav_uid}")
@api.depends("recurrence_id", "recurrence_id.dtstart")
def _recompute_caldav_recurrence_id(self):
for event in self:
_logger.info(
f"Recomputing caldav_recurrence_id for event {event} at {event.start}"
)
if event.recurrence_id:
time = event.recurrence_id.dtstart.time()
date = event.start.date()
event.caldav_recurrence_id = datetime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
time.second,
)
else:
event.caldav_recurrence_id = False
@api.depends("name", "description", "partner_ids", "location", "videocall_location")
def _compute_differs_from_base_event(self):
base_events = self.filtered("is_base_event")
non_base_events = self - base_events
for event in base_events:
event.differs_from_base_event = False
fields_to_check = [
"name",
"description",
"partner_ids",
"location",
"videocall_location",
]
for event in non_base_events:
base_event = event.recurrence_id.base_event_id
event.differs_from_base_event = (
any(
[
getattr(event, field) != getattr(base_event, field)
for field in fields_to_check
]
)
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.base_event_id")
def _compute_is_base_event(self):
for rec in self:
rec.is_base_event = (
not rec.recurrence_id or rec.recurrence_id.base_event_id == rec
)
@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"
)
2024-05-23 08:50:09 -04:00
def _is_caldav_enabled(self):
return self.user_id.is_caldav_enabled
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.
"""
event_tz = timezone(self.event_tz)
recurrence_id = utc.localize(self.caldav_recurrence_id).astimezone(event_tz)
return recurrence_id
#################################################################
#### Local Change Methods - Sending Updates to CalDAV Server ####
#################################################################
@api.model_create_multi
def create(self, vals_list):
events = super(
CalendarEvent, self.with_context({"caldav_no_sync": True})
).create(vals_list)
ctx = {"caldav_no_sync": True}
events.with_context(ctx)._recompute_caldav_uid()
events.with_context(ctx)._recompute_caldav_recurrence_id()
if not self.env.context.get("caldav_no_sync"):
events._to_sync()._sync_create_to_caldav()
return events
2024-05-23 08:50:09 -04:00
def _sync_create_to_caldav(self):
2024-05-23 08:50:09 -04:00
for event in self:
for user in event.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
# try:
_logger.info(f"Creating new CalDAV event for {event.name}")
caldav_events = event._create_in_icalendar(calendar)
for caldav_event in caldav_events:
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
_logger.info(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 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.info(f"Updating event {rec.name} at {rec.start} in " f"CalDAV.")
rec._sync_write_to_caldav()
return res
def _sync_write_to_caldav(self):
ical_event_data = self._create_event_data()
for user in self.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
event = None
try:
event = self._find_in_caldav_calendar(calendar)
except caldav.error.NotFoundError:
_logger.info(
f"CalDAV event {self.caldav_uid} not found on server during update."
f" Creating it instead."
)
if event:
event.save()
elif self.recurrence_id and not self.is_base_event:
base_event = self._get_caldav_base_event_by_uid(
calendar, self.caldav_uid
)
base_event.icalendar_instance.add_component(Event(**ical_event_data))
if self.caldav_recurrence_id != self.start:
tz = timezone(self.event_tz)
base_event.save()
else:
calendar.add_event(**ical_event_data)
def _get_caldav_base_event_by_uid(
self, calendar: caldav.Calendar, uid: str
) -> Optional[CalendarEvent]:
for event in calendar.events():
comp = event.icalendar_component
event_uid = self._extract_component_text(comp, "uid")
event_recurrence_id = self._extract_component_text(comp, "recurrence-id")
if event_uid == uid and not event_recurrence_id:
return event
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.info(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:
for user in self.caldav_user_ids:
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_url)
try:
_logger.info(
f"Removing CalDAV event {self.caldav_uid}, Odoo ID {self.id} starting at {self.start}"
)
caldav_event = self._find_in_caldav_calendar(
calendar, match_start=True
)
if caldav_event:
_logger.info(
f"Found event {caldav_event.icalendar_component.get("uid")}{caldav_event.icalendar_component.get("dtstart").dt}"
)
caldav_event.delete()
except caldav.error.NotFoundError:
_logger.info(
f"CalDAV event {self.caldav_uid} starting at {self.start}"
f" not found on server during deletion."
)
except Exception as e:
_logger.error(f"Failed to remove event from CalDAV server: {e}")
def _sync_recurrence_to_caldav(self):
for event in self._to_sync():
event._sync_write_to_caldav()
def _to_sync(self):
"""Determine which records in self we need to synchronize with the
CalDAV server. In essence, we only synchronize base events and those
differing from the base event in a recurring chain."""
return self.filtered(
lambda event: event._is_caldav_enabled()
and (event.is_base_event or event.differs_from_base_event)
)
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()
caldav_event = calendar.save_event(**ical_event_data)
if self.recurrence_id and self.is_base_event and not self.follow_recurrence:
ical_event_data = self._create_event_data()
second_caldav_event = calendar.save_event(**ical_event_data)
return [caldav_event, second_caldav_event]
return [caldav_event]
def _create_event_data(self) -> Dict:
"""Create a dictionary of iCalendar compatible event data."""
event_data = {}
self._add_event_dates(event_data)
self._add_event_header_info(event_data)
self._add_event_attendees(event_data)
if self.is_base_event and self.recurrence_id:
self._add_event_recurrence(event_data)
elif self.recurrence_id:
self._add_event_recurrence_id(event_data)
return 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)
if self.name:
event_data["summary"] = vText(self.name)
# TODO: Consider using X-ALT-DESC to stick HTML into the iCal event desc.
if self.description and self._html_to_text(self.description):
event_data["description"] = vText(self._html_to_text(self.description))
if self.location:
event_data["location"] = vText(self.location)
if self.videocall_location:
event_data["conference"] = self.videocall_location
def _add_event_dates(self, event_data: Dict) -> None:
"""Add pertinent dates to event data, based on self."""
tz = self.event_tz or self.env.user.tz
event_tz = timezone(tz)
event_data["last-modified"] = vDatetime(
utc.localize(self.write_date).astimezone(event_tz)
)
event_data["created"] = vDatetime(
utc.localize(self.create_date).astimezone(event_tz)
)
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
to a calendar.recurrence record."""
if self.recurrence_id:
event_data["recurrence-id"] = vDatetime(self._get_ical_recurrence_id())
def _add_event_recurrence(self, event_data: Dict) -> None:
"""Add the recurrence rule (rrule) to event data if self is
recurrent. This should only be called for base events."""
if self.recurrence_id:
rrule = str(self.recurrence_id._get_rrule())
rrule_dict = _parse_rrule_string(rrule)
event_data["rrule"] = vRecur(**rrule_dict)
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 = []
for partner in self.partner_ids:
if partner == self.user_id.partner_id:
continue
attendee = vCalAddress(f"MAILTO:{partner.email}")
attendee.params["cn"] = vText(partner.name)
attendee_record = self.env["calendar.attendee"].search(
[("event_id", "=", self.id), ("partner_id", "=", partner.id)],
limit=1,
)
if attendee_record:
attendee.params["partstat"] = vText(
self._map_attendee_status(attendee_record.state)
)
attendee_lines.append(attendee)
organizer = vCalAddress(f"MAILTO:{self.user_id.email}")
organizer.params["cn"] = self.user_id.name
event_data["organizer"] = organizer
event_data["attendee"] = attendee_lines
@api.model
def _html_to_text(self, html):
return md(html, heading_style="ATX")
@api.model
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 _find_in_caldav_calendar(
self,
calendar: caldav.Calendar,
force_recurrence_id: bool = False,
match_start: 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 and not match_start:
calendar.event_by_uid(self.caldav_uid)
else:
events = calendar.events()
for event in events:
event_instance = event.icalendar_component
if (
event_instance.get("uid") == self.caldav_uid
and (
not match_recurrence_id
or (rec_id := event_instance.get("recurrence-id"))
and rec_id == self._get_ical_recurrence_id()
)
and (
not match_start
or event_instance.get("dtstart").dt
== utc.localize(self.start).astimezone(
event_instance.get("dtstart").dt.tzinfo
)
)
):
return event
return None
###################################################################
#### Methods for synchronizing changes from the server to Odoo ####
###################################################################
2024-05-23 08:50:09 -04:00
@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)])
2024-05-24 12:00:13 -04:00
for user in all_users:
self._poll_user_caldav_server(user)
2024-05-24 12:00:13 -04:00
@api.model
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}")
events = user._get_caldav_events()
synced_events = self.env["calendar.event"]
for caldav_event in events:
ical_event = caldav_event.icalendar_instance
synced_events |= self._sync_event_from_ical(ical_event, user)
# TODO: check if this fails when the user is deleting someone else's event
# TODO: check if we should send updates to invitees
orphaned_events = self.search(
[
("caldav_uid", "!=", False),
("id", "not " "in", synced_events.ids),
("user_id", "=", user.id),
]
)
orphaned_events = orphaned_events._to_sync()
if orphaned_events:
_logger.info(f"Deleting orphaned events {orphaned_events.ids}")
orphaned_events.with_context(caldav_no_sync=True).with_user(user).unlink()
@api.model
def _sync_event_from_ical(
self, ical_event: icalendar.cal.Event, user: User
) -> CalendarEvent:
"""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.
:param ical_event: The iCalendar event to synchronize with Odoo.
:param user: The res.user record for whom to synchronize the event.
:return: The calendar.event records that were synchronized.
"""
synced_events = self.env["calendar.event"]
event_components = [
component for component in ical_event.walk() if component.name == "VEVENT"
]
for component in event_components:
uid = str(component.get("uid"))
recurrence_id = (
component.get("recurrence-id").dt
if "recurrence-id" in component
else None
)
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
)
values = self._get_values_from_ical_component(component, user)
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
if not existing_instance:
# If we're creating an instance and it doesn't follow the recurrence,
# just scrap the recurrency vals, they're not useful
if not recurrency_vals.get("follow_recurrence"):
recurrency_vals = {}
_logger.info(f"Creating with vals: {values | recurrency_vals}")
new_event = self.with_context(caldav_no_sync=True).create(
values | recurrency_vals
)
self.env["calendar.event"].flush_model()
if new_event.recurrency:
synced_events |= new_event.recurrence_id.calendar_event_ids
else:
synced_events |= new_event
continue
elif outdated or not owned:
_logger.info(
f"Event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} "
f"{'outdated' if outdated else ''}"
f"{'not owned by user' + user.name if not owned else ''}."
f" Skipping."
)
else:
changed_vals = existing_instance._get_recurrence_changes(
recurrency_vals
) | existing_instance._get_value_changes(values)
if changed_vals:
_logger.info(
f"Updating event {existing_instance.caldav_uid}-{existing_instance.caldav_recurrence_id} with : {changed_vals}"
)
existing_instance.with_context(
caldav_no_sync=True,
).write(changed_vals)
if existing_instance.recurrency and existing_instance.is_base_event:
synced_events |= existing_instance.recurrence_id.calendar_event_ids
else:
synced_events |= existing_instance
return 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()
}
2024-05-24 20:59:50 -04:00
# 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
last_modified = component.get("last-modified")
if (
existing_instance
and last_modified
and existing_instance not in synced_events
):
last_modified = last_modified.dt.astimezone(utc).replace(tzinfo=None)
if last_modified < existing_instance.write_date:
outdated = True
return outdated
@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")
if isinstance(start, datetime):
start = start.astimezone(utc).replace(tzinfo=None)
end = component.get("dtend") and component.decoded("dtend")
if isinstance(end, datetime):
end = end.astimezone(utc).replace(tzinfo=None)
organizer = self._get_organizer_partner(component)
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
values = {
"name": str(component.get("summary")),
"start": start,
"stop": end,
"description": self._text_to_html(
self._extract_component_text(component, "description")
),
"location": self._extract_component_text(component, "location"),
"videocall_location": self._extract_component_text(component, "conference"),
"caldav_uid": str(component.get("uid")),
"partner_ids": [(6, 0, attendee_ids.ids)],
"partner_id": organizer.id if organizer else user.partner_id.id,
"user_id": user.id,
}
return values
@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)
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
]
)
final_attendees = {}
all_partners = existing_partners | added_partners
# We do this because partners may have identical emails and we only want one
# attending partner per email. Otherwise invitations get sent out multiple times
# and nobody likes that.
# Prioritize users as attendees
for partner in all_partners.filtered(lambda partner: bool(partner.user_id)):
if partner.email not in final_attendees:
final_attendees[partner.email] = partner.id
for partner in all_partners.filtered(lambda partner: not partner.user_id):
if partner.email not in final_attendees:
final_attendees[partner.email] = partner.id
return all_partners.filtered(
lambda partner: partner.id in final_attendees.values()
)
@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")
if organizer:
partner = self.env["res.partner"].search(
[("email", "=", _extract_vcal_email(organizer))]
)
# TODO: prioritize partner with a user if there is one
return partner[0] if partner else partner # partner[0] in case many matches
else:
return self.env["res.partner"]
@api.model
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", [])
if not isinstance(attendees, list):
attendees = [attendees]
attendee_emails = [_extract_vcal_email(attendee) for attendee in attendees]
return attendee_emails
2024-05-28 11:15:49 -04:00
@api.model
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)
text = str(val) if val else ""
return text
@api.model
def _text_to_html(self, text):
return md2.markdown(text)