mirror of
https://github.com/infiniteCable2/openpilot.git
synced 2026-09-30 03:23:52 +08:00
more ty, part 2 (#38379)
This commit is contained in:
@@ -669,7 +669,10 @@ def log_handler(end_event: threading.Event) -> None:
|
||||
def ws_proxy_recv(ws: WebSocket, local_sock: socket.socket, ssock: socket.socket, end_event: threading.Event, global_end_event: threading.Event) -> None:
|
||||
while not (end_event.is_set() or global_end_event.is_set()):
|
||||
try:
|
||||
r = select.select((ws.sock,), (), (), 30)
|
||||
sock = ws.sock
|
||||
if sock is None:
|
||||
return
|
||||
r = select.select((sock,), (), (), 30)
|
||||
if r[0]:
|
||||
data = ws.recv()
|
||||
if isinstance(data, str):
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestAthenadMethods:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.SOCKET_PORT = 45454
|
||||
athenad.Api = MockApi
|
||||
athenad.Api = MockApi # ty: ignore[invalid-assignment] # test double
|
||||
athenad.LOCAL_PORT_WHITELIST = {cls.SOCKET_PORT}
|
||||
|
||||
def setup_method(self):
|
||||
@@ -351,6 +351,7 @@ class TestAthenadMethods:
|
||||
assert items[0] == asdict(item)
|
||||
assert not items[0]['current']
|
||||
|
||||
assert item.id is not None
|
||||
athenad.cancelled_uploads.add(item.id)
|
||||
items = dispatcher["listUploadQueue"]()
|
||||
assert len(items) == 0
|
||||
@@ -363,6 +364,7 @@ class TestAthenadMethods:
|
||||
athenad.upload_queue.put_nowait(item2)
|
||||
|
||||
# Ensure canceled items are not persisted
|
||||
assert item2.id is not None
|
||||
athenad.cancelled_uploads.add(item2.id)
|
||||
|
||||
# serialize item
|
||||
|
||||
@@ -34,7 +34,7 @@ class PowerMonitoring:
|
||||
self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 10), car_battery_capacity_uWh)
|
||||
|
||||
# Calculation tick
|
||||
def calculate(self, voltage: int | None, ignition: bool):
|
||||
def calculate(self, voltage: float | None, ignition: bool):
|
||||
try:
|
||||
now = time.monotonic()
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestPowerMonitoring:
|
||||
def test_panda_state_present(self):
|
||||
pm = PowerMonitoring()
|
||||
for _ in range(10):
|
||||
pm.calculate(None, None)
|
||||
pm.calculate(None, False)
|
||||
assert pm.get_power_used() == 0
|
||||
assert pm.get_car_battery_capacity() == (CAR_BATTERY_CAPACITY_uWh / 10)
|
||||
|
||||
|
||||
@@ -63,10 +63,10 @@ class UploaderTestCase:
|
||||
seg_dir: str
|
||||
|
||||
def set_ignore(self):
|
||||
uploader.Api = MockApiIgnore
|
||||
uploader.Api = MockApiIgnore # ty: ignore[invalid-assignment] # test double
|
||||
|
||||
def setup_method(self):
|
||||
uploader.Api = MockApi
|
||||
uploader.Api = MockApi # ty: ignore[invalid-assignment] # test double
|
||||
uploader.fake_upload = True
|
||||
uploader.force_wifi = True
|
||||
uploader.allow_sleep = False
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestDeleter(UploaderTestCase):
|
||||
self.f_type = "fcamera.hevc"
|
||||
super().setup_method()
|
||||
self.fake_stats = Stats(f_bavail=0, f_blocks=10, f_frsize=4096)
|
||||
deleter.os.statvfs = self.fake_statvfs
|
||||
deleter.os.statvfs = self.fake_statvfs # ty: ignore[invalid-assignment] # test double
|
||||
|
||||
def start_thread(self):
|
||||
self.end_event = threading.Event()
|
||||
|
||||
@@ -5,6 +5,7 @@ import random
|
||||
import string
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Collection
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
@@ -74,8 +75,8 @@ class TestLoggerd:
|
||||
end_type = SentinelType.endOfRoute if route else SentinelType.endOfSegment
|
||||
assert msgs[-1].sentinel.type == end_type
|
||||
|
||||
def _publish_random_messages(self, services: list[str]) -> dict[str, list]:
|
||||
pm = messaging.PubMaster(services)
|
||||
def _publish_random_messages(self, services: Collection[str]) -> dict[str, list]:
|
||||
pm = messaging.PubMaster(list(services))
|
||||
|
||||
managed_processes["loggerd"].start()
|
||||
for s in services:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, fields
|
||||
from dataclasses import dataclass
|
||||
from subprocess import check_output, CalledProcessError
|
||||
from time import sleep
|
||||
from typing import NoReturn, cast
|
||||
from typing import NoReturn
|
||||
|
||||
DEBUG = int(os.environ.get("DEBUG", "0"))
|
||||
|
||||
@@ -17,27 +17,27 @@ class GnssClockNmeaPort:
|
||||
# 0x10 = bias_uncertainty_ns valid
|
||||
# 0x20 = drift_nsps valid
|
||||
# 0x40 = drift_uncertainty_nsps valid
|
||||
flags: int
|
||||
leap_seconds: int
|
||||
time_ns: int
|
||||
time_uncertainty_ns: int # 1-sigma
|
||||
full_bias_ns: int
|
||||
bias_ns: float
|
||||
bias_uncertainty_ns: float # 1-sigma
|
||||
drift_nsps: float
|
||||
drift_uncertainty_nsps: float # 1-sigma
|
||||
flags: int | None
|
||||
leap_seconds: int | None
|
||||
time_ns: int | None
|
||||
time_uncertainty_ns: int | None # 1-sigma
|
||||
full_bias_ns: int | None
|
||||
bias_ns: float | None
|
||||
bias_uncertainty_ns: float | None # 1-sigma
|
||||
drift_nsps: float | None
|
||||
drift_uncertainty_nsps: float | None # 1-sigma
|
||||
|
||||
def __post_init__(self):
|
||||
for field in fields(self):
|
||||
val = getattr(self, field.name)
|
||||
field_type = cast(type, field.type)
|
||||
setattr(self, field.name, field_type(val) if val else None)
|
||||
@classmethod
|
||||
def from_fields(cls, values: list[str]) -> 'GnssClockNmeaPort':
|
||||
ints = [int(value) if value else None for value in values[:5]]
|
||||
floats = [float(value) if value else None for value in values[5:9]]
|
||||
return cls(*ints, *floats)
|
||||
|
||||
@dataclass
|
||||
class GnssMeasNmeaPort:
|
||||
messageCount: int
|
||||
messageNum: int
|
||||
svCount: int
|
||||
messageCount: int | None
|
||||
messageNum: int | None
|
||||
svCount: int | None
|
||||
# constellation enum:
|
||||
# 1 = GPS
|
||||
# 2 = SBAS
|
||||
@@ -45,10 +45,10 @@ class GnssMeasNmeaPort:
|
||||
# 4 = QZSS
|
||||
# 5 = BEIDOU
|
||||
# 6 = GALILEO
|
||||
constellation: int
|
||||
svId: int
|
||||
flags: int # always zero
|
||||
time_offset_ns: int
|
||||
constellation: int | None
|
||||
svId: int | None
|
||||
flags: int | None # always zero
|
||||
time_offset_ns: int | None
|
||||
# state bit mask:
|
||||
# 0x0001 = CODE LOCK
|
||||
# 0x0002 = BIT SYNC
|
||||
@@ -64,18 +64,18 @@ class GnssMeasNmeaPort:
|
||||
# 0x0800 = GALILEO E1C 2ND CODE LOCK
|
||||
# 0x1000 = GALILEO E1B PAGE SYNC
|
||||
# 0x2000 = GALILEO E1B PAGE SYNC
|
||||
state: int
|
||||
time_of_week_ns: int
|
||||
time_of_week_uncertainty_ns: int # 1-sigma
|
||||
carrier_to_noise_ratio: float
|
||||
pseudorange_rate: float
|
||||
pseudorange_rate_uncertainty: float # 1-sigma
|
||||
state: int | None
|
||||
time_of_week_ns: int | None
|
||||
time_of_week_uncertainty_ns: int | None # 1-sigma
|
||||
carrier_to_noise_ratio: float | None
|
||||
pseudorange_rate: float | None
|
||||
pseudorange_rate_uncertainty: float | None # 1-sigma
|
||||
|
||||
def __post_init__(self):
|
||||
for field in fields(self):
|
||||
val = getattr(self, field.name)
|
||||
field_type = cast(type, field.type)
|
||||
setattr(self, field.name, field_type(val) if val else None)
|
||||
@classmethod
|
||||
def from_fields(cls, values: list[str]) -> 'GnssMeasNmeaPort':
|
||||
ints = [int(value) if value else None for value in values[:10]]
|
||||
floats = [float(value) if value else None for value in values[10:13]]
|
||||
return cls(*ints, *floats)
|
||||
|
||||
def nmea_checksum_ok(s):
|
||||
checksum = 0
|
||||
@@ -109,11 +109,11 @@ def process_nmea_port_messages(device:str="/dev/ttyUSB1") -> NoReturn:
|
||||
match fields[0]:
|
||||
case "$GNCLK":
|
||||
# fields at end are reserved (not used)
|
||||
gnss_clock = GnssClockNmeaPort(*fields[1:10])
|
||||
gnss_clock = GnssClockNmeaPort.from_fields(fields[1:10])
|
||||
print(gnss_clock)
|
||||
case "$GNMEAS":
|
||||
# fields at end are reserved (not used)
|
||||
gnss_meas = GnssMeasNmeaPort(*fields[1:14])
|
||||
gnss_meas = GnssMeasNmeaPort.from_fields(fields[1:14])
|
||||
print(gnss_meas)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
@@ -20,7 +20,7 @@ SENSOR_CONFIGS = (
|
||||
)
|
||||
SENSOR_CONFIGS_BY_MEASUREMENT = {config.measurement: config for config in SENSOR_CONFIGS}
|
||||
|
||||
def get_irq_count(irq: int):
|
||||
def get_irq_count(irq: str):
|
||||
with open(f"/sys/kernel/irq/{irq}/per_cpu_count") as f:
|
||||
per_cpu = map(int, f.read().split(","))
|
||||
return sum(per_cpu)
|
||||
|
||||
@@ -184,7 +184,7 @@ class BinaryStruct:
|
||||
setattr(obj, name, value)
|
||||
return obj
|
||||
|
||||
cls._read = _read
|
||||
cls._read = _read # ty: ignore[invalid-assignment] # installed dynamically for each subclass
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: type[T], data: bytes) -> T:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
class GuiStyleContext:
|
||||
def __init__(self, styles: list[tuple[int, int, int]]):
|
||||
def __init__(self, styles: Sequence[tuple[int, int, int]]):
|
||||
"""styles is a list of tuples (control, prop, new_value)"""
|
||||
self.styles = styles
|
||||
self.prev_styles: list[tuple[int, int, int]] = []
|
||||
|
||||
@@ -257,7 +257,7 @@ class WifiManager:
|
||||
|
||||
def add_callbacks(self, need_auth: Callable[[str], None] | None = None,
|
||||
activated: Callable[[], None] | None = None,
|
||||
forgotten: Callable[[str], None] | None = None,
|
||||
forgotten: Callable[[str | None], None] | None = None,
|
||||
networks_updated: Callable[[list[Network]], None] | None = None,
|
||||
disconnected: Callable[[], None] | None = None):
|
||||
if need_auth is not None:
|
||||
|
||||
@@ -37,7 +37,11 @@ class Reset(Widget):
|
||||
self._reset_state = ResetState.NONE
|
||||
self._cancel_button = Button("Cancel", gui_app.request_close)
|
||||
self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
|
||||
self._reboot_button = Button("Reboot", lambda: subprocess.run("sudo reboot", shell=True))
|
||||
self._reboot_button = Button("Reboot", self._reboot)
|
||||
|
||||
@staticmethod
|
||||
def _reboot() -> None:
|
||||
subprocess.run("sudo reboot", shell=True)
|
||||
|
||||
def _do_erase(self):
|
||||
if PC:
|
||||
|
||||
@@ -3,16 +3,25 @@ from __future__ import annotations
|
||||
import abc
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from typing import TypeVar
|
||||
from typing import Protocol, TypeVar
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
|
||||
|
||||
try:
|
||||
from openpilot.selfdrive.ui.ui_state import device
|
||||
except ImportError:
|
||||
class Device:
|
||||
awake = True
|
||||
device = Device()
|
||||
class DeviceLike(Protocol):
|
||||
awake: bool
|
||||
|
||||
|
||||
def _get_device() -> DeviceLike:
|
||||
try:
|
||||
from openpilot.selfdrive.ui.ui_state import device
|
||||
return device
|
||||
except ImportError:
|
||||
class Device:
|
||||
awake = True
|
||||
return Device()
|
||||
|
||||
|
||||
device = _get_device()
|
||||
|
||||
W = TypeVar('W', bound='Widget')
|
||||
|
||||
@@ -185,16 +194,16 @@ class Widget(abc.ABC):
|
||||
"""Optionally update the widget's non-layout state. This is called before rendering."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _render(self, rect: rl.Rectangle) -> bool | int | None:
|
||||
def _render(self, rect: rl.Rectangle, /) -> bool | int | None:
|
||||
"""Render the widget within the given rectangle."""
|
||||
|
||||
def _update_layout_rects(self) -> None:
|
||||
"""Optionally update any layout rects on Widget rect change."""
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos) -> None:
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos, /) -> None:
|
||||
"""Optionally handle mouse press events."""
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos, /) -> None:
|
||||
"""Optionally handle mouse release events."""
|
||||
if self._click_delay is not None:
|
||||
self._click_release_time = rl.get_time() + self._click_delay
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import math
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
from itertools import zip_longest
|
||||
from typing import Union
|
||||
import pyray as rl
|
||||
|
||||
@@ -210,7 +209,7 @@ class Label(Widget):
|
||||
icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2
|
||||
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE)
|
||||
|
||||
for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]):
|
||||
for text, text_size, emojis in zip(self._text_wrapped, self._text_size, self._emojis, strict=True):
|
||||
line_pos = rl.Vector2(text_pos.x, text_pos.y)
|
||||
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
||||
line_pos.x += self._text_padding
|
||||
|
||||
@@ -78,7 +78,7 @@ class NavWidget(Widget, abc.ABC):
|
||||
# the top of a vertical scroll panel to prevent erroneous swipes
|
||||
return True
|
||||
|
||||
def set_back_callback(self, callback: Callable[[], None]) -> None:
|
||||
def set_back_callback(self, callback: Callable[[], None] | None) -> None:
|
||||
self._back_callback = callback
|
||||
|
||||
def set_shown_callback(self, callback: Callable[[], None] | None) -> None:
|
||||
|
||||
@@ -15,16 +15,6 @@ from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
|
||||
|
||||
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
|
||||
except Exception:
|
||||
Params = None
|
||||
ui_state = None
|
||||
PrimeType = None
|
||||
|
||||
NM_DEVICE_STATE_NEED_AUTH = 60
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 64
|
||||
@@ -105,11 +95,16 @@ class NetworkUI(Widget):
|
||||
|
||||
class AdvancedNetworkSettings(Widget):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
assert Params is not None
|
||||
# AdvancedNetworkSettings needs the full openpilot environment, standalone apps just use WifiManagerUI
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
|
||||
self._params = Params()
|
||||
self._prime_state = ui_state.prime_state
|
||||
self._cell_prime_types = (PrimeType.NONE, PrimeType.LITE)
|
||||
|
||||
self._keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
|
||||
|
||||
@@ -254,7 +249,7 @@ class AdvancedNetworkSettings(Widget):
|
||||
self._wifi_manager.process_callbacks()
|
||||
|
||||
# If not using prime SIM, show GSM settings and enable IPv4 forwarding
|
||||
show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE)
|
||||
show_cell_settings = self._prime_state.get_type() in self._cell_prime_types
|
||||
self._wifi_manager.set_ipv4_forward(show_cell_settings)
|
||||
self._roaming_btn.set_visible(show_cell_settings)
|
||||
self._apn_btn.set_visible(show_cell_settings)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
@@ -40,7 +40,7 @@ class ScrollIndicator(Widget):
|
||||
self._content_size = content_size
|
||||
self._viewport = viewport
|
||||
|
||||
def _render(self, _):
|
||||
def _render(self, _, /):
|
||||
# scale indicator width based on content size
|
||||
indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100]))
|
||||
|
||||
@@ -69,7 +69,7 @@ class ScrollIndicator(Widget):
|
||||
|
||||
class _Scroller(Widget):
|
||||
"""Should use wrapper below to reduce boilerplate"""
|
||||
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING,
|
||||
def __init__(self, items: Sequence[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING,
|
||||
pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True):
|
||||
super().__init__()
|
||||
self._items: list[Widget] = []
|
||||
@@ -150,7 +150,7 @@ class _Scroller(Widget):
|
||||
and not self.moving_items and (original_touch_valid_callback() if
|
||||
original_touch_valid_callback else True))
|
||||
|
||||
def add_widgets(self, items: list[Widget]) -> None:
|
||||
def add_widgets(self, items: Sequence[Widget]) -> None:
|
||||
for item in items:
|
||||
self.add_widget(item)
|
||||
|
||||
@@ -332,7 +332,7 @@ class _Scroller(Widget):
|
||||
else:
|
||||
item.render()
|
||||
|
||||
def _render(self, _):
|
||||
def _render(self, _, /):
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
|
||||
int(self._rect.width), int(self._rect.height))
|
||||
|
||||
@@ -397,7 +397,7 @@ class Scroller(Widget):
|
||||
# pass down enabled to child widget for nav stack
|
||||
self._scroller.set_enabled(lambda: self.enabled)
|
||||
|
||||
def _render(self, _):
|
||||
def _render(self, _, /):
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Sequence
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
@@ -23,7 +24,7 @@ class LineSeparator(Widget):
|
||||
|
||||
|
||||
class Scroller(Widget):
|
||||
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
|
||||
def __init__(self, items: Sequence[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
|
||||
super().__init__()
|
||||
self._items: list[Widget] = []
|
||||
self._spacing = spacing
|
||||
|
||||
@@ -357,7 +357,7 @@ class Updater:
|
||||
setup_git_options(OVERLAY_MERGED)
|
||||
output = run(["git", "ls-remote", "--heads"], OVERLAY_MERGED)
|
||||
|
||||
self.branches = defaultdict(lambda: None)
|
||||
self.branches.clear()
|
||||
for line in output.split('\n'):
|
||||
ls_remotes_re = r'(?P<commit_sha>\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P<branch_name>.*$)'
|
||||
x = re.fullmatch(ls_remotes_re, line.strip())
|
||||
|
||||
@@ -55,9 +55,11 @@ class TestStreamSession:
|
||||
|
||||
mocked_pubmaster.send.assert_called_once()
|
||||
mt, md = mocked_pubmaster.send.call_args.args
|
||||
assert mt == msg["type"]
|
||||
msg_type = msg["type"]
|
||||
assert isinstance(msg_type, str)
|
||||
assert mt == msg_type
|
||||
assert isinstance(md, capnp._DynamicStructBuilder)
|
||||
assert hasattr(md, msg["type"])
|
||||
assert hasattr(md, msg_type)
|
||||
|
||||
mocked_pubmaster.reset_mock()
|
||||
|
||||
|
||||
@@ -519,7 +519,7 @@ class WebrtcdHandler(BaseHTTPRequestHandler):
|
||||
def do_OPTIONS(self) -> None:
|
||||
self._dispatch_request()
|
||||
|
||||
def log_message(self, fmt, *args) -> None:
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override
|
||||
# silence default access logging; errors are logged explicitly in _dispatch_request
|
||||
pass
|
||||
|
||||
|
||||
Reference in New Issue
Block a user