Add MACHINE_PROGRAM_PICKER and MACHINE_CLEANER relay endpoints with GET/POST actions, unit tests, retry logic, and OpenAPI spec updates.
This commit is contained in:
+149
-1
@@ -7392,6 +7392,154 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/modules/self-serve/lane/relay/machine_program_picker/status:
|
||||
get:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Get MACHINE_PROGRAM_PICKER relay status for a lane
|
||||
description: |
|
||||
Reads the current Shelly MACHINE_PROGRAM_PICKER relay status (`on`/`off`) for the given lane.
|
||||
operationId: getSelfServeLaneMachineProgramPickerRelayStatus
|
||||
parameters:
|
||||
- name: lane_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: MACHINE_PROGRAM_PICKER relay status retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfServeLaneMachineRelayStatus'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/modules/self-serve/lane/relay/machine_program_picker/set:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Set MACHINE_PROGRAM_PICKER relay status for a lane
|
||||
description: |
|
||||
Sets the Shelly MACHINE_PROGRAM_PICKER relay state for the lane to on or off and returns the latest status.
|
||||
operationId: setSelfServeLaneMachineProgramPickerRelayStatus
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- lane_id
|
||||
- on
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
on:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: MACHINE_PROGRAM_PICKER relay status updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
relay:
|
||||
type: string
|
||||
enum: [MACHINE_PROGRAM_PICKER]
|
||||
requested_on:
|
||||
type: boolean
|
||||
relay_id:
|
||||
type: string
|
||||
online:
|
||||
type: boolean
|
||||
on:
|
||||
type: boolean
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/modules/self-serve/lane/relay/machine_cleaner/status:
|
||||
get:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Get MACHINE_CLEANER relay status for a lane
|
||||
description: |
|
||||
Reads the current Shelly MACHINE_CLEANER relay status (`on`/`off`) for the given lane.
|
||||
operationId: getSelfServeLaneMachineCleanerRelayStatus
|
||||
parameters:
|
||||
- name: lane_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: MACHINE_CLEANER relay status retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfServeLaneMachineRelayStatus'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/modules/self-serve/lane/relay/machine_cleaner/set:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Set MACHINE_CLEANER relay status for a lane
|
||||
description: |
|
||||
Sets the Shelly MACHINE_CLEANER relay state for the lane to on or off and returns the latest status.
|
||||
operationId: setSelfServeLaneMachineCleanerRelayStatus
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- lane_id
|
||||
- on
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
on:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: MACHINE_CLEANER relay status updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
relay:
|
||||
type: string
|
||||
enum: [MACHINE_CLEANER]
|
||||
requested_on:
|
||||
type: boolean
|
||||
relay_id:
|
||||
type: string
|
||||
online:
|
||||
type: boolean
|
||||
on:
|
||||
type: boolean
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/modules/self-serve/lane/relay/machine/set:
|
||||
post:
|
||||
tags:
|
||||
@@ -12348,7 +12496,7 @@ components:
|
||||
type: integer
|
||||
relay:
|
||||
type: string
|
||||
enum: [MACHINE]
|
||||
enum: [MACHINE, MACHINE_PROGRAM_PICKER, MACHINE_CLEANER]
|
||||
relay_id:
|
||||
type: string
|
||||
online:
|
||||
|
||||
@@ -358,6 +358,10 @@ Purpose: operational lane control and relay management.
|
||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
|
||||
|
||||
+150
-66
@@ -15,6 +15,9 @@ use modules\shelly\helpers\shelly_request_body_get_states;
|
||||
|
||||
trait selfserve_lane_relay_controller_t
|
||||
{
|
||||
private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RETRY_SLEEP_MICROSECONDS = 750000;
|
||||
|
||||
/**
|
||||
* Get current MACHINE relay status from Shelly.
|
||||
* @return array{relay_id: string, online: bool, on: bool}
|
||||
@@ -151,18 +154,25 @@ trait selfserve_lane_relay_controller_t
|
||||
$parameters = new shelly_request_body_get_states();
|
||||
$parameters->ids = [$relay_id];
|
||||
$parameters->select = ['status'];
|
||||
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
|
||||
$deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
|
||||
$last_reason = 'Shelly relay status is not ready yet';
|
||||
|
||||
if (is_object($result)) {
|
||||
$result = [$result];
|
||||
}
|
||||
if (!is_array($result)) {
|
||||
return [];
|
||||
}
|
||||
do {
|
||||
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
|
||||
$devices = $this->normalizeRelaySwitchesResponse($result);
|
||||
|
||||
return array_map(function ($device) {
|
||||
return (new shelly_device_switch())->populate($device);
|
||||
}, $result);
|
||||
if (count($devices) > 0 && $this->relayStatusPayloadExists($devices[0])) {
|
||||
return $devices;
|
||||
}
|
||||
|
||||
$last_reason = $this->describeShellyNotReadyReason($result, $devices);
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS);
|
||||
} while (true);
|
||||
|
||||
throw new \Exception($last_reason);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +206,128 @@ trait selfserve_lane_relay_controller_t
|
||||
throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Shelly relay status response to a switch object list.
|
||||
* @param array|object|null $response
|
||||
* @return array<shelly_device_switch>
|
||||
*/
|
||||
private function normalizeRelaySwitchesResponse(array|object|null $response): array
|
||||
{
|
||||
if (is_object($response)) {
|
||||
$response = [$response];
|
||||
}
|
||||
if (!is_array($response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$devices = [];
|
||||
foreach ($response as $device) {
|
||||
if (!is_array($device) && !is_object($device)) {
|
||||
continue;
|
||||
}
|
||||
$devices[] = (new shelly_device_switch())->populate($device);
|
||||
}
|
||||
return $devices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a parsed Shelly device has enough payload to resolve relay state.
|
||||
*/
|
||||
private function relayStatusPayloadExists(shelly_device_switch $device): bool
|
||||
{
|
||||
if (isset($device->on)) {
|
||||
return true;
|
||||
}
|
||||
if (!isset($device->status) || !is_object($device->status)) {
|
||||
return false;
|
||||
}
|
||||
$status = (array)$device->status;
|
||||
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
|
||||
if (!array_key_exists($switch_key, $status)) {
|
||||
continue;
|
||||
}
|
||||
$switch_state = $status[$switch_key];
|
||||
if (is_object($switch_state) && isset($switch_state->output)) {
|
||||
return true;
|
||||
}
|
||||
if (is_array($switch_state) && array_key_exists('output', $switch_state)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user-facing reason while waiting for Shelly to become ready.
|
||||
* @param array|object|null $response
|
||||
* @param array<shelly_device_switch> $devices
|
||||
*/
|
||||
private function describeShellyNotReadyReason(array|object|null $response, array $devices): string
|
||||
{
|
||||
$payload_text = strtolower($this->serializeShellyResponse($response));
|
||||
|
||||
// Shelly may respond with rate-limit style payloads when polled too quickly.
|
||||
foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) {
|
||||
if (str_contains($payload_text, $token)) {
|
||||
return 'Shelly rate limit reached, waiting for next available window';
|
||||
}
|
||||
}
|
||||
|
||||
if (count($devices) < 1) {
|
||||
return 'Shelly returned no device status yet';
|
||||
}
|
||||
return 'Shelly relay status payload is not ready yet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a Shelly response payload into a safe compact string.
|
||||
*/
|
||||
private function serializeShellyResponse(array|object|null $response): string
|
||||
{
|
||||
if ($response === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_scalar($response)) {
|
||||
return (string)$response;
|
||||
}
|
||||
if (is_array($response) || is_object($response)) {
|
||||
$encoded = json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
return is_string($encoded) ? $encoded : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute switch command and retry while Shelly is rate-limited.
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function switchRelayWithRetry(
|
||||
shelly_device_switch $device,
|
||||
bool $on,
|
||||
selfserve_lane_relay $relay
|
||||
): void {
|
||||
$deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
|
||||
|
||||
do {
|
||||
$switch_response = $device->switch($on, true);
|
||||
$payload_text = strtolower($this->serializeShellyResponse($switch_response));
|
||||
$rate_limited = false;
|
||||
foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) {
|
||||
if (str_contains($payload_text, $token)) {
|
||||
$rate_limited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$rate_limited) {
|
||||
return;
|
||||
}
|
||||
if (microtime(true) >= $deadline) {
|
||||
throw new \Exception("Shelly rate limit prevented switching {$relay->name} relay in time");
|
||||
}
|
||||
usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS);
|
||||
} while (true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on the lane relay
|
||||
* @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER)
|
||||
@@ -224,24 +356,12 @@ trait selfserve_lane_relay_controller_t
|
||||
// Get the relay ID based on the relay type
|
||||
$relay_id = $this->getRelayId($relay);
|
||||
|
||||
$shelly = new shelly();
|
||||
$shelly->requireModuleEnabled();
|
||||
$shelly->requireValidSecretKey();
|
||||
$parameters = new shelly_request_body_get_states();
|
||||
$parameters->ids = [$relay_id];
|
||||
$parameters->select = ['status'];
|
||||
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
|
||||
// Wait 1 second
|
||||
sleep(1);
|
||||
// Format the result
|
||||
$result = array_map(function ($device) {
|
||||
return (new shelly_device_switch())->populate($device);
|
||||
}, $result);
|
||||
$result = $this->fetchRelaySwitches($relay_id);
|
||||
// Turn on the switch
|
||||
foreach ($result as $device) {
|
||||
// Ensure machine switches never auto-toggle off; enforce toggle_after = 0
|
||||
$device->toggle_after = 0;
|
||||
$device->switch(true);
|
||||
$this->switchRelayWithRetry($device, true, $relay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -276,24 +396,12 @@ trait selfserve_lane_relay_controller_t
|
||||
// Directly control Shelly without checking allowed services
|
||||
$relay_id = $this->getRelayId($relay);
|
||||
|
||||
$shelly = new shelly();
|
||||
$shelly->requireModuleEnabled();
|
||||
$shelly->requireValidSecretKey();
|
||||
$parameters = new shelly_request_body_get_states();
|
||||
$parameters->ids = [$relay_id];
|
||||
$parameters->select = ['status'];
|
||||
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
|
||||
// Wait 1 second
|
||||
sleep(1);
|
||||
// Format the result
|
||||
$result = array_map(function ($device) {
|
||||
return (new shelly_device_switch())->populate($device);
|
||||
}, $result);
|
||||
$result = $this->fetchRelaySwitches($relay_id);
|
||||
// Turn on the switch
|
||||
foreach ($result as $device) {
|
||||
// Ensure machine switches never auto-toggle off; enforce toggle_after = 0
|
||||
$device->toggle_after = 0;
|
||||
$device->switch(true);
|
||||
$this->switchRelayWithRetry($device, true, $relay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -326,22 +434,10 @@ trait selfserve_lane_relay_controller_t
|
||||
// Directly control Shelly without checking allowed services
|
||||
$relay_id = $this->getRelayId($relay);
|
||||
|
||||
$shelly = new shelly();
|
||||
$shelly->requireModuleEnabled();
|
||||
$shelly->requireValidSecretKey();
|
||||
$parameters = new shelly_request_body_get_states();
|
||||
$parameters->ids = [$relay_id];
|
||||
$parameters->select = ['status'];
|
||||
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
|
||||
// Wait 1 second
|
||||
sleep(1);
|
||||
// Format the result
|
||||
$result = array_map(function ($device) {
|
||||
return (new shelly_device_switch())->populate($device);
|
||||
}, $result);
|
||||
$result = $this->fetchRelaySwitches($relay_id);
|
||||
// Turn off the switch
|
||||
foreach ($result as $device) {
|
||||
$device->switch(false);
|
||||
$this->switchRelayWithRetry($device, false, $relay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -364,22 +460,10 @@ trait selfserve_lane_relay_controller_t
|
||||
// Get the relay ID based on the relay type
|
||||
$relay_id = $this->getRelayId($relay);
|
||||
|
||||
$shelly = new shelly();
|
||||
$shelly->requireModuleEnabled();
|
||||
$shelly->requireValidSecretKey();
|
||||
$parameters = new shelly_request_body_get_states();
|
||||
$parameters->ids = [$relay_id];
|
||||
$parameters->select = ['status'];
|
||||
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
|
||||
// Wait 1 second
|
||||
sleep(1);
|
||||
// Format the result
|
||||
$result = array_map(function ($device) {
|
||||
return (new shelly_device_switch())->populate($device);
|
||||
}, $result);
|
||||
$result = $this->fetchRelaySwitches($relay_id);
|
||||
// Turn off the switch
|
||||
foreach ($result as $device) {
|
||||
$device->switch(false);
|
||||
$this->switchRelayWithRetry($device, false, $relay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class shelly_device_switch extends shelly_device_state
|
||||
'id' => (string)$this->id,
|
||||
'channel' => (int)$this->channel,
|
||||
'on' => $on,
|
||||
'toggle_after' => $skip_toggle_after ? 0 : (int)$this->toggle_after,
|
||||
...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]),
|
||||
];
|
||||
return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters);
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ class moduleSelfServeRoute
|
||||
}
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);
|
||||
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'enabled' => true, 'duration' => $duration]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
|
||||
@@ -413,7 +413,7 @@ class moduleSelfServeRoute
|
||||
}
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER);
|
||||
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'enabled' => true, 'duration' => $duration]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
|
||||
|
||||
@@ -33,6 +33,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin
|
||||
expect($content)->toContain('/relay/button/press/post:');
|
||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:');
|
||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:');
|
||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status:');
|
||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:');
|
||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:');
|
||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:');
|
||||
});
|
||||
|
||||
it('defines reusable self-serve wash and machine type schemas', function (): void {
|
||||
|
||||
@@ -38,6 +38,14 @@ it('wires machine relay status get and set endpoints', function (): void {
|
||||
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status');
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set');
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status');
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set');
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status');
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set');
|
||||
expect($moduleSelfServeRoute)->toContain('getMachineRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('setMachineRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('getMachineProgramPickerRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('setMachineProgramPickerRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user