Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add import calendar events endpoint #116314

59 changes: 59 additions & 0 deletions homeassistant/components/calendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
from typing import Any, Final, cast, final

from aiohttp import web
from aiohttp.web_request import FileField
from dateutil.rrule import rrulestr
from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from ical.exceptions import CalendarParseError
import voluptuous as vol

from homeassistant.components import frontend, http, websocket_api
Expand Down Expand Up @@ -294,6 +298,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

hass.http.register_view(CalendarListView(component))
hass.http.register_view(CalendarEventView(component))
hass.http.register_view(CalendarImportEventsView(component))

frontend.async_register_built_in_panel(
hass, "calendar", "calendar", "hass:calendar"
Expand Down Expand Up @@ -605,6 +610,10 @@ async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar."""
raise NotImplementedError

async def async_add_events(self, events: list[Event]) -> None:
"""Add a list of events to calendar."""
raise NotImplementedError

async def async_delete_event(
self,
uid: str,
Expand Down Expand Up @@ -699,6 +708,56 @@ async def get(self, request: web.Request) -> web.Response:
return self.json(sorted(calendar_list, key=lambda x: cast(str, x["name"])))


class CalendarImportEventsView(http.HomeAssistantView):
"""View to import an iCalendar file."""

url = "/api/calendars/import"
name = "api:calendars:import"

def __init__(self, component: EntityComponent[CalendarEntity]) -> None:
"""Initialize import calendar view."""
self.component = component
self.schema = vol.Schema(
{vol.Required("entity_id"): str, vol.Required("file"): FileField}
)

async def post(self, request: web.Request) -> web.Response:
"""Upload a file."""
try:
data = self.schema(dict(await request.post()))
except vol.Invalid as err:
return self.json_message(str(err), HTTPStatus.BAD_REQUEST)

entity_id: str = data["entity_id"]
if not (entity := self.component.get_entity(entity_id)):
return self.json_message("Entity not found", HTTPStatus.BAD_REQUEST)

if (
not entity.supported_features
or not entity.supported_features & CalendarEntityFeature.CREATE_EVENT
):
return self.json_message(
"Calendar does not support event creation", HTTPStatus.BAD_REQUEST
)

file: FileField = data["file"]
if file.content_type != "text/calendar":
return self.json_message(
"Only ics Calendar files are allowed", HTTPStatus.BAD_REQUEST
)

try:
file_bytes = file.file.read()
file_str = file_bytes.decode("utf-8")
calendar = IcsCalendarStream.calendar_from_ics(file_str)
except CalendarParseError:
return self.json_message("Failed to parse ics file", HTTPStatus.BAD_REQUEST)

await entity.async_add_events(calendar.events)

return self.json("Successfully imported event data")


@websocket_api.websocket_command(
{
vol.Required("type"): "calendar/event/create",
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/local_calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ async def async_create_event(self, **kwargs: Any) -> None:
await self._async_store()
await self.async_update_ha_state(force_refresh=True)

async def async_add_events(self, events: list[Event]) -> None:
"""Add a list of events to calendar."""
for event in events:
EventStore(self._calendar).add(event)
await self._async_store()
await self.async_update_ha_state(force_refresh=True)

async def async_delete_event(
self,
uid: str,
Expand Down
6 changes: 6 additions & 0 deletions tests/components/calendar/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
"""The tests for calendar sensor platforms."""

import pathlib

TEST_ICAL = pathlib.Path(__file__).parent / "test_data/ical.ics"
TEST_INVALID_ICAL = pathlib.Path(__file__).parent / "test_data/invalid_ical.ics"
TEST_TXT = pathlib.Path(__file__).parent / "test_data/t.txt"
20 changes: 17 additions & 3 deletions tests/components/calendar/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

import pytest

from homeassistant.components.calendar import DOMAIN, CalendarEntity, CalendarEvent
from homeassistant.components.calendar import (
DOMAIN,
CalendarEntity,
CalendarEntityFeature,
CalendarEvent,
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -44,9 +49,15 @@ class MockCalendarEntity(CalendarEntity):

_attr_has_entity_name = True

def __init__(self, name: str, events: list[CalendarEvent] | None = None) -> None:
def __init__(
self,
name: str,
events: list[CalendarEvent] | None = None,
supported_features: int | None = None,
) -> None:
"""Initialize entity."""
self._attr_name = name.capitalize()
self._attr_supported_features = supported_features
self._events = events or []

@property
Expand Down Expand Up @@ -196,4 +207,7 @@ def create_test_entities() -> list[MockCalendarEntity]:
)
entity2.async_get_events = AsyncMock(wraps=entity2.async_get_events)

return [entity1, entity2]
entity3 = MockCalendarEntity("Calendar 3", [], CalendarEntityFeature.CREATE_EVENT)
entity3.async_get_events = AsyncMock(wraps=entity3.async_get_events)

return [entity1, entity2, entity3]
Empty file.
1 change: 1 addition & 0 deletions tests/components/calendar/test_data/invalid_ical.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
invalid ics
Empty file.
39 changes: 39 additions & 0 deletions tests/components/calendar/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from homeassistant.helpers.issue_registry import IssueRegistry
import homeassistant.util.dt as dt_util

from . import TEST_ICAL, TEST_INVALID_ICAL, TEST_TXT
from .conftest import TEST_DOMAIN, MockCalendarEntity, MockConfigEntry

from tests.typing import ClientSessionGenerator, WebSocketGenerator
Expand Down Expand Up @@ -126,9 +127,47 @@ async def test_calendars_http_api(
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"},
]


@pytest.mark.parametrize(
("payload", "message"),
[
(
{"file": TEST_ICAL.open("rb")},
"required key not provided @ data['entity_id']",
),
(
{"file": TEST_ICAL.open("rb"), "entity_id": "calendar.calendar_4"},
"Entity not found",
),
(
{"file": TEST_ICAL.open("rb"), "entity_id": "calendar.calendar_1"},
"Calendar does not support event creation",
),
(
{"file": TEST_TXT.open("rb"), "entity_id": "calendar.calendar_3"},
"Only ics Calendar files are allowed",
),
(
{"file": TEST_INVALID_ICAL.open("rb"), "entity_id": "calendar.calendar_3"},
"Failed to parse ics file",
),
],
)
async def test_import_events_http_api_invalid_params(
hass: HomeAssistant, hass_client: ClientSessionGenerator, payload, message
) -> None:
"""Test the import events view."""
client = await hass_client()
response = await client.post("/api/calendars/import", data=payload)
assert response.status == HTTPStatus.BAD_REQUEST
data = await response.json()
assert data == {"message": message}
payload["file"].close()


@pytest.mark.parametrize(
("payload", "code"),
[
Expand Down
2 changes: 1 addition & 1 deletion tests/components/calendar/test_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ async def test_legacy_entity_type(
"action": TEST_AUTOMATION_ACTION,
"trigger": {
"platform": calendar.DOMAIN,
"entity_id": "calendar.calendar_3",
"entity_id": "calendar.calendar_4",
},
}
},
Expand Down