caldav_sync: removed the ability to choose a calendar, use calendar url instead

This commit is contained in:
Marc Durepos 2024-05-23 13:11:00 -04:00
parent 7e3d05470f
commit 9d2ea9a8fa
6 changed files with 111 additions and 186 deletions

View file

@ -1,3 +1,4 @@
CalDAV Synchronization CalDAV Synchronization
====================== ======================
@ -20,20 +21,18 @@ Features
- Synchronize Odoo calendar events with CalDAV servers. - Synchronize Odoo calendar events with CalDAV servers.
- Create, update, and delete events in Odoo and reflect changes on the CalDAV server. - Create, update, and delete events in Odoo and reflect changes on the CalDAV server.
- Poll CalDAV server for changes and update Odoo calendar accordingly. - Poll CalDAV server for changes and update Odoo calendar accordingly.
- Allow users to select which calendar to synchronize with on the CalDAV server.
Configuration Configuration
------------- -------------
1. Install the module in Odoo. 1. Install the module in Odoo.
2. Go to the User settings in Odoo. 2. Go to the User settings in Odoo.
3. Enter the CalDAV server URL, username, and password. 3. Enter the CalDAV calendar URL, username, and password.
4. Fetch the available calendars from the CalDAV server and select the one to synchronize with.
Usage Usage
----- -----
1. Create a calendar event in Odoo and it will be synchronized with the selected CalDAV calendar. 1. Create a calendar event in Odoo and it will be synchronized with the CalDAV calendar.
2. Update the event in Odoo and the changes will reflect on the CalDAV server. 2. Update the event in Odoo and the changes will reflect on the CalDAV server.
3. Delete the event in Odoo and it will be removed from the CalDAV server. 3. Delete the event in Odoo and it will be removed from the CalDAV server.
4. Changes made to the calendar on the CalDAV server will be polled and updated in Odoo. 4. Changes made to the calendar on the CalDAV server will be polled and updated in Odoo.

View file

@ -8,7 +8,7 @@
{ {
'name': 'CalDAV Synchronization', 'name': 'CalDAV Synchronization',
'version': '17.0.0.2.0', 'version': '17.0.0.3.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

@ -1,159 +1,125 @@
from odoo import models, api, fields
from odoo import models, fields, api
import caldav import caldav
from caldav.elements import dav, cdav
import logging import logging
from icalendar import Calendar, Event, vCalAddress, vText
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class CalendarEvent(models.Model): class CalendarEvent(models.Model):
_inherit = 'calendar.event' _inherit = 'calendar.event'
caldav_uid = fields.Char('CalDAV UID') caldav_uid = fields.Char(string='CalDAV UID', readonly=True)
@api.model @api.model
def create(self, values): def create(self, vals):
event = super(CalendarEvent, self).create(values) event = super(CalendarEvent, self).create(vals)
if event._is_caldav_enabled() and not self.env.context.get('skip_caldav_sync'): event.sync_to_caldav()
try:
event.sync_to_caldav()
except Exception as e:
_logger.error(f"Failed to sync event to CalDAV: {e}")
return event return event
def write(self, values): def write(self, vals):
result = super(CalendarEvent, self).write(values) res = super(CalendarEvent, self).write(vals)
if self._is_caldav_enabled() and not self.env.context.get('skip_caldav_sync'): self.sync_to_caldav()
try: return res
self.sync_to_caldav()
except Exception as e:
_logger.error(f"Failed to sync event to CalDAV: {e}")
return result
def unlink(self): def unlink(self):
if self._is_caldav_enabled(): for event in self:
try: event.remove_from_caldav()
self.remove_from_caldav()
except Exception as e:
_logger.error(f"Failed to remove event from CalDAV: {e}")
return super(CalendarEvent, self).unlink() return super(CalendarEvent, self).unlink()
def sync_to_caldav(self):
for event in self:
if event._is_caldav_enabled():
client = event._get_caldav_client()
calendar = client.calendar(url=event._get_caldav_calendar_url())
caldav_event = calendar.add_event(event._get_icalendar())
if caldav_event.id:
event.with_context(skip_caldav_sync=True).write({'caldav_uid': caldav_event.id})
else:
_logger.error(f"Failed to sync event to CalDAV: Event ID not returned")
def remove_from_caldav(self):
for event in self:
if event.caldav_uid and event._is_caldav_enabled():
client = event._get_caldav_client()
calendar = client.calendar(url=event._get_caldav_calendar_url())
caldav_event = calendar.event_by_uid(event.caldav_uid)
if caldav_event:
caldav_event.delete()
@api.model
def poll_caldav_server(self):
"""Poll the CalDAV server and synchronize events for all users"""
_logger.info('Polling CalDAV server for updates...')
users = self.env['res.users'].search([])
for user in users:
if user._is_caldav_enabled():
try:
_logger.info(f'Polling CalDAV server for user {user.name}...')
client = user._get_caldav_client()
calendar = client.calendar(url=user.caldav_calendar_id.url)
events = calendar.events()
# Collect all current CalDAV UIDs for this user
current_uids = set(event.caldav_uid for event in self.search([('caldav_uid', '!=', False), ('create_uid', '=', user.id)]))
for event in events:
ical = event.icalendar_instance
uid = ical.subcomponents[0]['UID']
current_uids.discard(uid) # Remove from the set of current UIDs
self.sync_event_from_ical(ical, user)
# Any UIDs remaining in current_uids are events that have been deleted on the CalDAV server
for uid in current_uids:
odoo_event = self.search([('caldav_uid', '=', uid), ('create_uid', '=', user.id)], limit=1)
if odoo_event:
odoo_event.unlink()
except Exception as e:
_logger.error(f"Failed to poll CalDAV server for user {user.name}: {e}")
def sync_event_from_ical(self, ical, user):
event = ical.subcomponents[0]
uid = event['UID']
start = event['DTSTART'].dt
end = event['DTEND'].dt
summary = event['SUMMARY']
description = event.get('DESCRIPTION', '')
location = event.get('LOCATION', '')
odoo_event = self.search([('caldav_uid', '=', uid), ('create_uid', '=', user.id)], limit=1)
values = {
'name': summary,
'start': start,
'stop': end,
'description': description,
'location': location,
'create_uid': user.id,
}
if odoo_event:
odoo_event.write(values)
else:
values['caldav_uid'] = uid
self.create(values)
def _get_caldav_client(self):
user = self.env.user
return caldav.DAVClient(url=user.caldav_server_url, username=user.caldav_username, password=user.caldav_password)
def _is_caldav_enabled(self): def _is_caldav_enabled(self):
user = self.env.user user = self.env.user
return bool(user.caldav_server_url and user.caldav_username and user.caldav_password and user.caldav_calendar_id) return all([user.caldav_calendar_url, user.caldav_username, user.caldav_password])
def _get_caldav_calendar_url(self): def _get_caldav_client(self):
user = self.env.user user = self.env.user
return user.caldav_calendar_id.url return caldav.DAVClient(
url=user.caldav_calendar_url,
username=user.caldav_username,
password=user.caldav_password
)
def sync_to_caldav(self):
if not self._is_caldav_enabled():
return
client = self._get_caldav_client()
calendar = client.calendar(self.env.user.caldav_calendar_url)
for event in self:
ical_event = event._get_icalendar()
if event.caldav_uid:
caldav_event = calendar.event_by_uid(event.caldav_uid)
caldav_event.save(ical_event)
else:
caldav_event = calendar.add_event(ical_event)
event.caldav_uid = caldav_event.id
def remove_from_caldav(self):
if not self._is_caldav_enabled():
return
client = self._get_caldav_client()
calendar = client.calendar(self.env.user.caldav_calendar_url)
for event in self:
if event.caldav_uid:
caldav_event = calendar.event_by_uid(event.caldav_uid)
caldav_event.delete()
def _get_icalendar(self): def _get_icalendar(self):
cal = Calendar() from icalendar import Calendar, Event
cal.add('prodid', '-//Odoo//') calendar = Calendar()
cal.add('version', '2.0') calendar.add('prodid', '-//Odoo//mxm.dk//')
calendar.add('version', '2.0')
event = Event() for event in self:
event.add('summary', self.name) ical_event = Event()
event.add('dtstart', self.start) ical_event.add('uid', event.caldav_uid or '')
event.add('dtend', self.stop) ical_event.add('dtstamp', event.write_date)
event.add('dtstamp', self.create_date) ical_event.add('dtstart', event.start)
event.add('uid', self.caldav_uid) ical_event.add('dtend', event.stop)
event.add('description', self.description or '') ical_event.add('summary', event.name)
event.add('location', self.location or '') ical_event.add('description', event.description)
ical_event.add('location', event.location)
calendar.add_component(ical_event)
# Add attendees return calendar.to_ical()
for attendee in self.attendee_ids:
vattendee = vCalAddress('MAILTO:%s' % attendee.email)
vattendee.params['cn'] = vText(attendee.partner_id.name)
vattendee.params['ROLE'] = vText('REQ-PARTICIPANT')
event.add('attendee', vattendee, encode=0)
# Add organizer @api.model
organizer = self.create_uid.partner_id def poll_caldav_server(self):
if organizer: users = self.env['res.users'].search([('caldav_calendar_url', '!=', False)])
vorganizer = vCalAddress('MAILTO:%s' % organizer.email) for user in users:
vorganizer.params['cn'] = vText(organizer.name) try:
vorganizer.params['ROLE'] = vText('CHAIR') self.with_user(user).poll_user_caldav_server()
event['organizer'] = vorganizer except Exception as e:
_logger.error(f"Failed to poll CalDAV server for user {user.name}: {e}")
cal.add_component(event) def poll_user_caldav_server(self):
if not self._is_caldav_enabled():
return
client = self._get_caldav_client()
calendar = client.calendar(self.env.user.caldav_calendar_url)
events = calendar.events()
for caldav_event in events:
ical_event = caldav_event.icalendar_instance
self.sync_event_from_ical(ical_event)
return cal.to_ical().decode('utf-8') def sync_event_from_ical(self, ical_event):
from icalendar import Event
for component in ical_event.subcomponents:
if isinstance(component, Event):
uid = str(component.get('uid'))
event = self.search([('caldav_uid', '=', uid)], limit=1)
if not event:
self.create({
'name': str(component.get('summary')),
'start': component.decoded('dtstart'),
'stop': component.decoded('dtend'),
'description': str(component.get('description')),
'location': str(component.get('location')),
'caldav_uid': uid,
})
else:
event.write({
'name': str(component.get('summary')),
'start': component.decoded('dtstart'),
'stop': component.decoded('dtend'),
'description': str(component.get('description')),
'location': str(component.get('location')),
})

View file

@ -1,37 +1,8 @@
from odoo import models, fields, api from odoo import models, fields
import caldav
import logging
_logger = logging.getLogger(__name__)
class ResUsers(models.Model): class ResUsers(models.Model):
_inherit = 'res.users' _inherit = 'res.users'
caldav_server_url = fields.Char('CalDAV Server URL') caldav_calendar_url = fields.Char(string='CalDAV Calendar URL')
caldav_username = fields.Char('CalDAV Username') caldav_username = fields.Char(string='CalDAV Username')
caldav_password = fields.Char('CalDAV Password') caldav_password = fields.Char(string='CalDAV Password', password=True)
caldav_calendar_id = fields.Many2one('caldav.calendar', string='CalDAV Calendar') # Updated field
@api.model
def _is_caldav_enabled(self):
self.ensure_one()
return bool(self.caldav_server_url and self.caldav_username and self.caldav_password and self.caldav_calendar_id)
def fetch_caldav_calendars(self):
self.ensure_one()
client = caldav.DAVClient(url=self.caldav_server_url, username=self.caldav_username, password=self.caldav_password)
principal = client.principal()
calendars = principal.calendars()
caldav_calendar_model = self.env['caldav.calendar']
caldav_calendar_model.search([('user_id', '=', self.id)]).unlink() # Clear existing calendars
for calendar in calendars:
caldav_calendar_model.create({
'user_id': self.id,
'name': calendar.name,
'url': str(calendar.url),
})
return True
def _get_caldav_client(self):
self.ensure_one()
return caldav.DAVClient(url=self.caldav_server_url, username=self.caldav_username, password=self.caldav_password)

View file

@ -1,4 +1,3 @@
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import caldav import caldav
@ -8,6 +7,7 @@ import logging
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class TestCaldavSync(TransactionCase): class TestCaldavSync(TransactionCase):
def setUp(self): def setUp(self):
@ -15,17 +15,11 @@ class TestCaldavSync(TransactionCase):
self.user = self.env['res.users'].create({ self.user = self.env['res.users'].create({
'name': 'Test User', 'name': 'Test User',
'login': 'testuser', 'login': 'testuser',
'caldav_server_url': 'http://testserver/caldav', 'caldav_calendar_url': 'http://test.calendar.url',
'caldav_username': 'testuser', 'caldav_username': 'testuser',
'caldav_password': 'password', 'caldav_password': 'password',
}) })
self.env = self.env(context=dict(self.env.context, no_reset_password=True)) self.env = self.env(context=dict(self.env.context, no_reset_password=True))
self.calendar = self.env['caldav.calendar'].create({
'user_id': self.user.id,
'name': 'Test Calendar',
'url': 'http://testserver/caldav/calendars/testuser/calendar'
})
self.user.write({'caldav_calendar_id': self.calendar.id})
@patch('odoo.addons.caldav_sync.models.calendar_event.CalendarEvent._get_caldav_client') @patch('odoo.addons.caldav_sync.models.calendar_event.CalendarEvent._get_caldav_client')
def test_create_caldav_event(self, mock_get_caldav_client): def test_create_caldav_event(self, mock_get_caldav_client):
@ -140,4 +134,3 @@ class TestCaldavSync(TransactionCase):
self.env['calendar.event'].poll_caldav_server() self.env['calendar.event'].poll_caldav_server()
mock_logger.error.assert_any_call('Failed to poll CalDAV server for user Test User: Invalid credentials') mock_logger.error.assert_any_call('Failed to poll CalDAV server for user Test User: Invalid credentials')

View file

@ -8,17 +8,15 @@
<xpath expr="//notebook" position="inside"> <xpath expr="//notebook" position="inside">
<page string="Calendar"> <page string="Calendar">
<group string="CalDAV"> <group string="CalDAV">
<field name="caldav_server_url"/> <field name="caldav_calendar_url"/>
<field name="caldav_username"/> <field name="caldav_username"/>
<field name="caldav_password"/> <field name="caldav_password"/>
<button name="fetch_caldav_calendars" type="object" string="Fetch Calendars"/>
<field name="caldav_calendar_id"/>
</group> </group>
</page> </page>
</xpath> </xpath>
</field> </field>
</record> </record>
<record id="view_users_preferences_form" model="ir.ui.view"> <record id="view_users_form" model="ir.ui.view">
<field name="name">res.users.form</field> <field name="name">res.users.form</field>
<field name="model">res.users</field> <field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/> <field name="inherit_id" ref="base.view_users_form_simple_modif"/>
@ -26,11 +24,9 @@
<xpath expr="//notebook" position="inside"> <xpath expr="//notebook" position="inside">
<page string="Calendar"> <page string="Calendar">
<group string="CalDAV"> <group string="CalDAV">
<field name="caldav_server_url"/> <field name="caldav_calendar_url"/>
<field name="caldav_username"/> <field name="caldav_username"/>
<field name="caldav_password"/> <field name="caldav_password"/>
<button name="fetch_caldav_calendars" type="object" string="Fetch Calendars"/>
<field name="caldav_calendar_id"/>
</group> </group>
</page> </page>
</xpath> </xpath>