Add Calendar API endpoint to get events (#14702)

* Add Calendar API endpoint to get events

* Set default event color

* Fix PR comments

* Fix PR comments

* Fix PR comments

* Remote local.py file

* Use iso 8601

* Fix lint

* Fix PR comments

* Fix PR comments

* Add Support for todoist and demo calendar

* Todoist events are allday events

* Add calendar demo api endpoint test

* Register only one api endpoint for calendar

* Rename demo calendar
This commit is contained in:
Thibault Cohen 2018-06-15 11:16:31 -04:00 committed by Paulus Schoutsen
parent 1128104281
commit 3cd4cb741c
8 changed files with 202 additions and 34 deletions

View File

@ -9,6 +9,8 @@ import logging
from datetime import timedelta from datetime import timedelta
import re import re
from aiohttp import web
from homeassistant.components.google import ( from homeassistant.components.google import (
CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME)
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
@ -18,11 +20,15 @@ from homeassistant.helpers.entity import Entity, generate_entity_id
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.util import dt from homeassistant.util import dt
from homeassistant.components import http
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = 'calendar' DOMAIN = 'calendar'
DEPENDENCIES = ['http']
ENTITY_ID_FORMAT = DOMAIN + '.{}' ENTITY_ID_FORMAT = DOMAIN + '.{}'
SCAN_INTERVAL = timedelta(seconds=60) SCAN_INTERVAL = timedelta(seconds=60)
@ -34,6 +40,8 @@ def async_setup(hass, config):
component = EntityComponent( component = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN) _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN)
hass.http.register_view(CalendarEventView(component))
yield from component.async_setup(config) yield from component.async_setup(config)
return True return True
@ -42,6 +50,14 @@ DEFAULT_CONF_TRACK_NEW = True
DEFAULT_CONF_OFFSET = '!!' DEFAULT_CONF_OFFSET = '!!'
def get_date(date):
"""Get the dateTime from date or dateTime as a local."""
if 'date' in date:
return dt.start_of_local_day(dt.dt.datetime.combine(
dt.parse_date(date['date']), dt.dt.time.min))
return dt.as_local(dt.parse_datetime(date['dateTime']))
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes
class CalendarEventDevice(Entity): class CalendarEventDevice(Entity):
"""A calendar event device.""" """A calendar event device."""
@ -144,15 +160,8 @@ class CalendarEventDevice(Entity):
self.cleanup() self.cleanup()
return return
def _get_date(date): start = get_date(self.data.event['start'])
"""Get the dateTime from date or dateTime as a local.""" end = get_date(self.data.event['end'])
if 'date' in date:
return dt.start_of_local_day(dt.dt.datetime.combine(
dt.parse_date(date['date']), dt.dt.time.min))
return dt.as_local(dt.parse_datetime(date['dateTime']))
start = _get_date(self.data.event['start'])
end = _get_date(self.data.event['end'])
summary = self.data.event.get('summary', '') summary = self.data.event.get('summary', '')
@ -176,10 +185,37 @@ class CalendarEventDevice(Entity):
# cleanup the string so we don't have a bunch of double+ spaces # cleanup the string so we don't have a bunch of double+ spaces
self._cal_data['message'] = re.sub(' +', '', summary).strip() self._cal_data['message'] = re.sub(' +', '', summary).strip()
self._cal_data['offset_time'] = offset_time self._cal_data['offset_time'] = offset_time
self._cal_data['location'] = self.data.event.get('location', '') self._cal_data['location'] = self.data.event.get('location', '')
self._cal_data['description'] = self.data.event.get('description', '') self._cal_data['description'] = self.data.event.get('description', '')
self._cal_data['start'] = start self._cal_data['start'] = start
self._cal_data['end'] = end self._cal_data['end'] = end
self._cal_data['all_day'] = 'date' in self.data.event['start'] self._cal_data['all_day'] = 'date' in self.data.event['start']
class CalendarEventView(http.HomeAssistantView):
"""View to retrieve calendar content."""
url = '/api/calendar/{entity_id}'
name = 'api:calendar'
def __init__(self, component):
"""Initialize calendar view."""
self.component = component
async def get(self, request, entity_id):
"""Return calendar events."""
entity = self.component.get_entity('calendar.' + entity_id)
start = request.query.get('start')
end = request.query.get('end')
if None in (start, end, entity):
return web.Response(status=400)
try:
start_date = dt.parse_datetime(start)
end_date = dt.parse_datetime(end)
except (ValueError, AttributeError):
return web.Response(status=400)
event_list = await entity.async_get_events(request.app['hass'],
start_date,
end_date)
return self.json(event_list)

View File

@ -11,7 +11,7 @@ import re
import voluptuous as vol import voluptuous as vol
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
PLATFORM_SCHEMA, CalendarEventDevice) PLATFORM_SCHEMA, CalendarEventDevice, get_date)
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME)
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -92,7 +92,7 @@ def setup_platform(hass, config, add_devices, disc_info=None):
if not config.get(CONF_CUSTOM_CALENDARS): if not config.get(CONF_CUSTOM_CALENDARS):
device_data = { device_data = {
CONF_NAME: calendar.name, CONF_NAME: calendar.name,
CONF_DEVICE_ID: calendar.name CONF_DEVICE_ID: calendar.name,
} }
calendar_devices.append( calendar_devices.append(
WebDavCalendarEventDevice(hass, device_data, calendar) WebDavCalendarEventDevice(hass, device_data, calendar)
@ -120,6 +120,10 @@ class WebDavCalendarEventDevice(CalendarEventDevice):
attributes = super().device_state_attributes attributes = super().device_state_attributes
return attributes return attributes
async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date)
class WebDavCalendarData(object): class WebDavCalendarData(object):
"""Class to utilize the calendar dav client object to get next event.""" """Class to utilize the calendar dav client object to get next event."""
@ -131,6 +135,33 @@ class WebDavCalendarData(object):
self.search = search self.search = search
self.event = None self.event = None
async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame."""
# Get event list from the current calendar
vevent_list = await hass.async_add_job(self.calendar.date_search,
start_date, end_date)
event_list = []
for event in vevent_list:
vevent = event.instance.vevent
uid = None
if hasattr(vevent, 'uid'):
uid = vevent.uid.value
data = {
"uid": uid,
"title": 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)
return event_list
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def update(self):
"""Get the latest data.""" """Get the latest data."""

View File

@ -4,8 +4,10 @@ Demo platform that has two fake binary sensors.
For more details about this platform, please refer to the documentation For more details about this platform, please refer to the documentation
https://home-assistant.io/components/demo/ https://home-assistant.io/components/demo/
""" """
import copy
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.components.calendar import CalendarEventDevice from homeassistant.components.calendar import CalendarEventDevice, get_date
from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME
@ -16,12 +18,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
add_devices([ add_devices([
DemoGoogleCalendar(hass, calendar_data_future, { DemoGoogleCalendar(hass, calendar_data_future, {
CONF_NAME: 'Future Event', CONF_NAME: 'Future Event',
CONF_DEVICE_ID: 'future_event', CONF_DEVICE_ID: 'calendar_1',
}), }),
DemoGoogleCalendar(hass, calendar_data_current, { DemoGoogleCalendar(hass, calendar_data_current, {
CONF_NAME: 'Current Event', CONF_NAME: 'Current Event',
CONF_DEVICE_ID: 'current_event', CONF_DEVICE_ID: 'calendar_2',
}), }),
]) ])
@ -29,11 +31,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
class DemoGoogleCalendarData(object): class DemoGoogleCalendarData(object):
"""Representation of a Demo Calendar element.""" """Representation of a Demo Calendar element."""
event = {}
# pylint: disable=no-self-use # pylint: disable=no-self-use
def update(self): def update(self):
"""Return true so entity knows we have new data.""" """Return true so entity knows we have new data."""
return True return True
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]
class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData):
"""Representation of a Demo Calendar for a future event.""" """Representation of a Demo Calendar for a future event."""
@ -80,3 +92,7 @@ class DemoGoogleCalendar(CalendarEventDevice):
"""Initialize Google Calendar but without the API calls.""" """Initialize Google Calendar but without the API calls."""
self.data = calendar_data self.data = calendar_data
super().__init__(hass, data) super().__init__(hass, data)
async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date)

View File

@ -51,6 +51,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
super().__init__(hass, data) super().__init__(hass, data)
async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date)
class GoogleCalendarData(object): class GoogleCalendarData(object):
"""Class to utilize calendar service object to get next event.""" """Class to utilize calendar service object to get next event."""
@ -64,9 +68,7 @@ class GoogleCalendarData(object):
self.ignore_availability = ignore_availability self.ignore_availability = ignore_availability
self.event = None self.event = None
@Throttle(MIN_TIME_BETWEEN_UPDATES) def _prepare_query(self):
def update(self):
"""Get the latest data."""
from httplib2 import ServerNotFoundError from httplib2 import ServerNotFoundError
try: try:
@ -74,13 +76,41 @@ class GoogleCalendarData(object):
except ServerNotFoundError: except ServerNotFoundError:
_LOGGER.warning("Unable to connect to Google, using cached data") _LOGGER.warning("Unable to connect to Google, using cached data")
return False return False
params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS)
params['timeMin'] = dt.now().isoformat('T')
params['calendarId'] = self.calendar_id params['calendarId'] = self.calendar_id
if self.search: if self.search:
params['q'] = self.search params['q'] = self.search
return service, params
async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame."""
service, params = await hass.async_add_job(self._prepare_query)
params['timeMin'] = start_date.isoformat('T')
params['timeMax'] = end_date.isoformat('T')
# pylint: disable=no-member
events = await hass.async_add_job(service.events)
# pylint: enable=no-member
result = await hass.async_add_job(events.list(**params).execute)
items = result.get('items', [])
event_list = []
for item in items:
if (not self.ignore_availability
and 'transparency' in item.keys()):
if item['transparency'] == 'opaque':
event_list.append(item)
else:
event_list.append(item)
return event_list
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data."""
service, params = self._prepare_query()
params['timeMin'] = dt.now().isoformat('T')
events = service.events() # pylint: disable=no-member events = service.events() # pylint: disable=no-member
result = events.list(**params).execute() result = events.list(**params).execute()

View File

@ -257,6 +257,10 @@ class TodoistProjectDevice(CalendarEventDevice):
super().cleanup() super().cleanup()
self._cal_data[ALL_TASKS] = [] self._cal_data[ALL_TASKS] = []
async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date)
@property @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the device state attributes.""" """Return the device state attributes."""
@ -485,6 +489,31 @@ class TodoistProjectData(object):
continue continue
return event return event
async def async_get_events(self, hass, start_date, end_date):
"""Get all tasks in a specific time frame."""
if self._id is None:
project_task_data = [
task for task in self._api.state[TASKS]
if not self._project_id_whitelist or
task[PROJECT_ID] in self._project_id_whitelist]
else:
project_task_data = self._api.projects.get_data(self._id)[TASKS]
events = []
time_format = '%a %d %b %Y %H:%M:%S %z'
for task in project_task_data:
due_date = datetime.strptime(task['due_date_utc'], time_format)
if due_date > start_date and due_date < end_date:
event = {
'uid': task['id'],
'title': task['content'],
'start': due_date.isoformat(),
'end': due_date.isoformat(),
'allDay': True,
}
events.append(event)
return events
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def update(self):
"""Get the latest data.""" """Get the latest data."""

View File

@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
DEVICE_DATA = { DEVICE_DATA = {
"name": "Private Calendar", "name": "Private Calendar",
"device_id": "Private Calendar" "device_id": "Private Calendar",
} }
EVENTS = [ EVENTS = [
@ -163,6 +163,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
def setUp(self): def setUp(self):
"""Set up things to be run when tests are started.""" """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant() self.hass = get_test_home_assistant()
self.hass.http = Mock()
self.calendar = _mock_calendar("Private") self.calendar = _mock_calendar("Private")
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -255,7 +256,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 17:00:00", "start_time": "2017-11-27 17:00:00",
"end_time": "2017-11-27 18:00:00", "end_time": "2017-11-27 18:00:00",
"location": "Hamburg", "location": "Hamburg",
"description": "Surprisingly rainy" "description": "Surprisingly rainy",
}) })
@patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30))
@ -274,7 +275,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 17:00:00", "start_time": "2017-11-27 17:00:00",
"end_time": "2017-11-27 18:00:00", "end_time": "2017-11-27 18:00:00",
"location": "Hamburg", "location": "Hamburg",
"description": "Surprisingly rainy" "description": "Surprisingly rainy",
}) })
@patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 00)) @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 00))
@ -293,7 +294,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 16:30:00", "start_time": "2017-11-27 16:30:00",
"description": "Sunny day", "description": "Sunny day",
"end_time": "2017-11-27 17:30:00", "end_time": "2017-11-27 17:30:00",
"location": "San Francisco" "location": "San Francisco",
}) })
@patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30))
@ -311,7 +312,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 10:00:00", "start_time": "2017-11-27 10:00:00",
"end_time": "2017-11-27 11:00:00", "end_time": "2017-11-27 11:00:00",
"location": "Hamburg", "location": "Hamburg",
"description": "Surprisingly shiny" "description": "Surprisingly shiny",
}) })
@patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00))
@ -332,7 +333,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 17:00:00", "start_time": "2017-11-27 17:00:00",
"end_time": "2017-11-27 18:00:00", "end_time": "2017-11-27 18:00:00",
"location": "Hamburg", "location": "Hamburg",
"description": "Surprisingly rainy" "description": "Surprisingly rainy",
}) })
@patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00))
@ -353,7 +354,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 17:00:00", "start_time": "2017-11-27 17:00:00",
"end_time": "2017-11-27 18:00:00", "end_time": "2017-11-27 18:00:00",
"location": "Hamburg", "location": "Hamburg",
"description": "Surprisingly rainy" "description": "Surprisingly rainy",
}) })
@patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00)) @patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00))
@ -395,5 +396,5 @@ class TestComponentsWebDavCalendar(unittest.TestCase):
"start_time": "2017-11-27 00:00:00", "start_time": "2017-11-27 00:00:00",
"end_time": "2017-11-28 00:00:00", "end_time": "2017-11-28 00:00:00",
"location": "Hamburg", "location": "Hamburg",
"description": "What a beautiful day" "description": "What a beautiful day",
}) })

View File

@ -0,0 +1,24 @@
"""The tests for the demo calendar component."""
from datetime import timedelta
from homeassistant.bootstrap import async_setup_component
import homeassistant.util.dt as dt_util
async def test_api_calendar_demo_view(hass, aiohttp_client):
"""Test the calendar demo view."""
await async_setup_component(hass, 'calendar',
{'calendar': {'platform': 'demo'}})
client = await aiohttp_client(hass.http.app)
response = await client.get(
'/api/calendar/calendar_2')
assert response.status == 400
start = dt_util.now()
end = start + timedelta(days=1)
response = await client.get(
'/api/calendar/calendar_1?start={}&end={}'.format(start.isoformat(),
end.isoformat()))
assert response.status == 200
events = await response.json()
assert events[0]['summary'] == 'Future Event'
assert events[0]['title'] == 'Future Event'

View File

@ -27,6 +27,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
def setUp(self): def setUp(self):
"""Setup things to be run when tests are started.""" """Setup things to be run when tests are started."""
self.hass = get_test_home_assistant() self.hass = get_test_home_assistant()
self.hass.http = Mock()
# Set our timezone to CST/Regina so we can check calculations # Set our timezone to CST/Regina so we can check calculations
# This keeps UTC-6 all year round # This keeps UTC-6 all year round
@ -99,7 +100,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
'start_time': '{} 00:00:00'.format(event['start']['date']), 'start_time': '{} 00:00:00'.format(event['start']['date']),
'end_time': '{} 00:00:00'.format(event['end']['date']), 'end_time': '{} 00:00:00'.format(event['end']['date']),
'location': event['location'], 'location': event['location'],
'description': event['description'] 'description': event['description'],
}) })
@patch('homeassistant.components.calendar.google.GoogleCalendarData') @patch('homeassistant.components.calendar.google.GoogleCalendarData')
@ -160,7 +161,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
(one_hour_from_now + dt_util.dt.timedelta(minutes=60)) (one_hour_from_now + dt_util.dt.timedelta(minutes=60))
.strftime(DATE_STR_FORMAT), .strftime(DATE_STR_FORMAT),
'location': '', 'location': '',
'description': '' 'description': '',
}) })
@patch('homeassistant.components.calendar.google.GoogleCalendarData') @patch('homeassistant.components.calendar.google.GoogleCalendarData')
@ -222,7 +223,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
(middle_of_event + dt_util.dt.timedelta(minutes=60)) (middle_of_event + dt_util.dt.timedelta(minutes=60))
.strftime(DATE_STR_FORMAT), .strftime(DATE_STR_FORMAT),
'location': '', 'location': '',
'description': '' 'description': '',
}) })
@patch('homeassistant.components.calendar.google.GoogleCalendarData') @patch('homeassistant.components.calendar.google.GoogleCalendarData')
@ -285,7 +286,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
(middle_of_event + dt_util.dt.timedelta(minutes=60)) (middle_of_event + dt_util.dt.timedelta(minutes=60))
.strftime(DATE_STR_FORMAT), .strftime(DATE_STR_FORMAT),
'location': '', 'location': '',
'description': '' 'description': '',
}) })
@pytest.mark.skip @pytest.mark.skip
@ -352,7 +353,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
'start_time': '{} 06:00:00'.format(event['start']['date']), 'start_time': '{} 06:00:00'.format(event['start']['date']),
'end_time': '{} 06:00:00'.format(event['end']['date']), 'end_time': '{} 06:00:00'.format(event['end']['date']),
'location': event['location'], 'location': event['location'],
'description': event['description'] 'description': event['description'],
}) })
@patch('homeassistant.components.calendar.google.GoogleCalendarData') @patch('homeassistant.components.calendar.google.GoogleCalendarData')
@ -419,7 +420,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase):
'start_time': '{} 00:00:00'.format(event['start']['date']), 'start_time': '{} 00:00:00'.format(event['start']['date']),
'end_time': '{} 00:00:00'.format(event['end']['date']), 'end_time': '{} 00:00:00'.format(event['end']['date']),
'location': event['location'], 'location': event['location'],
'description': event['description'] 'description': event['description'],
}) })
@MockDependency("httplib2") @MockDependency("httplib2")