Fix Office 365 calendars to be compatible with rfc5545 (#144230)
parent
445b38f25d
commit
3390dc0dbb
|
@ -5,8 +5,6 @@ import logging
|
|||
from typing import Any
|
||||
|
||||
from httpx import HTTPError, InvalidURL
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.exceptions import CalendarParseError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
@ -14,6 +12,7 @@ from homeassistant.const import CONF_URL
|
|||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_CALENDAR_NAME, DOMAIN
|
||||
from .ics import InvalidIcsException, parse_calendar
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -64,15 +63,9 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||
_LOGGER.debug("An error occurred: %s", err)
|
||||
else:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
IcsCalendarStream.calendar_from_ics, res.text
|
||||
)
|
||||
except CalendarParseError as err:
|
||||
await parse_calendar(self.hass, res.text)
|
||||
except InvalidIcsException:
|
||||
errors["base"] = "invalid_ics_file"
|
||||
_LOGGER.error("Error reading the calendar information: %s", err.message)
|
||||
_LOGGER.debug(
|
||||
"Additional calendar error detail: %s", str(err.detailed_error)
|
||||
)
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_CALENDAR_NAME], data=user_input
|
||||
|
|
|
@ -5,8 +5,6 @@ import logging
|
|||
|
||||
from httpx import HTTPError, InvalidURL
|
||||
from ical.calendar import Calendar
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.exceptions import CalendarParseError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
|
@ -15,6 +13,7 @@ from homeassistant.helpers.httpx_client import get_async_client
|
|||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .ics import InvalidIcsException, parse_calendar
|
||||
|
||||
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
|
||||
|
||||
|
@ -56,14 +55,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
|
|||
translation_placeholders={"err": str(err)},
|
||||
) from err
|
||||
try:
|
||||
# calendar_from_ics will dynamically load packages
|
||||
# the first time it is called, so we need to do it
|
||||
# in a separate thread to avoid blocking the event loop
|
||||
self.ics = res.text
|
||||
return await self.hass.async_add_executor_job(
|
||||
IcsCalendarStream.calendar_from_ics, self.ics
|
||||
)
|
||||
except CalendarParseError as err:
|
||||
return await parse_calendar(self.hass, res.text)
|
||||
except InvalidIcsException as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_parse",
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
"""Module for parsing ICS content.
|
||||
|
||||
This module exists to fix known issues where calendar providers return calendars
|
||||
that do not follow rfcc5545. This module will attempt to fix the calendar and return
|
||||
a valid calendar object.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ical.calendar import Calendar
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.compat import enable_compat_mode
|
||||
from ical.exceptions import CalendarParseError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidIcsException(Exception):
|
||||
"""Exception to indicate that the ICS content is invalid."""
|
||||
|
||||
|
||||
def _compat_calendar_from_ics(ics: str) -> Calendar:
|
||||
"""Parse the ICS content and return a Calendar object.
|
||||
|
||||
This function is called in a separate thread to avoid blocking the event
|
||||
loop while loading packages or parsing the ICS content for large calendars.
|
||||
|
||||
It uses the `enable_compat_mode` context manager to fix known issues with
|
||||
calendar providers that return invalid calendars.
|
||||
"""
|
||||
with enable_compat_mode(ics) as compat_ics:
|
||||
return IcsCalendarStream.calendar_from_ics(compat_ics)
|
||||
|
||||
|
||||
async def parse_calendar(hass: HomeAssistant, ics: str) -> Calendar:
|
||||
"""Parse the ICS content and return a Calendar object."""
|
||||
try:
|
||||
return await hass.async_add_executor_job(_compat_calendar_from_ics, ics)
|
||||
except CalendarParseError as err:
|
||||
_LOGGER.error("Error parsing calendar information: %s", err.message)
|
||||
_LOGGER.debug("Additional calendar error detail: %s", str(err.detailed_error))
|
||||
raise InvalidIcsException(err.message) from err
|
|
@ -0,0 +1,19 @@
|
|||
# serializer version: 1
|
||||
# name: test_calendar_examples[office365_invalid_tzid]
|
||||
list([
|
||||
dict({
|
||||
'description': None,
|
||||
'end': dict({
|
||||
'dateTime': '2024-04-26T15:00:00-06:00',
|
||||
}),
|
||||
'location': '',
|
||||
'recurrence_id': None,
|
||||
'rrule': None,
|
||||
'start': dict({
|
||||
'dateTime': '2024-04-26T14:00:00-06:00',
|
||||
}),
|
||||
'summary': 'Uffe',
|
||||
'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B',
|
||||
}),
|
||||
])
|
||||
# ---
|
|
@ -1,11 +1,13 @@
|
|||
"""Tests for calendar platform of Remote Calendar."""
|
||||
|
||||
from datetime import datetime
|
||||
import pathlib
|
||||
import textwrap
|
||||
|
||||
from httpx import Response
|
||||
import pytest
|
||||
import respx
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
@ -21,6 +23,13 @@ from .conftest import (
|
|||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
# Test data files with known calendars from various sources. You can add a new file
|
||||
# in the testdata directory and add it will be parsed and tested.
|
||||
TESTDATA_FILES = sorted(
|
||||
pathlib.Path("tests/components/remote_calendar/testdata/").glob("*.ics")
|
||||
)
|
||||
TESTDATA_IDS = [f.stem for f in TESTDATA_FILES]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_calendar(
|
||||
|
@ -392,3 +401,24 @@ async def test_all_day_iter_order(
|
|||
|
||||
events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z")
|
||||
assert [event["summary"] for event in events] == event_order
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.parametrize("ics_filename", TESTDATA_FILES, ids=TESTDATA_IDS)
|
||||
async def test_calendar_examples(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
get_events: GetEventsFn,
|
||||
ics_filename: pathlib.Path,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test parsing known calendars form test data files."""
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=ics_filename.read_text(),
|
||||
)
|
||||
)
|
||||
await setup_integration(hass, config_entry)
|
||||
events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00")
|
||||
assert events == snapshot
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
BEGIN:VCALENDAR
|
||||
METHOD:PUBLISH
|
||||
PRODID:Microsoft Exchange Server 2010
|
||||
VERSION:2.0
|
||||
X-WR-CALNAME:Kalender
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:W. Europe Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T030000
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:UTC
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T000000
|
||||
TZOFFSETFROM:+0000
|
||||
TZOFFSETTO:+0000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T000000
|
||||
TZOFFSETFROM:+0000
|
||||
TZOFFSETTO:+0000
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000
|
||||
010000000309AE93C8C3A94489F90ADBEA30C2F2B
|
||||
SUMMARY:Uffe
|
||||
DTSTART;TZID=Customized Time Zone:20240426T140000
|
||||
DTEND;TZID=Customized Time Zone:20240426T150000
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20250417T155647Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:0
|
||||
LOCATION:
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:0
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:0
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
Loading…
Reference in New Issue