caldav_sync: removed the ability to choose a calendar, use calendar url instead
This commit is contained in:
parent
7e3d05470f
commit
9d2ea9a8fa
6 changed files with 111 additions and 186 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue