Skip to content

Commit 0581ceb

Browse files
authored
Add ability for CalDAV to create calendar events (home-assistant#150030)
1 parent 7ba2e60 commit 0581ceb

File tree

2 files changed

+154
-0
lines changed

2 files changed

+154
-0
lines changed

homeassistant/components/caldav/calendar.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@
33
from __future__ import annotations
44

55
from datetime import datetime
6+
from functools import partial
67
import logging
8+
from typing import Any
79

810
import caldav
11+
from caldav.lib.error import DAVError
12+
import requests
913
import voluptuous as vol
1014

1115
from homeassistant.components.calendar import (
1216
ENTITY_ID_FORMAT,
1317
PLATFORM_SCHEMA as CALENDAR_PLATFORM_SCHEMA,
1418
CalendarEntity,
19+
CalendarEntityFeature,
1520
CalendarEvent,
1621
is_offset_reached,
1722
)
@@ -23,6 +28,7 @@
2328
CONF_VERIFY_SSL,
2429
)
2530
from homeassistant.core import HomeAssistant, callback
31+
from homeassistant.exceptions import HomeAssistantError
2632
from homeassistant.helpers import config_validation as cv
2733
from homeassistant.helpers.entity import async_generate_entity_id
2834
from homeassistant.helpers.entity_platform import (
@@ -175,6 +181,8 @@ async def async_setup_entry(
175181
class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity):
176182
"""A device for getting the next Task from a WebDav Calendar."""
177183

184+
_attr_supported_features = CalendarEntityFeature.CREATE_EVENT
185+
178186
def __init__(
179187
self,
180188
name: str | None,
@@ -203,6 +211,31 @@ async def async_get_events(
203211
"""Get all events in a specific time frame."""
204212
return await self.coordinator.async_get_events(hass, start_date, end_date)
205213

214+
async def async_create_event(self, **kwargs: Any) -> None:
215+
"""Create a new event in the calendar."""
216+
_LOGGER.debug("Event: %s", kwargs)
217+
218+
item_data: dict[str, Any] = {
219+
"summary": kwargs["summary"],
220+
"dtstart": kwargs["dtstart"],
221+
"dtend": kwargs["dtend"],
222+
}
223+
if description := kwargs.get("description"):
224+
item_data["description"] = description
225+
if location := kwargs.get("location"):
226+
item_data["location"] = location
227+
if rrule := kwargs.get("rrule"):
228+
item_data["rrule"] = rrule
229+
230+
_LOGGER.debug("ICS data %s", item_data)
231+
232+
try:
233+
await self.hass.async_add_executor_job(
234+
partial(self.coordinator.calendar.add_event, **item_data),
235+
)
236+
except (requests.ConnectionError, DAVError) as err:
237+
raise HomeAssistantError(f"CalDAV save error: {err}") from err
238+
206239
@callback
207240
def _handle_coordinator_update(self) -> None:
208241
"""Update event data."""

tests/components/caldav/test_calendar.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
from http import HTTPStatus
66
from typing import Any
77
from unittest.mock import MagicMock, Mock
8+
import zoneinfo
89

910
from caldav.objects import Event
1011
from freezegun import freeze_time
1112
from freezegun.api import FrozenDateTimeFactory
1213
import pytest
1314

15+
from homeassistant.components.calendar import CalendarEntityFeature
1416
from homeassistant.const import STATE_OFF, STATE_ON, Platform
1517
from homeassistant.core import HomeAssistant
1618
from homeassistant.setup import async_setup_component
@@ -455,6 +457,7 @@ async def test_ongoing_event(
455457
"end_time": "2017-11-27 18:00:00",
456458
"location": "Hamburg",
457459
"description": "Surprisingly rainy",
460+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
458461
}
459462

460463

@@ -479,6 +482,7 @@ async def test_just_ended_event(
479482
"end_time": "2017-11-27 18:00:00",
480483
"location": "Hamburg",
481484
"description": "Surprisingly rainy",
485+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
482486
}
483487

484488

@@ -503,6 +507,7 @@ async def test_ongoing_event_different_tz(
503507
"description": "Sunny day",
504508
"end_time": "2017-11-27 17:30:00",
505509
"location": "San Francisco",
510+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
506511
}
507512

508513

@@ -527,6 +532,7 @@ async def test_ongoing_floating_event_returned(
527532
"end_time": "2017-11-27 20:00:00",
528533
"location": "Hamburg",
529534
"description": "What a day",
535+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
530536
}
531537

532538

@@ -551,6 +557,7 @@ async def test_ongoing_event_with_offset(
551557
"end_time": "2017-11-27 11:00:00",
552558
"location": "Hamburg",
553559
"description": "Surprisingly shiny",
560+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
554561
}
555562

556563

@@ -591,6 +598,7 @@ async def test_matching_filter(
591598
"end_time": "2017-11-27 18:00:00",
592599
"location": "Hamburg",
593600
"description": "Surprisingly rainy",
601+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
594602
}
595603

596604

@@ -632,6 +640,7 @@ async def test_matching_filter_real_regexp(
632640
"end_time": "2017-11-27 18:00:00",
633641
"location": "Hamburg",
634642
"description": "Surprisingly rainy",
643+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
635644
}
636645

637646

@@ -664,6 +673,7 @@ async def test_filter_matching_past_event(
664673
assert dict(state.attributes) == {
665674
"friendly_name": CALENDAR_NAME,
666675
"offset_reached": False,
676+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
667677
}
668678

669679

@@ -695,6 +705,7 @@ async def test_no_result_with_filtering(
695705
assert dict(state.attributes) == {
696706
"friendly_name": CALENDAR_NAME,
697707
"offset_reached": False,
708+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
698709
}
699710

700711

@@ -749,6 +760,7 @@ async def test_all_day_event(
749760
"end_time": "2017-11-28 00:00:00",
750761
"location": "Hamburg",
751762
"description": "What a beautiful day",
763+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
752764
}
753765

754766

@@ -773,6 +785,7 @@ async def test_event_rrule(
773785
"end_time": "2017-11-27 22:30:00",
774786
"location": "Hamburg",
775787
"description": "Every day for a while",
788+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
776789
}
777790

778791

@@ -797,6 +810,7 @@ async def test_event_rrule_ongoing(
797810
"end_time": "2017-11-27 22:30:00",
798811
"location": "Hamburg",
799812
"description": "Every day for a while",
813+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
800814
}
801815

802816

@@ -821,6 +835,7 @@ async def test_event_rrule_duration(
821835
"end_time": "2017-11-27 23:30:00",
822836
"location": "Hamburg",
823837
"description": "Every day for a while as well",
838+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
824839
}
825840

826841

@@ -845,6 +860,7 @@ async def test_event_rrule_duration_ongoing(
845860
"end_time": "2017-11-27 23:30:00",
846861
"location": "Hamburg",
847862
"description": "Every day for a while as well",
863+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
848864
}
849865

850866

@@ -869,6 +885,7 @@ async def test_event_rrule_endless(
869885
"end_time": "2017-11-27 23:59:59",
870886
"location": "Hamburg",
871887
"description": "Every day forever",
888+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
872889
}
873890

874891

@@ -925,6 +942,7 @@ async def test_event_rrule_all_day_early(
925942
"end_time": "2016-12-02 00:00:00",
926943
"location": "Hamburg",
927944
"description": "Groundhog Day",
945+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
928946
}
929947

930948

@@ -949,6 +967,7 @@ async def test_event_rrule_hourly_on_first(
949967
"end_time": "2015-11-27 00:30:00",
950968
"location": "Hamburg",
951969
"description": "The bell tolls for thee",
970+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
952971
}
953972

954973

@@ -973,6 +992,7 @@ async def test_event_rrule_hourly_on_last(
973992
"end_time": "2015-11-27 11:30:00",
974993
"location": "Hamburg",
975994
"description": "The bell tolls for thee",
995+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
976996
}
977997

978998

@@ -1105,6 +1125,7 @@ async def test_setup_config_entry(
11051125
"end_time": "2017-11-28 00:00:00",
11061126
"location": "Hamburg",
11071127
"description": "What a beautiful day",
1128+
"supported_features": CalendarEntityFeature.CREATE_EVENT,
11081129
}
11091130

11101131

@@ -1140,3 +1161,103 @@ async def test_config_entry_supported_components(
11401161
# No entity created when no components exist
11411162
state = hass.states.get("calendar.calendar_4")
11421163
assert not state
1164+
1165+
1166+
@pytest.mark.parametrize("tz", [UTC])
1167+
@pytest.mark.parametrize(
1168+
("service_data", "expected_ics_fields"),
1169+
[
1170+
# Basic event with all fields
1171+
(
1172+
{
1173+
"summary": "Test Event",
1174+
"start_date_time": "2025-08-06T10:00:00+00:00",
1175+
"end_date_time": "2025-08-06T11:00:00+00:00",
1176+
"description": "Test Description",
1177+
"location": "Test Location",
1178+
},
1179+
{
1180+
"description": "Test Description",
1181+
"dtend": datetime.datetime(
1182+
2025, 8, 6, 11, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")
1183+
),
1184+
"dtstart": datetime.datetime(
1185+
2025, 8, 6, 10, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")
1186+
),
1187+
"location": "Test Location",
1188+
"summary": "Test Event",
1189+
},
1190+
),
1191+
# Event with only required fields
1192+
(
1193+
{
1194+
"summary": "Required Only",
1195+
"start_date_time": "2025-08-07T09:00:00+00:00",
1196+
"end_date_time": "2025-08-07T10:00:00+00:00",
1197+
},
1198+
{
1199+
"summary": "Required Only",
1200+
"dtstart": datetime.datetime(
1201+
2025, 8, 7, 9, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")
1202+
),
1203+
"dtend": datetime.datetime(
1204+
2025, 8, 7, 10, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")
1205+
),
1206+
},
1207+
),
1208+
# All-day event (date only)
1209+
(
1210+
{
1211+
"summary": "All Day Event",
1212+
"start_date": "2025-08-08",
1213+
"end_date": "2025-08-09",
1214+
},
1215+
{
1216+
"summary": "All Day Event",
1217+
"dtstart": datetime.date(2025, 8, 8),
1218+
"dtend": datetime.date(2025, 8, 9),
1219+
},
1220+
),
1221+
# Event with different timezone
1222+
(
1223+
{
1224+
"summary": "Different TZ",
1225+
"start_date_time": "2025-08-07T09:00:00+02:00",
1226+
"end_date_time": "2025-08-07T10:00:00+02:00",
1227+
},
1228+
{
1229+
"summary": "Different TZ",
1230+
"dtstart": datetime.datetime(
1231+
2025, 8, 7, 7, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")
1232+
),
1233+
"dtend": datetime.datetime(
1234+
2025, 8, 7, 8, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")
1235+
),
1236+
},
1237+
),
1238+
# Rrule is not supported in API (async_call) calls.
1239+
],
1240+
)
1241+
async def test_add_vevent(
1242+
hass: HomeAssistant,
1243+
setup_platform_cb: Callable[[], Awaitable[None]],
1244+
calendars: list[Mock],
1245+
service_data: dict,
1246+
expected_ics_fields: dict,
1247+
) -> None:
1248+
"""Test adding a VEVENT to the calendar."""
1249+
await setup_platform_cb()
1250+
1251+
calendars[0].add_event = MagicMock(return_value=[])
1252+
await hass.services.async_call(
1253+
"calendar",
1254+
"create_event",
1255+
service_data,
1256+
target={"entity_id": TEST_ENTITY},
1257+
blocking=True,
1258+
)
1259+
await hass.async_block_till_done()
1260+
1261+
calendars[0].add_event.assert_called_once()
1262+
assert calendars[0].add_event.call_args
1263+
assert calendars[0].add_event.call_args[1] == expected_ics_fields

0 commit comments

Comments
 (0)