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 Local Calendar ics events import on calendar creation #117955

Merged
Merged
4 changes: 1 addition & 3 deletions homeassistant/components/local_calendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@
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__)


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."""
Expand Down
71 changes: 70 additions & 1 deletion homeassistant/components/local_calendar/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,42 @@

from __future__ import annotations

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 (
CONF_CALENDAR_NAME,
CONF_ICS_FILE,
CONF_IMPORT_ICS,
CONF_STORAGE_KEY,
DOMAIN,
STORAGE_PATH,
)

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")
),
}
)

Expand All @@ -22,6 +46,11 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Local Calendar."""

VERSION = 1
MINOR_VERSION = 1
allenporter marked this conversation as resolved.
Show resolved Hide resolved

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
Expand All @@ -35,6 +64,46 @@ 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):
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
)

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
)


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)
4 changes: 4 additions & 0 deletions homeassistant/components/local_calendar/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
DOMAIN = "local_calendar"

CONF_CALENDAR_NAME = "calendar_name"
CONF_ICS_FILE = "ics_file"
CONF_IMPORT_ICS = "import_ics"
CONF_STORAGE_KEY = "storage_key"
STORAGE_PATH = ".storage/local_calendar.{key}.ics"

1 change: 1 addition & 0 deletions homeassistant/components/local_calendar/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/local_calendar/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)?"
allenporter marked this conversation as resolved.
Show resolved Hide resolved
}
},
"import": {
"description": "You can import events in iCal format (.ics file)."
}
}
}
Expand Down
106 changes: 105 additions & 1 deletion tests/components/local_calendar/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,66 @@
"""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 (
CONF_CALENDAR_NAME,
CONF_ICS_FILE,
CONF_IMPORT_ICS,
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


@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, None, None]:

Check warning on line 37 in tests/components/local_calendar/test_config_flow.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

R6007: Type `Generator[MagicMock, None, None]` has unnecessary default type args. Change it to `Generator[MagicMock]`. (unnecessary-default-type-args)

Check warning on line 37 in tests/components/local_calendar/test_config_flow.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

R6007: Type `Generator[MagicMock, None, None]` has unnecessary default type args. Change it to `Generator[MagicMock]`. (unnecessary-default-type-args)
"""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(
Expand Down Expand Up @@ -43,6 +90,38 @@
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_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:
Expand All @@ -65,3 +144,28 @@

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):
allenporter marked this conversation as resolved.
Show resolved Hide resolved
await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ICS_FILE: file_id[CONF_ICS_FILE]},
)
Loading