From 5c6b3ac8dbc6e80745676ba573bea2f0ee6bb17a Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Tue, 7 May 2024 01:34:48 +0000 Subject: [PATCH 01/12] add optional config_flow step of uploading .ics file to import local calendar events --- .../components/local_calendar/__init__.py | 17 +++++++- .../components/local_calendar/config_flow.py | 40 ++++++++++++++++++- .../components/local_calendar/const.py | 2 + .../components/local_calendar/manifest.json | 1 + .../components/local_calendar/strings.json | 6 ++- 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 2be5133a21c9c5..c5d30bdd965ce0 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -5,13 +5,17 @@ import logging from pathlib import Path +from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError + +from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform 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_ICS_FILE, CONF_STORAGE_KEY, DOMAIN from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -42,6 +46,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OSError as err: raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + if entry.data.get(CONF_ICS_FILE): + with process_uploaded_file(hass, entry.data[CONF_ICS_FILE]) as ics_file: + ics = ics_file.read_text(encoding="utf8") + try: + IcsCalendarStream.calendar_from_ics(ics) + except CalendarParseError as err: + raise ConfigEntryNotReady( + "Failed to import events: Invalid ICS file" + ) from err + await store.async_store(ics) + hass.data[DOMAIN][entry.entry_id] = store await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index 8caa3a5d5280ba..030576a64bf8fe 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -7,13 +7,29 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import selector from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN +from .const import ( + CONF_CALENDAR_NAME, + CONF_ICS_FILE, + CONF_IMPORT_ICS, + CONF_STORAGE_KEY, + DOMAIN, +) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_CALENDAR_NAME): str, + vol.Optional(CONF_IMPORT_ICS): bool, + } +) + +STEP_IMPORT_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ICS_FILE): selector.FileSelector( + config=selector.FileSelectorConfig(accept=".ics") + ), } ) @@ -22,6 +38,11 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Local Calendar.""" VERSION = 1 + MINOR_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 @@ -35,6 +56,23 @@ async def async_step_user( 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[CONF_IMPORT_ICS]: + self.data = user_input + return await self.async_step_import() return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input ) + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle optional iCal (.ics) import.""" + if user_input is None: + return self.async_show_form( + step_id="import", data_schema=STEP_IMPORT_DATA_SCHEMA + ) + + self.data[CONF_ICS_FILE] = user_input[CONF_ICS_FILE] + return self.async_create_entry( + title=self.data[CONF_CALENDAR_NAME], data=self.data + ) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index 1cfa774ab0ad7f..cd4636e0be76fa 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -3,4 +3,6 @@ DOMAIN = "local_calendar" CONF_CALENDAR_NAME = "calendar_name" +CONF_ICS_FILE = "ics_file" +CONF_IMPORT_ICS = "import_ics" CONF_STORAGE_KEY = "storage_key" diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 95c65089c79afb..ba9dde68c6cc2d 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -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"], diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index c6eb36ee88f0eb..9f5db6284d895f 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -5,8 +5,12 @@ "user": { "description": "Please choose a name for your new calendar", "data": { - "calendar_name": "Calendar Name" + "calendar_name": "Calendar Name", + "import_ics": "import events from an iCal file (.ics)?" } + }, + "import": { + "description": "You can import events in iCal format (.ics file)." } } } From 776ae53dfb49be2ad5c19cfdb7245a062b05c9aa Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Fri, 10 May 2024 00:47:15 +0000 Subject: [PATCH 02/12] feat: add unit test for import_ics step --- .../components/local_calendar/config_flow.py | 2 +- .../local_calendar/test_config_flow.py | 74 ++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index 030576a64bf8fe..b014cafb0fe624 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -56,7 +56,7 @@ async def async_step_user( 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[CONF_IMPORT_ICS]: + if user_input.get(CONF_IMPORT_ICS): self.data = user_input return await self.async_step_import() return self.async_create_entry( diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index c76fd9e283d050..5c4e66b9147ae9 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -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 random import getrandbits +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest from homeassistant import config_entries +from homeassistant.components import local_calendar from homeassistant.components.local_calendar.const import ( CONF_CALENDAR_NAME, + CONF_ICS_FILE, + CONF_IMPORT_ICS, CONF_STORAGE_KEY, DOMAIN, ) @@ -14,6 +24,32 @@ from tests.common import MockConfigEntry +@pytest.fixture +def mock_process_uploaded_file() -> Generator[MagicMock, None, None]: + """Mock upload ics file.""" + file_id_ics = str(uuid4()) + tmp_path = f"home-assistant-local_calendar-test-{getrandbits(10):03x}" + + @contextmanager + def _mock_process_uploaded_file(hass: HomeAssistant) -> Iterator[Path | None]: + with open(tmp_path / "test.ics", "wb") as icsfile: + icsfile.write(b"""BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + END:VCALENDAR + """) + yield tmp_path / "test.ics" + + with patch( + "homeassistant.components.local_calendar.process_uploaded_file", + side_effect=_mock_process_uploaded_file, + ) as mock_upload: + mock_upload.file_id = { + local_calendar.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( @@ -43,6 +79,42 @@ async def test_form(hass: HomeAssistant) -> None: 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 + + with patch( + "homeassistant.components.local_calendar.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT_ICS: True}, + ) + 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: From 10702f4f8ba2073df2e4359c1f74557eb8f5dc73 Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Wed, 29 May 2024 01:36:03 +0000 Subject: [PATCH 03/12] fix: remove unneeded test patch --- .../components/local_calendar/test_config_flow.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 5c4e66b9147ae9..595c3595d9d3b4 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -90,15 +90,11 @@ async def test_form_import_ics( assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with patch( - "homeassistant.components.local_calendar.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT_ICS: True}, - ) - assert result2["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT_ICS: True}, + ) + assert result2["type"] is FlowResultType.FORM with patch( "homeassistant.components.local_calendar.async_setup_entry", From 6195d98fd699b6412f9babb30e8f5545a9140876 Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Mon, 17 Jun 2024 01:31:19 +0000 Subject: [PATCH 04/12] feat: add helper for moving ics to storage location --- .../components/local_calendar/__init__.py | 19 +---------- .../components/local_calendar/config_flow.py | 5 ++- .../components/local_calendar/const.py | 1 + .../local_calendar/helpers/__init__.py | 1 + .../components/local_calendar/helpers/ics.py | 33 +++++++++++++++++++ .../local_calendar/test_config_flow.py | 30 +++++++++-------- 6 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/local_calendar/helpers/__init__.py create mode 100644 homeassistant/components/local_calendar/helpers/ics.py diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index c5d30bdd965ce0..baebeba4f26fed 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -5,17 +5,13 @@ import logging from pathlib import Path -from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError - -from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, CONF_ICS_FILE, CONF_STORAGE_KEY, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN, STORAGE_PATH from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -23,8 +19,6 @@ 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.""" @@ -46,17 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OSError as err: raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err - if entry.data.get(CONF_ICS_FILE): - with process_uploaded_file(hass, entry.data[CONF_ICS_FILE]) as ics_file: - ics = ics_file.read_text(encoding="utf8") - try: - IcsCalendarStream.calendar_from_ics(ics) - except CalendarParseError as err: - raise ConfigEntryNotReady( - "Failed to import events: Invalid ICS file" - ) from err - await store.async_store(ics) - hass.data[DOMAIN][entry.entry_id] = store await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index b014cafb0fe624..df3520264fd252 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -17,6 +17,7 @@ CONF_STORAGE_KEY, DOMAIN, ) +from .helpers.ics import save_uploaded_ics_file STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -72,7 +73,9 @@ async def async_step_import( step_id="import", data_schema=STEP_IMPORT_DATA_SCHEMA ) - self.data[CONF_ICS_FILE] = user_input[CONF_ICS_FILE] + await save_uploaded_ics_file( + self.hass, user_input[CONF_ICS_FILE], self.data[CONF_STORAGE_KEY] + ) return self.async_create_entry( title=self.data[CONF_CALENDAR_NAME], data=self.data ) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index cd4636e0be76fa..7bf26300fefdb6 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -6,3 +6,4 @@ CONF_ICS_FILE = "ics_file" CONF_IMPORT_ICS = "import_ics" CONF_STORAGE_KEY = "storage_key" +STORAGE_PATH = ".storage/local_calendar.{key}.ics" diff --git a/homeassistant/components/local_calendar/helpers/__init__.py b/homeassistant/components/local_calendar/helpers/__init__.py new file mode 100644 index 00000000000000..21c0a3deffb34f --- /dev/null +++ b/homeassistant/components/local_calendar/helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers for local_calendar.""" diff --git a/homeassistant/components/local_calendar/helpers/ics.py b/homeassistant/components/local_calendar/helpers/ics.py new file mode 100644 index 00000000000000..a3a24522f7128d --- /dev/null +++ b/homeassistant/components/local_calendar/helpers/ics.py @@ -0,0 +1,33 @@ +"""ics upload handler.""" + +from pathlib import Path +import shutil + +from ical.calendar_stream import CalendarStream +from ical.exceptions import CalendarParseError + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from ..const import STORAGE_PATH + + +async 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.""" + + def _process_upload(): + 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 ConfigEntryNotReady( + "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) + + return await hass.async_add_executor_job(_process_upload) diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 595c3595d9d3b4..8ec408f307c144 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -3,14 +3,12 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager from pathlib import Path -from random import getrandbits from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest from homeassistant import config_entries -from homeassistant.components import local_calendar from homeassistant.components.local_calendar.const import ( CONF_CALENDAR_NAME, CONF_ICS_FILE, @@ -25,27 +23,33 @@ @pytest.fixture -def mock_process_uploaded_file() -> Generator[MagicMock, None, None]: +def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, None]: """Mock upload ics file.""" file_id_ics = str(uuid4()) - tmp_path = f"home-assistant-local_calendar-test-{getrandbits(10):03x}" @contextmanager - def _mock_process_uploaded_file(hass: HomeAssistant) -> Iterator[Path | None]: - with open(tmp_path / "test.ics", "wb") as icsfile: + 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(b"""BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN END:VCALENDAR """) - yield tmp_path / "test.ics" - - with patch( - "homeassistant.components.local_calendar.process_uploaded_file", - side_effect=_mock_process_uploaded_file, - ) as mock_upload: + yield tmp_path / uploaded_file_id + + with ( + patch( + "homeassistant.components.local_calendar.helpers.ics.process_uploaded_file", + side_effect=_mock_process_uploaded_file, + ) as mock_upload, + patch( + "shutil.move", + ), + ): mock_upload.file_id = { - local_calendar.CONF_ICS_FILE: file_id_ics, + CONF_ICS_FILE: file_id_ics, } yield mock_upload From aad16fea9f3f935e6aa5f22d62d4d764dfc58ac6 Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Fri, 20 Sep 2024 19:05:08 -0500 Subject: [PATCH 05/12] move helper to config_flow --- .../components/local_calendar/config_flow.py | 30 +++++++++++++++++ .../local_calendar/helpers/__init__.py | 1 - .../components/local_calendar/helpers/ics.py | 33 ------------------- 3 files changed, 30 insertions(+), 34 deletions(-) delete mode 100644 homeassistant/components/local_calendar/helpers/__init__.py delete mode 100644 homeassistant/components/local_calendar/helpers/ics.py diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index df3520264fd252..c89238cb2c6d50 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -1,12 +1,20 @@ """Config flow for Local Calendar integration.""" from __future__ import annotations +from pathlib import Path +import shutil from typing import Any import voluptuous as vol +from ical.calendar_stream import CalendarStream +from ical.exceptions import CalendarParseError + +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 @@ -16,6 +24,7 @@ CONF_IMPORT_ICS, CONF_STORAGE_KEY, DOMAIN, + STORAGE_PATH, ) from .helpers.ics import save_uploaded_ics_file @@ -79,3 +88,24 @@ async def async_step_import( return self.async_create_entry( title=self.data[CONF_CALENDAR_NAME], data=self.data ) + + +async 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.""" + + def _process_upload(): + 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) + + return await hass.async_add_executor_job(_process_upload) diff --git a/homeassistant/components/local_calendar/helpers/__init__.py b/homeassistant/components/local_calendar/helpers/__init__.py deleted file mode 100644 index 21c0a3deffb34f..00000000000000 --- a/homeassistant/components/local_calendar/helpers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Helpers for local_calendar.""" diff --git a/homeassistant/components/local_calendar/helpers/ics.py b/homeassistant/components/local_calendar/helpers/ics.py deleted file mode 100644 index a3a24522f7128d..00000000000000 --- a/homeassistant/components/local_calendar/helpers/ics.py +++ /dev/null @@ -1,33 +0,0 @@ -"""ics upload handler.""" - -from pathlib import Path -import shutil - -from ical.calendar_stream import CalendarStream -from ical.exceptions import CalendarParseError - -from homeassistant.components.file_upload import process_uploaded_file -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from ..const import STORAGE_PATH - - -async 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.""" - - def _process_upload(): - 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 ConfigEntryNotReady( - "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) - - return await hass.async_add_executor_job(_process_upload) From 5529a5f22ff06e323ed5ced42b861b9258b9e3f6 Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Fri, 20 Sep 2024 19:34:42 -0500 Subject: [PATCH 06/12] ruff --- homeassistant/components/local_calendar/config_flow.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index c89238cb2c6d50..9a4fd07c8359e1 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -1,15 +1,14 @@ """Config flow for Local Calendar integration.""" from __future__ import annotations + from pathlib import Path import shutil - from typing import Any -import voluptuous as vol - 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 @@ -26,7 +25,6 @@ DOMAIN, STORAGE_PATH, ) -from .helpers.ics import save_uploaded_ics_file STEP_USER_DATA_SCHEMA = vol.Schema( { From aa122da42afa6bce177a185439e680a97a1e533a Mon Sep 17 00:00:00 2001 From: Raul Camacho Date: Sat, 21 Sep 2024 18:27:55 -0500 Subject: [PATCH 07/12] fix tests; add test for invalid ics content --- .../components/local_calendar/const.py | 1 + .../local_calendar/test_config_flow.py | 46 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index 7bf26300fefdb6..099bd2551a8cee 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -7,3 +7,4 @@ CONF_IMPORT_ICS = "import_ics" CONF_STORAGE_KEY = "storage_key" STORAGE_PATH = ".storage/local_calendar.{key}.ics" + \ No newline at end of file diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 8ec408f307c144..2ed4eaaf51d570 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -18,12 +18,23 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry @pytest.fixture -def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, None]: +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, None, None]: """Mock upload ics file.""" file_id_ics = str(uuid4()) @@ -32,16 +43,12 @@ 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(b"""BEGIN:VCALENDAR - VERSION:2.0 - PRODID:-//hacksw/handcal//NONSGML v1.0//EN - END:VCALENDAR - """) + icsfile.write(mock_ics_content) yield tmp_path / uploaded_file_id with ( patch( - "homeassistant.components.local_calendar.helpers.ics.process_uploaded_file", + "homeassistant.components.local_calendar.config_flow.process_uploaded_file", side_effect=_mock_process_uploaded_file, ) as mock_upload, patch( @@ -137,3 +144,28 @@ 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_ICS: True}, + ) + assert result2["type"] is FlowResultType.FORM + + file_id = mock_process_uploaded_file.file_id + with pytest.raises(HomeAssistantError): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ICS_FILE: file_id[CONF_ICS_FILE]}, + ) From 356598ebb5327f0610a84bd7282461f95870e9f5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 Sep 2024 18:13:33 -0700 Subject: [PATCH 08/12] Update homeassistant/components/local_calendar/config_flow.py --- homeassistant/components/local_calendar/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index 9a4fd07c8359e1..cfe00244104440 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -46,7 +46,6 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Local Calendar.""" VERSION = 1 - MINOR_VERSION = 1 def __init__(self) -> None: """Initialize the config flow.""" From a4551d2aec04586218e3989c70ba42ea946bc9a4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Sep 2024 01:05:24 +0000 Subject: [PATCH 09/12] Update import flow with radio button and improved text Signed-off-by: Allen Porter --- .../components/local_calendar/config_flow.py | 81 ++++++++++++------- .../components/local_calendar/const.py | 7 +- .../components/local_calendar/strings.json | 13 ++- .../local_calendar/test_config_flow.py | 27 ++++--- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index cfe00244104440..fef45f786f970e 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from pathlib import Path import shutil from typing import Any @@ -18,18 +19,30 @@ from homeassistant.util import slugify from .const import ( + ATTR_CREATE_EMPTY, + ATTR_IMPORT_ICS_FILE, CONF_CALENDAR_NAME, CONF_ICS_FILE, - CONF_IMPORT_ICS, + 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_ICS): bool, + vol.Optional(CONF_IMPORT, default=ATTR_CREATE_EMPTY): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + ATTR_CREATE_EMPTY, + ATTR_IMPORT_ICS_FILE, + ], + translation_key=CONF_IMPORT, + ) + ), } ) @@ -63,46 +76,52 @@ async def async_step_user( 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_ICS): + if user_input.get(CONF_IMPORT) == ATTR_IMPORT_ICS_FILE: self.data = user_input - return await self.async_step_import() + 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( + async def async_step_import_ics_file( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle optional iCal (.ics) import.""" - if user_input is None: - return self.async_show_form( - step_id="import", data_schema=STEP_IMPORT_DATA_SCHEMA - ) - - await save_uploaded_ics_file( - self.hass, user_input[CONF_ICS_FILE], self.data[CONF_STORAGE_KEY] - ) - return self.async_create_entry( - title=self.data[CONF_CALENDAR_NAME], data=self.data + 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, ) -async def save_uploaded_ics_file( +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.""" - def _process_upload(): - 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) - - return await hass.async_add_executor_job(_process_upload) + 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) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index 099bd2551a8cee..cbbd6c9308fde9 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -4,7 +4,10 @@ CONF_CALENDAR_NAME = "calendar_name" CONF_ICS_FILE = "ics_file" -CONF_IMPORT_ICS = "import_ics" +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" - \ No newline at end of file diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index 9f5db6284d895f..892b1870157668 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -6,12 +6,23 @@ "description": "Please choose a name for your new calendar", "data": { "calendar_name": "Calendar Name", - "import_ics": "import events from an iCal file (.ics)?" + "import": "Optional Import" } }, "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": "Import a iCalendar file (.ics)" + } } } } diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 2ed4eaaf51d570..1ed43f31320d86 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -10,15 +10,16 @@ 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_ICS, + CONF_IMPORT, CONF_STORAGE_KEY, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry @@ -34,7 +35,9 @@ def mock_ics_content(): @pytest.fixture -def mock_process_uploaded_file(tmp_path: Path, mock_ics_content: str) -> Generator[MagicMock, None, None]: +def mock_process_uploaded_file( + tmp_path: Path, mock_ics_content: str +) -> Generator[MagicMock, None, None]: """Mock upload ics file.""" file_id_ics = str(uuid4()) @@ -85,6 +88,7 @@ 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 @@ -103,7 +107,7 @@ async def test_form_import_ics( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT_ICS: True}, + {CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT: ATTR_IMPORT_ICS_FILE}, ) assert result2["type"] is FlowResultType.FORM @@ -145,6 +149,7 @@ 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, @@ -159,13 +164,15 @@ async def test_invalid_ics( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_CALENDAR_NAME: "My Calendar", CONF_IMPORT_ICS: True}, + {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 - with pytest.raises(HomeAssistantError): - await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_ICS_FILE: file_id[CONF_ICS_FILE]}, - ) + # with pytest.raises(HomeAssistantError): + 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"} From d3a74e48e85419f4be84025ea42b5520a6fca9df Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Sep 2024 01:26:05 +0000 Subject: [PATCH 10/12] Remove commented out code --- tests/components/local_calendar/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 1ed43f31320d86..02d5ab16381e7e 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -169,7 +169,6 @@ async def test_invalid_ics( assert result2["type"] is FlowResultType.FORM file_id = mock_process_uploaded_file.file_id - # with pytest.raises(HomeAssistantError): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_ICS_FILE: file_id[CONF_ICS_FILE]}, From c3ed87a3861f59983aee598526f56d1ac21af7bd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Sep 2024 01:27:51 +0000 Subject: [PATCH 11/12] Update with lint fixes --- tests/components/local_calendar/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 02d5ab16381e7e..cf37176a10f0fc 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -37,7 +37,7 @@ def mock_ics_content(): @pytest.fixture def mock_process_uploaded_file( tmp_path: Path, mock_ics_content: str -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock upload ics file.""" file_id_ics = str(uuid4()) From 433f9fe401598f700e55961556a4e0500386e7c3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 Sep 2024 21:02:19 -0700 Subject: [PATCH 12/12] Apply suggestions from code review Co-authored-by: Paulus Schoutsen --- homeassistant/components/local_calendar/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index 892b1870157668..387cfdcf092ce2 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -6,7 +6,7 @@ "description": "Please choose a name for your new calendar", "data": { "calendar_name": "Calendar Name", - "import": "Optional Import" + "import": "Starting Data" } }, "import": { @@ -20,8 +20,8 @@ "selector": { "import": { "options": { - "create_empty": "Create an Empty Calendar", - "import_ics_file": "Import a iCalendar file (.ics)" + "create_empty": "Create an empty calendar", + "import_ics_file": "Upload an iCalendar file (.ics)" } } }