mirror of
https://github.com/home-assistant/core.git
synced 2026-04-17 15:44:52 +01:00
Add config flow to touchline integration (#165790)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1 +1,46 @@
|
||||
"""The touchline component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pytouchline_extended import PyTouchline
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .data import TouchlineConfigEntry, TouchlineData
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TouchlineConfigEntry) -> bool:
|
||||
"""Set up touchline from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
_LOGGER.debug(
|
||||
"Touchline entry id: %s Unique id: %s", entry.entry_id, entry.unique_id
|
||||
)
|
||||
py_touchline = PyTouchline(url=host)
|
||||
try:
|
||||
number_of_devices = int(
|
||||
await hass.async_add_executor_job(py_touchline.get_number_of_devices)
|
||||
)
|
||||
except (OSError, ConnectionError, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while connecting to Touchline controller at {host}"
|
||||
) from err
|
||||
|
||||
entry.runtime_data = TouchlineData(
|
||||
touchline=py_touchline, number_of_devices=number_of_devices
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a touchline config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -13,12 +13,20 @@ from homeassistant.components.climate import (
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import TouchlineConfigEntry
|
||||
|
||||
|
||||
class PresetMode(NamedTuple):
|
||||
"""Settings for preset mode."""
|
||||
@@ -44,22 +52,73 @@ TOUCHLINE_HA_PRESETS = {
|
||||
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string})
|
||||
|
||||
|
||||
def setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
entry: TouchlineConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Touchline devices."""
|
||||
"""Set up Touchline devices from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
|
||||
host = config[CONF_HOST]
|
||||
py_touchline = PyTouchline(url=host)
|
||||
number_of_devices = int(py_touchline.get_number_of_devices())
|
||||
devices = [
|
||||
Touchline(PyTouchline(id=device_id, url=host))
|
||||
for device_id in range(number_of_devices)
|
||||
for device_id in range(entry.runtime_data.number_of_devices)
|
||||
]
|
||||
add_entities(devices, True)
|
||||
async_add_entities(devices, True)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Touchline devices from YAML.
|
||||
|
||||
Touchline now uses config entries. If an entry exists in configuration.yaml,
|
||||
the import flow will attempt to import it and create a config entry.
|
||||
"""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={CONF_HOST: config[CONF_HOST]},
|
||||
)
|
||||
if (
|
||||
result.get("type") is FlowResultType.ABORT
|
||||
and result.get("reason") != "already_configured"
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result.get('reason')}",
|
||||
breaks_in_ha_version="2026.10.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Roth Touchline",
|
||||
},
|
||||
)
|
||||
return
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2026.10.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Roth Touchline",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Touchline(ClimateEntity):
|
||||
|
||||
110
homeassistant/components/touchline/config_flow.py
Normal file
110
homeassistant/components/touchline/config_flow.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Config flow for Roth Touchline integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pytouchline_extended import PyTouchline
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def fetch_unique_id(host: str) -> str:
|
||||
"""Fetch the unique id for the Touchline controller."""
|
||||
client = PyTouchline(url=host)
|
||||
client.get_number_of_devices()
|
||||
client.update()
|
||||
return str(client.get_controller_id())
|
||||
|
||||
|
||||
async def _async_validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str:
|
||||
"""Validate the user input allows us to connect."""
|
||||
host = data[CONF_HOST]
|
||||
|
||||
try:
|
||||
return await hass.async_add_executor_job(fetch_unique_id, host)
|
||||
except (OSError, ConnectionError, TimeoutError) as err:
|
||||
_LOGGER.debug(
|
||||
"Error while connecting to Touchline controller at %s", host, exc_info=True
|
||||
)
|
||||
raise CannotConnect from err
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class TouchlineConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Roth Touchline."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
|
||||
try:
|
||||
unique_id = await _async_validate_input(self.hass, user_input)
|
||||
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import from YAML."""
|
||||
|
||||
# Abort if an entry with the same host already exists, to avoid duplicates
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
|
||||
# Validate the user input allows us to connect
|
||||
try:
|
||||
unique_id = await _async_validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception: # noqa: BLE001
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST],
|
||||
data=user_input,
|
||||
)
|
||||
3
homeassistant/components/touchline/const.py
Normal file
3
homeassistant/components/touchline/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Roth Touchline integration."""
|
||||
|
||||
DOMAIN = "touchline"
|
||||
19
homeassistant/components/touchline/data.py
Normal file
19
homeassistant/components/touchline/data.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Custom types for Touchline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pytouchline_extended import PyTouchline
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
type TouchlineConfigEntry = ConfigEntry[TouchlineData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TouchlineData:
|
||||
"""Runtime data for the Touchline integration."""
|
||||
|
||||
touchline: PyTouchline
|
||||
number_of_devices: int
|
||||
@@ -2,9 +2,10 @@
|
||||
"domain": "touchline",
|
||||
"name": "Roth Touchline",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/touchline",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pytouchline"],
|
||||
"quality_scale": "legacy",
|
||||
"loggers": ["pytouchline_extended"],
|
||||
"requirements": ["pytouchline_extended==0.4.5"]
|
||||
}
|
||||
|
||||
31
homeassistant/components/touchline/strings.json
Normal file
31
homeassistant/components/touchline/strings.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Roth Touchline controller."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Home Assistant could not connect to the Roth Touchline controller while importing your YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.",
|
||||
"title": "Roth Touchline YAML configuration import failed"
|
||||
},
|
||||
"deprecated_yaml_import_issue_unknown": {
|
||||
"description": "An unknown error occurred while importing your Roth Touchline YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.",
|
||||
"title": "Roth Touchline YAML configuration import issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -733,6 +733,7 @@ FLOWS = {
|
||||
"tomorrowio",
|
||||
"toon",
|
||||
"totalconnect",
|
||||
"touchline",
|
||||
"touchline_sl",
|
||||
"tplink",
|
||||
"tplink_omada",
|
||||
|
||||
@@ -5870,7 +5870,7 @@
|
||||
"integrations": {
|
||||
"touchline": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "Roth Touchline"
|
||||
},
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -2291,6 +2291,9 @@ pytile==2024.12.0
|
||||
# homeassistant.components.tomorrowio
|
||||
pytomorrowio==0.3.6
|
||||
|
||||
# homeassistant.components.touchline
|
||||
pytouchline_extended==0.4.5
|
||||
|
||||
# homeassistant.components.touchline_sl
|
||||
pytouchlinesl==0.6.0
|
||||
|
||||
|
||||
1
tests/components/touchline/__init__.py
Normal file
1
tests/components/touchline/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Roth Touchline integration."""
|
||||
28
tests/components/touchline/conftest.py
Normal file
28
tests/components/touchline/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Fixtures for the Touchline config flow tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_pytouchline() -> MagicMock:
|
||||
"""Patch PyTouchline used by the config flow."""
|
||||
with patch("homeassistant.components.touchline.config_flow.PyTouchline") as cls:
|
||||
instance = cls.return_value
|
||||
instance.get_number_of_devices.return_value = 1
|
||||
instance.update.return_value = None
|
||||
instance.get_controller_id.return_value = "controller-1"
|
||||
yield instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.touchline.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
157
tests/components/touchline/test_config_flow.py
Normal file
157
tests/components/touchline/test_config_flow.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Test the Touchline config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.touchline.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_HOST = "1.2.3.4"
|
||||
TEST_DATA = {CONF_HOST: TEST_HOST}
|
||||
TEST_UNIQUE_ID = "controller-1"
|
||||
|
||||
|
||||
async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_HOST
|
||||
assert result["data"] == TEST_DATA
|
||||
assert result["result"].unique_id == TEST_UNIQUE_ID
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistant, mock_pytouchline) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
# The config flow runs validation in a thread executor.
|
||||
# If `get_number_of_devices` fails, validation fails too.
|
||||
mock_pytouchline.get_number_of_devices.side_effect = ConnectionError
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
# "Fix" the problem, and try again.
|
||||
mock_pytouchline.get_number_of_devices.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=TEST_DATA,
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_HOST
|
||||
assert result["data"] == TEST_DATA
|
||||
assert result["result"].unique_id == TEST_UNIQUE_ID
|
||||
|
||||
|
||||
async def test_already_configured_by_host(hass: HomeAssistant) -> None:
|
||||
"""Test abort when host is already configured."""
|
||||
MockConfigEntry(domain=DOMAIN, data=TEST_DATA).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_already_configured_by_unique_id(hass: HomeAssistant) -> None:
|
||||
"""Test abort when unique id is already configured."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "5.6.7.8"},
|
||||
unique_id=TEST_UNIQUE_ID,
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_import_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test YAML import creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_HOST
|
||||
assert result["data"] == TEST_DATA
|
||||
assert result["result"].unique_id == TEST_UNIQUE_ID
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import_cannot_connect(hass: HomeAssistant, mock_pytouchline) -> None:
|
||||
"""Test YAML import aborts when it cannot connect."""
|
||||
mock_pytouchline.get_number_of_devices.side_effect = ConnectionError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_import_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test YAML import aborts when already configured."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "5.6.7.8"},
|
||||
unique_id=TEST_UNIQUE_ID,
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=TEST_DATA,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
Reference in New Issue
Block a user