Add Local Calendar ics events import on calendar creation (#117955)

* add optional config_flow step of uploading .ics file to import local calendar events

* feat: add unit test for import_ics step

* fix: remove unneeded test patch

* feat: add helper for moving ics to storage location

* move helper to config_flow

* ruff

* fix tests; add test for invalid ics content

* Update homeassistant/components/local_calendar/config_flow.py

* Update import flow with radio button and improved text

Signed-off-by: Allen Porter <allen.porter@gmail.com>

* Remove commented out code

* Update with lint fixes

* Apply suggestions from code review

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

---------

Signed-off-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Raul Camacho 2024-09-29 01:15:24 -04:00 committed by GitHub
parent a8d72cfdcf
commit 5399e2b648
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 225 additions and 7 deletions

View File

@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util import slugify
from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN
from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN, STORAGE_PATH
from .store import LocalCalendarStore
_LOGGER = logging.getLogger(__name__)
@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CALENDAR]
STORAGE_PATH = ".storage/local_calendar.{key}.ics"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Local Calendar from a config entry."""

View File

@ -2,18 +2,55 @@
from __future__ import annotations
import logging
from pathlib import Path
import shutil
from typing import Any
from ical.calendar_stream import CalendarStream
from ical.exceptions import CalendarParseError
import voluptuous as vol
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from homeassistant.util import slugify
from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN
from .const import (
ATTR_CREATE_EMPTY,
ATTR_IMPORT_ICS_FILE,
CONF_CALENDAR_NAME,
CONF_ICS_FILE,
CONF_IMPORT,
CONF_STORAGE_KEY,
DOMAIN,
STORAGE_PATH,
)
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CALENDAR_NAME): str,
vol.Optional(CONF_IMPORT, default=ATTR_CREATE_EMPTY): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
ATTR_CREATE_EMPTY,
ATTR_IMPORT_ICS_FILE,
],
translation_key=CONF_IMPORT,
)
),
}
)
STEP_IMPORT_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_ICS_FILE): selector.FileSelector(
config=selector.FileSelectorConfig(accept=".ics")
),
}
)
@ -23,6 +60,10 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -35,6 +76,52 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
key = slugify(user_input[CONF_CALENDAR_NAME])
self._async_abort_entries_match({CONF_STORAGE_KEY: key})
user_input[CONF_STORAGE_KEY] = key
if user_input.get(CONF_IMPORT) == ATTR_IMPORT_ICS_FILE:
self.data = user_input
return await self.async_step_import_ics_file()
return self.async_create_entry(
title=user_input[CONF_CALENDAR_NAME], data=user_input
title=user_input[CONF_CALENDAR_NAME],
data=user_input,
)
async def async_step_import_ics_file(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle optional iCal (.ics) import."""
errors = {}
if user_input is not None:
try:
await self.hass.async_add_executor_job(
save_uploaded_ics_file,
self.hass,
user_input[CONF_ICS_FILE],
self.data[CONF_STORAGE_KEY],
)
except HomeAssistantError as err:
_LOGGER.debug("Error saving uploaded file: %s", err)
errors[CONF_ICS_FILE] = "invalid_ics_file"
else:
return self.async_create_entry(
title=self.data[CONF_CALENDAR_NAME], data=self.data
)
return self.async_show_form(
step_id="import_ics_file",
data_schema=STEP_IMPORT_DATA_SCHEMA,
errors=errors,
)
def save_uploaded_ics_file(
hass: HomeAssistant, uploaded_file_id: str, storage_key: str
):
"""Validate the uploaded file and move it to the storage directory."""
with process_uploaded_file(hass, uploaded_file_id) as file:
ics = file.read_text(encoding="utf8")
try:
CalendarStream.from_ics(ics)
except CalendarParseError as err:
raise HomeAssistantError("Failed to upload file: Invalid ICS file") from err
dest_path = Path(hass.config.path(STORAGE_PATH.format(key=storage_key)))
shutil.move(file, dest_path)

View File

