260 lines
11 KiB
Python
260 lines
11 KiB
Python
import uuid
|
|
from odoo import models, api, fields
|
|
import caldav
|
|
import logging
|
|
from datetime import datetime
|
|
from icalendar import Calendar, Event, vCalAddress, Alarm, vText
|
|
from bs4 import BeautifulSoup
|
|
import re
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
class CalendarEvent(models.Model):
|
|
_inherit = 'calendar.event'
|
|
|
|
caldav_uid = fields.Char(string='CalDAV UID', readonly=True)
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
if not vals.get('caldav_uid'):
|
|
vals['caldav_uid'] = str(uuid.uuid4())
|
|
event = super(CalendarEvent, self).create(vals)
|
|
if not self.env.context.get('caldav_no_sync'):
|
|
try:
|
|
_logger.debug(f"Creating event {event.name} in CalDAV")
|
|
event.sync_create_to_caldav()
|
|
except Exception as e:
|
|
_logger.error(f"Failed to create event in CalDAV server: {e}")
|
|
return event
|
|
|
|
def write(self, vals):
|
|
res = super(CalendarEvent, self).write(vals)
|
|
if not self.env.context.get('caldav_no_sync') and self.id:
|
|
try:
|
|
_logger.debug(f"Updating event {self.name} in CalDAV")
|
|
self.sync_update_to_caldav()
|
|
except Exception as e:
|
|
_logger.error(f"Failed to update event in CalDAV server: {e}")
|
|
return res
|
|
|
|
def unlink(self):
|
|
if not self.env.context.get('caldav_no_sync'):
|
|
for event in self:
|
|
try:
|
|
_logger.debug(f"Removing event {event.name} from CalDAV")
|
|
event.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):
|
|
user = self.env.user
|
|
return all([user.caldav_calendar_url, user.caldav_username, user.caldav_password])
|
|
|
|
def _get_caldav_client(self):
|
|
user = self.env.user
|
|
return caldav.DAVClient(
|
|
url=user.caldav_calendar_url,
|
|
username=user.caldav_username,
|
|
password=user.caldav_password
|
|
)
|
|
|
|
def sync_create_to_caldav(self):
|
|
if not self._is_caldav_enabled():
|
|
return
|
|
client = self._get_caldav_client()
|
|
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
|
|
for event in self:
|
|
ical_event = event._get_icalendar()
|
|
try:
|
|
_logger.debug(f"Creating new CalDAV event for {event.name}")
|
|
caldav_event = calendar.add_event(ical_event)
|
|
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
|
|
_logger.debug(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 sync_update_to_caldav(self):
|
|
if not self._is_caldav_enabled():
|
|
return
|
|
client = self._get_caldav_client()
|
|
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
|
|
for event in self:
|
|
ical_event = event._get_icalendar()
|
|
try:
|
|
_logger.debug(f"Updating existing CalDAV event {event.caldav_uid}")
|
|
calendar.save_event(ical=ical_event)
|
|
except Exception as e:
|
|
_logger.error(f"Failed to sync event to CalDAV server: {e}")
|
|
|
|
def sync_remove_from_caldav(self):
|
|
if not self._is_caldav_enabled():
|
|
return
|
|
client = self._get_caldav_client()
|
|
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
|
|
for event in self:
|
|
if event.caldav_uid:
|
|
try:
|
|
_logger.debug(f"Removing CalDAV event {event.caldav_uid}")
|
|
caldav_event = calendar.object_by_uid(event.caldav_uid)
|
|
caldav_event.delete()
|
|
except caldav.error.NotFoundError:
|
|
_logger.warning(f"CalDAV event {event.caldav_uid} not found on server.")
|
|
except Exception as e:
|
|
_logger.error(f"Failed to remove event from CalDAV server: {e}")
|
|
|
|
def _get_icalendar(self):
|
|
calendar = Calendar()
|
|
calendar.add('prodid', '-//Odoo//mxm.dk//')
|
|
calendar.add('version', '2.0')
|
|
|
|
for event in self:
|
|
ical_event = Event()
|
|
ical_event.add('uid', event.caldav_uid)
|
|
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:
|
|
ical_event.add('summary', event.name)
|
|
if event.description:
|
|
ical_event.add('description', self._html_to_text(event.description))
|
|
if event.location:
|
|
ical_event.add('location', event.location)
|
|
if event.videocall_location:
|
|
ical_event.add('CONFERENCE', event.videocall_location)
|
|
for partner in event.partner_ids:
|
|
attendee = vCalAddress(f'MAILTO:{partner.email}')
|
|
attendee.params['cn'] = vText(partner.name)
|
|
attendee_record = self.env['calendar.attendee'].search([('event_id', '=', event.id), ('partner_id', '=', partner.id)], limit=1)
|
|
if attendee_record:
|
|
attendee.params['partstat'] = vText(self._map_attendee_status(attendee_record.state))
|
|
ical_event.add('attendee', attendee, encode=0)
|
|
for alarm in event.alarm_ids:
|
|
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)
|
|
return calendar.to_ical()
|
|
|
|
@api.model
|
|
def poll_caldav_server(self):
|
|
users = self.env['res.users'].search([('caldav_calendar_url', '!=', False)])
|
|
for user in users:
|
|
try:
|
|
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}")
|
|
|
|
def poll_user_caldav_server(self):
|
|
if not self._is_caldav_enabled():
|
|
return
|
|
client = self._get_caldav_client()
|
|
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
|
|
events = calendar.events()
|
|
caldav_uids = set()
|
|
|
|
_logger.info(f"Polling CalDAV server for user {self.env.user.name}")
|
|
|
|
for caldav_event in events:
|
|
ical_event = caldav_event.icalendar_instance
|
|
self.sync_event_from_ical(ical_event)
|
|
for component in ical_event.subcomponents:
|
|
if isinstance(component, Event):
|
|
uid = str(component.get('uid'))
|
|
caldav_uids.add(uid)
|
|
|
|
_logger.info(f"CalDAV UIDs fetched: {caldav_uids}")
|
|
|
|
# Remove Odoo events that no longer exist on the CalDAV server
|
|
odoo_events = self.search([('caldav_uid', '!=', False)])
|
|
for event in odoo_events:
|
|
if event.caldav_uid not in caldav_uids:
|
|
_logger.info(f"Deleting orphan event {event.name} with UID {event.caldav_uid}")
|
|
event.with_context(caldav_no_sync=True).unlink()
|
|
|
|
def sync_event_from_ical(self, ical_event):
|
|
email_regex = re.compile(r'[A-Za-z0-9\.\-+_]+@[A-Za-z0-9\.\-+_]+\.[A-Za-z]+')
|
|
|
|
for component in ical_event.subcomponents:
|
|
if isinstance(component, Event):
|
|
uid = str(component.get('uid'))
|
|
event = self.search([('caldav_uid', '=', uid)], limit=1)
|
|
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', [])
|
|
if isinstance(attendees, vCalAddress):
|
|
attendees = [attendees]
|
|
elif isinstance(attendees, str):
|
|
attendees = [vCalAddress(attendees)]
|
|
|
|
# Extract email addresses using regex
|
|
attendees_emails = [
|
|
email_regex.search(str(attendee)).group(0).lower().strip()
|
|
for attendee in attendees if email_regex.search(str(attendee))
|
|
]
|
|
|
|
_logger.info(f"Attendees emails: {attendees_emails}")
|
|
|
|
# Search for Odoo partners with the extracted email addresses
|
|
attendee_ids = self.env['res.partner'].search([('email', 'in', attendees_emails)])
|
|
|
|
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)
|
|
new_partner_ids = set(attendee_ids.ids)
|
|
combined_partner_ids = list(existing_partner_ids.union(new_partner_ids))
|
|
|
|
event.with_context({'caldav_no_sync': True}).write({
|
|
'name': str(component.get('summary')),
|
|
'start': start,
|
|
'stop': stop,
|
|
'description': str(component.get('description')),
|
|
'location': str(component.get('location')),
|
|
'partner_ids': [(6, 0, combined_partner_ids)],
|
|
})
|
|
|
|
@staticmethod
|
|
def _html_to_text(html):
|
|
return BeautifulSoup(html, "html.parser").getText()
|
|
|
|
|
|
@staticmethod
|
|
def _map_attendee_status(state):
|
|
mapping = {
|
|
'needsAction': 'NEEDS-ACTION',
|
|
'accepted': 'ACCEPTED',
|
|
'declined': 'DECLINED',
|
|
'tentative': 'TENTATIVE',
|
|
}
|
|
return mapping.get(state, 'NEEDS-ACTION')
|
|
|
|
@staticmethod
|
|
def _map_ical_status(ical_status):
|
|
mapping = {
|
|
'NEEDS-ACTION': 'needsAction',
|
|
'ACCEPTED': 'accepted',
|
|
'DECLINED': 'declined',
|
|
'TENTATIVE': 'tentative',
|
|
}
|
|
return mapping.get(ical_status, 'needsAction')
|