new poll mechanism
- remove secop.poller and basic poller - regular polls for 'important' parameters done by method doPoll - all other parameters are polled slower (slowInterval) and with lower priority (only one at a time when main poll is due) - nopoll decorator for read_* to disable poll - enablePoll attribute (default True) for disabling polling a module - fast polls may be implemented by means of a statemachine - configurable slow poll interval + allow a Parameter to override a Property (parameter Readable.pollinterval overrides Module.pollinterval) Change-Id: Ib1b3453041a233678b7c4b4add22ac399670e447 Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/27832 Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de> Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch> Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
This commit is contained in:
@ -69,8 +69,8 @@ def test_handler():
|
||||
data = []
|
||||
|
||||
class Mod(ModuleTest):
|
||||
a = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
b = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
a = Parameter('', FloatRange(), readonly=False)
|
||||
b = Parameter('', FloatRange(), readonly=False)
|
||||
|
||||
@ReadHandler(['a', 'b'])
|
||||
def read_hdl(self, pname):
|
||||
@ -115,8 +115,8 @@ def test_common_handler():
|
||||
data = []
|
||||
|
||||
class Mod(ModuleTest):
|
||||
a = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
b = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
a = Parameter('', FloatRange(), readonly=False)
|
||||
b = Parameter('', FloatRange(), readonly=False)
|
||||
|
||||
@CommonReadHandler(['a', 'b'])
|
||||
def read_hdl(self):
|
||||
@ -164,8 +164,8 @@ def test_common_handler():
|
||||
|
||||
def test_nopoll():
|
||||
class Mod1(ModuleTest):
|
||||
a = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
b = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
a = Parameter('', FloatRange(), readonly=False)
|
||||
b = Parameter('', FloatRange(), readonly=False)
|
||||
|
||||
@ReadHandler(['a', 'b'])
|
||||
def read_hdl(self):
|
||||
@ -175,8 +175,8 @@ def test_nopoll():
|
||||
assert Mod1.read_b.poll is True
|
||||
|
||||
class Mod2(ModuleTest):
|
||||
a = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
b = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
a = Parameter('', FloatRange(), readonly=False)
|
||||
b = Parameter('', FloatRange(), readonly=False)
|
||||
|
||||
@CommonReadHandler(['a', 'b'])
|
||||
def read_hdl(self):
|
||||
@ -186,8 +186,8 @@ def test_nopoll():
|
||||
assert Mod2.read_b.poll is False
|
||||
|
||||
class Mod3(ModuleTest):
|
||||
a = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
b = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
a = Parameter('', FloatRange(), readonly=False)
|
||||
b = Parameter('', FloatRange(), readonly=False)
|
||||
|
||||
@ReadHandler(['a', 'b'])
|
||||
@nopoll
|
||||
@ -198,8 +198,8 @@ def test_nopoll():
|
||||
assert Mod3.read_b.poll is False
|
||||
|
||||
class Mod4(ModuleTest):
|
||||
a = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
b = Parameter('', FloatRange(), readonly=False, poll=True)
|
||||
a = Parameter('', FloatRange(), readonly=False)
|
||||
b = Parameter('', FloatRange(), readonly=False)
|
||||
|
||||
@nopoll
|
||||
@ReadHandler(['a', 'b'])
|
||||
|
@ -22,15 +22,15 @@
|
||||
# *****************************************************************************
|
||||
"""test data types."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
from secop.datatypes import BoolType, FloatRange, StringType, IntRange, ScaledInteger
|
||||
from secop.errors import ProgrammingError, ConfigError
|
||||
from secop.modules import Communicator, Drivable, Readable, Module
|
||||
from secop.params import Command, Parameter
|
||||
from secop.poller import BasicPoller
|
||||
from secop.lib.multievent import MultiEvent
|
||||
from secop.rwhandler import ReadHandler, WriteHandler
|
||||
from secop.rwhandler import ReadHandler, WriteHandler, nopoll
|
||||
from secop.lib import generalConfig
|
||||
|
||||
|
||||
@ -67,11 +67,19 @@ class ServerStub:
|
||||
self.dispatcher = DispatcherStub(updates)
|
||||
|
||||
|
||||
class DummyMultiEvent(threading.Event):
|
||||
def get_trigger(self):
|
||||
def trigger(event=self):
|
||||
event.set()
|
||||
sys.exit()
|
||||
return trigger
|
||||
|
||||
|
||||
def test_Communicator():
|
||||
o = Communicator('communicator', LoggerStub(), {'.description': ''}, ServerStub({}))
|
||||
o.earlyInit()
|
||||
o.initModule()
|
||||
event = MultiEvent()
|
||||
event = DummyMultiEvent()
|
||||
o.startModule(event)
|
||||
assert event.is_set() # event should be set immediately
|
||||
|
||||
@ -96,8 +104,6 @@ def test_ModuleMagic():
|
||||
"""another stuff"""
|
||||
return not arg
|
||||
|
||||
pollerClass = BasicPoller
|
||||
|
||||
def read_param1(self):
|
||||
return True
|
||||
|
||||
@ -107,6 +113,7 @@ def test_ModuleMagic():
|
||||
def read_a1(self):
|
||||
return True
|
||||
|
||||
@nopoll
|
||||
def read_a2(self):
|
||||
return True
|
||||
|
||||
@ -140,8 +147,10 @@ def test_ModuleMagic():
|
||||
value = Parameter(datatype=FloatRange(unit='deg'))
|
||||
target = Parameter(datatype=FloatRange(), default=0)
|
||||
a1 = Parameter(datatype=FloatRange(unit='$/s'), readonly=False)
|
||||
# remark: it might be a programming error to override the datatype
|
||||
# and not overriding the read_* method. This is not checked!
|
||||
b2 = Parameter('<b2>', datatype=BoolType(), default=True,
|
||||
poll=True, readonly=False, initwrite=True)
|
||||
readonly=False, initwrite=True)
|
||||
|
||||
def write_a1(self, value):
|
||||
self._a1_written = value
|
||||
@ -182,12 +191,13 @@ def test_ModuleMagic():
|
||||
'value': 'first'}
|
||||
assert updates.pop('o1') == expectedBeforeStart
|
||||
o1.earlyInit()
|
||||
event = MultiEvent()
|
||||
event = DummyMultiEvent()
|
||||
o1.startModule(event)
|
||||
event.wait()
|
||||
# should contain polled values
|
||||
expectedAfterStart = {'status': (Drivable.Status.IDLE, 'ok'),
|
||||
'value': 'second'}
|
||||
expectedAfterStart = {
|
||||
'status': (Drivable.Status.IDLE, 'ok'), 'value': 'second',
|
||||
'param1': True, 'param2': 0.0, 'a1': True}
|
||||
assert updates.pop('o1') == expectedAfterStart
|
||||
|
||||
# check in addition if parameters are written
|
||||
@ -197,11 +207,12 @@ def test_ModuleMagic():
|
||||
expectedBeforeStart['target'] = 0.0
|
||||
assert updates.pop('o2') == expectedBeforeStart
|
||||
o2.earlyInit()
|
||||
event = MultiEvent()
|
||||
event = DummyMultiEvent()
|
||||
o2.startModule(event)
|
||||
event.wait()
|
||||
# value has changed type, b2 and a1 are written
|
||||
expectedAfterStart.update(value=0, b2=True, a1=2.7)
|
||||
expectedAfterStart.update(value=0, b2=True, a1=True)
|
||||
# ramerk: a1=True: this behaviour is a Porgamming error
|
||||
assert updates.pop('o2') == expectedAfterStart
|
||||
assert o2._a1_written == 2.7
|
||||
assert o2._b2_written is True
|
||||
@ -218,13 +229,15 @@ def test_ModuleMagic():
|
||||
# check '$' in unit works properly
|
||||
assert o2.parameters['a1'].datatype.unit == 'mm/s'
|
||||
cfg = Newclass2.configurables
|
||||
assert set(cfg.keys()) == {'export', 'group', 'description', 'disable_value_range_check',
|
||||
assert set(cfg.keys()) == {
|
||||
'export', 'group', 'description', 'disable_value_range_check',
|
||||
'meaning', 'visibility', 'implementation', 'interface_classes', 'target', 'stop',
|
||||
'status', 'param1', 'param2', 'cmd', 'a2', 'pollinterval', 'b2', 'cmd2', 'value',
|
||||
'a1'}
|
||||
assert set(cfg['value'].keys()) == {'group', 'export', 'relative_resolution',
|
||||
'status', 'param1', 'param2', 'cmd', 'a2', 'pollinterval', 'slowinterval', 'b2',
|
||||
'cmd2', 'value', 'a1'}
|
||||
assert set(cfg['value'].keys()) == {
|
||||
'group', 'export', 'relative_resolution',
|
||||
'visibility', 'unit', 'default', 'datatype', 'fmtstr',
|
||||
'absolute_resolution', 'poll', 'max', 'min', 'readonly', 'constant',
|
||||
'absolute_resolution', 'max', 'min', 'readonly', 'constant',
|
||||
'description', 'needscfg'}
|
||||
|
||||
# check on the level of classes
|
||||
|
@ -21,18 +21,20 @@
|
||||
# *****************************************************************************
|
||||
"""test poller."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from secop.modules import Drivable
|
||||
from secop.poller import DYNAMIC, REGULAR, SLOW, Poller
|
||||
from secop.core import Module, Parameter, FloatRange, Readable, ReadHandler, nopoll
|
||||
from secop.lib.multievent import MultiEvent
|
||||
|
||||
Status = Drivable.Status
|
||||
|
||||
class Time:
|
||||
STARTTIME = 1000 # artificial time zero
|
||||
STARTTIME = 1000 # artificial time zero
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
self.finish = float('inf')
|
||||
@ -61,190 +63,103 @@ class Time:
|
||||
self.seconds += seconds
|
||||
self.busytime += seconds
|
||||
|
||||
|
||||
artime = Time() # artificial test time
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_time(monkeypatch):
|
||||
monkeypatch.setattr(time, 'time', artime.time)
|
||||
|
||||
class Event(threading.Event):
|
||||
def wait(self, timeout=None):
|
||||
artime.sleep(max(0, timeout))
|
||||
|
||||
|
||||
class Event:
|
||||
def __init__(self):
|
||||
self.flag = False
|
||||
class DispatcherStub:
|
||||
maxcycles = 10
|
||||
|
||||
def wait(self, timeout):
|
||||
artime.sleep(max(0,timeout))
|
||||
|
||||
def set(self):
|
||||
self.flag = True
|
||||
|
||||
def clear(self):
|
||||
self.flag = False
|
||||
|
||||
def is_set(self):
|
||||
return self.flag
|
||||
|
||||
|
||||
class Parameter:
|
||||
def __init__(self, name, readonly, poll, polltype, interval):
|
||||
self.poll = poll
|
||||
self.polltype = polltype # used for check only
|
||||
self.export = name
|
||||
self.readonly = readonly
|
||||
self.interval = interval
|
||||
self.timestamp = 0
|
||||
self.handler = None
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.cnt = 0
|
||||
self.span = 0
|
||||
self.maxspan = 0
|
||||
|
||||
def rfunc(self):
|
||||
artime.busy(artime.commtime)
|
||||
def announce_update(self, modulename, pname, pobj):
|
||||
now = artime.time()
|
||||
self.span = now - self.timestamp
|
||||
self.maxspan = max(self.maxspan, self.span)
|
||||
self.timestamp = now
|
||||
self.cnt += 1
|
||||
return True
|
||||
|
||||
def __repr__(self):
|
||||
return 'Parameter(%s)' % ", ".join("%s=%r" % item for item in self.__dict__.items())
|
||||
|
||||
|
||||
class Module:
|
||||
properties = {}
|
||||
pollerClass = Poller
|
||||
|
||||
class io:
|
||||
name = 'common_io'
|
||||
|
||||
def __init__(self, name, pollinterval=5, fastfactor=0.25, slowfactor=4, busy=False,
|
||||
counts=(), auto=None):
|
||||
'''create a dummy module
|
||||
|
||||
nauto, ndynamic, nregular, nslow are the number of parameters of each polltype
|
||||
'''
|
||||
self.pollinterval = pollinterval
|
||||
self.fast_pollfactor = fastfactor
|
||||
self.slow_pollfactor = slowfactor
|
||||
self.parameters = OrderedDict()
|
||||
self.name = name
|
||||
self.is_busy = busy
|
||||
if auto is not None:
|
||||
self.pvalue = self.addPar('value', True, auto or DYNAMIC, DYNAMIC)
|
||||
# readonly = False should not matter:
|
||||
self.pstatus = self.addPar('status', False, auto or DYNAMIC, DYNAMIC)
|
||||
self.pregular = self.addPar('regular', True, auto or REGULAR, REGULAR)
|
||||
self.pslow = self.addPar('slow', False, auto or SLOW, SLOW)
|
||||
self.addPar('notpolled', True, False, 0)
|
||||
self.counts = 'auto'
|
||||
if hasattr(pobj, 'stat'):
|
||||
pobj.stat.append(now)
|
||||
else:
|
||||
ndynamic, nregular, nslow = counts
|
||||
for i in range(ndynamic):
|
||||
self.addPar('%s:d%d' % (name, i), True, DYNAMIC, DYNAMIC)
|
||||
for i in range(nregular):
|
||||
self.addPar('%s:r%d' % (name, i), True, REGULAR, REGULAR)
|
||||
for i in range(nslow):
|
||||
self.addPar('%s:s%d' % (name, i), False, SLOW, SLOW)
|
||||
self.counts = counts
|
||||
pobj.stat = [now]
|
||||
self.maxcycles -= 1
|
||||
if self.maxcycles <= 0:
|
||||
self.finish_event.set()
|
||||
sys.exit() # stop thread
|
||||
|
||||
def addPar(self, name, readonly, poll, expected_polltype):
|
||||
# self.count[polltype] += 1
|
||||
expected_interval = self.pollinterval
|
||||
if expected_polltype == SLOW:
|
||||
expected_interval *= self.slow_pollfactor
|
||||
elif expected_polltype == DYNAMIC and self.is_busy:
|
||||
expected_interval *= self.fast_pollfactor
|
||||
pobj = Parameter(name, readonly, poll, expected_polltype, expected_interval)
|
||||
setattr(self, 'read_' + pobj.export, pobj.rfunc)
|
||||
self.parameters[pobj.export] = pobj
|
||||
return pobj
|
||||
|
||||
def isBusy(self):
|
||||
return self.is_busy
|
||||
class ServerStub:
|
||||
def __init__(self):
|
||||
self.dispatcher = DispatcherStub()
|
||||
|
||||
def pollOneParam(self, pname):
|
||||
getattr(self, 'read_' + pname)()
|
||||
|
||||
def writeInitParams(self):
|
||||
pass
|
||||
class Base(Module):
|
||||
def __init__(self):
|
||||
srv = ServerStub()
|
||||
super().__init__('mod', logging.getLogger('dummy'), dict(description=''), srv)
|
||||
self.dispatcher = srv.dispatcher
|
||||
self.nextPollEvent = Event()
|
||||
|
||||
def __repr__(self):
|
||||
rdict = self.__dict__.copy()
|
||||
rdict.pop('parameters')
|
||||
return 'Module(%r, counts=%r, f=%r, pollinterval=%g, is_busy=%r)' % (self.name,
|
||||
self.counts, (self.fast_pollfactor, self.slow_pollfactor, 1),
|
||||
self.pollinterval, self.is_busy)
|
||||
def run(self, maxcycles):
|
||||
self.dispatcher.maxcycles = maxcycles
|
||||
self.dispatcher.finish_event = threading.Event()
|
||||
self.startModule(MultiEvent())
|
||||
self.dispatcher.finish_event.wait(1)
|
||||
|
||||
module_list = [
|
||||
[Module('x', 3.0, 0.125, 10, False, auto=True),
|
||||
Module('y', 3.0, 0.125, 10, False, auto=False)],
|
||||
[Module('a', 1.0, 0.25, 4, True, (5, 5, 10)),
|
||||
Module('b', 2.0, 0.25, 4, True, (5, 5, 50))],
|
||||
[Module('c', 1.0, 0.25, 4, False, (5, 0, 0))],
|
||||
[Module('d', 1.0, 0.25, 4, True, (0, 9, 0))],
|
||||
[Module('e', 1.0, 0.25, 4, True, (0, 0, 9))],
|
||||
[Module('f', 1.0, 0.25, 4, True, (0, 0, 0))],
|
||||
]
|
||||
@pytest.mark.parametrize('modules', module_list)
|
||||
def test_Poller(modules):
|
||||
# check for proper timing
|
||||
|
||||
for overloaded in False, True:
|
||||
artime.reset()
|
||||
count = {DYNAMIC: 0, REGULAR: 0, SLOW: 0}
|
||||
maxspan = {DYNAMIC: 0, REGULAR: 0, SLOW: 0}
|
||||
pollTable = dict()
|
||||
for module in modules:
|
||||
Poller.add_to_table(pollTable, module)
|
||||
for pobj in module.parameters.values():
|
||||
if pobj.poll:
|
||||
maxspan[pobj.polltype] = max(maxspan[pobj.polltype], pobj.interval)
|
||||
count[pobj.polltype] += 1
|
||||
pobj.reset()
|
||||
assert len(pollTable) == 1
|
||||
poller = pollTable[(Poller, 'common_io')]
|
||||
artime.stop = poller.stop
|
||||
poller._event = Event() # patch Event.wait
|
||||
class Mod1(Base, Readable):
|
||||
param1 = Parameter('', FloatRange())
|
||||
param2 = Parameter('', FloatRange())
|
||||
param3 = Parameter('', FloatRange())
|
||||
param4 = Parameter('', FloatRange())
|
||||
|
||||
assert (sum(count.values()) > 0) == bool(poller)
|
||||
@ReadHandler(('param1', 'param2', 'param3'))
|
||||
def read_param(self, name):
|
||||
artime.sleep(1.0)
|
||||
return 0
|
||||
|
||||
def started_callback(modules=modules):
|
||||
for module in modules:
|
||||
for pobj in module.parameters.values():
|
||||
assert pobj.cnt == bool(pobj.poll) # all parameters have to be polled once
|
||||
pobj.reset() # set maxspan and cnt to 0
|
||||
@nopoll
|
||||
def read_param4(self):
|
||||
return 0
|
||||
|
||||
if overloaded:
|
||||
# overloaded scenario
|
||||
artime.commtime = 1.0
|
||||
ncycles = 10
|
||||
if count[SLOW] > 0:
|
||||
cycletime = (count[REGULAR] + 1) * count[SLOW] * 2
|
||||
else:
|
||||
cycletime = max(count[REGULAR], count[DYNAMIC]) * 2
|
||||
artime.reset(cycletime * ncycles * 1.01) # poller will quit given time
|
||||
poller.run(started_callback)
|
||||
total = artime.time() - artime.STARTTIME
|
||||
for module in modules:
|
||||
for pobj in module.parameters.values():
|
||||
if pobj.poll:
|
||||
# average_span = total / (pobj.cnt + 1)
|
||||
assert total / (pobj.cnt + 1) <= max(cycletime, pobj.interval * 1.1)
|
||||
else:
|
||||
# normal scenario
|
||||
artime.commtime = 0.001
|
||||
artime.reset(max(maxspan.values()) * 5) # poller will quit given time
|
||||
poller.run(started_callback)
|
||||
total = artime.time() - artime.STARTTIME
|
||||
for module in modules:
|
||||
for pobj in module.parameters.values():
|
||||
if pobj.poll:
|
||||
assert pobj.cnt > 0
|
||||
assert pobj.maxspan <= maxspan[pobj.polltype] * 1.1
|
||||
assert (pobj.cnt + 1) * pobj.interval >= total * 0.99
|
||||
assert abs(pobj.span - pobj.interval) < 0.01
|
||||
pobj.reset()
|
||||
def read_status(self):
|
||||
artime.sleep(1.0)
|
||||
return 0
|
||||
|
||||
def read_value(self):
|
||||
artime.sleep(1.0)
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'ncycles, pollinterval, slowinterval, mspan, pspan',
|
||||
[ # normal case: 5+-1 15+-1
|
||||
( 60, 5, 15, (4, 6), (14, 16)),
|
||||
# pollinterval faster then reading: mspan max 3 s (polls of value, status and ONE other parameter)
|
||||
( 60, 1, 5, (1, 3), (5, 16)),
|
||||
])
|
||||
def test_poll(ncycles, pollinterval, slowinterval, mspan, pspan, monkeypatch):
|
||||
monkeypatch.setattr(time, 'time', artime.time)
|
||||
artime.reset()
|
||||
m = Mod1()
|
||||
m.pollinterval = pollinterval
|
||||
m.slowInterval = slowinterval
|
||||
m.run(ncycles)
|
||||
assert not hasattr(m.parameters['param4'], 'stat')
|
||||
for pname in ['value', 'status']:
|
||||
pobj = m.parameters[pname]
|
||||
lowcnt = 0
|
||||
for t1, t2 in zip(pobj.stat[1:], pobj.stat[2:-1]):
|
||||
if t2 - t1 < mspan[0]:
|
||||
print(t2 - t1)
|
||||
lowcnt += 1
|
||||
assert t2 - t1 <= mspan[1]
|
||||
assert lowcnt <= 1
|
||||
for pname in ['param1', 'param2', 'param3']:
|
||||
pobj = m.parameters[pname]
|
||||
lowcnt = 0
|
||||
for t1, t2 in zip(pobj.stat[1:], pobj.stat[2:-1]):
|
||||
if t2 - t1 < pspan[0]:
|
||||
print(pname, t2 - t1)
|
||||
lowcnt += 1
|
||||
assert t2 - t1 <= pspan[1]
|
||||
assert lowcnt <= 1
|
||||
|
@ -26,6 +26,7 @@ import pytest
|
||||
from secop.datatypes import FloatRange, IntRange, StringType, ValueType
|
||||
from secop.errors import BadValueError, ConfigError, ProgrammingError
|
||||
from secop.properties import HasProperties, Property
|
||||
from secop.core import Parameter
|
||||
|
||||
|
||||
def Prop(*args, name=None, **kwds):
|
||||
@ -149,17 +150,25 @@ def test_Property_override():
|
||||
assert o2.a == 3
|
||||
|
||||
with pytest.raises(ProgrammingError) as e:
|
||||
class cx(c): # pylint: disable=unused-variable
|
||||
class cx(c): # pylint: disable=unused-variable
|
||||
def a(self):
|
||||
pass
|
||||
assert 'collides with' in str(e.value)
|
||||
|
||||
with pytest.raises(ProgrammingError) as e:
|
||||
class cz(c): # pylint: disable=unused-variable
|
||||
class cy(c): # pylint: disable=unused-variable
|
||||
a = 's'
|
||||
|
||||
assert 'can not set' in str(e.value)
|
||||
|
||||
with pytest.raises(ProgrammingError) as e:
|
||||
class cz(c): # pylint: disable=unused-variable
|
||||
a = 's'
|
||||
|
||||
class cp(c): # pylint: disable=unused-variable
|
||||
# overriding a Property with a Parameter is allowed
|
||||
a = Parameter('x', IntRange())
|
||||
|
||||
|
||||
def test_Properties_mro():
|
||||
class Base(HasProperties):
|
||||
|
Reference in New Issue
Block a user