Merge "Added suspended audit state"
This commit is contained in:
@@ -407,6 +407,9 @@ be one of the following:
|
|||||||
- **CANCELLED** : the :ref:`Audit <audit_definition>` was in **PENDING** or
|
- **CANCELLED** : the :ref:`Audit <audit_definition>` was in **PENDING** or
|
||||||
**ONGOING** state and was cancelled by the
|
**ONGOING** state and was cancelled by the
|
||||||
:ref:`Administrator <administrator_definition>`
|
:ref:`Administrator <administrator_definition>`
|
||||||
|
- **SUSPENDED** : the :ref:`Audit <audit_definition>` was in **ONGOING**
|
||||||
|
state and was suspended by the
|
||||||
|
:ref:`Administrator <administrator_definition>`
|
||||||
|
|
||||||
The following diagram shows the different possible states of an
|
The following diagram shows the different possible states of an
|
||||||
:ref:`Audit <audit_definition>` and what event makes the state change to a new
|
:ref:`Audit <audit_definition>` and what event makes the state change to a new
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
PENDING --> ONGOING: Audit request is received\nby the Watcher Decision Engine
|
PENDING --> ONGOING: Audit request is received\nby the Watcher Decision Engine
|
||||||
ONGOING --> FAILED: Audit fails\n(no solution found, technical error, ...)
|
ONGOING --> FAILED: Audit fails\n(no solution found, technical error, ...)
|
||||||
ONGOING --> SUCCEEDED: The Watcher Decision Engine\ncould find at least one Solution
|
ONGOING --> SUCCEEDED: The Watcher Decision Engine\ncould find at least one Solution
|
||||||
|
ONGOING --> SUSPENDED: Administrator wants to\nsuspend the Audit
|
||||||
|
SUSPENDED --> ONGOING: Administrator wants to\nresume the Audit
|
||||||
FAILED --> DELETED : Administrator wants to\narchive/delete the Audit
|
FAILED --> DELETED : Administrator wants to\narchive/delete the Audit
|
||||||
SUCCEEDED --> DELETED : Administrator wants to\narchive/delete the Audit
|
SUCCEEDED --> DELETED : Administrator wants to\narchive/delete the Audit
|
||||||
PENDING --> CANCELLED : Administrator cancels\nthe Audit
|
PENDING --> CANCELLED : Administrator cancels\nthe Audit
|
||||||
ONGOING --> CANCELLED : Administrator cancels\nthe Audit
|
ONGOING --> CANCELLED : Administrator cancels\nthe Audit
|
||||||
CANCELLED --> DELETED : Administrator wants to\narchive/delete the Audit
|
CANCELLED --> DELETED : Administrator wants to\narchive/delete the Audit
|
||||||
|
SUSPENDED --> DELETED: Administrator wants to\narchive/delete the Audit
|
||||||
DELETED --> [*]
|
DELETED --> [*]
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Added SUSPENDED audit state
|
||||||
@@ -50,21 +50,6 @@ from watcher.decision_engine import rpcapi
|
|||||||
from watcher import objects
|
from watcher import objects
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_AUDIT_TRANSITIONS = {
|
|
||||||
objects.audit.State.PENDING:
|
|
||||||
[objects.audit.State.ONGOING, objects.audit.State.CANCELLED],
|
|
||||||
objects.audit.State.ONGOING:
|
|
||||||
[objects.audit.State.FAILED, objects.audit.State.SUCCEEDED,
|
|
||||||
objects.audit.State.CANCELLED],
|
|
||||||
objects.audit.State.FAILED:
|
|
||||||
[objects.audit.State.DELETED],
|
|
||||||
objects.audit.State.SUCCEEDED:
|
|
||||||
[objects.audit.State.DELETED],
|
|
||||||
objects.audit.State.CANCELLED:
|
|
||||||
[objects.audit.State.DELETED]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AuditPostType(wtypes.Base):
|
class AuditPostType(wtypes.Base):
|
||||||
|
|
||||||
audit_template_uuid = wtypes.wsattr(types.uuid, mandatory=False)
|
audit_template_uuid = wtypes.wsattr(types.uuid, mandatory=False)
|
||||||
@@ -144,8 +129,15 @@ class AuditPatchType(types.JsonPatchType):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate(patch):
|
def validate(patch):
|
||||||
serialized_patch = {'path': patch.path, 'op': patch.op}
|
|
||||||
if patch.path in AuditPatchType.mandatory_attrs():
|
def is_new_state_none(p):
|
||||||
|
return p.path == '/state' and p.op == 'replace' and p.value is None
|
||||||
|
|
||||||
|
serialized_patch = {'path': patch.path,
|
||||||
|
'op': patch.op,
|
||||||
|
'value': patch.value}
|
||||||
|
if (patch.path in AuditPatchType.mandatory_attrs() or
|
||||||
|
is_new_state_none(patch)):
|
||||||
msg = _("%(field)s can't be updated.")
|
msg = _("%(field)s can't be updated.")
|
||||||
raise exception.PatchError(
|
raise exception.PatchError(
|
||||||
patch=serialized_patch,
|
patch=serialized_patch,
|
||||||
@@ -572,21 +564,22 @@ class AuditsController(rest.RestController):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
audit_dict = audit_to_update.as_dict()
|
audit_dict = audit_to_update.as_dict()
|
||||||
|
|
||||||
|
initial_state = audit_dict['state']
|
||||||
|
new_state = api_utils.get_patch_value(patch, 'state')
|
||||||
|
if not api_utils.check_audit_state_transition(
|
||||||
|
patch, initial_state):
|
||||||
|
error_message = _("State transition not allowed: "
|
||||||
|
"(%(initial_state)s -> %(new_state)s)")
|
||||||
|
raise exception.PatchError(
|
||||||
|
patch=patch,
|
||||||
|
reason=error_message % dict(
|
||||||
|
initial_state=initial_state, new_state=new_state))
|
||||||
|
|
||||||
audit = Audit(**api_utils.apply_jsonpatch(audit_dict, patch))
|
audit = Audit(**api_utils.apply_jsonpatch(audit_dict, patch))
|
||||||
except api_utils.JSONPATCH_EXCEPTIONS as e:
|
except api_utils.JSONPATCH_EXCEPTIONS as e:
|
||||||
raise exception.PatchError(patch=patch, reason=e)
|
raise exception.PatchError(patch=patch, reason=e)
|
||||||
|
|
||||||
initial_state = audit_dict['state']
|
|
||||||
new_state = api_utils.get_patch_value(patch, 'state')
|
|
||||||
allowed_states = ALLOWED_AUDIT_TRANSITIONS.get(initial_state, [])
|
|
||||||
if new_state is not None and new_state not in allowed_states:
|
|
||||||
error_message = _("State transition not allowed: "
|
|
||||||
"(%(initial_state)s -> %(new_state)s)")
|
|
||||||
raise exception.PatchError(
|
|
||||||
patch=patch,
|
|
||||||
reason=error_message % dict(
|
|
||||||
initial_state=initial_state, new_state=new_state))
|
|
||||||
|
|
||||||
# Update only the fields that have changed
|
# Update only the fields that have changed
|
||||||
for field in objects.Audit.fields:
|
for field in objects.Audit.fields:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ def get_patch_value(patch, key):
|
|||||||
return p['value']
|
return p['value']
|
||||||
|
|
||||||
|
|
||||||
|
def check_audit_state_transition(patch, initial):
|
||||||
|
is_transition_valid = True
|
||||||
|
state_value = get_patch_value(patch, "state")
|
||||||
|
if state_value is not None:
|
||||||
|
is_transition_valid = objects.audit.AuditStateTransitionManager(
|
||||||
|
).check_transition(initial, state_value)
|
||||||
|
return is_transition_valid
|
||||||
|
|
||||||
|
|
||||||
def as_filters_dict(**filters):
|
def as_filters_dict(**filters):
|
||||||
filters_dict = {}
|
filters_dict = {}
|
||||||
for filter_name, filter_value in filters.items():
|
for filter_name, filter_value in filters.items():
|
||||||
|
|||||||
@@ -49,9 +49,7 @@ class ContinuousAuditHandler(base.AuditHandler):
|
|||||||
def _is_audit_inactive(self, audit):
|
def _is_audit_inactive(self, audit):
|
||||||
audit = objects.Audit.get_by_uuid(
|
audit = objects.Audit.get_by_uuid(
|
||||||
self.context_show_deleted, audit.uuid)
|
self.context_show_deleted, audit.uuid)
|
||||||
if audit.state in (objects.audit.State.CANCELLED,
|
if objects.audit.AuditStateTransitionManager().is_inactive(audit):
|
||||||
objects.audit.State.DELETED,
|
|
||||||
objects.audit.State.FAILED):
|
|
||||||
# if audit isn't in active states, audit's job must be removed to
|
# if audit isn't in active states, audit's job must be removed to
|
||||||
# prevent using of inactive audit in future.
|
# prevent using of inactive audit in future.
|
||||||
job_to_delete = [job for job in self.jobs
|
job_to_delete = [job for job in self.jobs
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ be one of the following:
|
|||||||
- **CANCELLED** : the :ref:`Audit <audit_definition>` was in **PENDING** or
|
- **CANCELLED** : the :ref:`Audit <audit_definition>` was in **PENDING** or
|
||||||
**ONGOING** state and was cancelled by the
|
**ONGOING** state and was cancelled by the
|
||||||
:ref:`Administrator <administrator_definition>`
|
:ref:`Administrator <administrator_definition>`
|
||||||
|
- **SUSPENDED** : the :ref:`Audit <audit_definition>` was in **ONGOING**
|
||||||
|
state and was suspended by the
|
||||||
|
:ref:`Administrator <administrator_definition>`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
@@ -66,6 +69,7 @@ class State(object):
|
|||||||
CANCELLED = 'CANCELLED'
|
CANCELLED = 'CANCELLED'
|
||||||
DELETED = 'DELETED'
|
DELETED = 'DELETED'
|
||||||
PENDING = 'PENDING'
|
PENDING = 'PENDING'
|
||||||
|
SUSPENDED = 'SUSPENDED'
|
||||||
|
|
||||||
|
|
||||||
class AuditType(enum.Enum):
|
class AuditType(enum.Enum):
|
||||||
@@ -296,3 +300,25 @@ class Audit(base.WatcherPersistentObject, base.WatcherObject,
|
|||||||
notifications.audit.send_delete(self._context, self)
|
notifications.audit.send_delete(self._context, self)
|
||||||
|
|
||||||
_notify()
|
_notify()
|
||||||
|
|
||||||
|
|
||||||
|
class AuditStateTransitionManager(object):
|
||||||
|
|
||||||
|
TRANSITIONS = {
|
||||||
|
State.PENDING: [State.ONGOING, State.CANCELLED],
|
||||||
|
State.ONGOING: [State.FAILED, State.SUCCEEDED,
|
||||||
|
State.CANCELLED, State.SUSPENDED],
|
||||||
|
State.FAILED: [State.DELETED],
|
||||||
|
State.SUCCEEDED: [State.DELETED],
|
||||||
|
State.CANCELLED: [State.DELETED],
|
||||||
|
State.SUSPENDED: [State.ONGOING, State.DELETED],
|
||||||
|
}
|
||||||
|
|
||||||
|
INACTIVE_STATES = (State.CANCELLED, State.DELETED,
|
||||||
|
State.FAILED, State.SUSPENDED)
|
||||||
|
|
||||||
|
def check_transition(self, initial, new):
|
||||||
|
return new in self.TRANSITIONS.get(initial, [])
|
||||||
|
|
||||||
|
def is_inactive(self, audit):
|
||||||
|
return audit.state in self.INACTIVE_STATES
|
||||||
|
|||||||
@@ -345,23 +345,10 @@ class TestPatch(api_base.FunctionalTest):
|
|||||||
|
|
||||||
|
|
||||||
ALLOWED_TRANSITIONS = [
|
ALLOWED_TRANSITIONS = [
|
||||||
{"original_state": objects.audit.State.PENDING,
|
{"original_state": key, "new_state": value}
|
||||||
"new_state": objects.audit.State.ONGOING},
|
for key, values in (
|
||||||
{"original_state": objects.audit.State.PENDING,
|
objects.audit.AuditStateTransitionManager.TRANSITIONS.items())
|
||||||
"new_state": objects.audit.State.CANCELLED},
|
for value in values]
|
||||||
{"original_state": objects.audit.State.ONGOING,
|
|
||||||
"new_state": objects.audit.State.FAILED},
|
|
||||||
{"original_state": objects.audit.State.ONGOING,
|
|
||||||
"new_state": objects.audit.State.SUCCEEDED},
|
|
||||||
{"original_state": objects.audit.State.ONGOING,
|
|
||||||
"new_state": objects.audit.State.CANCELLED},
|
|
||||||
{"original_state": objects.audit.State.FAILED,
|
|
||||||
"new_state": objects.audit.State.DELETED},
|
|
||||||
{"original_state": objects.audit.State.SUCCEEDED,
|
|
||||||
"new_state": objects.audit.State.DELETED},
|
|
||||||
{"original_state": objects.audit.State.CANCELLED,
|
|
||||||
"new_state": objects.audit.State.DELETED},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPatchStateTransitionDenied(api_base.FunctionalTest):
|
class TestPatchStateTransitionDenied(api_base.FunctionalTest):
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class TestDbAuditFilters(base.DbTestCase):
|
|||||||
self.audit3 = utils.create_test_audit(
|
self.audit3 = utils.create_test_audit(
|
||||||
audit_template_id=self.audit_template.id, id=3, uuid=None,
|
audit_template_id=self.audit_template.id, id=3, uuid=None,
|
||||||
state=objects.audit.State.CANCELLED)
|
state=objects.audit.State.CANCELLED)
|
||||||
|
with freezegun.freeze_time(self.FAKE_OLDER_DATE):
|
||||||
|
self.audit4 = utils.create_test_audit(
|
||||||
|
audit_template_id=self.audit_template.id, id=4, uuid=None,
|
||||||
|
state=objects.audit.State.SUSPENDED)
|
||||||
|
|
||||||
def _soft_delete_audits(self):
|
def _soft_delete_audits(self):
|
||||||
with freezegun.freeze_time(self.FAKE_TODAY):
|
with freezegun.freeze_time(self.FAKE_TODAY):
|
||||||
@@ -92,8 +96,9 @@ class TestDbAuditFilters(base.DbTestCase):
|
|||||||
res = self.dbapi.get_audit_list(
|
res = self.dbapi.get_audit_list(
|
||||||
self.context, filters={'deleted': False})
|
self.context, filters={'deleted': False})
|
||||||
|
|
||||||
self.assertEqual([self.audit2['id'], self.audit3['id']],
|
self.assertEqual(
|
||||||
[r.id for r in res])
|
[self.audit2['id'], self.audit3['id'], self.audit4['id']],
|
||||||
|
[r.id for r in res])
|
||||||
|
|
||||||
def test_get_audit_list_filter_deleted_at_eq(self):
|
def test_get_audit_list_filter_deleted_at_eq(self):
|
||||||
self._soft_delete_audits()
|
self._soft_delete_audits()
|
||||||
@@ -154,7 +159,7 @@ class TestDbAuditFilters(base.DbTestCase):
|
|||||||
self.context, filters={'created_at__lt': self.FAKE_TODAY})
|
self.context, filters={'created_at__lt': self.FAKE_TODAY})
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[self.audit2['id'], self.audit3['id']],
|
[self.audit2['id'], self.audit3['id'], self.audit4['id']],
|
||||||
[r.id for r in res])
|
[r.id for r in res])
|
||||||
|
|
||||||
def test_get_audit_list_filter_created_at_lte(self):
|
def test_get_audit_list_filter_created_at_lte(self):
|
||||||
@@ -162,7 +167,7 @@ class TestDbAuditFilters(base.DbTestCase):
|
|||||||
self.context, filters={'created_at__lte': self.FAKE_OLD_DATE})
|
self.context, filters={'created_at__lte': self.FAKE_OLD_DATE})
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[self.audit2['id'], self.audit3['id']],
|
[self.audit2['id'], self.audit3['id'], self.audit4['id']],
|
||||||
[r.id for r in res])
|
[r.id for r in res])
|
||||||
|
|
||||||
def test_get_audit_list_filter_created_at_gt(self):
|
def test_get_audit_list_filter_created_at_gt(self):
|
||||||
@@ -230,18 +235,22 @@ class TestDbAuditFilters(base.DbTestCase):
|
|||||||
def test_get_audit_list_filter_state_in(self):
|
def test_get_audit_list_filter_state_in(self):
|
||||||
res = self.dbapi.get_audit_list(
|
res = self.dbapi.get_audit_list(
|
||||||
self.context,
|
self.context,
|
||||||
filters={'state__in': (objects.audit.State.FAILED,
|
filters={
|
||||||
objects.audit.State.CANCELLED)})
|
'state__in':
|
||||||
|
objects.audit.AuditStateTransitionManager.INACTIVE_STATES
|
||||||
|
})
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[self.audit2['id'], self.audit3['id']],
|
[self.audit2['id'], self.audit3['id'], self.audit4['id']],
|
||||||
[r.id for r in res])
|
[r.id for r in res])
|
||||||
|
|
||||||
def test_get_audit_list_filter_state_notin(self):
|
def test_get_audit_list_filter_state_notin(self):
|
||||||
res = self.dbapi.get_audit_list(
|
res = self.dbapi.get_audit_list(
|
||||||
self.context,
|
self.context,
|
||||||
filters={'state__notin': (objects.audit.State.FAILED,
|
filters={
|
||||||
objects.audit.State.CANCELLED)})
|
'state__notin':
|
||||||
|
objects.audit.AuditStateTransitionManager.INACTIVE_STATES
|
||||||
|
})
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[self.audit1['id']],
|
[self.audit1['id']],
|
||||||
|
|||||||
@@ -274,16 +274,18 @@ class TestContinuousAuditHandler(base.DbTestCase):
|
|||||||
audit_handler = continuous.ContinuousAuditHandler(mock.MagicMock())
|
audit_handler = continuous.ContinuousAuditHandler(mock.MagicMock())
|
||||||
mock_list.return_value = self.audits
|
mock_list.return_value = self.audits
|
||||||
mock_jobs.return_value = mock.MagicMock()
|
mock_jobs.return_value = mock.MagicMock()
|
||||||
self.audits[1].state = objects.audit.State.CANCELLED
|
|
||||||
calls = [mock.call(audit_handler.execute_audit, 'interval',
|
|
||||||
args=[mock.ANY, mock.ANY],
|
|
||||||
seconds=3600,
|
|
||||||
name='execute_audit',
|
|
||||||
next_run_time=mock.ANY)]
|
|
||||||
audit_handler.launch_audits_periodically()
|
|
||||||
m_add_job.assert_has_calls(calls)
|
|
||||||
|
|
||||||
audit_handler.update_audit_state(self.audits[1],
|
for state in [objects.audit.State.CANCELLED,
|
||||||
objects.audit.State.CANCELLED)
|
objects.audit.State.SUSPENDED]:
|
||||||
is_inactive = audit_handler._is_audit_inactive(self.audits[1])
|
self.audits[1].state = state
|
||||||
|
calls = [mock.call(audit_handler.execute_audit, 'interval',
|
||||||
|
args=[mock.ANY, mock.ANY],
|
||||||
|
seconds=3600,
|
||||||
|
name='execute_audit',
|
||||||
|
next_run_time=mock.ANY)]
|
||||||
|
audit_handler.launch_audits_periodically()
|
||||||
|
m_add_job.assert_has_calls(calls)
|
||||||
|
|
||||||
|
audit_handler.update_audit_state(self.audits[1], state)
|
||||||
|
is_inactive = audit_handler._is_audit_inactive(self.audits[1])
|
||||||
self.assertTrue(is_inactive)
|
self.assertTrue(is_inactive)
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ class BaseInfraOptimTest(test.BaseTestCase):
|
|||||||
"""Base class for Infrastructure Optimization API tests."""
|
"""Base class for Infrastructure Optimization API tests."""
|
||||||
|
|
||||||
# States where the object is waiting for some event to perform a transition
|
# States where the object is waiting for some event to perform a transition
|
||||||
IDLE_STATES = ('RECOMMENDED', 'FAILED', 'SUCCEEDED', 'CANCELLED')
|
IDLE_STATES = ('RECOMMENDED',
|
||||||
|
'FAILED',
|
||||||
|
'SUCCEEDED',
|
||||||
|
'CANCELLED',
|
||||||
|
'SUSPENDED')
|
||||||
# States where the object can only be DELETED (end of its life-cycle)
|
# States where the object can only be DELETED (end of its life-cycle)
|
||||||
FINISHED_STATES = ('FAILED', 'SUCCEEDED', 'CANCELLED', 'SUPERSEDED')
|
FINISHED_STATES = ('FAILED',
|
||||||
|
'SUCCEEDED',
|
||||||
|
'CANCELLED',
|
||||||
|
'SUPERSEDED')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_credentials(cls):
|
def setup_credentials(cls):
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class TestCreateUpdateDeleteAudit(base.BaseInfraOptimTest):
|
|||||||
"""Tests for audit."""
|
"""Tests for audit."""
|
||||||
|
|
||||||
audit_states = ['ONGOING', 'SUCCEEDED', 'FAILED',
|
audit_states = ['ONGOING', 'SUCCEEDED', 'FAILED',
|
||||||
'CANCELLED', 'DELETED', 'PENDING']
|
'CANCELLED', 'DELETED', 'PENDING', 'SUSPENDED']
|
||||||
|
|
||||||
def assert_expected(self, expected, actual,
|
def assert_expected(self, expected, actual,
|
||||||
keys=('created_at', 'updated_at',
|
keys=('created_at', 'updated_at',
|
||||||
@@ -154,7 +154,7 @@ class TestShowListAudit(base.BaseInfraOptimTest):
|
|||||||
"""Tests for audit."""
|
"""Tests for audit."""
|
||||||
|
|
||||||
audit_states = ['ONGOING', 'SUCCEEDED', 'FAILED',
|
audit_states = ['ONGOING', 'SUCCEEDED', 'FAILED',
|
||||||
'CANCELLED', 'DELETED', 'PENDING']
|
'CANCELLED', 'DELETED', 'PENDING', 'SUSPENDED']
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resource_setup(cls):
|
def resource_setup(cls):
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class TestExecuteBasicStrategy(base.BaseInfraOptimScenarioTest):
|
|||||||
self.fail("The audit has failed!")
|
self.fail("The audit has failed!")
|
||||||
|
|
||||||
_, finished_audit = self.client.show_audit(audit['uuid'])
|
_, finished_audit = self.client.show_audit(audit['uuid'])
|
||||||
if finished_audit.get('state') in ('FAILED', 'CANCELLED'):
|
if finished_audit.get('state') in ('FAILED', 'CANCELLED', 'SUSPENDED'):
|
||||||
self.fail("The audit ended in unexpected state: %s!"
|
self.fail("The audit ended in unexpected state: %s!"
|
||||||
% finished_audit.get('state'))
|
% finished_audit.get('state'))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user