caldav_sync: fixed a bug in events created from appointments, added test module test_caldav_sync_appointments
This commit is contained in:
parent
800214b19c
commit
ece737826c
5 changed files with 223 additions and 3 deletions
|
|
@ -223,7 +223,9 @@ class CalendarEvent(models.Model):
|
||||||
{"caldav_uid": caldav_uid}
|
{"caldav_uid": caldav_uid}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"Failed to sync event to CalDAV server: {e}")
|
_logger.error(
|
||||||
|
f"Failed to sync event to CalDAV server: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
res = super(CalendarEvent, self.with_context(caldav_no_sync=True)).write(vals)
|
res = super(CalendarEvent, self.with_context(caldav_no_sync=True)).write(vals)
|
||||||
|
|
@ -347,7 +349,6 @@ class CalendarEvent(models.Model):
|
||||||
calendar = client.calendar(url=user.caldav_calendar_url)
|
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||||
try:
|
try:
|
||||||
caldav_event = calendar.event_by_uid(self.caldav_uid)
|
caldav_event = calendar.event_by_uid(self.caldav_uid)
|
||||||
assert isinstance(caldav_event, caldav.Event)
|
|
||||||
if not delete_all and self.recurrence_id and not self.is_base_event:
|
if not delete_all and self.recurrence_id and not self.is_base_event:
|
||||||
index = self._get_subcomponent_index_for_recurrence(
|
index = self._get_subcomponent_index_for_recurrence(
|
||||||
caldav_event
|
caldav_event
|
||||||
|
|
@ -488,7 +489,12 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
def _add_event_dates(self, event_data: Dict) -> None:
|
def _add_event_dates(self, event_data: Dict) -> None:
|
||||||
"""Add pertinent dates to event data, based on self."""
|
"""Add pertinent dates to event data, based on self."""
|
||||||
tz = self.event_tz or self.env.user.tz
|
# Determine timezone: prefer event_tz, then user tz, finally UTC
|
||||||
|
# Note: All datetimes in Odoo are stored in UTC, so defaulting to UTC is correct.
|
||||||
|
# UTC times are sent in from the appointments app when installed, without
|
||||||
|
# timezone information. This was breaking the sync process due to a call to
|
||||||
|
# upper() on boolean value False.
|
||||||
|
tz = self.event_tz or self.env.user.tz or "UTC"
|
||||||
event_tz = timezone(tz)
|
event_tz = timezone(tz)
|
||||||
event_data["last-modified"] = vDatetime(
|
event_data["last-modified"] = vDatetime(
|
||||||
utc.localize(self.write_date).astimezone(event_tz)
|
utc.localize(self.write_date).astimezone(event_tz)
|
||||||
|
|
|
||||||
2
test_caldav_sync_appointments/__init__.py
Normal file
2
test_caldav_sync_appointments/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Test module for CalDAV sync appointments integration
|
||||||
28
test_caldav_sync_appointments/__manifest__.py
Normal file
28
test_caldav_sync_appointments/__manifest__.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'Test CalDAV Sync Appointments Integration',
|
||||||
|
'version': '18.0.1.0.0',
|
||||||
|
'category': 'Hidden/Tests',
|
||||||
|
'summary': 'Test module for CalDAV sync integration with appointments',
|
||||||
|
'description': """
|
||||||
|
Test CalDAV Sync Appointments Integration
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
This module contains tests for the integration between CalDAV sync and the
|
||||||
|
Appointments app. It verifies that appointment events are properly handled
|
||||||
|
during CalDAV synchronization operations.
|
||||||
|
|
||||||
|
This is a test-only module and should not be installed in production.
|
||||||
|
""",
|
||||||
|
'author': 'Bemade Inc.',
|
||||||
|
'website': 'https://www.bemade.org',
|
||||||
|
'depends': [
|
||||||
|
'caldav_sync',
|
||||||
|
'appointment',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'application': False,
|
||||||
|
# Mark as test module
|
||||||
|
'post_init_hook': None,
|
||||||
|
}
|
||||||
1
test_caldav_sync_appointments/tests/__init__.py
Normal file
1
test_caldav_sync_appointments/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_appointment_caldav_integration
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Test CalDAV sync integration with appointment events."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo import Command
|
||||||
|
from odoo.addons.caldav_sync.tests.common import CaldavTestCommon
|
||||||
|
from odoo.tools import mute_logger
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _patch_caldav_for_appointments(user):
|
||||||
|
"""Patch CalDAV operations for appointment testing, capturing real iCalendar events."""
|
||||||
|
with mute_logger("odoo.addons.caldav_sync.models.res_users"), patch(
|
||||||
|
"caldav.DAVClient"
|
||||||
|
) as MockDAVClient:
|
||||||
|
mock_client = MockDAVClient.return_value
|
||||||
|
mock_calendar = MagicMock()
|
||||||
|
mock_client.calendar.return_value = mock_calendar
|
||||||
|
|
||||||
|
# Storage for created CalDAV events and global delete tracking
|
||||||
|
created_events = {} # uid -> caldav_event
|
||||||
|
global_delete_calls = [] # Track all delete calls
|
||||||
|
|
||||||
|
def save_event_side_effect(**ical_data):
|
||||||
|
"""Let the real CalDAV event creation happen, then capture and store it."""
|
||||||
|
# Create a mock CalDAV event that we can track delete() calls on
|
||||||
|
mock_caldav_event = MagicMock()
|
||||||
|
|
||||||
|
# Set up the vobject_instance structure that CalDAV sync expects
|
||||||
|
uid = ical_data.get("uid", f"test-uid-{len(created_events)}")
|
||||||
|
mock_caldav_event.vobject_instance.vevent.uid.value = uid
|
||||||
|
|
||||||
|
# Ensure delete method is properly trackable and records calls globally
|
||||||
|
def delete_side_effect():
|
||||||
|
global_delete_calls.append(uid)
|
||||||
|
|
||||||
|
mock_caldav_event.delete = MagicMock(side_effect=delete_side_effect)
|
||||||
|
|
||||||
|
# Set up icalendar_component structure using the real iCalendar data
|
||||||
|
mock_ical_component = MagicMock()
|
||||||
|
|
||||||
|
def ical_component_get(key):
|
||||||
|
if key == "dtstart":
|
||||||
|
mock_dtstart = MagicMock()
|
||||||
|
dtstart_value = ical_data.get("dtstart")
|
||||||
|
|
||||||
|
# Handle vDatetime objects from iCalendar library
|
||||||
|
if hasattr(dtstart_value, "dt"):
|
||||||
|
# It's already a vDatetime, extract the actual datetime
|
||||||
|
actual_dt = dtstart_value.dt
|
||||||
|
else:
|
||||||
|
# It's a regular datetime
|
||||||
|
actual_dt = dtstart_value
|
||||||
|
|
||||||
|
# Create a mock that has tzinfo like a regular datetime
|
||||||
|
mock_dt = MagicMock()
|
||||||
|
mock_dt.tzinfo = getattr(actual_dt, "tzinfo", None)
|
||||||
|
mock_dt.__eq__ = lambda self, other: actual_dt == other
|
||||||
|
|
||||||
|
mock_dtstart.dt = mock_dt
|
||||||
|
return mock_dtstart
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
mock_ical_component.get.side_effect = ical_component_get
|
||||||
|
mock_caldav_event.icalendar_component = mock_ical_component
|
||||||
|
|
||||||
|
# Store the event by UID for later retrieval
|
||||||
|
created_events[uid] = mock_caldav_event
|
||||||
|
|
||||||
|
return mock_caldav_event
|
||||||
|
|
||||||
|
def event_by_uid_side_effect(uid):
|
||||||
|
"""Return the previously created CalDAV event by UID."""
|
||||||
|
if uid in created_events:
|
||||||
|
# Always return the same mock object for the same UID
|
||||||
|
return created_events[uid]
|
||||||
|
# Raise NotFoundError if event doesn't exist (like real CalDAV)
|
||||||
|
from caldav.lib.error import NotFoundError
|
||||||
|
|
||||||
|
raise NotFoundError("Event not found")
|
||||||
|
|
||||||
|
# Set up the mock methods
|
||||||
|
mock_calendar.events.return_value = []
|
||||||
|
mock_calendar.save_event.side_effect = save_event_side_effect
|
||||||
|
mock_calendar.event_by_uid.side_effect = event_by_uid_side_effect
|
||||||
|
|
||||||
|
# Enable CalDAV for the user
|
||||||
|
user.write({"is_caldav_enabled": True})
|
||||||
|
|
||||||
|
# Return both the mock calendar and the global delete tracking
|
||||||
|
mock_calendar.global_delete_calls = global_delete_calls
|
||||||
|
yield mock_calendar
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
|
class TestAppointmentCalDAVIntegration(TransactionCase, CaldavTestCommon):
|
||||||
|
"""Test CalDAV sync behavior with appointment events."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create test user with CalDAV enabled using the common method
|
||||||
|
cls.test_user = cls._generate_user(
|
||||||
|
"test_caldav_user",
|
||||||
|
caldav_username="testuser",
|
||||||
|
caldav_password="testpass",
|
||||||
|
caldav_url="https://test.caldav.server/calendar/testuser",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create test partner
|
||||||
|
cls.test_partner = cls.env["res.partner"].create(
|
||||||
|
{
|
||||||
|
"name": "Test Partner",
|
||||||
|
"email": "partner@example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create appointment type
|
||||||
|
cls.appointment_type = cls.env["appointment.type"].create(
|
||||||
|
{
|
||||||
|
"category": "custom",
|
||||||
|
"appointment_duration": 1.0,
|
||||||
|
"staff_user_ids": [Command.link(cls.test_user.id)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appointment_booking_syncs_to_caldav(self):
|
||||||
|
"""Test that appointments created through the booking interface sync to CalDAV.
|
||||||
|
|
||||||
|
BUG: When a public user books an appointment through the booking interface,
|
||||||
|
the event is created in Odoo but NOT synced to the CalDAV server. This causes
|
||||||
|
the event to be deleted on the next polling round since CalDAV sync thinks it
|
||||||
|
was deleted from the server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with _patch_caldav_for_appointments(self.test_user) as mock_calendar:
|
||||||
|
# Simulate the appointment booking flow - appointments are created with
|
||||||
|
# the staff user but initiated by a public/portal user
|
||||||
|
# The key is that we need to simulate how the appointment controller creates events
|
||||||
|
|
||||||
|
# Create appointment event as it would be created during booking
|
||||||
|
appointment_event = (
|
||||||
|
self.env["calendar.event"]
|
||||||
|
.with_user(self.test_user)
|
||||||
|
.create(
|
||||||
|
{
|
||||||
|
"name": f"{self.appointment_type.name} with {self.test_user.name}",
|
||||||
|
"start": datetime.now() + timedelta(days=1),
|
||||||
|
"stop": datetime.now() + timedelta(days=1, hours=1),
|
||||||
|
"user_id": self.test_user.id,
|
||||||
|
"partner_ids": [(4, self.test_partner.id)],
|
||||||
|
"appointment_type_id": self.appointment_type.id,
|
||||||
|
"appointment_status": "booked",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debug output
|
||||||
|
print(f"\nDEBUG Appointment Booking:")
|
||||||
|
print(f" save_event called: {mock_calendar.save_event.called}")
|
||||||
|
print(f" save_event call_count: {mock_calendar.save_event.call_count}")
|
||||||
|
print(f" caldav_uid: {appointment_event.caldav_uid}")
|
||||||
|
print(f" user_id: {appointment_event.user_id.name}")
|
||||||
|
print(f" is_caldav_enabled: {appointment_event._is_caldav_enabled()}")
|
||||||
|
|
||||||
|
# THE BUG: Appointment events should be synced to CalDAV on creation
|
||||||
|
# This will FAIL if the bug exists
|
||||||
|
self.assertTrue(
|
||||||
|
mock_calendar.save_event.called,
|
||||||
|
"BUG: Appointment event was NOT synced to CalDAV server on creation! "
|
||||||
|
"This will cause it to be deleted on next polling round.",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
appointment_event.caldav_uid,
|
||||||
|
"BUG: Appointment event has no caldav_uid! Event will be deleted on next sync.",
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue