mirror of
https://github.com/home-assistant/core.git
synced 2025-07-08 05:47:10 +00:00
Fix Office 365 calendars to be compatible with rfc5545 (#144230)
This commit is contained in:
parent
445b38f25d
commit
3390dc0dbb
@ -5,8 +5,6 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from httpx import HTTPError, InvalidURL
|
from httpx import HTTPError, InvalidURL
|
||||||
from ical.calendar_stream import IcsCalendarStream
|
|
||||||
from ical.exceptions import CalendarParseError
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
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 homeassistant.helpers.httpx_client import get_async_client
|
||||||
|
|
||||||
from .const import CONF_CALENDAR_NAME, DOMAIN
|
from .const import CONF_CALENDAR_NAME, DOMAIN
|
||||||
|
from .ics import InvalidIcsException, parse_calendar
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -64,15 +63,9 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.debug("An error occurred: %s", err)
|
_LOGGER.debug("An error occurred: %s", err)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(
|
await parse_calendar(self.hass, res.text)
|
||||||
IcsCalendarStream.calendar_from_ics, res.text
|
except InvalidIcsException:
|
||||||
)
|
|
||||||
except CalendarParseError as err:
|
|
||||||
errors["base"] = "invalid_ics_file"
|
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:
|
else:
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=user_input[CONF_CALENDAR_NAME], data=user_input
|
title=user_input[CONF_CALENDAR_NAME], data=user_input
|
||||||
|
@ -5,8 +5,6 @@ import logging
|
|||||||
|
|
||||||
from httpx import HTTPError, InvalidURL
|
from httpx import HTTPError, InvalidURL
|
||||||
from ical.calendar import Calendar
|
from ical.calendar import Calendar
|
||||||
from ical.calendar_stream import IcsCalendarStream
|
|
||||||
from ical.exceptions import CalendarParseError
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_URL
|
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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .ics import InvalidIcsException, parse_calendar
|
||||||
|
|
||||||
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
|
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
|
||||||
|
|
||||||
@ -56,14 +55,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
|
|||||||
translation_placeholders={"err": str(err)},
|
translation_placeholders={"err": str(err)},
|
||||||
) from err
|
) from err
|
||||||
try:
|
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
|
self.ics = res.text
|
||||||
return await self.hass.async_add_executor_job(
|
return await parse_calendar(self.hass, res.text)
|
||||||
IcsCalendarStream.calendar_from_ics, self.ics
|
except InvalidIcsException as err:
|
||||||
)
|
|
||||||
except CalendarParseError as err:
|
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="unable_to_parse",
|
translation_key="unable_to_parse",
|
||||||
|
44
homeassistant/components/remote_calendar/ics.py
Normal file
44
homeassistant/components/remote_calendar/ics.py
Normal file
@ -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."""
|
"""Tests for calendar platform of Remote Calendar."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import pathlib
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from httpx import Response
|
from httpx import Response
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.const import STATE_OFF, STATE_ON
|
from homeassistant.const import STATE_OFF, STATE_ON
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -21,6 +23,13 @@ from .conftest import (
|
|||||||
|
|
||||||
from tests.common import MockConfigEntry
|
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
|
@respx.mock
|
||||||
async def test_empty_calendar(
|
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")
|
events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z")
|
||||||
assert [event["summary"] for event in events] == event_order
|
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
|
||||||
|
58
tests/components/remote_calendar/testdata/office365_invalid_tzid.ics
vendored
Normal file
58
tests/components/remote_calendar/testdata/office365_invalid_tzid.ics
vendored
Normal file
@ -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…
x
Reference in New Issue
Block a user