mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-12 21:08:13 +00:00
feat(prediction): add native pvnode.com PV forecast provider
Add PVForecastPVNode, a native 15-minute PV forecast provider for the
pvnode.com V2 API, giving operators another forecast source to choose from
alongside Akkudoktor, VRM and Import.
Two request modes, selected by configuration:
* site_id set -> GET /v2/forecast/{site_id} (a saved, possibly calibrated
site managed on pvnode.com — the operator enters site id + API key)
* site_id empty -> POST /v2/forecast/inline (geometry sent inline from the
configured pvforecast.planes; no web-app setup required)
V2 response timestamps are site-local wall-clock accompanied by an IANA
timezone; they are resolved to absolute instants before EOS resamples them.
Nullable pv_power (e.g. at night) is treated as 0 W so the optimizer's linear
resampling does not interpolate phantom production across the night.
Registered in pvforecast.py (provider settings + id list) and prediction.py
(singleton, factory list, container union). Adds tests covering timezone
resolution, null handling, both request modes and HTTP-error propagation.
This commit is contained in:
@@ -46,6 +46,7 @@ from akkudoktoreos.prediction.loadvrm import LoadVrm
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||
@@ -84,6 +85,7 @@ loadforecast_vrm = LoadVrm()
|
||||
loadforecast_import = LoadImport()
|
||||
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
||||
pvforecast_vrm = PVForecastVrm()
|
||||
pvforecast_pvnode = PVForecastPVNode()
|
||||
pvforecast_import = PVForecastImport()
|
||||
weather_brightsky = WeatherBrightSky()
|
||||
weather_clearoutside = WeatherClearOutside()
|
||||
@@ -105,6 +107,7 @@ def prediction_providers() -> list[
|
||||
LoadImport,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastVrm,
|
||||
PVForecastPVNode,
|
||||
PVForecastImport,
|
||||
WeatherBrightSky,
|
||||
WeatherClearOutside,
|
||||
@@ -129,6 +132,7 @@ def prediction_providers() -> list[
|
||||
loadforecast_import, \
|
||||
pvforecast_akkudoktor, \
|
||||
pvforecast_vrm, \
|
||||
pvforecast_pvnode, \
|
||||
pvforecast_import, \
|
||||
weather_brightsky, \
|
||||
weather_clearoutside, \
|
||||
@@ -149,6 +153,7 @@ def prediction_providers() -> list[
|
||||
loadforecast_import,
|
||||
pvforecast_akkudoktor,
|
||||
pvforecast_vrm,
|
||||
pvforecast_pvnode,
|
||||
pvforecast_import,
|
||||
weather_brightsky,
|
||||
weather_clearoutside,
|
||||
@@ -174,6 +179,7 @@ class Prediction(PredictionContainer):
|
||||
LoadImport,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastVrm,
|
||||
PVForecastPVNode,
|
||||
PVForecastImport,
|
||||
WeatherBrightSky,
|
||||
WeatherClearOutside,
|
||||
|
||||
@@ -8,6 +8,7 @@ from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import get_prediction
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings
|
||||
|
||||
|
||||
@@ -18,7 +19,7 @@ def pvforecast_provider_ids() -> list[str]:
|
||||
except:
|
||||
# Prediction may not be initialized
|
||||
# Return at least provider used in example
|
||||
return ["PVForecastAkkudoktor", "PVForecastImport", "PVForecastVrm"]
|
||||
return ["PVForecastAkkudoktor", "PVForecastImport", "PVForecastVrm", "PVForecastPVNode"]
|
||||
|
||||
return [
|
||||
provider.provider_id()
|
||||
@@ -179,6 +180,10 @@ class PVForecastCommonProviderSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
json_schema_extra={"description": "PVForecastVrm settings", "examples": [None]},
|
||||
)
|
||||
PVForecastPVNode: Optional[PVForecastPVNodeCommonSettings] = Field(
|
||||
default=None,
|
||||
json_schema_extra={"description": "PVForecastPVNode settings", "examples": [None]},
|
||||
)
|
||||
|
||||
|
||||
class PVForecastCommonSettings(SettingsBaseModel):
|
||||
|
||||
241
src/akkudoktoreos/prediction/pvforecastpvnode.py
Normal file
241
src/akkudoktoreos/prediction/pvforecastpvnode.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Retrieves PV forecast data from the pvnode.com V2 API.
|
||||
|
||||
pvnode.com delivers native 15-minute PV power forecasts. Two request modes,
|
||||
decided by configuration:
|
||||
|
||||
* ``site_id`` set -> ``GET /v2/forecast/{site_id}`` — a saved (and possibly
|
||||
calibrated) site managed in the pvnode web app. This is the operator's primary
|
||||
path: register the plant once on pvnode.com, then enter the site id + API key.
|
||||
* ``site_id`` empty -> ``POST /v2/forecast/inline`` — geometry is sent inline from
|
||||
the configured ``pvforecast.planes`` (works without any web-app setup).
|
||||
|
||||
V2 response timestamps are SITE-LOCAL wall-clock (no offset) accompanied by an
|
||||
IANA ``timezone`` field. We resolve them to absolute instants here so the rest of
|
||||
EOS keeps working in its own timezone. ``pv_power`` is nullable (e.g. at night) —
|
||||
null is treated as 0 W so the optimizer's linear resampling does not interpolate
|
||||
phantom production across the night.
|
||||
|
||||
Notes:
|
||||
- Requires ``pvforecast.provider_settings.PVForecastPVNode.api_key`` (Bearer auth).
|
||||
- API: https://api.pvnode.com/v2 (15-minute resolution).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
import pendulum
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
PVNODE_BASE = "https://api.pvnode.com/v2"
|
||||
|
||||
_TZ_SUFFIX = re.compile(r"([zZ]|[+-]\d\d:?\d\d)$")
|
||||
|
||||
|
||||
class PVForecastPVNodeCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for the pvnode.com PV forecast provider."""
|
||||
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
json_schema_extra={
|
||||
"description": "pvnode.com API key (Bearer auth). Required.",
|
||||
"examples": ["pvn_live_xxxxxxxxxxxxxxxx"],
|
||||
},
|
||||
)
|
||||
site_id: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the "
|
||||
"saved (possibly calibrated) site is used. Leave empty to send the "
|
||||
"configured pvforecast.planes inline instead."
|
||||
),
|
||||
"examples": ["abcd-1234"],
|
||||
},
|
||||
)
|
||||
forecast_days: int = Field(
|
||||
default=2,
|
||||
ge=1,
|
||||
le=7,
|
||||
json_schema_extra={
|
||||
"description": "Forecast horizon in days (1-7, capped by the pvnode plan).",
|
||||
"examples": [2],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PVForecastPVNode(PVForecastProvider):
|
||||
"""Fetch and process PV forecast data from the pvnode.com V2 API."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the PV-Forecast-Provider."""
|
||||
return "PVForecastPVNode"
|
||||
|
||||
@property
|
||||
def _settings(self) -> PVForecastPVNodeCommonSettings:
|
||||
settings = self.config.pvforecast.provider_settings.PVForecastPVNode
|
||||
if settings is None:
|
||||
settings = PVForecastPVNodeCommonSettings()
|
||||
return settings
|
||||
|
||||
def _to_utc_datetime(self, local_ts: Any, iana_tz: Optional[str]) -> Any:
|
||||
"""Resolve a pvnode V2 wall-clock timestamp to a timezone-aware datetime.
|
||||
|
||||
V2 timestamps are local wall-clock without offset (e.g. "2026-06-22T14:00:00")
|
||||
plus a response-level IANA ``timezone``. If the string already carries an
|
||||
explicit offset or 'Z' it is trusted as-is.
|
||||
"""
|
||||
s = str(local_ts).strip()
|
||||
if _TZ_SUFFIX.search(s):
|
||||
# Already absolute (offset or Z present) — parse as-is.
|
||||
return to_datetime(s)
|
||||
tz = iana_tz or str(self.config.general.timezone)
|
||||
# Interpret the naive wall-clock string AS local time in tz, then resolve.
|
||||
dt = pendulum.parse(s, tz=tz)
|
||||
return to_datetime(dt.isoformat())
|
||||
|
||||
def _extract_values(self, body: Any) -> list[tuple[Any, float]]:
|
||||
"""Extract (datetime, power_w) rows from a pvnode V2 response body.
|
||||
|
||||
Canonical shape: ``{"timezone": ..., "values": [{"timestamp", "pv_power"}, ...]}``.
|
||||
Tolerant of edge/legacy shapes (mirrors the production DVhub client).
|
||||
"""
|
||||
tz: Optional[str] = None
|
||||
arr: Any = None
|
||||
if isinstance(body, list):
|
||||
arr = body
|
||||
elif isinstance(body, dict):
|
||||
tz = body.get("timezone") if isinstance(body.get("timezone"), str) else None
|
||||
for key in ("values", "forecasts", "data", "forecast"):
|
||||
if isinstance(body.get(key), list):
|
||||
arr = body[key]
|
||||
break
|
||||
if not isinstance(arr, list):
|
||||
return []
|
||||
|
||||
rows: list[tuple[Any, float]] = []
|
||||
for entry in arr:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
ts = (
|
||||
entry.get("timestamp")
|
||||
or entry.get("time")
|
||||
or entry.get("ts")
|
||||
or entry.get("ts_utc")
|
||||
or entry.get("datetime")
|
||||
)
|
||||
if ts is None:
|
||||
continue
|
||||
# pv_power is nullable (night) -> treat missing as 0 W, not a gap, so the
|
||||
# optimizer's linear resampling does not interpolate across the night.
|
||||
raw_power = entry.get("pv_power")
|
||||
if raw_power is None:
|
||||
raw_power = entry.get("power_w")
|
||||
if raw_power is None:
|
||||
raw_power = entry.get("power")
|
||||
if raw_power is None:
|
||||
raw_power = entry.get("watts")
|
||||
power = 0.0 if raw_power is None else float(raw_power)
|
||||
try:
|
||||
date = self._to_utc_datetime(ts, tz)
|
||||
except Exception as e: # noqa: BLE001 - skip unparseable rows
|
||||
logger.warning(f"pvnode: skipping unparseable timestamp {ts!r}: {e}")
|
||||
continue
|
||||
rows.append((date, round(power, 1)))
|
||||
return rows
|
||||
|
||||
@cache_in_file(with_ttl="1 hour")
|
||||
def _request_forecast(self) -> Any:
|
||||
"""Fetch the PV forecast from pvnode.com (saved site or inline planes)."""
|
||||
settings = self._settings
|
||||
api_key = settings.api_key
|
||||
if not api_key:
|
||||
raise ValueError("PVForecastPVNode requires pvforecast...PVForecastPVNode.api_key")
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
|
||||
params = {"forecast_days": str(settings.forecast_days)}
|
||||
site_id = (settings.site_id or "").strip()
|
||||
|
||||
try:
|
||||
if site_id:
|
||||
url = f"{PVNODE_BASE}/forecast/{requests.utils.quote(site_id, safe='')}"
|
||||
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
else:
|
||||
body = self._inline_body()
|
||||
url = f"{PVNODE_BASE}/forecast/inline"
|
||||
headers["Content-Type"] = "application/json"
|
||||
response = requests.post(url, headers=headers, params=params, json=body, timeout=30)
|
||||
logger.debug(f"Requesting pvnode forecast: {url}")
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch pvforecast from pvnode: {e}")
|
||||
raise RuntimeError("Failed to fetch pvforecast from pvnode API") from e
|
||||
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return response.json()
|
||||
|
||||
def _inline_body(self) -> dict:
|
||||
"""Build the inline-mode request body from latitude/longitude + planes."""
|
||||
latitude = self.config.general.latitude
|
||||
longitude = self.config.general.longitude
|
||||
if latitude is None or longitude is None:
|
||||
raise ValueError(
|
||||
"PVForecastPVNode inline mode needs general.latitude/longitude "
|
||||
"(or set pvforecast...PVForecastPVNode.site_id)"
|
||||
)
|
||||
planes = self.config.pvforecast.planes or []
|
||||
strings = []
|
||||
for plane in planes:
|
||||
tilt = getattr(plane, "surface_tilt", None)
|
||||
azimuth = getattr(plane, "surface_azimuth", None)
|
||||
peakpower = getattr(plane, "peakpower", None)
|
||||
if peakpower is None or tilt is None or azimuth is None:
|
||||
continue
|
||||
strings.append(
|
||||
{
|
||||
"slope": float(tilt),
|
||||
# pvnode V2 azimuth convention (0=N, 90=E, 180=S, 270=W) matches
|
||||
# EOS surface_azimuth, so it is forwarded unchanged.
|
||||
"orientation": float(azimuth),
|
||||
"power_kw": float(peakpower),
|
||||
}
|
||||
)
|
||||
if not strings:
|
||||
raise ValueError(
|
||||
"PVForecastPVNode inline mode needs at least one pvforecast.planes "
|
||||
"entry with peakpower, surface_tilt and surface_azimuth"
|
||||
)
|
||||
return {"latitude": float(latitude), "longitude": float(longitude), "strings": strings}
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update forecast data in the PVForecastDataRecord format."""
|
||||
if not self.enabled():
|
||||
logger.info("PVForecastPVNode is disabled, skipping update.")
|
||||
return
|
||||
|
||||
body = self._request_forecast(force_update=force_update) # type: ignore[call-arg]
|
||||
rows = self._extract_values(body)
|
||||
|
||||
for date, power_w in rows:
|
||||
# pvnode returns the plant's expected output power; feed it as AC power
|
||||
# (the key the optimizer reads) and mirror it to DC for reporting.
|
||||
self.update_value(
|
||||
date,
|
||||
{"pvforecast_ac_power": power_w, "pvforecast_dc_power": power_w},
|
||||
)
|
||||
|
||||
logger.debug(f"Updated pvforecast from pvnode with {len(rows)} entries.")
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
pv = PVForecastPVNode()
|
||||
pv._update_data()
|
||||
143
tests/test_pvforecastpvnode.py
Normal file
143
tests/test_pvforecastpvnode.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pendulum
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pvforecast_instance(config_eos):
|
||||
settings = {
|
||||
"general": {"latitude": 52.5, "longitude": 13.4},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastPVNode",
|
||||
"provider_settings": {
|
||||
"PVForecastPVNode": {
|
||||
"api_key": "dummy-key",
|
||||
"site_id": "test-site-123",
|
||||
"forecast_days": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
start_dt = pendulum.datetime(2025, 1, 1, tz="Europe/Berlin")
|
||||
return PVForecastPVNode(config=config_eos.load, start_datetime=start_dt)
|
||||
|
||||
|
||||
def mock_v2_body():
|
||||
"""A canonical pvnode V2 response: site-local wall-clock + IANA timezone.
|
||||
|
||||
The second slot carries a null ``pv_power`` (night) which must become 0 W.
|
||||
"""
|
||||
return {
|
||||
"timezone": "Europe/Berlin",
|
||||
"values": [
|
||||
{"timestamp": "2025-01-01T12:00:00", "pv_power": 1200.0},
|
||||
{"timestamp": "2025-01-01T12:15:00", "pv_power": None},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_provider_id(pvforecast_instance):
|
||||
assert PVForecastPVNode.provider_id() == "PVForecastPVNode"
|
||||
assert pvforecast_instance.enabled() is True
|
||||
|
||||
|
||||
def test_extract_values_resolves_local_wall_clock_to_instant(pvforecast_instance):
|
||||
"""V2 timestamps are local wall-clock; 12:00 Europe/Berlin (CET) == 11:00 UTC."""
|
||||
rows = pvforecast_instance._extract_values(mock_v2_body())
|
||||
|
||||
assert len(rows) == 2
|
||||
# Same instant, regardless of representation.
|
||||
assert rows[0][0] == pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin")
|
||||
assert rows[0][0].in_timezone("UTC").hour == 11
|
||||
assert rows[0][1] == 1200.0
|
||||
# Null pv_power -> 0 W (not a gap, to avoid phantom night interpolation).
|
||||
assert rows[1][1] == 0.0
|
||||
|
||||
|
||||
def test_extract_values_trusts_explicit_offset(pvforecast_instance):
|
||||
"""A timestamp already carrying an offset is trusted as-is (no double shift)."""
|
||||
body = {
|
||||
"timezone": "Europe/Berlin",
|
||||
"values": [{"timestamp": "2025-01-01T12:00:00+00:00", "pv_power": 500.0}],
|
||||
}
|
||||
rows = pvforecast_instance._extract_values(body)
|
||||
assert rows[0][0].in_timezone("UTC").hour == 12
|
||||
|
||||
|
||||
def test_update_data_sets_ac_and_dc_power(pvforecast_instance):
|
||||
with patch.object(pvforecast_instance, "_request_forecast", return_value=mock_v2_body()), \
|
||||
patch.object(PVForecastPVNode, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
|
||||
assert mock_update.call_count == 2
|
||||
expected = [
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin"),
|
||||
{"pvforecast_ac_power": 1200.0, "pvforecast_dc_power": 1200.0},
|
||||
),
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 15, tz="Europe/Berlin"),
|
||||
{"pvforecast_ac_power": 0.0, "pvforecast_dc_power": 0.0},
|
||||
),
|
||||
]
|
||||
mock_update.assert_has_calls(expected, any_order=False)
|
||||
|
||||
|
||||
def test_update_data_skips_when_disabled(pvforecast_instance, config_eos):
|
||||
config_eos.merge_settings_from_dict({"pvforecast": {"provider": "PVForecastAkkudoktor"}})
|
||||
with patch.object(pvforecast_instance, "_request_forecast") as mock_req, \
|
||||
patch.object(PVForecastPVNode, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
mock_req.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_data_skips_on_empty_forecast(pvforecast_instance):
|
||||
with patch.object(pvforecast_instance, "_request_forecast", return_value={"values": []}), \
|
||||
patch.object(PVForecastPVNode, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_request_forecast_uses_saved_site_get(pvforecast_instance):
|
||||
"""site_id set -> GET /v2/forecast/{site_id} with Bearer auth."""
|
||||
fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})()
|
||||
with patch("requests.get", return_value=fake) as mock_get:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
url = mock_get.call_args[0][0]
|
||||
assert url.endswith("/v2/forecast/test-site-123")
|
||||
assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer dummy-key"
|
||||
|
||||
|
||||
def test_request_forecast_inline_post_when_no_site(config_eos):
|
||||
"""No site_id -> POST /v2/forecast/inline with planes geometry."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"general": {"latitude": 52.5, "longitude": 13.4},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastPVNode",
|
||||
"planes": [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}],
|
||||
"provider_settings": {"PVForecastPVNode": {"api_key": "k", "site_id": None}},
|
||||
},
|
||||
}
|
||||
)
|
||||
pv = PVForecastPVNode(config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC"))
|
||||
fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})()
|
||||
with patch("requests.post", return_value=fake) as mock_post:
|
||||
pv._request_forecast(force_update=True)
|
||||
url = mock_post.call_args[0][0]
|
||||
assert url.endswith("/v2/forecast/inline")
|
||||
body = mock_post.call_args.kwargs["json"]
|
||||
assert body["strings"][0] == {"slope": 30.0, "orientation": 180.0, "power_kw": 5.0}
|
||||
|
||||
|
||||
def test_request_forecast_raises_on_http_error(pvforecast_instance):
|
||||
with patch("requests.get", side_effect=requests.Timeout("timed out")):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
assert "Failed to fetch pvforecast from pvnode" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user