Add support for recurring events.

This commit is contained in:
Marc Durepos 2024-05-24 12:00:13 -04:00
parent d229f0c25e
commit 0c93437e2c
3 changed files with 151 additions and 65 deletions

View file

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

View file

@ -5,14 +5,37 @@ import logging
from datetime import datetime from datetime import datetime
from icalendar import Calendar, Event, vCalAddress, Alarm, vText from icalendar import Calendar, Event, vCalAddress, Alarm, vText
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from dateutil.rrule import rrulestr
from datetime import timedelta
import re import re
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
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(';')
params_dict = {}
for param in params:
parts = param.split('=')
params_dict.update({parts[0]: try_to_int(parts[1])})
return params_dict
class CalendarEvent(models.Model): class CalendarEvent(models.Model):
_inherit = 'calendar.event' _inherit = 'calendar.event'
caldav_uid = fields.Char(string='CalDAV UID', readonly=True) caldav_uid = fields.Char(string='CalDAV UID', readonly=True)
caldav_recurrence_id = fields.Char(string='CalDAV Recurrence ID', readonly=True)
@api.model @api.model
def create(self, vals): def create(self, vals):
@ -48,8 +71,7 @@ class CalendarEvent(models.Model):
return super(CalendarEvent, self).unlink() return super(CalendarEvent, self).unlink()
def _is_caldav_enabled(self): def _is_caldav_enabled(self):
user = self.env.user return self.env.user.is_caldav_enabled()
return all([user.caldav_calendar_url, user.caldav_username, user.caldav_password])
def _get_caldav_client(self): def _get_caldav_client(self):
user = self.env.user user = self.env.user
@ -113,41 +135,34 @@ class CalendarEvent(models.Model):
ical_event = Event() ical_event = Event()
ical_event.add('uid', event.caldav_uid) ical_event.add('uid', event.caldav_uid)
ical_event.add('dtstamp', event.write_date.replace(tzinfo=None)) ical_event.add('dtstamp', event.write_date.replace(tzinfo=None))
ical_event.add('dtstart', event.start.replace(tzinfo=None))
ical_event.add('dtend', event.stop.replace(tzinfo=None))
if event.name: if event.name:
ical_event.add('summary', event.name) ical_event.add('summary', event.name)
if event.description: if event.description:
ical_event.add('description', self._html_to_text(event.description)) ical_event.add('description', event.description)
if event.location: if event.location:
ical_event.add('location', event.location) ical_event.add('location', event.location)
if event.videocall_location:
ical_event.add('CONFERENCE', event.videocall_location) # Add RRULE if the event is recurrent
for partner in event.partner_ids: if event.recurrency and event.recurrence_id:
attendee = vCalAddress(f'MAILTO:{partner.email}') rrule = event.recurrence_id._get_rrule()
attendee.params['cn'] = vText(partner.name) rrule_dict = _parse_rrule_string(str(rrule))
attendee_record = self.env['calendar.attendee'].search([('event_id', '=', event.id), ('partner_id', '=', partner.id)], limit=1) ical_event.add('rrule', rrule_dict)
if attendee_record:
attendee.params['partstat'] = vText(self._map_attendee_status(attendee_record.state)) # Add DTSTART and DTEND
ical_event.add('attendee', attendee, encode=0) ical_event.add('dtstart', event.start.replace(tzinfo=None))
for alarm in event.alarm_ids: ical_event.add('dtend', event.stop.replace(tzinfo=None))
ical_alarm = Alarm()
ical_alarm.add('trigger', alarm.trigger)
ical_alarm.add('action', 'DISPLAY')
ical_alarm.add('description', alarm.name or 'Reminder')
ical_event.add_component(ical_alarm)
calendar.add_component(ical_event) calendar.add_component(ical_event)
return calendar.to_ical() return calendar.to_ical()
@api.model @api.model
def poll_caldav_server(self): def poll_caldav_server(self):
users = self.env['res.users'].search([('caldav_calendar_url', '!=', False)]) all_users = self.env['res.users'].search([]).filtered(lambda u: u.is_caldav_enabled())
for user in users: for user in all_users:
try: self.with_user(user).poll_user_caldav_server()
self.with_user(user).poll_user_caldav_server()
except Exception as e:
_logger.error(f"Failed to poll CalDAV server for user {user.name}: {e}")
@api.model
def poll_user_caldav_server(self): def poll_user_caldav_server(self):
if not self._is_caldav_enabled(): if not self._is_caldav_enabled():
return return
@ -155,6 +170,8 @@ class CalendarEvent(models.Model):
calendar = client.calendar(url=self.env.user.caldav_calendar_url) calendar = client.calendar(url=self.env.user.caldav_calendar_url)
events = calendar.events() events = calendar.events()
caldav_uids = set() caldav_uids = set()
now = datetime.now()
one_year_later = now + timedelta(days=365)
_logger.info(f"Polling CalDAV server for user {self.env.user.name}") _logger.info(f"Polling CalDAV server for user {self.env.user.name}")
@ -164,38 +181,51 @@ class CalendarEvent(models.Model):
for component in ical_event.subcomponents: for component in ical_event.subcomponents:
if isinstance(component, Event): if isinstance(component, Event):
uid = str(component.get('uid')) uid = str(component.get('uid'))
caldav_uids.add(uid) rrule = component.get('rrule')
if rrule:
dtstart = component.decoded('dtstart')
if dtstart.tzinfo is not None:
dtstart = dtstart.replace(tzinfo=None)
rrule_str = rrule.to_ical().decode('utf-8')
rrule = rrulestr(rrule_str, dtstart=dtstart)
instances = list(rrule.between(now, one_year_later))
for instance_start in instances:
recurrence_id = instance_start.strftime('%Y%m%dT%H%M%S')
instance_uid = f"{uid}-{recurrence_id}"
caldav_uids.add(instance_uid)
else:
caldav_uids.add(uid)
_logger.info(f"CalDAV UIDs fetched: {caldav_uids}") _logger.info(f"CalDAV UIDs fetched: {caldav_uids}")
# Remove Odoo events that no longer exist on the CalDAV server # Remove Odoo events that no longer exist on the CalDAV server
odoo_events = self.search([('caldav_uid', '!=', False)]) odoo_events = self.search([('caldav_uid', '!=', False)])
for event in odoo_events: for event in odoo_events:
if event.caldav_uid not in caldav_uids: if event.caldav_recurrence_id:
if event.caldav_recurrence_id not in caldav_uids:
_logger.info(f"Deleting orphan event {event.name} with Recurrence ID {event.caldav_recurrence_id}")
event.with_context(caldav_no_sync=True).unlink()
elif event.caldav_uid not in caldav_uids:
_logger.info(f"Deleting orphan event {event.name} with UID {event.caldav_uid}") _logger.info(f"Deleting orphan event {event.name} with UID {event.caldav_uid}")
event.with_context(caldav_no_sync=True).unlink() event.with_context(caldav_no_sync=True).unlink()
def sync_event_from_ical(self, ical_event): def sync_event_from_ical(self, ical_event):
email_regex = re.compile(r'[A-Za-z0-9\.\-+_]+@[A-Za-z0-9\.\-+_]+\.[A-Za-z]+') email_regex = re.compile(r'[a-z0-9\.\-+_]+@[a-z0-9\.\-+_]+\.[a-z]+')
now = datetime.now()
one_year_later = now + timedelta(days=365)
current_user_email = self.env.user.email.lower()
for component in ical_event.subcomponents: for component in ical_event.subcomponents:
if isinstance(component, Event): if isinstance(component, Event):
uid = str(component.get('uid')) uid = str(component.get('uid'))
event = self.search([('caldav_uid', '=', uid)], limit=1) rrule = component.get('rrule')
start = component.decoded('dtstart')
stop = component.decoded('dtend')
if isinstance(start, datetime):
start = start.replace(tzinfo=None)
if isinstance(stop, datetime):
stop = stop.replace(tzinfo=None)
attendees = component.get('attendee', []) attendees = component.get('attendee', [])
if isinstance(attendees, vCalAddress): if isinstance(attendees, vCalAddress):
attendees = [attendees] attendees = [attendees]
elif isinstance(attendees, str): elif isinstance(attendees, str):
attendees = [vCalAddress(attendees)] attendees = [vCalAddress(attendees)]
# Extract email addresses using regex
attendees_emails = [ attendees_emails = [
email_regex.search(str(attendee)).group(0).lower().strip() email_regex.search(str(attendee)).group(0).lower().strip()
for attendee in attendees if email_regex.search(str(attendee)) for attendee in attendees if email_regex.search(str(attendee))
@ -203,42 +233,94 @@ class CalendarEvent(models.Model):
_logger.info(f"Attendees emails: {attendees_emails}") _logger.info(f"Attendees emails: {attendees_emails}")
# Search for Odoo partners with the extracted email addresses # Add current user email to attendees if not already present
if current_user_email not in attendees_emails:
attendees_emails.append(current_user_email)
attendee_ids = self.env['res.partner'].search([('email', 'in', attendees_emails)]) attendee_ids = self.env['res.partner'].search([('email', 'in', attendees_emails)])
if not event: if rrule:
_logger.info(f"Creating new event {str(component.get('summary'))} with UID {uid}") dtstart = component.decoded('dtstart')
self.with_context({'caldav_no_sync': True}).create({ dtend = component.decoded('dtend')
'name': str(component.get('summary')), # Convert to naive datetime
'start': start, if dtstart.tzinfo:
'stop': stop, dtstart = dtstart.replace(tzinfo=None)
'description': str(component.get('description')), if dtend.tzinfo:
'location': str(component.get('location')), dtend = dtend.replace(tzinfo=None)
'caldav_uid': uid, rrule_str = rrule.to_ical().decode('utf-8')
'partner_ids': [(6, 0, attendee_ids.ids)], rrule = rrulestr(rrule_str, dtstart=dtstart)
}) instances = list(rrule.between(now, one_year_later))
for instance_start in instances:
instance_stop = instance_start + (dtend - dtstart)
recurrence_id = instance_start.strftime('%Y%m%dT%H%M%S')
instance_uid = f"{uid}-{recurrence_id}"
existing_instance = self.search([('caldav_recurrence_id', '=', instance_uid)], limit=1)
_logger.info(f"Processing instance UID: {instance_uid}")
_logger.info(f"Instance start: {instance_start}, stop: {instance_stop}")
if not existing_instance:
_logger.info(f"Creating new recurring event instance with UID {instance_uid}")
self.with_context({'caldav_no_sync': True}).create({
'name': str(component.get('summary')),
'start': instance_start,
'stop': instance_stop,
'description': str(component.get('description')),
'location': str(component.get('location')),
'caldav_uid': uid,
'caldav_recurrence_id': instance_uid,
'partner_ids': [(6, 0, attendee_ids.ids)],
})
else:
_logger.info(f"Updating existing recurring event instance with UID {instance_uid}")
existing_instance.with_context({'caldav_no_sync': True}).write({
'name': str(component.get('summary')),
'start': instance_start,
'stop': instance_stop,
'description': str(component.get('description')),
'location': str(component.get('location')),
'partner_ids': [(6, 0, attendee_ids.ids)],
})
else: else:
_logger.info(f"Updating existing event {event.name} with UID {uid}") start = component.decoded('dtstart')
stop = component.decoded('dtend')
if isinstance(start, datetime):
start = start.replace(tzinfo=None)
if isinstance(stop, datetime):
stop = stop.replace(tzinfo=None)
event = self.search([('caldav_uid', '=', uid)], limit=1)
if not event:
_logger.info(f"Creating new event {str(component.get('summary'))} with UID {uid}")
self.with_context({'caldav_no_sync': True}).create({
'name': str(component.get('summary')),
'start': start,
'stop': stop,
'description': str(component.get('description')),
'location': str(component.get('location')),
'caldav_uid': uid,
'partner_ids': [(6, 0, attendee_ids.ids)],
})
else:
_logger.info(f"Updating existing event {event.name} with UID {uid}")
# Get current partner_ids and merge with new attendees existing_partner_ids = set(event.partner_ids.ids)
existing_partner_ids = set(event.partner_ids.ids) new_partner_ids = set(attendee_ids.ids)
new_partner_ids = set(attendee_ids.ids) combined_partner_ids = list(existing_partner_ids.union(new_partner_ids))
combined_partner_ids = list(existing_partner_ids.union(new_partner_ids))
event.with_context({'caldav_no_sync': True}).write({ event.with_context({'caldav_no_sync': True}).write({
'name': str(component.get('summary')), 'name': str(component.get('summary')),
'start': start, 'start': start,
'stop': stop, 'stop': stop,
'description': str(component.get('description')), 'description': str(component.get('description')),
'location': str(component.get('location')), 'location': str(component.get('location')),
'partner_ids': [(6, 0, combined_partner_ids)], 'partner_ids': [(6, 0, combined_partner_ids)],
}) })
@staticmethod @staticmethod
def _html_to_text(html): def _html_to_text(html):
return BeautifulSoup(html, "html.parser").getText() return BeautifulSoup(html, "html.parser").getText()
@staticmethod @staticmethod
def _map_attendee_status(state): def _map_attendee_status(state):
mapping = { mapping = {

View file

@ -6,3 +6,7 @@ class ResUsers(models.Model):
caldav_calendar_url = fields.Char(string='CalDAV Calendar URL') caldav_calendar_url = fields.Char(string='CalDAV Calendar URL')
caldav_username = fields.Char(string='CalDAV Username') caldav_username = fields.Char(string='CalDAV Username')
caldav_password = fields.Char(string='CalDAV Password', password=True) caldav_password = fields.Char(string='CalDAV Password', password=True)
def is_caldav_enabled(self):
self.ensure_one()
return bool(self.caldav_calendar_url and self.caldav_username and self.caldav_password)