@ -3,4 +3,11 @@
DOMAIN = "local_calendar"
CONF_CALENDAR_NAME = "calendar_name"
CONF_ICS_FILE = "ics_file"
CONF_IMPORT = "import"
CONF_STORAGE_KEY = "storage_key"
ATTR_CREATE_EMPTY = "create_empty"
ATTR_IMPORT_ICS_FILE = "import_ics_file"
STORAGE_PATH = ".storage/local_calendar.{key}.ics"

View File

@ -3,6 +3,7 @@
"name": "Local Calendar",
"codeowners": ["@allenporter"],
"config_flow": true,
"dependencies": ["file_upload"],
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],

View File

@ -5,8 +5,23 @@
"user": {
"description": "Please choose a name for your new calendar",
"data": {
"calendar_name": "Calendar Name"
"calendar_name": "Calendar Name",
"import": "Starting Data"
}
},
"import": {
"description": "You can import events in iCal format (.ics file)."
}
},
"error": {
"invalid_ics_file": "Invalid .ics file"
}
},
"selector": {
"import": {
"options": {
"create_empty": "Create an empty calendar",
"import_ics_file": "Upload an iCalendar file (.ics)"
}
}
}

View File

@ -1,10 +1,20 @@
"""Test the Local Calendar config flow."""
from unittest.mock import patch
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from homeassistant import config_entries
from homeassistant.components.local_calendar.const import (
ATTR_CREATE_EMPTY,
ATTR_IMPORT_ICS_FILE,
CONF_CALENDAR_NAME,
CONF_ICS_FILE,
CONF_IMPORT,
CONF_STORAGE_KEY,
DOMAIN,
)
@ -14,6 +24,46 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
@pytest.fixture
def mock_ics_content():
"""Mock ics file content."""
return b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
END:VCALENDAR
"""
@pytest.fixture
def mock_process_uploaded_file(
tmp_path: Path, mock_ics_content: str
) -> Generator[MagicMock]:
"""Mock upload ics file."""
file_id_ics = str(uuid4())
@contextmanager
def _mock_process_uploaded_file(
hass: HomeAssistant, uploaded_file_id: str
) -> Iterator[Path | None]:
with open(tmp_path / uploaded_file_id, "wb") as icsfile:
icsfile.write(mock_ics_content)
yield tmp_path / uploaded_file_id
with (
patch(
"homeassistant.components.local_calendar.config_flow.process_uploaded_file",
side_effect=_mock_process_uploaded_file,
) as mock_upload,
patch(
"shutil.move",
),
):
mock_upload.file_id = {
CONF_ICS_FILE: file_id_ics,
}
yield mock_upload
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
@ -38,11 +88,44 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["title"] == "My Calendar"
assert result2["data"] == {
CONF_CALENDAR_NAME: "My Calendar",
CONF_IMPORT: ATTR_CREATE_EMPTY,
CONF_STORAGE_KEY: "my_calendar",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_import_ics(
hass: HomeAssistant,
mock_process_uploaded_file: MagicMock,
) -> None:
"""Test we get the import form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT: ATTR_IMPORT_ICS_FILE},
)
assert result2["type"] is FlowResultType.FORM
with patch(
"homeassistant.components.local_calendar.async_setup_entry",
return_value=True,
) as mock_setup_entry:
file_id = mock_process_uploaded_file.file_id
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ICS_FILE: file_id[CONF_ICS_FILE]},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert len(mock_setup_entry.mock_calls) == 1
async def test_duplicate_name(
hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry
) -> None:
@ -65,3 +148,30 @@ async def test_duplicate_name(
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
@pytest.mark.parametrize("mock_ics_content", [b"invalid-ics-content"])
async def test_invalid_ics(
hass: HomeAssistant,
mock_process_uploaded_file: MagicMock,
) -> None:
"""Test invalid ics content raises error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT: ATTR_IMPORT_ICS_FILE},
)
assert result2["type"] is FlowResultType.FORM
file_id = mock_process_uploaded_file.file_id
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ICS_FILE: file_id[CONF_ICS_FILE]},
)
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {CONF_ICS_FILE: "invalid_ics_file"}