mirror of
https://github.com/ansible/awx.git
synced 2026-03-01 00:38:45 -03:30
Adds Add button to schedules list along with rbac restrictions
This commit is contained in:
12
awx/ui_next/src/api/mixins/Schedules.mixin.js
Normal file
12
awx/ui_next/src/api/mixins/Schedules.mixin.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const SchedulesMixin = parent =>
|
||||||
|
class extends parent {
|
||||||
|
readSchedules(id, params) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
readScheduleOptions(id) {
|
||||||
|
return this.http.options(`${this.baseUrl}${id}/schedules/`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SchedulesMixin;
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import NotificationsMixin from '../mixins/Notifications.mixin';
|
import NotificationsMixin from '../mixins/Notifications.mixin';
|
||||||
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
|
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
|
||||||
|
import SchedulesMixin from '../mixins/Schedules.mixin';
|
||||||
|
|
||||||
class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) {
|
class JobTemplates extends SchedulesMixin(
|
||||||
|
InstanceGroupsMixin(NotificationsMixin(Base))
|
||||||
|
) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/job_templates/';
|
this.baseUrl = '/api/v2/job_templates/';
|
||||||
@@ -61,10 +64,6 @@ class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
readScheduleList(id, params) {
|
|
||||||
return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default JobTemplates;
|
export default JobTemplates;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import NotificationsMixin from '../mixins/Notifications.mixin';
|
import NotificationsMixin from '../mixins/Notifications.mixin';
|
||||||
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
||||||
|
import SchedulesMixin from '../mixins/Schedules.mixin';
|
||||||
|
|
||||||
class Projects extends LaunchUpdateMixin(NotificationsMixin(Base)) {
|
class Projects extends SchedulesMixin(
|
||||||
|
LaunchUpdateMixin(NotificationsMixin(Base))
|
||||||
|
) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/projects/';
|
this.baseUrl = '/api/v2/projects/';
|
||||||
@@ -21,10 +24,6 @@ class Projects extends LaunchUpdateMixin(NotificationsMixin(Base)) {
|
|||||||
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
|
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
readScheduleList(id, params) {
|
|
||||||
return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
readSync(id) {
|
readSync(id) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/update/`);
|
return this.http.get(`${this.baseUrl}${id}/update/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
|
import SchedulesMixin from '../mixins/Schedules.mixin';
|
||||||
|
|
||||||
class WorkflowJobTemplates extends Base {
|
class WorkflowJobTemplates extends SchedulesMixin(Base) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/workflow_job_templates/';
|
this.baseUrl = '/api/v2/workflow_job_templates/';
|
||||||
@@ -45,12 +46,6 @@ class WorkflowJobTemplates extends Base {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
readScheduleList(id, params) {
|
|
||||||
return this.http.get(`${this.baseUrl}${id}/schedules/`, {
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WorkflowJobTemplates;
|
export default WorkflowJobTemplates;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { bool, func } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { SchedulesAPI } from '@api';
|
import { SchedulesAPI } from '@api';
|
||||||
@@ -7,6 +8,7 @@ import AlertModal from '@components/AlertModal';
|
|||||||
import ErrorDetail from '@components/ErrorDetail';
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
import DataListToolbar from '@components/DataListToolbar';
|
import DataListToolbar from '@components/DataListToolbar';
|
||||||
import PaginatedDataList, {
|
import PaginatedDataList, {
|
||||||
|
ToolbarAddButton,
|
||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '@components/PaginatedDataList';
|
} from '@components/PaginatedDataList';
|
||||||
import useRequest, { useDeleteItems } from '@util/useRequest';
|
import useRequest, { useDeleteItems } from '@util/useRequest';
|
||||||
@@ -19,28 +21,40 @@ const QS_CONFIG = getQSConfig('schedule', {
|
|||||||
order_by: 'unified_job_template__polymorphic_ctype__model',
|
order_by: 'unified_job_template__polymorphic_ctype__model',
|
||||||
});
|
});
|
||||||
|
|
||||||
function ScheduleList({ i18n, loadSchedules }) {
|
function ScheduleList({
|
||||||
|
i18n,
|
||||||
|
loadSchedules,
|
||||||
|
loadScheduleOptions,
|
||||||
|
hideAddButton,
|
||||||
|
}) {
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { schedules, itemCount },
|
result: { schedules, itemCount, actions },
|
||||||
error: contentError,
|
error: contentError,
|
||||||
isLoading,
|
isLoading,
|
||||||
request: fetchSchedules,
|
request: fetchSchedules,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(QS_CONFIG, location.search);
|
||||||
const response = loadSchedules(params);
|
const [
|
||||||
const {
|
{
|
||||||
data: { count, results },
|
data: { count, results },
|
||||||
} = await response;
|
},
|
||||||
return { itemCount: count, schedules: results };
|
scheduleActions,
|
||||||
}, [location, loadSchedules]),
|
] = await Promise.all([loadSchedules(params), loadScheduleOptions()]);
|
||||||
|
return {
|
||||||
|
schedules: results,
|
||||||
|
itemCount: count,
|
||||||
|
actions: scheduleActions.data.actions,
|
||||||
|
};
|
||||||
|
}, [location, loadSchedules, loadScheduleOptions]),
|
||||||
{
|
{
|
||||||
schedules: [],
|
schedules: [],
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
|
actions: {},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,6 +98,11 @@ function ScheduleList({ i18n, loadSchedules }) {
|
|||||||
setSelected([]);
|
setSelected([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canAdd =
|
||||||
|
actions &&
|
||||||
|
Object.prototype.hasOwnProperty.call(actions, 'POST') &&
|
||||||
|
!hideAddButton;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
@@ -130,6 +149,14 @@ function ScheduleList({ i18n, loadSchedules }) {
|
|||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
|
...(canAdd
|
||||||
|
? [
|
||||||
|
<ToolbarAddButton
|
||||||
|
key="add"
|
||||||
|
linkTo={`${location.pathname}/add`}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
@@ -155,4 +182,13 @@ function ScheduleList({ i18n, loadSchedules }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ScheduleList.propTypes = {
|
||||||
|
hideAddButton: bool,
|
||||||
|
loadSchedules: func.isRequired,
|
||||||
|
loadScheduleOptions: func.isRequired,
|
||||||
|
};
|
||||||
|
ScheduleList.defaultProps = {
|
||||||
|
hideAddButton: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default withI18n()(ScheduleList);
|
export default withI18n()(ScheduleList);
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ SchedulesAPI.destroy = jest.fn();
|
|||||||
SchedulesAPI.update.mockResolvedValue({
|
SchedulesAPI.update.mockResolvedValue({
|
||||||
data: mockSchedules.results[0],
|
data: mockSchedules.results[0],
|
||||||
});
|
});
|
||||||
|
SchedulesAPI.read.mockResolvedValue({ data: mockSchedules });
|
||||||
|
SchedulesAPI.readOptions.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
POST: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSchedules = params => SchedulesAPI.read(params);
|
||||||
|
const loadScheduleOptions = () => SchedulesAPI.readOptions();
|
||||||
|
|
||||||
describe('ScheduleList', () => {
|
describe('ScheduleList', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
@@ -21,11 +33,12 @@ describe('ScheduleList', () => {
|
|||||||
|
|
||||||
describe('read call successful', () => {
|
describe('read call successful', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
SchedulesAPI.read.mockResolvedValue({ data: mockSchedules });
|
|
||||||
const loadSchedules = params => SchedulesAPI.read(params);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ScheduleList loadSchedules={loadSchedules} />
|
<ScheduleList
|
||||||
|
loadSchedules={loadSchedules}
|
||||||
|
loadScheduleOptions={loadScheduleOptions}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
@@ -40,6 +53,10 @@ describe('ScheduleList', () => {
|
|||||||
expect(wrapper.find('ScheduleListItem').length).toBe(5);
|
expect(wrapper.find('ScheduleListItem').length).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show add button', () => {
|
||||||
|
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('should check and uncheck the row item', async () => {
|
test('should check and uncheck the row item', async () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
||||||
@@ -153,11 +170,32 @@ describe('ScheduleList', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hidden add button', () => {
|
||||||
|
test('should hide add button when flag is passed', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ScheduleList
|
||||||
|
loadSchedules={loadSchedules}
|
||||||
|
loadScheduleOptions={loadScheduleOptions}
|
||||||
|
hideAddButton
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('read call unsuccessful', () => {
|
describe('read call unsuccessful', () => {
|
||||||
test('should show content error when read call unsuccessful', async () => {
|
test('should show content error when read call unsuccessful', async () => {
|
||||||
SchedulesAPI.read.mockRejectedValue(new Error());
|
SchedulesAPI.read.mockRejectedValue(new Error());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<ScheduleList />);
|
wrapper = mountWithContexts(
|
||||||
|
<ScheduleList
|
||||||
|
loadSchedules={loadSchedules}
|
||||||
|
loadScheduleOptions={loadScheduleOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('ContentError').length).toBe(1);
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Project extends Component {
|
|||||||
this.loadProject = this.loadProject.bind(this);
|
this.loadProject = this.loadProject.bind(this);
|
||||||
this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this);
|
this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this);
|
||||||
this.loadSchedules = this.loadSchedules.bind(this);
|
this.loadSchedules = this.loadSchedules.bind(this);
|
||||||
|
this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
@@ -104,9 +105,14 @@ class Project extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadScheduleOptions() {
|
||||||
|
const { project } = this.state;
|
||||||
|
return ProjectsAPI.readScheduleOptions(project.id);
|
||||||
|
}
|
||||||
|
|
||||||
loadSchedules(params) {
|
loadSchedules(params) {
|
||||||
const { project } = this.state;
|
const { project } = this.state;
|
||||||
return ProjectsAPI.readScheduleList(project.id, params);
|
return ProjectsAPI.readSchedules(project.id, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -241,7 +247,10 @@ class Project extends Component {
|
|||||||
<Route
|
<Route
|
||||||
path="/projects/:id/schedules"
|
path="/projects/:id/schedules"
|
||||||
render={() => (
|
render={() => (
|
||||||
<ScheduleList loadSchedules={this.loadSchedules} />
|
<ScheduleList
|
||||||
|
loadSchedules={this.loadSchedules}
|
||||||
|
loadScheduleOptions={this.loadScheduleOptions}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { SchedulesAPI } from '@api';
|
|||||||
import { PageSection, Card } from '@patternfly/react-core';
|
import { PageSection, Card } from '@patternfly/react-core';
|
||||||
|
|
||||||
function Schedules({ i18n }) {
|
function Schedules({ i18n }) {
|
||||||
|
const loadScheduleOptions = () => {
|
||||||
|
return SchedulesAPI.readOptions();
|
||||||
|
};
|
||||||
|
|
||||||
const loadSchedules = params => {
|
const loadSchedules = params => {
|
||||||
return SchedulesAPI.read(params);
|
return SchedulesAPI.read(params);
|
||||||
};
|
};
|
||||||
@@ -24,7 +28,11 @@ function Schedules({ i18n }) {
|
|||||||
<Route path="/schedules">
|
<Route path="/schedules">
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<ScheduleList loadSchedules={loadSchedules} />
|
<ScheduleList
|
||||||
|
loadSchedules={loadSchedules}
|
||||||
|
loadScheduleOptions={loadScheduleOptions}
|
||||||
|
hideAddButton
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Template extends Component {
|
|||||||
this.loadTemplate = this.loadTemplate.bind(this);
|
this.loadTemplate = this.loadTemplate.bind(this);
|
||||||
this.loadTemplateAndRoles = this.loadTemplateAndRoles.bind(this);
|
this.loadTemplateAndRoles = this.loadTemplateAndRoles.bind(this);
|
||||||
this.loadSchedules = this.loadSchedules.bind(this);
|
this.loadSchedules = this.loadSchedules.bind(this);
|
||||||
|
this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
@@ -83,9 +84,14 @@ class Template extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadScheduleOptions() {
|
||||||
|
const { template } = this.state;
|
||||||
|
return JobTemplatesAPI.readScheduleOptions(template.id);
|
||||||
|
}
|
||||||
|
|
||||||
loadSchedules(params) {
|
loadSchedules(params) {
|
||||||
const { template } = this.state;
|
const { template } = this.state;
|
||||||
return JobTemplatesAPI.readScheduleList(template.id, params);
|
return JobTemplatesAPI.readSchedules(template.id, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -111,6 +117,13 @@ class Template extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
tabsArray.push({
|
||||||
|
name: i18n._(t`Schedules`),
|
||||||
|
link: `${match.url}/schedules`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tabsArray.push(
|
tabsArray.push(
|
||||||
{
|
{
|
||||||
name: i18n._(t`Completed Jobs`),
|
name: i18n._(t`Completed Jobs`),
|
||||||
@@ -122,13 +135,6 @@ class Template extends Component {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (template) {
|
|
||||||
tabsArray.push({
|
|
||||||
name: i18n._(t`Schedules`),
|
|
||||||
link: `${match.url}/schedules`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tabsArray.forEach((tab, n) => {
|
tabsArray.forEach((tab, n) => {
|
||||||
tab.id = n;
|
tab.id = n;
|
||||||
});
|
});
|
||||||
@@ -225,7 +231,10 @@ class Template extends Component {
|
|||||||
<Route
|
<Route
|
||||||
path="/templates/:templateType/:id/schedules"
|
path="/templates/:templateType/:id/schedules"
|
||||||
render={() => (
|
render={() => (
|
||||||
<ScheduleList loadSchedules={this.loadSchedules} />
|
<ScheduleList
|
||||||
|
loadSchedules={this.loadSchedules}
|
||||||
|
loadScheduleOptions={this.loadScheduleOptions}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class WorkflowJobTemplate extends Component {
|
|||||||
};
|
};
|
||||||
this.loadTemplate = this.loadTemplate.bind(this);
|
this.loadTemplate = this.loadTemplate.bind(this);
|
||||||
this.loadSchedules = this.loadSchedules.bind(this);
|
this.loadSchedules = this.loadSchedules.bind(this);
|
||||||
|
this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
@@ -76,9 +77,14 @@ class WorkflowJobTemplate extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadScheduleOptions() {
|
||||||
|
const { template } = this.state;
|
||||||
|
return WorkflowJobTemplatesAPI.readScheduleOptions(template.id);
|
||||||
|
}
|
||||||
|
|
||||||
loadSchedules(params) {
|
loadSchedules(params) {
|
||||||
const { template } = this.state;
|
const { template } = this.state;
|
||||||
return WorkflowJobTemplatesAPI.readScheduleList(template.id, params);
|
return WorkflowJobTemplatesAPI.readSchedules(template.id, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -199,7 +205,10 @@ class WorkflowJobTemplate extends Component {
|
|||||||
<Route
|
<Route
|
||||||
path="/templates/:templateType/:id/schedules"
|
path="/templates/:templateType/:id/schedules"
|
||||||
render={() => (
|
render={() => (
|
||||||
<ScheduleList loadSchedules={this.loadSchedules} />
|
<ScheduleList
|
||||||
|
loadSchedules={this.loadSchedules}
|
||||||
|
loadScheduleOptions={this.loadScheduleOptions}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user