mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'release_24.0' into dev
This commit is contained in:
@@ -25,7 +25,7 @@ const name = computed(() => {
|
||||
|
||||
<template>
|
||||
<span id="drag-ghost" class="py-2 px-3 rounded">
|
||||
<FontAwesomeIcon icon="paper-plane" class="mr-1" />
|
||||
<FontAwesomeIcon :icon="faPaperPlane" class="mr-1" />
|
||||
<TextShort class="font-weight-bold" :text="name" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -32,6 +32,7 @@ type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined;
|
||||
*/
|
||||
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
|
||||
const { data, headers } = await historiesFetcher({
|
||||
view: "summary",
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
|
||||
@@ -22,6 +22,8 @@ type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined;
|
||||
*/
|
||||
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
|
||||
const { data, headers } = await historiesFetcher({
|
||||
view: "summary",
|
||||
keys: "username,create_time",
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
|
||||
@@ -317,9 +317,12 @@ function unexpandedClick(event: Event) {
|
||||
:data-state="dataState"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
draggable
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@keydown="onKeyDown">
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
||||
<div class="p-1 cursor-pointer" draggable @dragstart="onDragStart" @dragend="onDragEnd" @click.stop="onClick">
|
||||
<div class="p-1 cursor-pointer" @click.stop="onClick">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="p-1" data-description="content item header info">
|
||||
<BButton v-if="selectable" class="selector p-0" @click.stop="emit('update:selected', !selected)">
|
||||
|
||||
@@ -212,6 +212,36 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
function dragSameHistory() {
|
||||
return getDragData().sameHistory;
|
||||
}
|
||||
|
||||
function getDragData() {
|
||||
const eventStore = useEventStore();
|
||||
const multiple = eventStore.multipleDragData;
|
||||
let data: HistoryItem[] | undefined;
|
||||
let historyId: string | undefined;
|
||||
try {
|
||||
if (multiple) {
|
||||
const dragData = eventStore.getDragData() as Record<string, HistoryItem>;
|
||||
// set historyId to the first history_id in the multiple drag data
|
||||
const firstItem = Object.values(dragData)[0];
|
||||
if (firstItem) {
|
||||
historyId = firstItem.history_id;
|
||||
}
|
||||
data = Object.values(dragData);
|
||||
} else {
|
||||
data = [eventStore.getDragData() as HistoryItem];
|
||||
if (data[0]) {
|
||||
historyId = data[0].history_id;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// this was not a valid object for this dropzone, ignore
|
||||
}
|
||||
return { data, sameHistory: historyId === props.history.id, multiple };
|
||||
}
|
||||
|
||||
function getHighlight(item: HistoryItem) {
|
||||
if (unref(isLoading)) {
|
||||
return undefined;
|
||||
@@ -319,45 +349,33 @@ function onOperationError(error: any) {
|
||||
}
|
||||
|
||||
function onDragEnter(e: DragEvent) {
|
||||
if (dragSameHistory()) {
|
||||
return;
|
||||
}
|
||||
dragTarget.value = e.target;
|
||||
showDropZone.value = true;
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (dragSameHistory()) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onDragLeave(e: DragEvent) {
|
||||
if (dragSameHistory()) {
|
||||
return;
|
||||
}
|
||||
if (dragTarget.value === e.target) {
|
||||
showDropZone.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(evt: any) {
|
||||
const eventStore = useEventStore();
|
||||
async function onDrop() {
|
||||
showDropZone.value = false;
|
||||
let data: HistoryItem[] | undefined;
|
||||
let historyId: string | undefined;
|
||||
const multiple = eventStore.multipleDragData;
|
||||
try {
|
||||
if (multiple) {
|
||||
const dragData = eventStore.getDragData() as Record<string, HistoryItem>;
|
||||
// set historyId to the first history_id in the multiple drag data
|
||||
const firstItem = Object.values(dragData)[0];
|
||||
if (firstItem) {
|
||||
historyId = firstItem.history_id;
|
||||
}
|
||||
data = Object.values(dragData);
|
||||
} else {
|
||||
data = [eventStore.getDragData() as HistoryItem];
|
||||
if (data[0]) {
|
||||
historyId = data[0].history_id;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// this was not a valid object for this dropzone, ignore
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
} else if (historyId === props.history.id) {
|
||||
Toast.error("Cannot copy to the same history");
|
||||
const { data, sameHistory, multiple } = getDragData();
|
||||
if (!data || sameHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,7 +504,7 @@ function setItemDragstart(
|
||||
class="history-layout d-flex flex-column w-100 h-100"
|
||||
@drop.prevent="onDrop"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent
|
||||
@dragover="onDragOver"
|
||||
@dragleave.prevent="onDragLeave">
|
||||
<slot name="navigation" :history="history" />
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("HeadlessMultiselect", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const sampleOptions = ["#named", "#named_2", "#named_3", "abc", "def", "ghi"];
|
||||
const sampleOptions = ["name:named", "name:named_2", "name:named_3", "abc", "def", "ghi"];
|
||||
|
||||
const selectors = {
|
||||
openButton: ".toggle-button",
|
||||
@@ -197,27 +197,27 @@ describe("HeadlessMultiselect", () => {
|
||||
const input = await open(wrapper);
|
||||
|
||||
await keyPress(input, "Enter");
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named"]);
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named"]);
|
||||
|
||||
await keyPress(input, "ArrowDown");
|
||||
await keyPress(input, "Enter");
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named_2"]);
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named_2"]);
|
||||
});
|
||||
|
||||
it("deselects options via keyboard", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: ["#named", "#named_2", "#named_3"],
|
||||
selected: ["name:named", "name:named_2", "name:named_3"],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
|
||||
await keyPress(input, "Enter");
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named_2", "#named_3"]);
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named_2", "name:named_3"]);
|
||||
|
||||
await keyPress(input, "ArrowDown");
|
||||
await keyPress(input, "Enter");
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named", "#named_3"]);
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named", "name:named_3"]);
|
||||
});
|
||||
|
||||
it("allows for adding new options", async () => {
|
||||
@@ -243,26 +243,26 @@ describe("HeadlessMultiselect", () => {
|
||||
const options = wrapper.findAll(selectors.option);
|
||||
|
||||
await options.at(0).trigger("click");
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named"]);
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named"]);
|
||||
|
||||
await options.at(1).trigger("click");
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named_2"]);
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named_2"]);
|
||||
});
|
||||
|
||||
it("deselects options with mouse", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: ["#named", "#named_2", "#named_3"],
|
||||
selected: ["name:named", "name:named_2", "name:named_3"],
|
||||
});
|
||||
|
||||
await open(wrapper);
|
||||
const options = wrapper.findAll(selectors.option);
|
||||
|
||||
await options.at(0).trigger("click");
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named_2", "#named_3"]);
|
||||
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named_2", "name:named_3"]);
|
||||
|
||||
await options.at(1).trigger("click");
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named", "#named_3"]);
|
||||
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named", "name:named_3"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { computed, nextTick, ref, watch } from "vue";
|
||||
import Vue2Teleport from "vue2-teleport";
|
||||
|
||||
import { useUid } from "@/composables/utils/uid";
|
||||
import { normalizeTag } from "@/stores/userTagsStore";
|
||||
|
||||
library.add(faCheck, faChevronUp, faPlus, faTags, faTimes);
|
||||
|
||||
@@ -147,6 +148,8 @@ function getOptionWithId(id: number) {
|
||||
* was not part of the provided options
|
||||
*/
|
||||
function onOptionSelected(option: string) {
|
||||
option = normalizeTag(option);
|
||||
|
||||
if (!optionsAsSet.value.has(option)) {
|
||||
emit("addOption", option);
|
||||
return;
|
||||
@@ -318,7 +321,7 @@ whenever(isOpen, async () => {
|
||||
:data-parent-id="props.id"
|
||||
class="headless-multiselect__option"
|
||||
role="option"
|
||||
:aria-selected="props.selected.includes(option)"
|
||||
:aria-selected="props.selected.includes(normalizeTag(option))"
|
||||
:class="{
|
||||
invalid: i === 0 && !searchValueValid,
|
||||
highlighted: highlightedOption === i,
|
||||
@@ -332,7 +335,7 @@ whenever(isOpen, async () => {
|
||||
{{ option }}
|
||||
</span>
|
||||
|
||||
<span v-if="props.selected.includes(option)" class="headless-multiselect__info">
|
||||
<span v-if="props.selected.includes(normalizeTag(option))" class="headless-multiselect__info">
|
||||
<template v-if="highlightedOption === i">
|
||||
<FontAwesomeIcon
|
||||
class="headless-multiselect__needs-highlight"
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useToast } from "composables/toast";
|
||||
import { getLocalVue } from "tests/jest/helpers";
|
||||
import { computed } from "vue";
|
||||
|
||||
import { useUserTagsStore } from "@/stores/userTagsStore";
|
||||
import { normalizeTag, useUserTagsStore } from "@/stores/userTagsStore";
|
||||
|
||||
import StatelessTags from "./StatelessTags";
|
||||
|
||||
const autocompleteTags = ["#named_user_tag", "abc", "my_tag"];
|
||||
const autocompleteTags = ["name:named_user_tag", "abc", "my_tag"];
|
||||
const toggleButton = ".toggle-button";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
@@ -28,6 +28,12 @@ useUserTagsStore.mockReturnValue({
|
||||
onMultipleNewTagsSeen: jest.fn(),
|
||||
});
|
||||
|
||||
function normalize(tag) {
|
||||
return tag.replace(/^#/, "name:");
|
||||
}
|
||||
|
||||
normalizeTag.mockImplementation(normalize);
|
||||
|
||||
jest.mock("composables/toast");
|
||||
const warningMock = jest.fn((message, title) => {
|
||||
return { message, title };
|
||||
@@ -88,7 +94,7 @@ describe("StatelessTags", () => {
|
||||
expect(visibleOptions.length).toBe(autocompleteTags.length);
|
||||
|
||||
visibleOptions.wrappers.forEach((option, i) => {
|
||||
expect(option.text()).toContain(autocompleteTags[i]);
|
||||
expect(normalize(option.text())).toContain(autocompleteTags[i]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ function onTagClicked(tag: string) {
|
||||
|
||||
<HeadlessMultiselect
|
||||
:options="userTags"
|
||||
:selected="tags"
|
||||
:selected="props.value"
|
||||
:placeholder="props.placeholder"
|
||||
:validator="isValid"
|
||||
@addOption="onAddTag"
|
||||
|
||||
@@ -20,13 +20,13 @@ class UserTagStoreDatabase extends Dexie {
|
||||
|
||||
constructor() {
|
||||
super("userTagStoreDatabase");
|
||||
this.version(1).stores({ tags: "++id, userHash, lastUsed" });
|
||||
this.version(2).stores({ tags: "++id, userHash, lastUsed, tag" });
|
||||
}
|
||||
}
|
||||
|
||||
const maxDbEntriesPerUser = 10000;
|
||||
|
||||
function normalizeTag(tag: string) {
|
||||
export function normalizeTag(tag: string) {
|
||||
return tag.replace(/^#/, "name:");
|
||||
}
|
||||
|
||||
@@ -113,12 +113,17 @@ export const useUserTagsStore = defineStore("userTagsStore", () => {
|
||||
await until(dbLoaded).toBe(true);
|
||||
tag = normalizeTag(tag);
|
||||
|
||||
const storedTag = tags.value.find((o) => o.tag === tag);
|
||||
const id = storedTag?.id;
|
||||
const dbTag = await db.tags.get({ tag });
|
||||
|
||||
if (id !== undefined) {
|
||||
// put instead of update, because `removeOldestEntries` may have deleted this tag on init
|
||||
await db.tags.put({ ...storedTag, lastUsed: Date.now() } as StoredTag, id);
|
||||
if (dbTag) {
|
||||
await db.tags.update(dbTag, { lastUsed: Date.now() });
|
||||
} else {
|
||||
const storedTag = tags.value.find((o) => o.tag === tag);
|
||||
const id = storedTag?.id;
|
||||
|
||||
if (id !== undefined) {
|
||||
await db.tags.add({ ...storedTag, lastUsed: Date.now() } as StoredTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMix
|
||||
total_matches = None
|
||||
if payload.sort_by == "username":
|
||||
sort_column = model.User.username
|
||||
stmt = stmt.add_columns(sort_column)
|
||||
else:
|
||||
sort_column = getattr(model.History, payload.sort_by)
|
||||
if payload.sort_desc:
|
||||
|
||||
@@ -191,7 +191,7 @@ class FastAPIHistories:
|
||||
trans, serialization_params, filter_query_params, deleted_only=deleted, all_histories=all
|
||||
)
|
||||
else:
|
||||
payload = HistoryIndexQueryPayload.construct(
|
||||
payload = HistoryIndexQueryPayload.model_construct(
|
||||
show_own=show_own,
|
||||
show_published=show_published,
|
||||
show_shared=show_shared,
|
||||
@@ -201,7 +201,9 @@ class FastAPIHistories:
|
||||
offset=offset,
|
||||
search=search,
|
||||
)
|
||||
entries, total_matches = self.service.index_query(trans, payload, include_total_count=True)
|
||||
entries, total_matches = self.service.index_query(
|
||||
trans, payload, serialization_params, include_total_count=True
|
||||
)
|
||||
response.headers["total_matches"] = str(total_matches)
|
||||
return entries
|
||||
|
||||
|
||||
@@ -891,12 +891,11 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
|
||||
|
||||
extra_info = None
|
||||
mode = kwargs.get("mode", "Auto")
|
||||
indexer = None
|
||||
|
||||
# Coverage mode uses index data.
|
||||
if mode == "Coverage":
|
||||
# Get summary using minimal cutoffs.
|
||||
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
|
||||
indexer = self._get_indexer(trans, dataset)
|
||||
return indexer.get_data(chrom, low, high, **kwargs)
|
||||
|
||||
# TODO:
|
||||
@@ -906,7 +905,7 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
|
||||
# If mode is Auto, need to determine what type of data to return.
|
||||
if mode == "Auto":
|
||||
# Get stats from indexer.
|
||||
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
|
||||
indexer = self._get_indexer(trans, dataset)
|
||||
stats = indexer.get_data(chrom, low, high, stats=True)
|
||||
|
||||
# If stats were requested, return them.
|
||||
@@ -956,8 +955,7 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
|
||||
)
|
||||
|
||||
# Get mean depth.
|
||||
if not indexer:
|
||||
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
|
||||
indexer = self._get_indexer(trans, dataset)
|
||||
stats = indexer.get_data(chrom, low, high, stats=True)
|
||||
mean_depth = stats["data"]["mean"]
|
||||
|
||||
@@ -1008,3 +1006,11 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
|
||||
data = data_provider.get_data(**kwargs)
|
||||
|
||||
return data
|
||||
|
||||
def _get_indexer(self, trans, dataset):
|
||||
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
|
||||
if indexer is None:
|
||||
msg = f"No indexer available for dataset {self.encode_id(dataset.id)}"
|
||||
log.exception(msg)
|
||||
raise galaxy_exceptions.ObjectNotFound(msg)
|
||||
return indexer
|
||||
|
||||
@@ -216,6 +216,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
self,
|
||||
trans,
|
||||
payload: HistoryIndexQueryPayload,
|
||||
serialization_params: SerializationParams,
|
||||
include_total_count: bool = False,
|
||||
) -> Tuple[List[AnyHistoryView], int]:
|
||||
"""Return a list of History accessible by the user
|
||||
@@ -223,10 +224,9 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
:rtype: list
|
||||
:returns: dictionaries containing History details
|
||||
"""
|
||||
serialization_params = SerializationParams(default_view="detailed")
|
||||
entries, total_matches = self.manager.index_query(trans, payload, include_total_count)
|
||||
return (
|
||||
[self._serialize_history(trans, entry, serialization_params) for entry in entries],
|
||||
[self._serialize_history(trans, entry, serialization_params, default_view="summary") for entry in entries],
|
||||
total_matches,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,10 +122,9 @@ class UsersService(ServiceBase):
|
||||
def _anon_user_api_value(self, trans: ProvidesHistoryContext):
|
||||
"""Return data for an anonymous user, truncated to only usage and quota_percent"""
|
||||
if not trans.user and not trans.history:
|
||||
# Can't return info about this user, may not have a history yet.
|
||||
# return {}
|
||||
raise glx_exceptions.MessageException(err_msg="The user has no history, which should always be the case.")
|
||||
usage = self.quota_agent.get_usage(trans, history=trans.history)
|
||||
usage = None
|
||||
else:
|
||||
usage = self.quota_agent.get_usage(trans, history=trans.history)
|
||||
percent = self.quota_agent.get_percent(trans=trans, usage=usage)
|
||||
usage = usage or 0
|
||||
return {
|
||||
|
||||
@@ -140,6 +140,56 @@ class TestHistoriesApi(ApiTestCase, BaseHistories):
|
||||
assert len(index_response) == 1
|
||||
assert index_response[0]["name"] == expected_history_name
|
||||
|
||||
def test_index_views(self):
|
||||
# Make sure there is at least one history
|
||||
self._create_history(f"TestHistoryForViews_{uuid4()}")["id"]
|
||||
# By default the view is summary
|
||||
index_response = self._get("histories").json()
|
||||
for history in index_response:
|
||||
assert "state" not in history
|
||||
|
||||
# Change the view to detailed
|
||||
index_response = self._get("histories?view=detailed").json()
|
||||
for history in index_response:
|
||||
assert "state" in history
|
||||
|
||||
# Expect only specific keys
|
||||
expected_keys = ["name"]
|
||||
unexpected_keys = ["deleted", "state"]
|
||||
index_response = self._get(f"histories?keys={','.join(expected_keys)}").json()
|
||||
for history in index_response:
|
||||
for key in expected_keys:
|
||||
assert key in history
|
||||
for key in unexpected_keys:
|
||||
assert key not in history
|
||||
|
||||
def test_index_search_mode_views(self):
|
||||
# Make sure there is at least one history
|
||||
expected_name_contains = "SearchMode"
|
||||
self._create_history(f"TestHistory{expected_name_contains}_{uuid4()}")["id"]
|
||||
# By default the view is summary
|
||||
data = dict(search=expected_name_contains, show_published=False)
|
||||
index_response = self._get("histories", data=data).json()
|
||||
for history in index_response:
|
||||
assert "state" not in history
|
||||
|
||||
# Change the view to detailed
|
||||
data = dict(search=expected_name_contains, show_published=False)
|
||||
index_response = self._get("histories?view=detailed", data=data).json()
|
||||
for history in index_response:
|
||||
assert "state" in history
|
||||
|
||||
# Expect only specific keys
|
||||
expected_keys = ["name"]
|
||||
unexpected_keys = ["deleted", "state"]
|
||||
data = dict(search=expected_name_contains, show_published=False, keys=",".join(expected_keys))
|
||||
index_response = self._get("histories", data=data).json()
|
||||
for history in index_response:
|
||||
for key in expected_keys:
|
||||
assert key in history
|
||||
for key in unexpected_keys:
|
||||
assert key not in history
|
||||
|
||||
def test_index_case_insensitive_contains_query(self):
|
||||
# Create the histories with a different user to ensure the test
|
||||
# is not conflicted with the current user's histories.
|
||||
|
||||
@@ -296,7 +296,8 @@ class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTe
|
||||
response = session.get(response.json()["redirect_uri"], verify=False)
|
||||
# make sure we can no longer request the user
|
||||
response = session.get(self._api_url("users/current"))
|
||||
self._assert_status_code_is(response, 400)
|
||||
self._assert_status_code_is(response, 200)
|
||||
assert "email" not in response.json()
|
||||
|
||||
def test_auth_by_access_token_logged_in_once(self):
|
||||
# login at least once
|
||||
|
||||
Reference in New Issue
Block a user