Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
663acd236b |
15 changed files with 855 additions and 0 deletions
58
caldav_sync/README.rst
Normal file
58
caldav_sync/README.rst
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
CalDAV Synchronization
|
||||
======================
|
||||
|
||||
Bemade Inc.
|
||||
|
||||
Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
|
||||
This program is under the terms of the GNU Lesser General Public License (LGPL-3)
|
||||
For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
The CalDAV Synchronization module for Odoo allows users to synchronize their
|
||||
calendar events with CalDAV servers. This enables seamless integration of Odoo
|
||||
calendar with external applications like Apple Calendar or Thunderbird.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- Synchronize Odoo calendar events with CalDAV servers.
|
||||
- Create, update, and delete events in Odoo and reflect changes on the CalDAV
|
||||
server.
|
||||
- Poll CalDAV server for changes and update Odoo calendar accordingly.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
1. Install the module in Odoo.
|
||||
2. Go to the User settings in Odoo.
|
||||
3. Enter the CalDAV calendar URL, username, and password on the user settings.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
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.
|
||||
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 (other email apps) will be
|
||||
polled and updated in Odoo.
|
||||
|
||||
Technical Details
|
||||
-----------------
|
||||
|
||||
- The module extends the `calendar.event` model to add CalDAV synchronization
|
||||
functionality.
|
||||
- It uses the `icalendar` library to format events and the `caldav` library to
|
||||
interact with CalDAV servers.
|
||||
- Polling for changes on the CalDAV server can be triggered manually by
|
||||
triggering the scheduled action in Odoo.
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
This program is under the terms of the GNU Lesser General Public License (LGPL-3)
|
||||
For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html
|
||||
2
caldav_sync/__init__.py
Normal file
2
caldav_sync/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
29
caldav_sync/__manifest__.py
Normal file
29
caldav_sync/__manifest__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
|
||||
#
|
||||
# This program is under the terms of the GNU Lesser General Public License (LGPL-3)
|
||||
# For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html
|
||||
|
||||
{
|
||||
"name": "CalDAV Synchronization",
|
||||
"version": "16.0.0.5.6",
|
||||
"license": "LGPL-3",
|
||||
"category": "Productivity",
|
||||
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
|
||||
"author": "Bemade Inc.",
|
||||
"website": "https://www.bemade.org",
|
||||
"depends": ["base", "calendar"],
|
||||
"external_dependencies": {
|
||||
"python": ["caldav", "icalendar", "bs4"],
|
||||
},
|
||||
"images": ["static/description/images/main_screenshot.png"],
|
||||
"data": [
|
||||
"views/res_users_views.xml",
|
||||
"data/caldav_sync_data.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": True,
|
||||
"auto_install": False,
|
||||
}
|
||||
13
caldav_sync/data/caldav_sync_data.xml
Normal file
13
caldav_sync/data/caldav_sync_data.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<odoo noupdate="1">
|
||||
<record id="ir_cron_caldav_sync" model="ir.cron">
|
||||
<field name="name">CalDAV Sync</field>
|
||||
<field name="model_id" ref="model_calendar_event" />
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.poll_caldav_server()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False" />
|
||||
<field name="active" eval="True" />
|
||||
</record>
|
||||
</odoo>
|
||||
8
caldav_sync/data/neutralize.sql
Normal file
8
caldav_sync/data/neutralize.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Neutralize CalDAV synchronization by setting credentials to NULL
|
||||
UPDATE res_users
|
||||
SET caldav_calendar_url = NULL,
|
||||
caldav_username = NULL,
|
||||
caldav_password = NULL
|
||||
WHERE caldav_calendar_url IS NOT NULL
|
||||
OR caldav_username IS NOT NULL
|
||||
OR caldav_password IS NOT NULL;
|
||||
3
caldav_sync/models/__init__.py
Normal file
3
caldav_sync/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import calendar_event
|
||||
from . import res_users
|
||||
447
caldav_sync/models/calendar_event.py
Normal file
447
caldav_sync/models/calendar_event.py
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
import uuid
|
||||
from odoo import models, api, fields
|
||||
from odoo.exceptions import UserError
|
||||
import caldav
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from icalendar import Calendar, Event, vCalAddress, vText
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
from pytz import timezone, utc
|
||||
from caldav.elements.cdav import CompFilter
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
WEEKDAY_MAP = {
|
||||
0: "MO",
|
||||
1: "TU",
|
||||
2: "WE",
|
||||
3: "TH",
|
||||
4: "FR",
|
||||
5: "SA",
|
||||
6: "SU",
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
_inherit = "calendar.event"
|
||||
|
||||
caldav_uid = fields.Char(string="CalDAV UID", readonly=True)
|
||||
caldav_recurrence_id = fields.Char(string="CalDAV Recurrence ID", 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.ids:
|
||||
for rec in self:
|
||||
try:
|
||||
_logger.debug(f"Updating event {self.name} in CalDAV")
|
||||
rec.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):
|
||||
return self.env.user.is_caldav_enabled()
|
||||
|
||||
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}")
|
||||
event._get_icalendar().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:
|
||||
user_tz = timezone("UTC")
|
||||
if event.user_id.tz:
|
||||
user_tz = timezone(event.user_id.tz)
|
||||
ical_event = Event()
|
||||
ical_event.add("uid", event.caldav_uid)
|
||||
ical_event.add(
|
||||
"dtstamp", utc.localize(event.write_date).astimezone(user_tz)
|
||||
)
|
||||
if event.name:
|
||||
ical_event.add("summary", event.name)
|
||||
if event.description and self._html_to_text(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:
|
||||
if partner == event.user_id.partner_id:
|
||||
continue
|
||||
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)
|
||||
organizer = vCalAddress(f"MAILTO:{event.user_id.email}")
|
||||
organizer.params["cn"] = event.user_id.name
|
||||
ical_event.add("organizer", organizer)
|
||||
# Add RRULE if the event is recurrent
|
||||
if event.recurrency and event.recurrence_id:
|
||||
rrule = event.recurrence_id._get_rrule()
|
||||
rrule_dict = _parse_rrule_string(str(rrule))
|
||||
ical_event.add("rrule", rrule_dict)
|
||||
|
||||
# Add DTSTART and DTEND
|
||||
ical_event.add("dtstart", utc.localize(event.start).astimezone(user_tz))
|
||||
ical_event.add("dtend", utc.localize(event.stop).astimezone(user_tz))
|
||||
|
||||
calendar.add_component(ical_event)
|
||||
|
||||
return calendar.to_ical()
|
||||
|
||||
@api.model
|
||||
def poll_caldav_server(self):
|
||||
all_users = (
|
||||
self.env["res.users"].search([]).filtered(lambda u: u.is_caldav_enabled())
|
||||
)
|
||||
for user in all_users:
|
||||
self.with_user(user).poll_user_caldav_server()
|
||||
|
||||
@api.model
|
||||
def poll_user_caldav_server(self):
|
||||
if not self._is_caldav_enabled():
|
||||
return
|
||||
client = self._get_caldav_client()
|
||||
try:
|
||||
calendar = client.calendar(url=self.env.user.caldav_calendar_url)
|
||||
events = calendar.events()
|
||||
except Exception as e:
|
||||
_logger.error(e)
|
||||
try:
|
||||
principal = client.principal()
|
||||
msg = f"""Failed to connect to the calendar, but successfully connected to the
|
||||
server at {client.url}.
|
||||
You may need to select another calendar URL from those below.
|
||||
|
||||
Available calendars:
|
||||
|
||||
"""
|
||||
for calendar in principal.calendars():
|
||||
msg += f"{calendar.name}: {calendar.url}\n"
|
||||
raise UserError(msg)
|
||||
except Exception as e:
|
||||
_logger.error(e)
|
||||
return
|
||||
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"))
|
||||
recurrence_id = str(component.get("recurrence-id"))
|
||||
if recurrence_id == "None":
|
||||
recurrence_id = ""
|
||||
caldav_uids.add(f"{uid}{recurrence_id}")
|
||||
|
||||
_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:
|
||||
recurrence_id = event.caldav_recurrence_id or ""
|
||||
event_uid = f"{event.caldav_uid}{event.caldav_recurrence_id or ''}"
|
||||
if event_uid not in caldav_uids:
|
||||
_logger.info(
|
||||
f"Deleting orphan event {event.name} with UID {event.caldav_uid} "
|
||||
f"and Recurrence ID {event.caldav_recurrence_id or ''}"
|
||||
)
|
||||
event.with_context(caldav_no_sync=True).unlink()
|
||||
|
||||
@api.model
|
||||
def _get_existing_instance(self, uid, recurrence_id):
|
||||
instance = self.env["calendar.event"].search(
|
||||
[("caldav_uid", "=", uid), ("recurrence_id", "=", recurrence_id)]
|
||||
)
|
||||
return instance or self.env["calendar.event"].search(
|
||||
[
|
||||
("caldav_uid", "=", "uid"),
|
||||
("recurrence_id", "=", False),
|
||||
]
|
||||
)
|
||||
|
||||
def _get_recurrency_values_from_ical_event(self, component):
|
||||
"""Match the fields from calendar.event (recurring fields) to the fields specified in RRULE at
|
||||
https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html"""
|
||||
|
||||
rrule = component.get("rrule")
|
||||
if not rrule:
|
||||
if not self.recurrency:
|
||||
# No change, this was already not a recurring event
|
||||
return {}
|
||||
else:
|
||||
# This was a recurring event and has been made non-recurring
|
||||
if self.recurrence_id.base_event_id != self:
|
||||
# This is not the base event, so change its recurrency only
|
||||
return {
|
||||
"recurrence_update": "self_only",
|
||||
"recurrency": False,
|
||||
"follow_recurrence": False,
|
||||
}
|
||||
else:
|
||||
# This is the base event, so change all events in the list
|
||||
return {"recurrence_update": "all_events", "recurrency": False}
|
||||
rrule_str = rrule.to_ical().decode("utf-8")
|
||||
sequence = component.get("sequence")
|
||||
if sequence and sequence != 0:
|
||||
# This is not the base event so we can't change recurrence properties
|
||||
return {}
|
||||
|
||||
caldav_recurrence_id = component.get("recurrence-id")
|
||||
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
|
||||
rrule_str, component.decoded("dtstart")
|
||||
)
|
||||
vals = {
|
||||
"recurrency": True,
|
||||
"follow_recurrence": True,
|
||||
"caldav_recurrence_id": caldav_recurrence_id,
|
||||
"recurrence_update": "all_events",
|
||||
"rrule_type": rrule_params.get("rrule_type"),
|
||||
"end_type": rrule_params.get("end_type"),
|
||||
"interval": rrule_params.get("interval"),
|
||||
"count": rrule_params.get("count"),
|
||||
"month_by": rrule_params.get("monty_by"),
|
||||
"day": rrule_params.get("day"),
|
||||
"byday": rrule_params.get("byday"),
|
||||
"until": rrule_params.get("until"),
|
||||
}
|
||||
|
||||
if rrule_params.get("weekday"):
|
||||
vals.update(rrule_params.get("weekday"))
|
||||
day_list = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
vals.update(
|
||||
{day: rrule_params.get(day) for day in day_list if day in rrule_params}
|
||||
)
|
||||
|
||||
return vals
|
||||
|
||||
def sync_event_from_ical(self, ical_event):
|
||||
email_regex = re.compile(r"[a-z0-9.\-+_]+@[a-z0-9.\-+_]+\.[a-z]+")
|
||||
current_user_email = self.env.user.email.lower()
|
||||
|
||||
for component in ical_event.subcomponents:
|
||||
if isinstance(component, Event):
|
||||
uid = component.get("uid")
|
||||
recurrence_id = component.get(
|
||||
"recurrence_id"
|
||||
) # Unique identifier for a single event in a recurrence set
|
||||
attendees = component.get("attendee", [])
|
||||
|
||||
if isinstance(attendees, vCalAddress):
|
||||
attendees = [attendees]
|
||||
elif isinstance(attendees, str):
|
||||
attendees = [vCalAddress(attendees)]
|
||||
|
||||
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}")
|
||||
|
||||
# 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)]
|
||||
)
|
||||
|
||||
existing_instance = self._get_existing_instance(uid, recurrence_id)
|
||||
start = component.decoded("dtstart")
|
||||
if isinstance(start, datetime):
|
||||
start = start.astimezone(utc).replace(tzinfo=None)
|
||||
end = component.decoded("dtend")
|
||||
if isinstance(end, datetime):
|
||||
end = end.astimezone(utc).replace(tzinfo=None)
|
||||
values = {
|
||||
"name": str(component.get("summary")),
|
||||
"start": start,
|
||||
"stop": end,
|
||||
"description": self._extract_component_text(
|
||||
component, "description"
|
||||
),
|
||||
"location": self._extract_component_text(component, "location"),
|
||||
"videocall_location": self._extract_component_text(
|
||||
component, "conference"
|
||||
),
|
||||
"caldav_uid": uid,
|
||||
"partner_ids": [(6, 0, attendee_ids.ids)],
|
||||
}
|
||||
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
|
||||
if recurrency_vals:
|
||||
values.update(recurrency_vals)
|
||||
if not existing_instance:
|
||||
_logger.info(f"Creating with vals: {values}")
|
||||
self.with_context(caldav_no_sync=True).create(values)
|
||||
else:
|
||||
_logger.info(f"Updating with vals: {values}")
|
||||
changed_vals = {}
|
||||
# Don't update partner_ids if no change
|
||||
if attendee_ids - existing_instance.partner_ids:
|
||||
changed_vals.update(
|
||||
{
|
||||
"partner_ids",
|
||||
values.pop("partner_ids"),
|
||||
}
|
||||
)
|
||||
|
||||
# Don't write values that haven't changed
|
||||
for key, val in values.items():
|
||||
if getattr(existing_instance, key) != val:
|
||||
changed_vals.update({key: values.get(key)})
|
||||
if (
|
||||
recurrency_vals
|
||||
and recurrency_vals.get("recurrency")
|
||||
and (
|
||||
not existing_instance.recurrency
|
||||
or not existing_instance.follow_recurrence
|
||||
)
|
||||
):
|
||||
existing_instance.write(
|
||||
{
|
||||
"recurrency": True,
|
||||
"follow_recurrence": True,
|
||||
}
|
||||
)
|
||||
existing_instance.with_context(
|
||||
caldav_no_sync=True,
|
||||
).write(changed_vals)
|
||||
|
||||
@staticmethod
|
||||
def _extract_component_text(component, subcomponent_name):
|
||||
text = str(component.get(subcomponent_name))
|
||||
text = text if text != "None" else ""
|
||||
|
||||
@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")
|
||||
17
caldav_sync/models/res_users.py
Normal file
17
caldav_sync/models/res_users.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from odoo import models, fields
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
caldav_calendar_url = fields.Char(string='CalDAV Calendar URL')
|
||||
caldav_username = fields.Char(string='CalDAV Username')
|
||||
caldav_password = fields.Char(string='CalDAV Password', password=True)
|
||||
|
||||
@property
|
||||
def SELF_WRITEABLE_FIELDS(self):
|
||||
return super().SELF_WRITEABLE_FIELDS+ ["caldav_calendar_url",
|
||||
"caldav_username",
|
||||
"caldav_password",]
|
||||
|
||||
def is_caldav_enabled(self):
|
||||
self.ensure_one()
|
||||
return bool(self.caldav_calendar_url and self.caldav_username and self.caldav_password)
|
||||
3
caldav_sync/requirements.txt
Normal file
3
caldav_sync/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
caldav==1.3.9
|
||||
icalendar==5.0.13
|
||||
bs4==0.0.2
|
||||
BIN
caldav_sync/static/description/icon.png
Normal file
BIN
caldav_sync/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
caldav_sync/static/description/images/main_screenshot.png
Normal file
BIN
caldav_sync/static/description/images/main_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
72
caldav_sync/static/description/index.html
Normal file
72
caldav_sync/static/description/index.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CalDAV Synchronization</title>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
background-color: #000000; /* Black background */
|
||||
color: #b48a1d; /* Gold color */
|
||||
}
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
h1, h2 {
|
||||
color: #b48a1d; /* Gold color */
|
||||
}
|
||||
p, ul, ol {
|
||||
color: #ffffff; /* White color for better readability on black background */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="text-center">CalDAV Synchronization</h1>
|
||||
<img src="images/main_screenshot.png" class="img-fluid my-4" alt="Main Screenshot">
|
||||
<p><strong>Author:</strong> Bemade Inc. (Marc Durepos)</p>
|
||||
<p><strong>Website:</strong> <a href="https://www.bemade.org" class="text-warning">www.bemade.org</a></p>
|
||||
<p><strong>License:</strong> GNU Lesser General Public License (LGPL-3)</p>
|
||||
<h2>Overview</h2>
|
||||
<p>The CalDAV Synchronization module for Odoo allows users to synchronize their calendar events with CalDAV servers. This enables seamless integration of Odoo calendar with external applications like Apple Calendar or Thunderbird.</p>
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Synchronize Odoo calendar events with CalDAV servers.</li>
|
||||
<li>Create, update, and delete events in Odoo and reflect changes on the CalDAV server.</li>
|
||||
<li>Poll CalDAV server for changes and update Odoo calendar accordingly.</li>
|
||||
</ul>
|
||||
<h2>Configuration</h2>
|
||||
<ol>
|
||||
<li>Install the module in Odoo.</li>
|
||||
<li>Go to the User settings in Odoo.</li>
|
||||
<li>
|
||||
Enter the CalDAV calendar URL, username, and password on the user settings.
|
||||
In some cases, the calendar URL to use is not evident. For Apple iCloud
|
||||
calendars, for example, we recommend starting with the base server url
|
||||
at https://caldav.icloud.com/ and then selecting a specific calendar url
|
||||
from the error given in the server logs.
|
||||
</li>
|
||||
</ol>
|
||||
<p><strong>Note: </strong>Some calendar service providers such as
|
||||
Apple iCloud require app-specific passwords to be set so that
|
||||
the calendar API can bypass 2-factor authentication. Please look
|
||||
into setting up app-specific passwords for your cloud calendar
|
||||
provider if you're faced with authentication errors.</p>
|
||||
<h2>Usage</h2>
|
||||
<ol>
|
||||
<li>Create a calendar event in Odoo and it will be synchronized with the CalDAV calendar.</li>
|
||||
<li>Update the event in Odoo and the changes will reflect on the CalDAV server.</li>
|
||||
<li>Delete the event in Odoo and it will be removed from the CalDAV server.</li>
|
||||
<li>Changes made to the calendar on the CalDAV server will be polled and updated in Odoo.</li>
|
||||
</ol>
|
||||
<h2>Technical Details</h2>
|
||||
<p>The module extends the <code>calendar.event</code> model to add CalDAV synchronization functionality. It uses the <code>icalendar</code> library to format events and the <code>caldav</code> library to interact with CalDAV servers. Polling for changes on the CalDAV server can be triggered manually by triggering the scheduled action in Odoo.</p>
|
||||
</div>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
caldav_sync/tests/__init__.py
Normal file
1
caldav_sync/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_caldav_sync
|
||||
167
caldav_sync/tests/test_caldav_sync.py
Normal file
167
caldav_sync/tests/test_caldav_sync.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from odoo.tests.common import TransactionCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
import caldav
|
||||
from icalendar import Calendar, Event
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestCaldavSync(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestCaldavSync, self).setUp()
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser",
|
||||
"caldav_calendar_url": "http://test.calendar.url",
|
||||
"caldav_username": "testuser",
|
||||
"caldav_password": "password",
|
||||
}
|
||||
)
|
||||
self.env = self.env(context=dict(self.env.context, no_reset_password=True))
|
||||
|
||||
@patch(
|
||||
"odoo.addons.caldav_sync.models.calendar_event.CalendarEvent._get_caldav_client"
|
||||
)
|
||||
def test_create_caldav_event(self, mock_get_caldav_client):
|
||||
mock_client = MagicMock()
|
||||
mock_calendar = MagicMock()
|
||||
|
||||
def add_event_side_effect(ical_event):
|
||||
caldav_event = MagicMock()
|
||||
cal = Calendar.from_ical(ical_event)
|
||||
ical_event_instance = next(iter(cal.subcomponents))
|
||||
caldav_event.vobject_instance.vevent.uid.value = ical_event_instance["UID"]
|
||||
return caldav_event
|
||||
|
||||
mock_calendar.add_event.side_effect = add_event_side_effect
|
||||
mock_get_caldav_client.return_value = mock_client
|
||||
mock_client.calendar.return_value = mock_calendar
|
||||
|
||||
event_data = {
|
||||
"name": "Test Event",
|
||||
"start": "2024-05-22 10:00:00",
|
||||
"stop": "2024-05-22 11:00:00",
|
||||
"description": "This is a test event",
|
||||
"location": "Test Location",
|
||||
}
|
||||
|
||||
event = self.env["calendar.event"].with_user(self.user).create(event_data)
|
||||
|
||||
cal = Calendar.from_ical(mock_calendar.add_event.call_args[0][0])
|
||||
ical_event = next(iter(cal.subcomponents))
|
||||
|
||||
self.assertEqual(str(ical_event.get("summary")), event_data["name"])
|
||||
self.assertEqual(str(ical_event.get("location")), event_data["location"])
|
||||
self.assertEqual(ical_event.get("description"), event_data["description"])
|
||||
self.assertIsNotNone(event.caldav_uid)
|
||||
self.assertEqual(event.caldav_uid, ical_event["UID"])
|
||||
|
||||
@patch(
|
||||
"odoo.addons.caldav_sync.models.calendar_event.CalendarEvent._get_caldav_client"
|
||||
)
|
||||
@patch(
|
||||
"odoo.addons.caldav_sync.models.calendar_event.CalendarEvent.sync_update_to_caldav"
|
||||
)
|
||||
def test_update_caldav_event(
|
||||
self, mock_sync_update_to_caldav, mock_get_caldav_client
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_calendar = MagicMock()
|
||||
mock_event = MagicMock()
|
||||
mock_event.id = "test-uid-12345"
|
||||
|
||||
mock_client.calendar.return_value = mock_calendar
|
||||
mock_calendar.add_event.return_value = mock_event
|
||||
mock_get_caldav_client.return_value = mock_client
|
||||
|
||||
event = (
|
||||
self.env["calendar.event"]
|
||||
.with_user(self.user)
|
||||
.create(
|
||||
{
|
||||
"name": "Test Event",
|
||||
"start": "2024-05-22 10:00:00",
|
||||
"stop": "2024-05-22 11:00:00",
|
||||
"description": "This is a test event",
|
||||
"location": "Test Location",
|
||||
"create_uid": self.user.id,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
event.with_user(self.user).write(
|
||||
{
|
||||
"name": "Updated Test Event",
|
||||
"start": "2024-05-22 12:00:00",
|
||||
"stop": "2024-05-22 13:00:00",
|
||||
}
|
||||
)
|
||||
mock_sync_update_to_caldav.assert_called_once()
|
||||
|
||||
self.assertEqual(event.name, "Updated Test Event")
|
||||
self.assertEqual(event.start, datetime(2024, 5, 22, 12, 0))
|
||||
|
||||
@patch(
|
||||
"odoo.addons.caldav_sync.models.calendar_event.CalendarEvent._get_caldav_client"
|
||||
)
|
||||
def test_delete_caldav_event(self, mock_get_caldav_client):
|
||||
mock_client = MagicMock()
|
||||
mock_calendar = MagicMock()
|
||||
mock_event = MagicMock()
|
||||
|
||||
mock_client.calendar.return_value = mock_calendar
|
||||
mock_calendar.object_by_uid.return_value = mock_event
|
||||
mock_get_caldav_client.return_value = mock_client
|
||||
|
||||
event = (
|
||||
self.env["calendar.event"]
|
||||
.with_user(self.user)
|
||||
.create(
|
||||
{
|
||||
"name": "Test Event",
|
||||
"start": "2024-05-22 10:00:00",
|
||||
"stop": "2024-05-22 11:00:00",
|
||||
"description": "This is a test event",
|
||||
"location": "Test Location",
|
||||
"create_uid": self.user.id,
|
||||
}
|
||||
)
|
||||
)
|
||||
uid = event.caldav_uid
|
||||
event.with_user(self.user).unlink()
|
||||
|
||||
mock_calendar.object_by_uid.assert_called_once_with(uid)
|
||||
mock_event.delete.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"odoo.addons.caldav_sync.models.calendar_event.CalendarEvent.sync_event_from_ical"
|
||||
)
|
||||
def test_poll_caldav_server(self, mock_sync_event_from_ical):
|
||||
mock_sync_event_from_ical.return_value = None
|
||||
with patch("caldav.DAVClient") as MockClient:
|
||||
mock_client = MockClient.return_value
|
||||
mock_calendar = mock_client.calendar.return_value
|
||||
mock_event = MagicMock()
|
||||
|
||||
# Create a Calendar object and add an Event to it
|
||||
cal = Calendar()
|
||||
event = Event()
|
||||
event.add("uid", "test-uid-12345")
|
||||
event.add("dtstamp", datetime(2024, 5, 22, 10, 0, 0))
|
||||
event.add("dtstart", datetime(2024, 5, 22, 10, 0, 0))
|
||||
event.add("dtend", datetime(2024, 5, 22, 11, 0, 0))
|
||||
event.add("summary", "Polled Event")
|
||||
event.add("description", "This event was polled from CalDAV")
|
||||
event.add("location", "Polled Location")
|
||||
cal.add_component(event)
|
||||
|
||||
# Set the mock event's icalendar_instance to the iCal string
|
||||
mock_event.icalendar_instance = Calendar.from_ical(cal.to_ical())
|
||||
mock_calendar.events.return_value = [mock_event]
|
||||
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
mock_sync_event_from_ical.assert_called_once()
|
||||
35
caldav_sync/views/res_users_views.xml
Normal file
35
caldav_sync/views/res_users_views.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_users_form_inherit" model="ir.ui.view">
|
||||
<field name="name">res.users.form.inherit</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Calendar">
|
||||
<group string="CalDAV">
|
||||
<field name="caldav_calendar_url" />
|
||||
<field name="caldav_username" />
|
||||
<field name="caldav_password" password="True" />
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_users_form_simple_modif_inherit" model="ir.ui.view">
|
||||
<field name="name">res.users.form.simple.modif.inherit</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Calendar">
|
||||
<group string="CalDAV">
|
||||
<field name="caldav_calendar_url" />
|
||||
<field name="caldav_username" />
|
||||
<field name="caldav_password" password="True" />
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue