mirror of
https://github.com/home-assistant/core.git
synced 2025-05-01 20:57:51 +00:00

* Fix CalDAV parsing of recurring events Some CaDAV servers (see: SOGo) return the original event that contains the recurrence rules. The CalDAV calendar component sorts and filters events based on their start and end dates, and was failing to properly show recurring events based on these recurrence rules. This this change checks if an event has recurrence rules and changes the start/end dates of the event to today if the event is set to occur today. This allows the rest of the component logic to function properly. * Use date from nextmost occurence * Adding unit tests * Add endless event unit test * Create new vevent for each event recurrence today * Remove redundant unit test * Add timezone to events that have none Python cannot compare them otherwise. * Simplify code, add comments & guard clause * Add test for recurring all day event * Account for all-day events * Remove redundant code * Remove redundant code * Remove unnecessary deepcopy * Add hourly recurring tests * Add tests for hourly repeating event * Fix unit test * Use event.copy()
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""Support for WebDav Calendar."""
|
|
import copy
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
import re
|
|
|
|
import caldav
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.calendar import (
|
|
ENTITY_ID_FORMAT,
|
|
PLATFORM_SCHEMA,
|
|
CalendarEventDevice,
|
|
calculate_offset,
|
|
get_date,
|
|
is_offset_reached,
|
|
)
|
|
from homeassistant.const import (
|
|
CONF_NAME,
|
|
CONF_PASSWORD,
|
|
CONF_URL,
|
|
CONF_USERNAME,
|
|
CONF_VERIFY_SSL,
|
|
)
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.entity import generate_entity_id
|
|
from homeassistant.util import Throttle, dt
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONF_CALENDARS = "calendars"
|
|
CONF_CUSTOM_CALENDARS = "custom_calendars"
|
|
CONF_CALENDAR = "calendar"
|
|
CONF_SEARCH = "search"
|
|
|
|
OFFSET = "!!"
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
{
|
|
# pylint: disable=no-value-for-parameter
|
|
vol.Required(CONF_URL): vol.Url(),
|
|
vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
|
vol.Inclusive(CONF_USERNAME, "authentication"): cv.string,
|
|
vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string,
|
|
vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): vol.All(
|
|
cv.ensure_list,
|
|
[
|
|
vol.Schema(
|
|
{
|
|
vol.Required(CONF_CALENDAR): cv.string,
|
|
vol.Required(CONF_NAME): cv.string,
|
|
vol.Required(CONF_SEARCH): cv.string,
|
|
}
|
|
)
|
|
],
|
|
),
|
|
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
|
}
|
|
)
|
|
|
|
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
|
|
|
|
|
|
def setup_platform(hass, config, add_entities, disc_info=None):
|
|
"""Set up the WebDav Calendar platform."""
|
|
url = config[CONF_URL]
|
|
username = config.get(CONF_USERNAME)
|
|
password = config.get(CONF_PASSWORD)
|
|
|
|
client = caldav.DAVClient(
|
|
url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL]
|
|
)
|
|
|
|
calendars = client.principal().calendars()
|
|
|
|
calendar_devices = []
|
|
for calendar in list(calendars):
|
|
# If a calendar name was given in the configuration,
|
|
# ignore all the others
|
|
if config[CONF_CALENDARS] and calendar.name not in config[CONF_CALENDARS]:
|
|
_LOGGER.debug("Ignoring calendar '%s'", calendar.name)
|
|
continue
|
|
|
|
# Create additional calendars based on custom filtering rules
|
|
for cust_calendar in config[CONF_CUSTOM_CALENDARS]:
|
|
# Check that the base calendar matches
|
|
if cust_calendar[CONF_CALENDAR] != calendar.name:
|
|
continue
|
|
|
|
name = cust_calendar[CONF_NAME]
|
|
device_id = "{} {}".format(
|
|
cust_calendar[CONF_CALENDAR], cust_calendar[CONF_NAME]
|
|
)
|
|
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
|
|
calendar_devices.append(
|
|
WebDavCalendarEventDevice(
|
|
name, calendar, entity_id, True, cust_calendar[CONF_SEARCH]
|
|
)
|
|
)
|
|
|
|
# Create a default calendar if there was no custom one
|
|
if not config[CONF_CUSTOM_CALENDARS]:
|
|
name = calendar.name
|
|
device_id = calendar.name
|
|
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
|
|
calendar_devices.append(
|
|
WebDavCalendarEventDevice(name, calendar, entity_id)
|
|
)
|
|
|
|
add_entities(calendar_devices, True)
|
|
|
|
|
|
class WebDavCalendarEventDevice(CalendarEventDevice):
|
|
"""A device for getting the next Task from a WebDav Calendar."""
|
|
|
|
def __init__(self, name, calendar, entity_id, all_day=False, search=None):
|
|
"""Create the WebDav Calendar Event Device."""
|
|
self.data = WebDavCalendarData(calendar, all_day, search)
|
|
self.entity_id = entity_id
|
|
self._event = None
|
|
self._name = name
|
|
self._offset_reached = False
|
|
|
|
@property
|
|
def device_state_attributes(self):
|
|
"""Return the device state attributes."""
|
|
return {"offset_reached": self._offset_reached}
|
|
|
|
@property
|
|
def event(self):
|
|
"""Return the next upcoming event."""
|
|
return self._event
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of the entity."""
|
|
return self._name
|
|
|
|
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)
|
|
|
|
def update(self):
|
|
"""Update event data."""
|
|
self.data.update()
|
|
event = copy.deepcopy(self.data.event)
|
|
if event is None:
|
|
self._event = event
|
|
return
|
|
event = calculate_offset(event, OFFSET)
|
|
self._offset_reached = is_offset_reached(event)
|
|
self._event = event
|
|
|
|
|
|
class WebDavCalendarData:
|
|
"""Class to utilize the calendar dav client object to get next event."""
|
|
|
|
def __init__(self, calendar, include_all_day, search):
|
|
"""Set up how we are going to search the WebDav calendar."""
|
|
self.calendar = calendar
|
|
self.include_all_day = include_all_day
|
|
self.search = search
|
|
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)
|
|
def update(self):
|
|
"""Get the latest data."""
|
|
start_of_today = dt.start_of_local_day()
|
|
start_of_tomorrow = dt.start_of_local_day() + timedelta(days=1)
|
|
|
|
# We have to retrieve the results for the whole day as the server
|
|
# won't return events that have already started
|
|
results = self.calendar.date_search(start_of_today, start_of_tomorrow)
|
|
|
|
# Create new events for each recurrence of an event that happens today.
|
|
# For recurring events, some servers return the original event with recurrence rules
|
|
# and they would not be properly parsed using their original start/end dates.
|
|
new_events = []
|
|
for event in results:
|
|
vevent = event.instance.vevent
|
|
for start_dt in vevent.getrruleset() or []:
|
|
_start_of_today = start_of_today
|
|
_start_of_tomorrow = start_of_tomorrow
|
|
if self.is_all_day(vevent):
|
|
start_dt = start_dt.date()
|
|
_start_of_today = _start_of_today.date()
|
|
_start_of_tomorrow = _start_of_tomorrow.date()
|
|
if _start_of_today <= start_dt < _start_of_tomorrow:
|
|
new_event = event.copy()
|
|
new_vevent = new_event.instance.vevent
|
|
if hasattr(new_vevent, "dtend"):
|
|
dur = new_vevent.dtend.value - new_vevent.dtstart.value
|
|
new_vevent.dtend.value = start_dt + dur
|
|
new_vevent.dtstart.value = start_dt
|
|
new_events.append(new_event)
|
|
elif _start_of_tomorrow <= start_dt:
|
|
break
|
|
vevents = [event.instance.vevent for event in results + new_events]
|
|
|
|
# dtstart can be a date or datetime depending if the event lasts a
|
|
# whole day. Convert everything to datetime to be able to sort it
|
|
vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value))
|
|
|
|
vevent = next(
|
|
(
|
|
vevent
|
|
for vevent in vevents
|
|
if (
|
|
self.is_matching(vevent, self.search)
|
|
and (not self.is_all_day(vevent) or self.include_all_day)
|
|
and not self.is_over(vevent)
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
|
|
# If no matching event could be found
|
|
if vevent is None:
|
|
_LOGGER.debug(
|
|
"No matching event found in the %d results for %s",
|
|
len(vevents),
|
|
self.calendar.name,
|
|
)
|
|
self.event = None
|
|
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"),
|
|
}
|
|
|
|
@staticmethod
|
|
def is_matching(vevent, search):
|
|
"""Return if the event matches the filter criteria."""
|
|
if search is None:
|
|
return True
|
|
|
|
pattern = re.compile(search)
|
|
return (
|
|
hasattr(vevent, "summary")
|
|
and pattern.match(vevent.summary.value)
|
|
or hasattr(vevent, "location")
|
|
and pattern.match(vevent.location.value)
|
|
or hasattr(vevent, "description")
|
|
and pattern.match(vevent.description.value)
|
|
)
|
|
|
|
@staticmethod
|
|
def is_all_day(vevent):
|
|
"""Return if the event last the whole day."""
|
|
return not isinstance(vevent.dtstart.value, datetime)
|
|
|
|
@staticmethod
|
|
def is_over(vevent):
|
|
"""Return if the event is over."""
|
|
return dt.now() >= WebDavCalendarData.to_datetime(
|
|
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."""
|
|
if isinstance(obj, datetime):
|
|
if obj.tzinfo is None:
|
|
# floating value, not bound to any time zone in particular
|
|
# represent same time regardless of which time zone is currently being observed
|
|
return obj.replace(tzinfo=dt.DEFAULT_TIME_ZONE)
|
|
return obj
|
|
return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min))
|
|
|
|
@staticmethod
|
|
def get_attr_value(obj, attribute):
|
|
"""Return the value of the attribute if defined."""
|
|
if hasattr(obj, attribute):
|
|
return getattr(obj, attribute).value
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_end_date(obj):
|
|
"""Return the end datetime as determined by dtend or duration."""
|
|
if hasattr(obj, "dtend"):
|
|
enddate = obj.dtend.value
|
|
|
|
elif hasattr(obj, "duration"):
|
|
enddate = obj.dtstart.value + obj.duration.value
|
|
|
|
else:
|
|
enddate = obj.dtstart.value + timedelta(days=1)
|
|
|
|
return enddate
|