Add support for recurring events.
This commit is contained in:
parent
d229f0c25e
commit
0c93437e2c
3 changed files with 151 additions and 65 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in a new issue