Cleanup calendar APIs and introduce a dataclass for representing events (#68843)
* Introduce data class to hold calendar event data * Rename CalendarEventDevice to CalendarEntity * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Fix docstring on google calendar api conversion function. * Update todoist to new calendar enttiy api, tested manually * Add back old API for a legacy compatibility layer * Add deprecation warning for old calendar APIs * Fix deprecation warning * Fix merge for missing summary #69520 * Add mypy typing for newly introduced classes Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/69826/head
parent
c98d120ba0
commit
f99b6004ea
|
@ -12,9 +12,9 @@ import voluptuous as vol
|
|||
from homeassistant.components.calendar import (
|
||||
ENTITY_ID_FORMAT,
|
||||
PLATFORM_SCHEMA,
|
||||
CalendarEventDevice,
|
||||
CalendarEntity,
|
||||
CalendarEvent,
|
||||
extract_offset,
|
||||
get_date,
|
||||
is_offset_reached,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
|
@ -104,7 +104,7 @@ def setup_platform(
|
|||
device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}"
|
||||
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
|
||||
calendar_devices.append(
|
||||
WebDavCalendarEventDevice(
|
||||
WebDavCalendarEntity(
|
||||
name, calendar, entity_id, days, True, cust_calendar[CONF_SEARCH]
|
||||
)
|
||||
)
|
||||
|
@ -115,24 +115,24 @@ def setup_platform(
|
|||
device_id = calendar.name
|
||||
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
|
||||
calendar_devices.append(
|
||||
WebDavCalendarEventDevice(name, calendar, entity_id, days)
|
||||
WebDavCalendarEntity(name, calendar, entity_id, days)
|
||||
)
|
||||
|
||||
add_entities(calendar_devices, True)
|
||||
|
||||
|
||||
class WebDavCalendarEventDevice(CalendarEventDevice):
|
||||
class WebDavCalendarEntity(CalendarEntity):
|
||||
"""A device for getting the next Task from a WebDav Calendar."""
|
||||
|
||||
def __init__(self, name, calendar, entity_id, days, all_day=False, search=None):
|
||||
"""Create the WebDav Calendar Event Device."""
|
||||
self.data = WebDavCalendarData(calendar, days, all_day, search)
|
||||
self.entity_id = entity_id
|
||||
self._event = None
|
||||
self._event: CalendarEvent | None = None
|
||||
self._attr_name = name
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
return self._event
|
||||
|
||||
|
@ -147,11 +147,11 @@ class WebDavCalendarEventDevice(CalendarEventDevice):
|
|||
if event is None:
|
||||
self._event = event
|
||||
return
|
||||
(summary, offset) = extract_offset(event["summary"], OFFSET)
|
||||
event["summary"] = summary
|
||||
(summary, offset) = extract_offset(event.summary, OFFSET)
|
||||
event.summary = summary
|
||||
self._event = event
|
||||
self._attr_extra_state_attributes = {
|
||||
"offset_reached": is_offset_reached(get_date(event["start"]), offset)
|
||||
"offset_reached": is_offset_reached(event.start_datetime_local, offset)
|
||||
}
|
||||
|
||||
|
||||
|
@ -166,7 +166,9 @@ class WebDavCalendarData:
|
|||
self.search = search
|
||||
self.event = None
|
||||
|
||||
async def async_get_events(self, hass, start_date, end_date):
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
# Get event list from the current calendar
|
||||
vevent_list = await hass.async_add_executor_job(
|
||||
|
@ -180,22 +182,15 @@ class WebDavCalendarData:
|
|||
vevent = event.instance.vevent
|
||||
if not self.is_matching(vevent, self.search):
|
||||
continue
|
||||
uid = None
|
||||
if hasattr(vevent, "uid"):
|
||||
uid = vevent.uid.value
|
||||
data = {
|
||||
"uid": uid,
|
||||
"summary": vevent.summary.value,
|
||||
"start": self.get_hass_date(vevent.dtstart.value),
|
||||
"end": self.get_hass_date(self.get_end_date(vevent)),
|
||||
"location": self.get_attr_value(vevent, "location"),
|
||||
"description": self.get_attr_value(vevent, "description"),
|
||||
}
|
||||
|
||||
data["start"] = get_date(data["start"]).isoformat()
|
||||
data["end"] = get_date(data["end"]).isoformat()
|
||||
|
||||
event_list.append(data)
|
||||
event_list.append(
|
||||
CalendarEvent(
|
||||
summary=vevent.summary.value,
|
||||
start=vevent.dtstart.value,
|
||||
end=self.get_end_date(vevent),
|
||||
location=self.get_attr_value(vevent, "location"),
|
||||
description=self.get_attr_value(vevent, "description"),
|
||||
)
|
||||
)
|
||||
|
||||
return event_list
|
||||
|
||||
|
@ -269,13 +264,13 @@ class WebDavCalendarData:
|
|||
return
|
||||
|
||||
# Populate the entity attributes with the event values
|
||||
self.event = {
|
||||
"summary": vevent.summary.value,
|
||||
"start": self.get_hass_date(vevent.dtstart.value),
|
||||
"end": self.get_hass_date(self.get_end_date(vevent)),
|
||||
"location": self.get_attr_value(vevent, "location"),
|
||||
"description": self.get_attr_value(vevent, "description"),
|
||||
}
|
||||
self.event = CalendarEvent(
|
||||
summary=vevent.summary.value,
|
||||
start=vevent.dtstart.value,
|
||||
end=self.get_end_date(vevent),
|
||||
location=self.get_attr_value(vevent, "location"),
|
||||
description=self.get_attr_value(vevent, "description"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_matching(vevent, search):
|
||||
|
@ -305,14 +300,6 @@ class WebDavCalendarData:
|
|||
WebDavCalendarData.get_end_date(vevent)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_hass_date(obj):
|
||||
"""Return if the event matches."""
|
||||
if isinstance(obj, datetime):
|
||||
return {"dateTime": obj.isoformat()}
|
||||
|
||||
return {"date": obj.isoformat()}
|
||||
|
||||
@staticmethod
|
||||
def to_datetime(obj):
|
||||
"""Return a datetime."""
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""Support for Google Calendar event device sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
@ -73,6 +74,48 @@ def get_date(date: dict[str, Any]) -> datetime.datetime:
|
|||
return dt.as_local(parsed_datetime)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarEvent:
|
||||
"""An event on a calendar."""
|
||||
|
||||
start: datetime.date | datetime.datetime
|
||||
end: datetime.date | datetime.datetime
|
||||
summary: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
|
||||
@property
|
||||
def start_datetime_local(self) -> datetime.datetime:
|
||||
"""Return event start time as a local datetime."""
|
||||
return _get_datetime_local(self.start)
|
||||
|
||||
@property
|
||||
def end_datetime_local(self) -> datetime.datetime:
|
||||
"""Return event end time as a local datetime."""
|
||||
return _get_datetime_local(self.end)
|
||||
|
||||
@property
|
||||
def all_day(self) -> bool:
|
||||
"""Return true if the event is an all day event."""
|
||||
return not isinstance(self.start, datetime.datetime)
|
||||
|
||||
|
||||
def _get_datetime_local(
|
||||
dt_or_d: datetime.datetime | datetime.date,
|
||||
) -> datetime.datetime:
|
||||
"""Convert a calendar event date/datetime to a datetime if needed."""
|
||||
if isinstance(dt_or_d, datetime.datetime):
|
||||
return dt.as_local(dt_or_d)
|
||||
return dt.start_of_local_day(dt_or_d)
|
||||
|
||||
|
||||
def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]:
|
||||
"""Convert a calendar event date/datetime to a datetime if needed."""
|
||||
if isinstance(dt_or_d, datetime.datetime):
|
||||
return {"dateTime": dt.as_local(dt_or_d).isoformat()}
|
||||
return {"date": dt_or_d.isoformat()}
|
||||
|
||||
|
||||
def normalize_event(event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a calendar event."""
|
||||
normalized_event: dict[str, Any] = {}
|
||||
|
@ -132,7 +175,15 @@ def is_offset_reached(
|
|||
|
||||
|
||||
class CalendarEventDevice(Entity):
|
||||
"""Base class for calendar event entities."""
|
||||
"""Legacy API for calendar event entities."""
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Print deprecation warning."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
_LOGGER.warning(
|
||||
"CalendarEventDevice is deprecated, modify %s to extend CalendarEntity",
|
||||
cls.__name__,
|
||||
)
|
||||
|
||||
@property
|
||||
def event(self) -> dict[str, Any] | None:
|
||||
|
@ -143,6 +194,7 @@ class CalendarEventDevice(Entity):
|
|||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the entity state attributes."""
|
||||
|
||||
if (event := self.event) is None:
|
||||
return None
|
||||
|
||||
|
@ -186,6 +238,53 @@ class CalendarEventDevice(Entity):
|
|||
raise NotImplementedError()
|
||||
|
||||
|
||||
class CalendarEntity(Entity):
|
||||
"""Base class for calendar event entities."""
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the entity state attributes."""
|
||||
if (event := self.event) is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"message": event.summary,
|
||||
"all_day": event.all_day,
|
||||
"start_time": event.start_datetime_local.strftime(DATE_STR_FORMAT),
|
||||
"end_time": event.end_datetime_local.strftime(DATE_STR_FORMAT),
|
||||
"location": event.location if event.location else "",
|
||||
"description": event.description if event.description else "",
|
||||
}
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the calendar event."""
|
||||
if (event := self.event) is None:
|
||||
return STATE_OFF
|
||||
|
||||
now = dt.now()
|
||||
|
||||
if event.start_datetime_local <= now < event.end_datetime_local:
|
||||
return STATE_ON
|
||||
|
||||
return STATE_OFF
|
||||
|
||||
async def async_get_events(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
) -> list[CalendarEvent]:
|
||||
"""Return calendar events within a datetime range."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class CalendarEventView(http.HomeAssistantView):
|
||||
"""View to retrieve calendar content."""
|
||||
|
||||
|
@ -203,7 +302,6 @@ class CalendarEventView(http.HomeAssistantView):
|
|||
end = request.query.get("end")
|
||||
if start is None or end is None or entity is None:
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
assert isinstance(entity, CalendarEventDevice)
|
||||
try:
|
||||
start_date = dt.parse_datetime(start)
|
||||
end_date = dt.parse_datetime(end)
|
||||
|
@ -211,11 +309,31 @@ class CalendarEventView(http.HomeAssistantView):
|
|||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
if start_date is None or end_date is None:
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
# Compatibility shim for old API
|
||||
if isinstance(entity, CalendarEventDevice):
|
||||
event_list = await entity.async_get_events(
|
||||
request.app["hass"], start_date, end_date
|
||||
)
|
||||
return self.json(event_list)
|
||||
|
||||
if not isinstance(entity, CalendarEntity):
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
calendar_event_list = await entity.async_get_events(
|
||||
request.app["hass"], start_date, end_date
|
||||
)
|
||||
return self.json(
|
||||
[
|
||||
{
|
||||
"summary": event.summary,
|
||||
"start": _get_api_date(event.start),
|
||||
"end": _get_api_date(event.end),
|
||||
}
|
||||
for event in calendar_event_list
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CalendarListView(http.HomeAssistantView):
|
||||
"""View to retrieve calendar list."""
|
||||
|
|
|
@ -2,8 +2,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
|
||||
from homeassistant.components.calendar import CalendarEventDevice, get_date
|
||||
from homeassistant.components.calendar import (
|
||||
CalendarEntity,
|
||||
CalendarEvent,
|
||||
CalendarEventDevice,
|
||||
get_date,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
@ -17,37 +23,66 @@ def setup_platform(
|
|||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Demo Calendar platform."""
|
||||
calendar_data_future = DemoGoogleCalendarDataFuture()
|
||||
calendar_data_current = DemoGoogleCalendarDataCurrent()
|
||||
add_entities(
|
||||
[
|
||||
DemoGoogleCalendar(hass, calendar_data_future, "Calendar 1"),
|
||||
DemoGoogleCalendar(hass, calendar_data_current, "Calendar 2"),
|
||||
DemoCalendar(calendar_data_future(), "Calendar 1"),
|
||||
DemoCalendar(calendar_data_current(), "Calendar 2"),
|
||||
LegacyDemoCalendar("Calendar 3"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DemoGoogleCalendarData:
|
||||
def calendar_data_future() -> CalendarEvent:
|
||||
"""Representation of a Demo Calendar for a future event."""
|
||||
one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30)
|
||||
return CalendarEvent(
|
||||
start=one_hour_from_now,
|
||||
end=one_hour_from_now + datetime.timedelta(minutes=60),
|
||||
summary="Future Event",
|
||||
)
|
||||
|
||||
|
||||
def calendar_data_current() -> CalendarEvent:
|
||||
"""Representation of a Demo Calendar for a current event."""
|
||||
middle_of_event = dt_util.now() - datetime.timedelta(minutes=30)
|
||||
return CalendarEvent(
|
||||
start=middle_of_event,
|
||||
end=middle_of_event + datetime.timedelta(minutes=60),
|
||||
summary="Current Event",
|
||||
)
|
||||
|
||||
|
||||
class DemoCalendar(CalendarEntity):
|
||||
"""Representation of a Demo Calendar element."""
|
||||
|
||||
event = None
|
||||
def __init__(self, event: CalendarEvent, name: str) -> None:
|
||||
"""Initialize demo calendar."""
|
||||
self._event = event
|
||||
self._name = name
|
||||
|
||||
async def async_get_events(self, hass, start_date, end_date):
|
||||
"""Get all events in a specific time frame."""
|
||||
event = copy.copy(self.event)
|
||||
event["title"] = event["summary"]
|
||||
event["start"] = get_date(event["start"]).isoformat()
|
||||
event["end"] = get_date(event["end"]).isoformat()
|
||||
return [event]
|
||||
@property
|
||||
def event(self) -> CalendarEvent:
|
||||
"""Return the next upcoming event."""
|
||||
return self._event
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
async def async_get_events(self, hass, start_date, end_date) -> list[CalendarEvent]:
|
||||
"""Return calendar events within a datetime range."""
|
||||
return [self._event]
|
||||
|
||||
|
||||
class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData):
|
||||
"""Representation of a Demo Calendar for a future event."""
|
||||
class LegacyDemoCalendar(CalendarEventDevice):
|
||||
"""Calendar for exercising shim API."""
|
||||
|
||||
def __init__(self):
|
||||
"""Set the event to a future event."""
|
||||
def __init__(self, name):
|
||||
"""Initialize demo calendar."""
|
||||
self._name = name
|
||||
one_hour_from_now = dt_util.now() + dt_util.dt.timedelta(minutes=30)
|
||||
self.event = {
|
||||
self._event = {
|
||||
"start": {"dateTime": one_hour_from_now.isoformat()},
|
||||
"end": {
|
||||
"dateTime": (
|
||||
|
@ -57,36 +92,10 @@ class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData):
|
|||
"summary": "Future Event",
|
||||
}
|
||||
|
||||
|
||||
class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData):
|
||||
"""Representation of a Demo Calendar for a current event."""
|
||||
|
||||
def __init__(self):
|
||||
"""Set the event data."""
|
||||
middle_of_event = dt_util.now() - dt_util.dt.timedelta(minutes=30)
|
||||
self.event = {
|
||||
"start": {"dateTime": middle_of_event.isoformat()},
|
||||
"end": {
|
||||
"dateTime": (
|
||||
middle_of_event + dt_util.dt.timedelta(minutes=60)
|
||||
).isoformat()
|
||||
},
|
||||
"summary": "Current Event",
|
||||
}
|
||||
|
||||
|
||||
class DemoGoogleCalendar(CalendarEventDevice):
|
||||
"""Representation of a Demo Calendar element."""
|
||||
|
||||
def __init__(self, hass, calendar_data, name):
|
||||
"""Initialize demo calendar."""
|
||||
self.data = calendar_data
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
"""Return the next upcoming event."""
|
||||
return self.data.event
|
||||
return self._event
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -94,5 +103,9 @@ class DemoGoogleCalendar(CalendarEventDevice):
|
|||
return self._name
|
||||
|
||||
async def async_get_events(self, hass, start_date, end_date):
|
||||
"""Return calendar events within a datetime range."""
|
||||
return await self.data.async_get_events(hass, start_date, end_date)
|
||||
"""Get all events in a specific time frame."""
|
||||
event = copy.copy(self.event)
|
||||
event["title"] = event["summary"]
|
||||
event["start"] = get_date(event["start"]).isoformat()
|
||||
event["end"] = get_date(event["end"]).isoformat()
|
||||
return [event]
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
@ -10,9 +10,9 @@ from httplib2 import ServerNotFoundError
|
|||
|
||||
from homeassistant.components.calendar import (
|
||||
ENTITY_ID_FORMAT,
|
||||
CalendarEventDevice,
|
||||
CalendarEntity,
|
||||
CalendarEvent,
|
||||
extract_offset,
|
||||
get_date,
|
||||
is_offset_reached,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
@ -22,7 +22,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
|
|||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.util import Throttle, dt
|
||||
|
||||
from . import (
|
||||
CONF_CAL_ID,
|
||||
|
@ -94,7 +94,7 @@ def _async_setup_entities(
|
|||
entity_id = generate_entity_id(
|
||||
ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass
|
||||
)
|
||||
entity = GoogleCalendarEventDevice(
|
||||
entity = GoogleCalendarEntity(
|
||||
calendar_service, disc_info[CONF_CAL_ID], data, entity_id
|
||||
)
|
||||
entities.append(entity)
|
||||
|
@ -102,7 +102,7 @@ def _async_setup_entities(
|
|||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class GoogleCalendarEventDevice(CalendarEventDevice):
|
||||
class GoogleCalendarEntity(CalendarEntity):
|
||||
"""A calendar event device."""
|
||||
|
||||
def __init__(
|
||||
|
@ -117,7 +117,7 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||
self._calendar_id = calendar_id
|
||||
self._search: str | None = data.get(CONF_SEARCH)
|
||||
self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False)
|
||||
self._event: dict[str, Any] | None = None
|
||||
self._event: CalendarEvent | None = None
|
||||
self._name: str = data[CONF_NAME]
|
||||
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
|
||||
self._offset_reached = False
|
||||
|
@ -129,7 +129,7 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||
return {"offset_reached": self._offset_reached}
|
||||
|
||||
@property
|
||||
def event(self) -> dict[str, Any] | None:
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
return self._event
|
||||
|
||||
|
@ -146,7 +146,7 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
event_list: list[dict[str, Any]] = []
|
||||
page_token: str | None = None
|
||||
|
@ -166,7 +166,8 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||
event_list.extend(filter(self._event_filter, items))
|
||||
if not page_token:
|
||||
break
|
||||
return event_list
|
||||
|
||||
return [_get_calendar_event(event) for event in event_list]
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def async_update(self) -> None:
|
||||
|
@ -181,12 +182,35 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||
|
||||
# Pick the first visible event and apply offset calculations.
|
||||
valid_items = filter(self._event_filter, items)
|
||||
self._event = copy.deepcopy(next(valid_items, None))
|
||||
if self._event:
|
||||
(summary, offset) = extract_offset(
|
||||
self._event.get("summary", ""), self._offset
|
||||
)
|
||||
self._event["summary"] = summary
|
||||
event = copy.deepcopy(next(valid_items, None))
|
||||
if event:
|
||||
(summary, offset) = extract_offset(event.get("summary", ""), self._offset)
|
||||
event["summary"] = summary
|
||||
self._event = _get_calendar_event(event)
|
||||
self._offset_reached = is_offset_reached(
|
||||
get_date(self._event["start"]), offset
|
||||
self._event.start_datetime_local, offset
|
||||
)
|
||||
else:
|
||||
self._event = None
|
||||
|
||||
|
||||
def _get_date_or_datetime(date_dict: dict[str, str]) -> datetime | date:
|
||||
"""Convert a google calendar API response to a datetime or date object."""
|
||||
if "date" in date_dict:
|
||||
parsed_date = dt.parse_date(date_dict["date"])
|
||||
assert parsed_date
|
||||
return parsed_date
|
||||
parsed_datetime = dt.parse_datetime(date_dict["dateTime"])
|
||||
assert parsed_datetime
|
||||
return parsed_datetime
|
||||
|
||||
|
||||
def _get_calendar_event(event: dict[str, Any]) -> CalendarEvent:
|
||||
"""Return a CalendarEvent from an API event."""
|
||||
return CalendarEvent(
|
||||
summary=event["summary"],
|
||||
start=_get_date_or_datetime(event["start"]),
|
||||
end=_get_date_or_datetime(event["end"]),
|
||||
description=event.get("description"),
|
||||
location=event.get("location"),
|
||||
)
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
"""Support for Todoist task management (https://todoist.com)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import logging
|
||||
|
||||
from todoist.api import TodoistAPI
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.calendar import PLATFORM_SCHEMA, CalendarEventDevice
|
||||
from homeassistant.components.calendar import (
|
||||
PLATFORM_SCHEMA,
|
||||
CalendarEntity,
|
||||
CalendarEvent,
|
||||
)
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.template import DATE_STR_FORMAT
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt
|
||||
|
||||
|
@ -28,7 +31,6 @@ from .const import (
|
|||
CONF_PROJECT_LABEL_WHITELIST,
|
||||
CONF_PROJECT_WHITELIST,
|
||||
CONTENT,
|
||||
DATETIME,
|
||||
DESCRIPTION,
|
||||
DOMAIN,
|
||||
DUE,
|
||||
|
@ -102,7 +104,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||
}
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=15)
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
|
@ -136,7 +138,7 @@ def setup_platform(
|
|||
# Project is an object, not a dict!
|
||||
# Because of that, we convert what we need to a dict.
|
||||
project_data = {CONF_NAME: project[NAME], CONF_ID: project[ID]}
|
||||
project_devices.append(TodoistProjectDevice(hass, project_data, labels, api))
|
||||
project_devices.append(TodoistProjectEntity(hass, project_data, labels, api))
|
||||
# Cache the names so we can easily look up name->ID.
|
||||
project_id_lookup[project[NAME].lower()] = project[ID]
|
||||
|
||||
|
@ -166,7 +168,7 @@ def setup_platform(
|
|||
|
||||
# Create the custom project and add it to the devices array.
|
||||
project_devices.append(
|
||||
TodoistProjectDevice(
|
||||
TodoistProjectEntity(
|
||||
hass,
|
||||
project,
|
||||
labels,
|
||||
|
@ -176,7 +178,6 @@ def setup_platform(
|
|||
project_id_filter,
|
||||
)
|
||||
)
|
||||
|
||||
add_entities(project_devices)
|
||||
|
||||
def handle_new_task(call: ServiceCall) -> None:
|
||||
|
@ -271,7 +272,7 @@ def _parse_due_date(data: DueDate, timezone_offset: int) -> datetime | None:
|
|||
return dt.as_utc(nowtime)
|
||||
|
||||
|
||||
class TodoistProjectDevice(CalendarEventDevice):
|
||||
class TodoistProjectEntity(CalendarEntity):
|
||||
"""A device for getting the next Task from a Todoist Project."""
|
||||
|
||||
def __init__(
|
||||
|
@ -284,7 +285,7 @@ class TodoistProjectDevice(CalendarEventDevice):
|
|||
whitelisted_labels=None,
|
||||
whitelisted_projects=None,
|
||||
):
|
||||
"""Create the Todoist Calendar Event Device."""
|
||||
"""Create the Todoist Calendar Entity."""
|
||||
self.data = TodoistProjectData(
|
||||
data,
|
||||
labels,
|
||||
|
@ -297,9 +298,9 @@ class TodoistProjectDevice(CalendarEventDevice):
|
|||
self._name = data[CONF_NAME]
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
def event(self) -> CalendarEvent:
|
||||
"""Return the next upcoming event."""
|
||||
return self.data.event
|
||||
return self.data.calendar_event
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -314,7 +315,12 @@ class TodoistProjectDevice(CalendarEventDevice):
|
|||
task[SUMMARY] for task in self.data.all_project_tasks
|
||||
]
|
||||
|
||||
async def async_get_events(self, hass, start_date, end_date):
|
||||
async def async_get_events(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
return await self.data.async_get_events(hass, start_date, end_date)
|
||||
|
||||
|
@ -336,7 +342,7 @@ class TodoistProjectDevice(CalendarEventDevice):
|
|||
|
||||
class TodoistProjectData:
|
||||
"""
|
||||
Class used by the Task Device service object to hold all Todoist Tasks.
|
||||
Class used by the Task Entity service object to hold all Todoist Tasks.
|
||||
|
||||
This is analogous to the GoogleCalendarData found in the Google Calendar
|
||||
component.
|
||||
|
@ -409,6 +415,22 @@ class TodoistProjectData:
|
|||
else:
|
||||
self._project_id_whitelist = []
|
||||
|
||||
@property
|
||||
def calendar_event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming calendar event."""
|
||||
if not self.event:
|
||||
return None
|
||||
if not self.event.get(END) or self.event.get(ALL_DAY):
|
||||
start = self.event[START].date()
|
||||
return CalendarEvent(
|
||||
summary=self.event[SUMMARY],
|
||||
start=start,
|
||||
end=start + timedelta(days=1),
|
||||
)
|
||||
return CalendarEvent(
|
||||
summary=self.event[SUMMARY], start=self.event[START], end=self.event[END]
|
||||
)
|
||||
|
||||
def create_todoist_task(self, data):
|
||||
"""
|
||||
Create a dictionary based on a Task passed from the Todoist API.
|
||||
|
@ -566,7 +588,7 @@ class TodoistProjectData:
|
|||
if task["due"] is None:
|
||||
continue
|
||||
# @NOTE: _parse_due_date always returns the date in UTC time.
|
||||
due_date = _parse_due_date(
|
||||
due_date: datetime | None = _parse_due_date(
|
||||
task["due"], self._api.state["user"]["tz_info"]["hours"]
|
||||
)
|
||||
if not due_date:
|
||||
|
@ -580,20 +602,16 @@ class TodoistProjectData:
|
|||
)
|
||||
|
||||
if start_date < due_date < end_date:
|
||||
due_date_value: datetime | date = due_date
|
||||
if due_date == midnight:
|
||||
# If the due date has no time data, return just the date so that it
|
||||
# will render correctly as an all day event on a calendar.
|
||||
due_date_value = due_date.strftime("%Y-%m-%d")
|
||||
else:
|
||||
due_date_value = due_date.isoformat()
|
||||
event = {
|
||||
"uid": task["id"],
|
||||
"title": task["content"],
|
||||
"start": due_date_value,
|
||||
"end": due_date_value,
|
||||
"allDay": True,
|
||||
"summary": task["content"],
|
||||
}
|
||||
due_date_value = due_date.date()
|
||||
event = CalendarEvent(
|
||||
summary=task["content"],
|
||||
start=due_date_value,
|
||||
end=due_date_value,
|
||||
)
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
@ -644,22 +662,10 @@ class TodoistProjectData:
|
|||
project_tasks.remove(best_task)
|
||||
self.all_project_tasks.append(best_task)
|
||||
|
||||
self.event = self.all_project_tasks[0]
|
||||
|
||||
# Convert datetime to a string again
|
||||
if self.event is not None:
|
||||
if self.event[START] is not None:
|
||||
self.event[START] = {
|
||||
DATETIME: self.event[START].strftime(DATE_STR_FORMAT)
|
||||
}
|
||||
if self.event[END] is not None:
|
||||
self.event[END] = {DATETIME: self.event[END].strftime(DATE_STR_FORMAT)}
|
||||
else:
|
||||
# Home Assistant gets cranky if a calendar event never ends
|
||||
# Let's set our "due date" to tomorrow
|
||||
self.event[END] = {
|
||||
DATETIME: (datetime.utcnow() + timedelta(days=1)).strftime(
|
||||
DATE_STR_FORMAT
|
||||
)
|
||||
}
|
||||
event = self.all_project_tasks[0]
|
||||
if event is None or event[START] is None:
|
||||
_LOGGER.debug("No valid event or event start for %s", self._name)
|
||||
self.event = None
|
||||
return
|
||||
self.event = event
|
||||
_LOGGER.debug("Updated %s", self._name)
|
||||
|
|
|
@ -2,9 +2,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.calendar import CalendarEventDevice
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
@ -26,7 +25,7 @@ async def async_setup_entry(
|
|||
async_add_entities([TwenteMilieuCalendar(coordinator, entry)])
|
||||
|
||||
|
||||
class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice):
|
||||
class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity):
|
||||
"""Defines a Twente Milieu calendar."""
|
||||
|
||||
_attr_name = "Twente Milieu"
|
||||
|
@ -40,26 +39,25 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice):
|
|||
"""Initialize the Twente Milieu entity."""
|
||||
super().__init__(coordinator, entry)
|
||||
self._attr_unique_id = str(entry.data[CONF_ID])
|
||||
self._event: dict[str, Any] | None = None
|
||||
self._event: CalendarEvent | None = None
|
||||
|
||||
@property
|
||||
def event(self) -> dict[str, Any] | None:
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
return self._event
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[CalendarEvent]:
|
||||
"""Return calendar events within a datetime range."""
|
||||
events: list[dict[str, Any]] = []
|
||||
events: list[CalendarEvent] = []
|
||||
for waste_type, waste_dates in self.coordinator.data.items():
|
||||
events.extend(
|
||||
{
|
||||
"all_day": True,
|
||||
"start": {"date": waste_date.isoformat()},
|
||||
"end": {"date": waste_date.isoformat()},
|
||||
"summary": WASTE_TYPE_TO_DESCRIPTION[waste_type],
|
||||
}
|
||||
CalendarEvent(
|
||||
summary=WASTE_TYPE_TO_DESCRIPTION[waste_type],
|
||||
start=waste_date,
|
||||
end=waste_date,
|
||||
)
|
||||
for waste_date in waste_dates
|
||||
if start_date.date() <= waste_date <= end_date.date()
|
||||
)
|
||||
|
@ -86,12 +84,11 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice):
|
|||
|
||||
self._event = None
|
||||
if next_waste_pickup_date is not None and next_waste_pickup_type is not None:
|
||||
self._event = {
|
||||
"all_day": True,
|
||||
"start": {"date": next_waste_pickup_date.isoformat()},
|
||||
"end": {"date": next_waste_pickup_date.isoformat()},
|
||||
"summary": WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type],
|
||||
}
|
||||
self._event = CalendarEvent(
|
||||
summary=WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type],
|
||||
start=next_waste_pickup_date,
|
||||
end=next_waste_pickup_date,
|
||||
)
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
|
|
|
@ -934,11 +934,8 @@ async def test_get_events_custom_calendars(hass, calendar, get_api_events):
|
|||
events = await get_api_events("calendar.private_private")
|
||||
assert events == [
|
||||
{
|
||||
"description": "Surprisingly rainy",
|
||||
"end": "2017-11-27T10:00:00-08:00",
|
||||
"location": "Hamburg",
|
||||
"start": "2017-11-27T09:00:00-08:00",
|
||||
"end": {"dateTime": "2017-11-27T10:00:00-08:00"},
|
||||
"start": {"dateTime": "2017-11-27T09:00:00-08:00"},
|
||||
"summary": "This is a normal event",
|
||||
"uid": "1",
|
||||
}
|
||||
]
|
||||
|
|
|
@ -23,7 +23,6 @@ async def test_events_http_api(hass, hass_client):
|
|||
assert response.status == HTTPStatus.OK
|
||||
events = await response.json()
|
||||
assert events[0]["summary"] == "Future Event"
|
||||
assert events[0]["title"] == "Future Event"
|
||||
|
||||
|
||||
async def test_calendars_http_api(hass, hass_client):
|
||||
|
@ -37,4 +36,24 @@ async def test_calendars_http_api(hass, hass_client):
|
|||
assert data == [
|
||||
{"entity_id": "calendar.calendar_1", "name": "Calendar 1"},
|
||||
{"entity_id": "calendar.calendar_2", "name": "Calendar 2"},
|
||||
{"entity_id": "calendar.calendar_3", "name": "Calendar 3"},
|
||||
]
|
||||
|
||||
|
||||
async def test_events_http_api_shim(hass, hass_client):
|
||||
"""Test the legacy shim calendar demo view."""
|
||||
await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}})
|
||||
await hass.async_block_till_done()
|
||||
client = await hass_client()
|
||||
response = await client.get("/api/calendars/calendar.calendar_3")
|
||||
assert response.status == HTTPStatus.BAD_REQUEST
|
||||
start = dt_util.now()
|
||||
end = start + timedelta(days=1)
|
||||
response = await client.get(
|
||||
"/api/calendars/calendar.calendar_1?start={}&end={}".format(
|
||||
start.isoformat(), end.isoformat()
|
||||
)
|
||||
)
|
||||
assert response.status == HTTPStatus.OK
|
||||
events = await response.json()
|
||||
assert events[0]["summary"] == "Future Event"
|
||||
|
|
|
@ -76,7 +76,6 @@ async def test_api_events(
|
|||
events = await response.json()
|
||||
assert len(events) == 1
|
||||
assert events[0] == {
|
||||
"all_day": True,
|
||||
"start": {"date": "2022-01-06"},
|
||||
"end": {"date": "2022-01-06"},
|
||||
"summary": "Christmas Tree Pickup",
|
||||
|
|
Loading…
Reference in New Issue