diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index 430819c8f4c..50f525d9a68 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,18 +1,15 @@ """The Vulcan component.""" -import logging from aiohttp import ClientConnectorError -from vulcan import Account, Keystore, Vulcan -from vulcan._utils import VulcanAPIException +from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - PLATFORMS = ["calendar"] @@ -22,54 +19,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: keystore = Keystore.load(entry.data["keystore"]) account = Account.load(entry.data["account"]) - client = Vulcan(keystore, account) + client = Vulcan(keystore, account, async_get_clientsession(hass)) await client.select_student() students = await client.get_students() for student in students: if str(student.pupil.id) == str(entry.data["student_id"]): client.student = student break - except VulcanAPIException as err: - if str(err) == "The certificate is not authorized.": - _LOGGER.error( - "The certificate is not authorized, please authorize integration again" - ) - raise ConfigEntryAuthFailed from err - _LOGGER.error("Vulcan API error: %s", err) - return False + except UnauthorizedCertificateException as err: + raise ConfigEntryAuthFailed("The certificate is not authorized.") from err except ClientConnectorError as err: - if "connection_error" not in hass.data[DOMAIN]: - _LOGGER.error( - "Connection error - please check your internet connection: %s", err - ) - hass.data[DOMAIN]["connection_error"] = True - await client.close() - raise ConfigEntryNotReady from err - hass.data[DOMAIN]["students_number"] = len( - hass.config_entries.async_entries(DOMAIN) - ) + raise ConfigEntryNotReady( + f"Connection error - please check your internet connection: {err}" + ) from err hass.data[DOMAIN][entry.entry_id] = client - if not entry.update_listeners: - entry.add_update_listener(_async_update_options) - - for platform in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - await hass.data[DOMAIN][entry.entry_id].close() - for platform in PLATFORMS: - await hass.config_entries.async_forward_entry_unload(entry, platform) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) - return True - - -async def _async_update_options(hass, entry): - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index 2e9e79063f9..ac302940c72 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -1,24 +1,25 @@ """Support for Vulcan Calendar platform.""" -import copy +from __future__ import annotations + from datetime import date, datetime, timedelta import logging from aiohttp import ClientConnectorError -from vulcan._utils import VulcanAPIException +from vulcan import UnauthorizedCertificateException -from homeassistant.components.calendar import ENTITY_ID_FORMAT, CalendarEventDevice +from homeassistant.components.calendar import ( + ENTITY_ID_FORMAT, + CalendarEntity, + CalendarEvent, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template import DATE_STR_FORMAT -from homeassistant.util import Throttle, dt from . import DOMAIN -from .const import DEFAULT_SCAN_INTERVAL from .fetch_data import get_lessons, get_student_info _LOGGER = logging.getLogger(__name__) @@ -29,20 +30,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the calendar platform for event devices.""" - VulcanCalendarData.MIN_TIME_BETWEEN_UPDATES = timedelta( - minutes=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) + """Set up the calendar platform for entity.""" client = hass.data[DOMAIN][config_entry.entry_id] data = { "student_info": await get_student_info( client, config_entry.data.get("student_id") ), - "students_number": hass.data[DOMAIN]["students_number"], } async_add_entities( [ - VulcanCalendarEventDevice( + VulcanCalendarEntity( client, data, generate_entity_id( @@ -55,80 +52,33 @@ async def async_setup_entry( ) -class VulcanCalendarEventDevice(CalendarEventDevice): - """A calendar event device.""" +class VulcanCalendarEntity(CalendarEntity): + """A calendar entity.""" - def __init__(self, client, data, entity_id): - """Create the Calendar event device.""" + def __init__(self, client, data, entity_id) -> None: + """Create the Calendar entity.""" self.student_info = data["student_info"] - self.data = VulcanCalendarData( - client, - self.student_info, - self.hass, - ) - self._event = None + self._event: CalendarEvent | None = None + self.client = client self.entity_id = entity_id self._unique_id = f"vulcan_calendar_{self.student_info['id']}" - - if data["students_number"] == 1: - self._attr_name = "Vulcan calendar" - self.device_name = "Calendar" - else: - self._attr_name = f"Vulcan calendar - {self.student_info['full_name']}" - self.device_name = f"{self.student_info['full_name']}: Calendar" + self._attr_name = f"Vulcan calendar - {self.student_info['full_name']}" self._attr_unique_id = f"vulcan_calendar_{self.student_info['id']}" self._attr_device_info = { "identifiers": {(DOMAIN, f"calendar_{self.student_info['id']}")}, "entry_type": DeviceEntryType.SERVICE, - "name": self.device_name, + "name": f"{self.student_info['full_name']}: Calendar", "model": f"{self.student_info['full_name']} - {self.student_info['class']} {self.student_info['school']}", "manufacturer": "Uonet +", "configuration_url": f"https://uonetplus.vulcan.net.pl/{self.student_info['symbol']}", } @property - def event(self): + def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" return self._event - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) - - async def async_update(self): - """Update event data.""" - await self.data.async_update() - event = copy.deepcopy(self.data.event) - if event is None: - self._event = event - return - event["start"] = { - "dateTime": datetime.combine(event["date"], event["time"].from_) - .astimezone(dt.DEFAULT_TIME_ZONE) - .isoformat() - } - event["end"] = { - "dateTime": datetime.combine(event["date"], event["time"].to) - .astimezone(dt.DEFAULT_TIME_ZONE) - .isoformat() - } - self._event = event - - -class VulcanCalendarData: - """Class to utilize calendar service object to get next event.""" - - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=DEFAULT_SCAN_INTERVAL) - - def __init__(self, client, student_info, hass): - """Set up how we are going to search the Vulcan calendar.""" - self.client = client - self.event = None - self.hass = hass - self.student_info = student_info - self._available = True - - async def async_get_events(self, hass, start_date, end_date): + async def async_get_events(self, hass, start_date, end_date) -> list[CalendarEvent]: """Get all events in a specific time frame.""" try: events = await get_lessons( @@ -136,16 +86,12 @@ class VulcanCalendarData: date_from=start_date, date_to=end_date, ) - except VulcanAPIException as err: - if str(err) == "The certificate is not authorized.": - _LOGGER.error( - "The certificate is not authorized, please authorize integration again" - ) - raise ConfigEntryAuthFailed from err - _LOGGER.error("An API error has occurred: %s", err) - events = [] + except UnauthorizedCertificateException as err: + raise ConfigEntryAuthFailed( + "The certificate is not authorized, please authorize integration again" + ) from err except ClientConnectorError as err: - if self._available: + if self.available: _LOGGER.warning( "Connection error - please check your internet connection: %s", err ) @@ -153,37 +99,27 @@ class VulcanCalendarData: event_list = [] for item in events: - event = { - "uid": item["id"], - "start": { - "dateTime": datetime.combine( - item["date"], item["time"].from_ - ).strftime(DATE_STR_FORMAT) - }, - "end": { - "dateTime": datetime.combine( - item["date"], item["time"].to - ).strftime(DATE_STR_FORMAT) - }, - "summary": item["lesson"], - "location": item["room"], - "description": item["teacher"], - } + event = CalendarEvent( + start=datetime.combine(item["date"], item["time"].from_), + end=datetime.combine(item["date"], item["time"].to), + summary=item["lesson"], + location=item["room"], + description=item["teacher"], + ) event_list.append(event) return event_list - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): + async def async_update(self) -> None: """Get the latest data.""" try: events = await get_lessons(self.client) - if not self._available: + if not self.available: _LOGGER.info("Restored connection with API") - self._available = True + self._attr_available = True if events == []: events = await get_lessons( @@ -191,22 +127,18 @@ class VulcanCalendarData: date_to=date.today() + timedelta(days=7), ) if events == []: - self.event = None + self._event = None return - except VulcanAPIException as err: - if str(err) == "The certificate is not authorized.": - _LOGGER.error( - "The certificate is not authorized, please authorize integration again" - ) - raise ConfigEntryAuthFailed from err - _LOGGER.error("An API error has occurred: %s", err) - return + except UnauthorizedCertificateException as err: + raise ConfigEntryAuthFailed( + "The certificate is not authorized, please authorize integration again" + ) from err except ClientConnectorError as err: - if self._available: + if self.available: _LOGGER.warning( "Connection error - please check your internet connection: %s", err ) - self._available = False + self._attr_available = False return new_event = min( @@ -216,11 +148,10 @@ class VulcanCalendarData: abs(datetime.combine(d["date"], d["time"].to) - datetime.now()), ), ) - self.event = { - "uid": new_event["id"], - "date": new_event["date"], - "time": new_event["time"], - "summary": new_event["lesson"], - "location": new_event["room"], - "description": new_event["teacher"], - } + self._event = CalendarEvent( + start=datetime.combine(new_event["date"], new_event["time"].from_), + end=datetime.combine(new_event["date"], new_event["time"].to), + summary=new_event["lesson"], + location=new_event["room"], + description=new_event["teacher"], + ) diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index ef700560d73..09acb13ea27 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -3,16 +3,22 @@ import logging from aiohttp import ClientConnectionError import voluptuous as vol -from vulcan import Account, Keystore, Vulcan -from vulcan._utils import VulcanAPIException +from vulcan import ( + Account, + ExpiredTokenException, + InvalidPINException, + InvalidSymbolException, + InvalidTokenException, + Keystore, + UnauthorizedCertificateException, + Vulcan, +) from homeassistant import config_entries -from homeassistant.const import CONF_PIN, CONF_REGION, CONF_SCAN_INTERVAL, CONF_TOKEN -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import DOMAIN -from .const import DEFAULT_SCAN_INTERVAL from .register import register _LOGGER = logging.getLogger(__name__) @@ -29,11 +35,11 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return VulcanOptionsFlowHandler(config_entry) + def __init__(self): + """Initialize config flow.""" + self.account = None + self.keystore = None + self.students = None async def async_step_user(self, user_input=None): """Handle config flow.""" @@ -53,22 +59,14 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_REGION], user_input[CONF_PIN], ) - except VulcanAPIException as err: - if str(err) == "Invalid token!" or str(err) == "Invalid token.": - errors = {"base": "invalid_token"} - elif str(err) == "Expired token.": - errors = {"base": "expired_token"} - elif str(err) == "Invalid PIN.": - errors = {"base": "invalid_pin"} - else: - errors = {"base": "unknown"} - _LOGGER.error(err) - except RuntimeError as err: - if str(err) == "Internal Server Error (ArgumentException)": - errors = {"base": "invalid_symbol"} - else: - errors = {"base": "unknown"} - _LOGGER.error(err) + except InvalidSymbolException: + errors = {"base": "invalid_symbol"} + except InvalidTokenException: + errors = {"base": "invalid_token"} + except InvalidPINException: + errors = {"base": "invalid_pin"} + except ExpiredTokenException: + errors = {"base": "expired_token"} except ClientConnectionError as err: errors = {"base": "cannot_connect"} _LOGGER.error("Connection error: %s", err) @@ -78,12 +76,10 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not errors: account = credentials["account"] keystore = credentials["keystore"] - client = Vulcan(keystore, account) + client = Vulcan(keystore, account, async_get_clientsession(self.hass)) students = await client.get_students() - await client.close() if len(students) > 1: - # pylint:disable=attribute-defined-outside-init self.account = account self.keystore = keystore self.students = students @@ -109,10 +105,10 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_select_student(self, user_input=None): """Allow user to select student.""" errors = {} - students_list = {} + students = {} if self.students is not None: for student in self.students: - students_list[ + students[ str(student.pupil.id) ] = f"{student.pupil.first_name} {student.pupil.last_name}" if user_input is not None: @@ -120,7 +116,7 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(str(student_id)) self._abort_if_unique_id_configured() return self.async_create_entry( - title=students_list[student_id], + title=students[student_id], data={ "student_id": str(student_id), "keystore": self.keystore.as_dict, @@ -128,37 +124,30 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - data_schema = { - vol.Required( - "student", - ): vol.In(students_list), - } return self.async_show_form( step_id="select_student", - data_schema=vol.Schema(data_schema), + data_schema=vol.Schema({vol.Required("student"): vol.In(students)}), errors=errors, ) async def async_step_select_saved_credentials(self, user_input=None, errors=None): """Allow user to select saved credentials.""" - credentials_list = {} + + credentials = {} for entry in self.hass.config_entries.async_entries(DOMAIN): - credentials_list[entry.entry_id] = entry.data["account"]["UserName"] + credentials[entry.entry_id] = entry.data["account"]["UserName"] if user_input is not None: entry = self.hass.config_entries.async_get_entry(user_input["credentials"]) keystore = Keystore.load(entry.data["keystore"]) account = Account.load(entry.data["account"]) - client = Vulcan(keystore, account) + client = Vulcan(keystore, account, async_get_clientsession(self.hass)) try: students = await client.get_students() - except VulcanAPIException as err: - if str(err) == "The certificate is not authorized.": - return await self.async_step_auth( - errors={"base": "expired_credentials"} - ) - _LOGGER.error(err) - return await self.async_step_auth(errors={"base": "unknown"}) + except UnauthorizedCertificateException: + return await self.async_step_auth( + errors={"base": "expired_credentials"} + ) except ClientConnectionError as err: _LOGGER.error("Connection error: %s", err) return await self.async_step_select_saved_credentials( @@ -167,8 +156,6 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return await self.async_step_auth(errors={"base": "unknown"}) - finally: - await client.close() if len(students) == 1: student = students[0] await self.async_set_unique_id(str(student.pupil.id)) @@ -181,7 +168,6 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "account": account.as_dict, }, ) - # pylint:disable=attribute-defined-outside-init self.account = account self.keystore = keystore self.students = students @@ -190,7 +176,7 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema = { vol.Required( "credentials", - ): vol.In(credentials_list), + ): vol.In(credentials), } return self.async_show_form( step_id="select_saved_credentials", @@ -200,46 +186,46 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_add_next_config_entry(self, user_input=None): """Flow initialized when user is adding next entry of that integration.""" + existing_entries = [] for entry in self.hass.config_entries.async_entries(DOMAIN): existing_entries.append(entry) errors = {} + if user_input is not None: - if user_input["use_saved_credentials"]: - if len(existing_entries) == 1: - keystore = Keystore.load(existing_entries[0].data["keystore"]) - account = Account.load(existing_entries[0].data["account"]) - client = Vulcan(keystore, account) - students = await client.get_students() - await client.close() - new_students = [] - existing_entry_ids = [] - for entry in self.hass.config_entries.async_entries(DOMAIN): - existing_entry_ids.append(entry.data["student_id"]) - for student in students: - if str(student.pupil.id) not in existing_entry_ids: - new_students.append(student) - if not new_students: - return self.async_abort(reason="all_student_already_configured") - if len(new_students) == 1: - await self.async_set_unique_id(str(new_students[0].pupil.id)) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=f"{new_students[0].pupil.first_name} {new_students[0].pupil.last_name}", - data={ - "student_id": str(new_students[0].pupil.id), - "keystore": keystore.as_dict, - "account": account.as_dict, - }, - ) - # pylint:disable=attribute-defined-outside-init - self.account = account - self.keystore = keystore - self.students = new_students - return await self.async_step_select_student() + if not user_input["use_saved_credentials"]: + return await self.async_step_auth() + if len(existing_entries) > 1: return await self.async_step_select_saved_credentials() - return await self.async_step_auth() + keystore = Keystore.load(existing_entries[0].data["keystore"]) + account = Account.load(existing_entries[0].data["account"]) + client = Vulcan(keystore, account, async_get_clientsession(self.hass)) + students = await client.get_students() + new_students = [] + existing_entry_ids = [] + for entry in self.hass.config_entries.async_entries(DOMAIN): + existing_entry_ids.append(entry.data["student_id"]) + for student in students: + if str(student.pupil.id) not in existing_entry_ids: + new_students.append(student) + if not new_students: + return self.async_abort(reason="all_student_already_configured") + if len(new_students) == 1: + await self.async_set_unique_id(str(new_students[0].pupil.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{new_students[0].pupil.first_name} {new_students[0].pupil.last_name}", + data={ + "student_id": str(new_students[0].pupil.id), + "keystore": keystore.as_dict, + "account": account.as_dict, + }, + ) + self.account = account + self.keystore = keystore + self.students = new_students + return await self.async_step_select_student() data_schema = { vol.Required("use_saved_credentials", default=True): bool, @@ -251,6 +237,10 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) async def async_step_reauth(self, user_input=None): + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): """Reauthorize integration.""" errors = {} if user_input is not None: @@ -261,22 +251,14 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_REGION], user_input[CONF_PIN], ) - except VulcanAPIException as err: - if str(err) == "Invalid token!" or str(err) == "Invalid token.": - errors["base"] = "invalid_token" - elif str(err) == "Expired token.": - errors["base"] = "expired_token" - elif str(err) == "Invalid PIN.": - errors["base"] = "invalid_pin" - else: - errors["base"] = "unknown" - _LOGGER.error(err) - except RuntimeError as err: - if str(err) == "Internal Server Error (ArgumentException)": - errors["base"] = "invalid_symbol" - else: - errors["base"] = "unknown" - _LOGGER.error(err) + except InvalidSymbolException: + errors = {"base": "invalid_symbol"} + except InvalidTokenException: + errors = {"base": "invalid_token"} + except InvalidPINException: + errors = {"base": "invalid_pin"} + except ExpiredTokenException: + errors = {"base": "expired_token"} except ClientConnectionError as err: errors["base"] = "cannot_connect" _LOGGER.error("Connection error: %s", err) @@ -286,12 +268,12 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not errors: account = credentials["account"] keystore = credentials["keystore"] - client = Vulcan(keystore, account) + client = Vulcan(keystore, account, async_get_clientsession(self.hass)) students = await client.get_students() - await client.close() existing_entries = [] for entry in self.hass.config_entries.async_entries(DOMAIN): existing_entries.append(entry) + matching_entries = False for student in students: for entry in existing_entries: if str(student.pupil.id) == str(entry.data["student_id"]): @@ -305,38 +287,13 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) await self.hass.config_entries.async_reload(entry.entry_id) + matching_entries = True + if not matching_entries: + return self.async_abort(reason="no_matching_entries") return self.async_abort(reason="reauth_successful") return self.async_show_form( - step_id="reauth", + step_id="reauth_confirm", data_schema=vol.Schema(LOGIN_SCHEMA), errors=errors, ) - - -class VulcanOptionsFlowHandler(config_entries.OptionsFlow): - """Config flow options for Uonet+ Vulcan.""" - - def __init__(self, config_entry): - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None): - """Manage the options.""" - errors = {} - - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): cv.positive_int, - } - - return self.async_show_form( - step_id="init", data_schema=vol.Schema(options), errors=errors - ) diff --git a/homeassistant/components/vulcan/const.py b/homeassistant/components/vulcan/const.py index 938cc4df8cd..4f17d43c342 100644 --- a/homeassistant/components/vulcan/const.py +++ b/homeassistant/components/vulcan/const.py @@ -1,4 +1,3 @@ """Constants for the Vulcan integration.""" DOMAIN = "vulcan" -DEFAULT_SCAN_INTERVAL = 5 diff --git a/homeassistant/components/vulcan/fetch_data.py b/homeassistant/components/vulcan/fetch_data.py index 04da8d125d7..c706bfa805f 100644 --- a/homeassistant/components/vulcan/fetch_data.py +++ b/homeassistant/components/vulcan/fetch_data.py @@ -94,4 +94,5 @@ async def get_student_info(client, student_id): student_info["class"] = student.class_ student_info["school"] = student.school.name student_info["symbol"] = student.symbol + break return student_info diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index 6449f07a3fb..b7f1f5fe4d6 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -3,9 +3,8 @@ "name": "Uonet+ Vulcan", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vulcan", - "requirements": ["vulcan-api==2.0.3"], - "dependencies": [], + "requirements": ["vulcan-api==2.1.1"], "codeowners": ["@Antoni-Czaplicki"], "iot_class": "cloud_polling", - "quality_scale": "platinum" + "quality_scale": "silver" } diff --git a/homeassistant/components/vulcan/register.py b/homeassistant/components/vulcan/register.py index 802805d1db8..67cceb8d7b8 100644 --- a/homeassistant/components/vulcan/register.py +++ b/homeassistant/components/vulcan/register.py @@ -1,13 +1,10 @@ """Support for register Vulcan account.""" -from functools import partial from vulcan import Account, Keystore async def register(hass, token, symbol, pin): """Register integration and save credentials.""" - keystore = await hass.async_add_executor_job( - partial(Keystore.create, device_model="Home Assistant") - ) + keystore = await Keystore.create(device_model="Home Assistant") account = await Account.register(keystore, token, symbol, pin) return {"account": account, "keystore": keystore} diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index abb3dce7c7f..bb9e1d4d848 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", - "reauth_successful": "Reauth successful" + "reauth_successful": "Reauth successful", + "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." }, "error": { "unknown": "Unknown error occurred", @@ -23,7 +24,7 @@ "pin": "Pin" } }, - "reauth": { + "reauth_confirm": { "description": "Login to your Vulcan Account using mobile app registration page.", "data": { "token": "Token", @@ -50,20 +51,5 @@ } } } - }, - "options": { - "error": { - "error": "Error occurred" - }, - "step": { - "init": { - "data": { - "message_notify": "Show notifications when new message received", - "attendance_notify": "Show notifications about the latest attendance entries", - "grade_notify": "Show notifications about the latest grades", - "scan_interval": "Update interval (in minutes)" - } - } - } } } diff --git a/requirements_all.txt b/requirements_all.txt index 7faa9dad813..53712397c4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2396,7 +2396,7 @@ vsure==1.7.3 vtjp==0.1.14 # homeassistant.components.vulcan -vulcan-api==2.0.3 +vulcan-api==2.1.1 # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89192ad4f2f..78f7da53d35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1563,7 +1563,7 @@ vilfo-api-client==0.3.2 vsure==1.7.3 # homeassistant.components.vulcan -vulcan-api==2.0.3 +vulcan-api==2.1.1 # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py index 91f0dd769c8..20f030bb99f 100644 --- a/tests/components/vulcan/test_config_flow.py +++ b/tests/components/vulcan/test_config_flow.py @@ -3,17 +3,20 @@ import json from unittest import mock from unittest.mock import patch -from vulcan import Account +from vulcan import ( + Account, + ExpiredTokenException, + InvalidPINException, + InvalidSymbolException, + InvalidTokenException, + UnauthorizedCertificateException, +) from vulcan.model import Student from homeassistant import config_entries, data_entry_flow from homeassistant.components.vulcan import config_flow, const, register -from homeassistant.components.vulcan.config_flow import ( - ClientConnectionError, - Keystore, - VulcanAPIException, -) -from homeassistant.const import CONF_PIN, CONF_REGION, CONF_SCAN_INTERVAL, CONF_TOKEN +from homeassistant.components.vulcan.config_flow import ClientConnectionError, Keystore +from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN from tests.common import MockConfigEntry, load_fixture @@ -56,13 +59,20 @@ async def test_config_flow_auth_success( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "auth" assert result["errors"] is None - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) + + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 1 @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @@ -96,13 +106,18 @@ async def test_config_flow_auth_success_with_multiple_students( assert result["step_id"] == "select_student" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"student": "0"}, - ) + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"student": "0"}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 1 @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @@ -120,14 +135,53 @@ async def test_config_flow_reauth_success( MockConfigEntry( domain=const.DOMAIN, unique_id="0", - data={"student_id": "0", "login": "example@example.com"}, + data={"student_id": "0"}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_REAUTH} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +async def test_config_flow_reauth_without_matching_entries( + mock_account, mock_keystore, mock_student, hass +): + """Test a aborted config flow reauth caused by leak of matching entries.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + MockConfigEntry( + domain=const.DOMAIN, + unique_id="0", + data={"student_id": "1"}, + ).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( @@ -136,7 +190,7 @@ async def test_config_flow_reauth_success( ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "no_matching_entries" @mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") @@ -149,11 +203,11 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) const.DOMAIN, context={"source": config_entries.SOURCE_REAUTH} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Invalid token."), + side_effect=InvalidTokenException, ): result = await hass.config_entries.flow.async_configure( @@ -162,12 +216,12 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "invalid_token"} with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Expired token."), + side_effect=ExpiredTokenException, ): result = await hass.config_entries.flow.async_configure( @@ -176,12 +230,12 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "expired_token"} with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Invalid PIN."), + side_effect=InvalidPINException, ): result = await hass.config_entries.flow.async_configure( @@ -190,12 +244,12 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "invalid_pin"} with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Unknown error"), + side_effect=InvalidSymbolException, ): result = await hass.config_entries.flow.async_configure( @@ -204,37 +258,9 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" - assert result["errors"] == {"base": "unknown"} - - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=RuntimeError("Internal Server Error (ArgumentException)"), - ): - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "invalid_symbol"} - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=RuntimeError("Unknown error"), - ): - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" - assert result["errors"] == {"base": "unknown"} - with patch( "homeassistant.components.vulcan.config_flow.Account.register", side_effect=ClientConnectionError, @@ -246,7 +272,7 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "cannot_connect"} with patch( @@ -260,7 +286,7 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "unknown"} @@ -297,13 +323,18 @@ async def test_multiple_config_entries(mock_account, mock_keystore, mock_student assert result["step_id"] == "auth" assert result["errors"] is None - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 2 @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @@ -326,13 +357,18 @@ async def test_multiple_config_entries_using_saved_credentials(mock_student, has assert result["step_id"] == "add_next_config_entry" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 2 @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @@ -364,13 +400,18 @@ async def test_multiple_config_entries_using_saved_credentials_2(mock_student, h assert result["step_id"] == "select_student" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"student": "0"}, - ) + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"student": "0"}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 2 @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @@ -410,13 +451,18 @@ async def test_multiple_config_entries_using_saved_credentials_3(mock_student, h assert result["step_id"] == "select_saved_credentials" assert result["errors"] is None - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 3 @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @@ -465,13 +511,18 @@ async def test_multiple_config_entries_using_saved_credentials_4(mock_student, h assert result["step_id"] == "select_student" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"student": "0"}, - ) + with patch( + "homeassistant.components.vulcan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"student": "0"}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Jan Kowalski" + assert len(mock_setup_entry.mock_calls) == 3 async def test_multiple_config_entries_without_valid_saved_credentials(hass): @@ -504,7 +555,7 @@ async def test_multiple_config_entries_without_valid_saved_credentials(hass): ) with patch( "homeassistant.components.vulcan.config_flow.Vulcan.get_students", - side_effect=VulcanAPIException("The certificate is not authorized."), + side_effect=UnauthorizedCertificateException, ): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "select_saved_credentials" @@ -614,54 +665,6 @@ async def test_multiple_config_entries_using_saved_credentials_with_unknown_erro assert result["errors"] == {"base": "unknown"} -async def test_multiple_config_entries_using_saved_credentials_with_unknown_api_error( - hass, -): - """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" - MockConfigEntry( - entry_id="456", - domain=const.DOMAIN, - unique_id="234567", - data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) - | {"student_id": "456"}, - ).add_to_hass(hass) - MockConfigEntry( - entry_id="123", - domain=const.DOMAIN, - unique_id="123456", - data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - with patch( - "homeassistant.components.vulcan.config_flow.Vulcan.get_students", - side_effect=VulcanAPIException("Unknown error"), - ): - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "unknown"} - - @mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") @mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") @mock.patch("homeassistant.components.vulcan.config_flow.Account.register") @@ -704,7 +707,7 @@ async def test_config_flow_auth_invalid_token(mock_keystore, hass): mock_keystore.return_value = fake_keystore with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Invalid token."), + side_effect=InvalidTokenException, ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -730,7 +733,7 @@ async def test_config_flow_auth_invalid_region(mock_keystore, hass): mock_keystore.return_value = fake_keystore with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=RuntimeError("Internal Server Error (ArgumentException)"), + side_effect=InvalidSymbolException, ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -756,7 +759,7 @@ async def test_config_flow_auth_invalid_pin(mock_keystore, hass): mock_keystore.return_value = fake_keystore with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Invalid PIN."), + side_effect=InvalidPINException, ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -782,7 +785,7 @@ async def test_config_flow_auth_expired_token(mock_keystore, hass): mock_keystore.return_value = fake_keystore with patch( "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Expired token."), + side_effect=ExpiredTokenException, ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -802,58 +805,6 @@ async def test_config_flow_auth_expired_token(mock_keystore, hass): assert result["errors"] == {"base": "expired_token"} -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_api_unknown_error(mock_keystore, hass): - """Test a config flow with unknown API error.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=VulcanAPIException("Unknown error"), - ): - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "unknown"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_api_unknown_runtime_error(mock_keystore, hass): - """Test a config flow with runtime error.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=RuntimeError("Unknown error"), - ): - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "unknown"} - - @mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") async def test_config_flow_auth_connection_error(mock_keystore, hass): """Test a config flow with connection error.""" @@ -904,32 +855,3 @@ async def test_config_flow_auth_unknown_error(mock_keystore, hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "auth" assert result["errors"] == {"base": "unknown"} - - -@mock.patch("homeassistant.components.vulcan.Vulcan.get_students") -async def test_options_flow(mock_student, hass): - """Test config flow options.""" - mock_student.return_value = [ - Student.load(load_fixture("fake_student_1.json", "vulcan")) - ] - config_entry = MockConfigEntry( - domain=const.DOMAIN, - unique_id="0", - data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), - ) - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_SCAN_INTERVAL: 2137} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == {CONF_SCAN_INTERVAL: 2137}