Merge pull request #11701 from OlegZharkov/share-to-users

Refactor Sharing
This commit is contained in:
Marius van den Beek
2021-09-20 19:46:54 +02:00
committed by GitHub
22 changed files with 1715 additions and 1283 deletions
-263
View File
@@ -1,263 +0,0 @@
<template>
<div v-if="ready">
<h3>Share or Publish {{ model_class }} `{{ item.title }}`</h3>
<b-alert :show="showDanger" variant="danger" dismissible> {{ errMsg }} </b-alert>
<br />
<div v-if="!hasUsername">
<div>To make a {{ model_class }} accessible via link or publish it, you must create a public username:</div>
<form class="form-group" @submit.prevent="setUsername()">
<input class="form-control" type="text" v-model="newUsername" />
</form>
<b-button type="submit" variant="primary" @click="setUsername()">Set Username</b-button>
</div>
<div v-else>
<b-form-checkbox switch class="make-accessible" v-model="item.importable" @change="onImportable">
Make {{ model_class }} accessible
</b-form-checkbox>
<b-form-checkbox
v-if="item.importable"
class="make-publishable"
switch
v-model="item.published"
@change="onPublish"
>
Make {{ model_class }} publicly available in
<a :href="published_url" target="_top">Published {{ plural_name }}</a>
</b-form-checkbox>
<br />
<div v-if="item.importable">
<div>
This {{ model_class }} is currently <strong>{{ itemStatus }}</strong
>.
</div>
<p>Anyone can view and import this {{ model_class }} by visiting the following URL:</p>
<blockquote>
<b-button title="Edit URL" @click="onEdit" v-b-tooltip.hover variant="link" size="sm">
<font-awesome-icon icon="edit" />
</b-button>
<b-button id="tooltip-clipboard" @click="onCopy" @mouseout="onCopyOut" variant="link" size="sm">
<font-awesome-icon icon="link" />
</b-button>
<b-tooltip target="tooltip-clipboard" triggers="hover">
{{ tooltipClipboard }}
</b-tooltip>
<a v-if="showUrl" id="item-url" :href="itemUrl" target="_top" class="ml-2">
url:
{{ itemUrl }}
</a>
<span v-else id="item-url-text">
slug:
{{ itemUrlParts[0] }}<SlugInput class="ml-1" :slug="itemUrlParts[1]" @onChange="onChange" />
</span>
</blockquote>
</div>
<div v-else>
Access to this {{ model_class }} is currently restricted so that only you and the users listed below can
access it. Note that sharing a History will also allow access to all of its datasets.
</div>
<br />
<h4>Share {{ model_class }} with Individual Users</h4>
<div>
<div v-if="item.users_shared_with && item.users_shared_with.length > 0">
<b-table small caption-top :fields="shareFields" :items="item.users_shared_with">
<template v-slot:table-caption>
The following users will see this {{ model_class }} in their {{ model_class }} list and will
be able to view, import and run it.
</template>
<template v-slot:cell(id)="cell">
<b-button
class="unshare_user"
size="sm"
@click.stop="setSharing('unshare_user', cell.value)"
>Remove</b-button
>
</template>
</b-table>
</div>
<div v-else>
<p>You have not shared this {{ model_class }} with any users.</p>
</div>
<b-button :href="shareUrl" id="share_with_a_user"> <span>Share with a user</span> </b-button>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faLink, faEdit } from "@fortawesome/free-solid-svg-icons";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import SlugInput from "components/Common/SlugInput";
import axios from "axios";
Vue.use(BootstrapVue);
library.add(faLink);
library.add(faEdit);
export default {
components: {
FontAwesomeIcon,
SlugInput,
},
props: {
id: {
type: String,
required: true,
},
plural_name: {
type: String,
required: true,
},
model_class: {
type: String,
required: true,
},
},
data() {
const Galaxy = getGalaxyInstance();
return {
ready: false,
hasUsername: Galaxy.user.get("username"),
newUsername: "",
errMsg: null,
item: {
title: "title",
username_and_slug: "username/slug",
importable: false,
published: false,
users_shared_with: [],
},
shareFields: ["email", { key: "id", label: "" }],
makeMembersPublic: false,
showUrl: true,
tooltipClipboard: "Copy URL",
};
},
computed: {
modelClassLower() {
return this.model_class.toLowerCase();
},
pluralNameLower() {
return this.plural_name.toLowerCase();
},
itemStatus() {
return this.item.published ? "accessible via link and published" : "accessible via link";
},
itemRoot() {
const port = window.location.port ? `:${window.location.port}` : "";
return `${window.location.protocol}//${window.location.hostname}${port}${getAppRoot()}`;
},
itemUrl() {
return `${this.itemRoot}${this.item.username_and_slug}`;
},
itemSlugParts() {
const str = this.item.username_and_slug;
const index = str.lastIndexOf("/");
return [str.substring(0, index + 1), str.substring(index + 1)];
},
itemUrlParts() {
const str = this.itemUrl;
const index = str.lastIndexOf("/");
return [str.substring(0, index + 1), str.substring(index + 1)];
},
published_url() {
return `${getAppRoot()}${this.pluralNameLower}/list_published`;
},
shareUrl() {
return `${getAppRoot()}${this.modelClassLower}/share/?id=${this.id}`;
},
slugUrl() {
return `${getAppRoot()}${this.modelClassLower}/set_slug_async/?id=${this.id}`;
},
showDanger() {
return this.errMsg !== null;
},
},
created: function () {
this.getModel();
},
methods: {
onCopy() {
const clipboard = document.createElement("input");
document.body.appendChild(clipboard);
clipboard.value = this.itemUrl;
clipboard.select();
document.execCommand("copy");
document.body.removeChild(clipboard);
this.tooltipClipboard = "Copied!";
},
onCopyOut() {
this.tooltipClipboard = "Copy URL";
},
onEdit() {
this.showUrl = false;
},
onChange(newSlug) {
this.showUrl = true;
this.item.username_and_slug = `${this.itemSlugParts[0]}${newSlug}`;
const requestUrl = `${this.slugUrl}&new_slug=${newSlug}`;
axios.get(requestUrl).catch((error) => (this.errMsg = error.response.data.err_msg));
},
onImportable(importable) {
if (importable) {
this.setSharing(`make_accessible_via_link-${this.item.published ? "publish" : "unpublish"}`);
} else {
this.item.published = false;
this.setSharing("disable_link_access-unpublish");
}
},
onPublish(published) {
if (published) {
this.item.importable = true;
this.setSharing("make_accessible_and_publish");
} else {
this.setSharing("unpublish");
}
},
getModel() {
this.ready = false;
axios
.get(`${getAppRoot()}api/${this.pluralNameLower}/${this.id}/sharing`)
.then((response) => {
this.item = response.data;
this.ready = true;
})
.catch((error) => (this.errMsg = error.response.data.err_msg));
},
setUsername() {
const Galaxy = getGalaxyInstance();
axios
.put(`${getAppRoot()}api/users/${Galaxy.user.id}/information/inputs`, {
username: this.newUsername || "",
})
.then((response) => {
this.errMsg = null;
this.hasUsername = true;
this.getModel();
})
.catch((error) => (this.errMsg = error.response.data.err_msg));
},
setSharing(action, user_id) {
const data = {
action: action,
user_id: user_id,
};
return axios
.post(`${getAppRoot()}api/${this.pluralNameLower}/${this.id}/sharing`, data)
.then((response) => {
if (response.data.skipped) {
this.errMsg = "Some of the items within this object were not published due to an error.";
}
this.item = response.data;
this.ready = true;
})
.catch((error) => (this.errMsg = error.response.data.err_msg));
},
},
};
</script>
+593
View File
@@ -0,0 +1,593 @@
<template>
<div v-if="ready">
<h3>Share or Publish {{ modelClass }} `{{ item.title }}`</h3>
<div v-for="error in errors" :key="error">
<b-alert show variant="danger" dismissible @dismissed="errors = errors.filter((e) => e !== error)">
{{ error }}
</b-alert>
</div>
<br />
<div v-if="!hasUsername">
<div>To make a {{ modelClass }} accessible via link or publish it, you must create a public username:</div>
<form class="form-group" @submit.prevent="setUsername()">
<input class="form-control" type="text" v-model="newUsername" />
</form>
<b-button type="submit" variant="primary" @click="setUsername()">Set Username</b-button>
</div>
<div v-else>
<b-form-checkbox switch class="make-accessible" v-model="item.importable" @change="onImportable">
Make {{ modelClass }} accessible
</b-form-checkbox>
<b-form-checkbox
v-if="item.importable"
class="make-publishable"
switch
v-model="item.published"
@change="onPublish"
>
Make {{ modelClass }} publicly available in
<a :href="published_url" target="_top">Published {{ pluralName }}</a>
</b-form-checkbox>
<br />
<div v-if="item.importable">
<div>
This {{ modelClass }} is currently <strong>{{ itemStatus }}</strong
>.
</div>
<p>Anyone can view and import this {{ modelClass }} by visiting the following URL:</p>
<blockquote>
<b-button title="Edit URL" @click="onEdit" v-b-tooltip.hover variant="link" size="sm">
<font-awesome-icon icon="edit" />
</b-button>
<b-button id="tooltip-clipboard" @click="onCopy" @mouseout="onCopyOut" variant="link" size="sm">
<font-awesome-icon :icon="['far', 'copy']" />
</b-button>
<b-tooltip target="tooltip-clipboard" triggers="hover">
{{ tooltipClipboard }}
</b-tooltip>
<a v-if="showUrl" id="item-url" :href="itemUrl" target="_top" class="ml-2">
url:
{{ itemUrl }}
</a>
<span v-else id="item-url-text">
slug:
{{ itemUrlParts[0] }}<SlugInput class="ml-1" :slug="itemUrlParts[1]" @onChange="onChange" />
</span>
</blockquote>
</div>
<div v-else>
Access to this {{ modelClass }} is currently restricted so that only you and the users listed below can
access it. Note that sharing a History will also allow access to all of its datasets.
</div>
<br />
<b-card no-body>
<b-button
class="share-with-collapse"
@click="isCollapseVisible = !isCollapseVisible"
v-b-toggle.accordion-1
variant="light"
>
Share {{ modelClass }} with Individual Users
<font-awesome-icon :icon="isCollapseVisible ? `caret-up` : `caret-down`" />
</b-button>
<b-collapse id="accordion-1" accordion="main-accordion" role="tabpanel">
<ConfigProvider v-slot="{ config }">
<CurrentUser v-slot="{ user }">
<div v-if="user && config && !permissionsChangeRequired(item)">
<p class="share_with_title" v-if="item.users_shared_with.length === 0">
You have not shared this {{ modelClass }} with any users.
</p>
<p v-else class="share_with_title">
The following users will see this {{ modelClass }} in their {{ modelClass }} list
and will be able to view, import and run it.
</p>
<b-alert
:show="dismissCountDown"
dismissible
class="success-alert"
variant="success"
@dismissed="dismissCountDown = 0"
@dismiss-count-down="dismissCountDown = $event"
>
Sharing preferences are saved!
</b-alert>
<div class="share_with_view">
<multiselect
class="multiselect-users"
v-model="multiselectValues.sharingCandidates"
:options="multiselectValues.userOptions"
:clear-on-select="true"
:multiple="true"
:internal-search="false"
:max-height="config.expose_user_email || user.is_admin ? 300 : 0"
label="email"
@close="onMultiselectBlur(config.expose_user_email || user.is_admin)"
track-by="email"
@search-change="
searchChanged($event, config.expose_user_email || user.is_admin)
"
placeholder="Please specify user email"
>
<template slot="caret" v-if="!(config.expose_user_email || user.is_admin)">
<div></div>
</template>
<template slot="noResult" v-if="config.expose_user_email || user.is_admin">
<div v-if="threeCharactersEntered">
{{ elementsNotFoundWarning }}
</div>
<div v-else>{{ charactersThresholdWarning }}</div>
</template>
<template slot="tag" slot-scope="{ option, remove }">
<span class="multiselect__tag">
<span>{{ option.email }}</span>
<i
aria-hidden="true"
@click="remove(option)"
tabindex="1"
class="multiselect__tag-icon"
></i>
</span>
</template>
<template slot="noOptions">
<div v-if="threeCharactersEntered">
{{ charactersThresholdWarning }}
</div>
<div v-else>
{{ elementsNotFoundWarning }}
</div>
</template>
</multiselect>
<div class="share-with-card-buttons">
<!--submit/cancel buttons-->
<b-button
@click="getSharing()"
variant="outline-danger"
class="sharing_icon cancel-sharing-with"
>
Cancel
</b-button>
<b-button
variant="outline-primary"
:disabled="
!(sharedWithUsersChanged || !!multiselectValues.currentUserSearch)
"
@click.stop="
setSharing(
actions.share_with,
multiselectValues.sharingCandidates.map(({ email }) => email)
)
"
v-b-tooltip.hover.bottom
:title="submitBtnTitle"
class="sharing_icon submit-sharing-with"
>
{{ multiselectValues.currentUserSearch ? `Add` : `Save` }}
</b-button>
</div>
</div>
</div>
</CurrentUser>
</ConfigProvider>
<b-alert variant="warning" dismissible fade :show="permissionsChangeRequired(item)">
<div class="text-center">
{{
item.extra.can_change.length > 0
? `${item.extra.can_change.length} datasets are exclusively private to you`
: `You are not authorized to share ${item.extra.cannot_change.length} datasets`
}}
</div>
</b-alert>
<b-row v-if="permissionsChangeRequired(item)">
<b-col v-if="item.extra.can_change.length > 0">
<b-card>
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button block v-b-toggle.can-share variant="warning">
Datasets can be shared by updating their permissions
</b-button>
</b-card-header>
<b-collapse id="can-share" visible accordion="can-share-accordion" role="tabpanel">
<b-list-group>
<b-list-group-item :key="dataset.id" v-for="dataset in item.extra.can_change">{{
dataset.name
}}</b-list-group-item>
</b-list-group>
</b-collapse>
</b-card>
</b-col>
<b-col v-if="item.extra.cannot_change.length > 0">
<b-card>
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button block v-b-toggle.cannot-share variant="danger"
>Datasets cannot be shared, you are not authorized to change
permissions</b-button
>
</b-card-header>
<b-collapse id="cannot-share" visible accordion="cannot-accordion2" role="tabpanel">
<b-list-group>
<b-list-group-item
:key="dataset.id"
v-for="dataset in item.extra.cannot_change"
>{{ dataset.name }}</b-list-group-item
>
</b-list-group>
</b-collapse>
</b-card>
</b-col>
<b-col>
<b-card
border-variant="primary"
header="How would you like to proceed?"
header-bg-variant="primary"
header-text-variant="white"
align="center"
>
<b-button
@click="
setSharing(
actions.share_with,
multiselectValues.sharingCandidates.map(({ email }) => email),
share_option.make_public
)
"
v-if="item.extra.can_change.length > 0"
block
variant="outline-primary"
>Make datasets public</b-button
>
<b-button
@click="
setSharing(
actions.share_with,
multiselectValues.sharingCandidates.map(({ email }) => email),
share_option.make_accessible_to_shared
)
"
v-if="item.extra.can_change.length > 0"
block
variant="outline-primary"
>Make datasets private to me and
{{ multiselectValues.sharingCandidates.map(({ email }) => email).join() }}</b-button
>
<b-button
@click="
setSharing(
actions.share_with,
multiselectValues.sharingCandidates.map(({ email }) => email),
share_option.no_changes
)
"
block
variant="outline-primary"
>
Share Anyway
</b-button>
<b-button @click="getSharing()" block variant="outline-danger">Cancel </b-button>
</b-card>
</b-col>
</b-row>
</b-collapse>
</b-card>
</div>
</div>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCopy, faEdit, faUserPlus, faUserSlash, faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import SlugInput from "components/Common/SlugInput";
import axios from "axios";
import Multiselect from "vue-multiselect";
import { copy } from "utils/clipboard";
import ConfigProvider from "components/providers/ConfigProvider";
import CurrentUser from "components/providers/CurrentUser";
Vue.use(BootstrapVue);
library.add(faCopy, faEdit, faUserPlus, faUserSlash, faCaretDown, faCaretUp);
const defaultExtra = () => {
return {
cannot_change: [],
can_change: [],
can_share: true,
};
};
export default {
components: {
ConfigProvider,
FontAwesomeIcon,
SlugInput,
Multiselect,
CurrentUser,
},
props: {
id: {
type: String,
required: true,
},
pluralName: {
type: String,
required: true,
},
modelClass: {
type: String,
required: true,
},
},
data() {
const Galaxy = getGalaxyInstance();
return {
isCollapseVisible: false,
dismissCountDown: 0,
charactersThresholdWarning: "Enter at least 3 characters to see suggestions",
elementsNotFoundWarning: "No elements found. Consider changing the search query.",
ready: false,
threeCharactersEntered: true,
hasUsername: Galaxy.user.get("username"),
newUsername: "",
errors: [],
multiselectValues: {
sharingCandidates: [],
userOptions: [],
currentUserSearch: "",
},
item: {
title: "title",
username_and_slug: "username/slug",
importable: false,
published: false,
users_shared_with: [],
extra: defaultExtra(),
},
shareFields: ["email", { key: "id", label: "" }],
makeMembersPublic: false,
showUrl: true,
tooltipClipboard: "Copy URL",
actions: {
enable_link_access: "enable_link_access",
disable_link_access: "disable_link_access",
publish: "publish",
unpublish: "unpublish",
share_with: "share_with_users",
},
share_option: {
make_public: "make_public",
make_accessible_to_shared: "make_accessible_to_shared",
no_changes: "no_changes",
},
};
},
computed: {
sharedWithUsersChanged() {
if (this.item.users_shared_with.length !== this.multiselectValues.sharingCandidates.length) {
return true;
}
return !this.multiselectValues.sharingCandidates.every(({ email }) =>
this.item.users_shared_with.some((user) => user.email === email)
);
},
submitBtnTitle() {
if (this.multiselectValues.currentUserSearch) {
return "";
} else {
return this.multiselectValues.sharingCandidates && this.multiselectValues.sharingCandidates.length > 0
? `Share with ${this.multiselectValues.sharingCandidates.map(({ email }) => email)}`
: "Please enter user email";
}
},
pluralNameLower() {
return this.pluralName.toLowerCase();
},
itemStatus() {
return this.item.published ? "accessible via link and published" : "accessible via link";
},
itemRoot() {
const port = window.location.port ? `:${window.location.port}` : "";
return `${window.location.protocol}//${window.location.hostname}${port}${getAppRoot()}`;
},
itemUrl() {
return `${this.itemRoot}${this.item.username_and_slug}`;
},
itemSlugParts() {
const str = this.item.username_and_slug;
const index = str.lastIndexOf("/");
return [str.substring(0, index + 1), str.substring(index + 1)];
},
itemUrlParts() {
const str = this.itemUrl;
const index = str.lastIndexOf("/");
return [str.substring(0, index + 1), str.substring(index + 1)];
},
published_url() {
return `${getAppRoot()}${this.pluralNameLower}/list_published`;
},
slugUrl() {
return `${getAppRoot()}api/${this.pluralNameLower}/${this.id}/slug`;
},
},
created: function () {
this.getSharing();
},
methods: {
permissionsChangeRequired(item) {
if (!item.extra) {
return false;
}
return item.extra && (item.extra.can_change.length > 0 || item.extra.cannot_change.length > 0);
},
onMultiselectBlur(isAdmin) {
const isValueChosen = this.multiselectValues.sharingCandidates.some(
(item) => item.email === this.multiselectValues.currentUserSearch
);
if (this.multiselectValues.currentUserSearch && !isValueChosen && !isAdmin) {
this.multiselectValues.sharingCandidates.push({ email: this.multiselectValues.currentUserSearch });
}
},
addError(newError) {
// temporary turning Set into Array, until we update till Vue 3.0, that supports Set reactivity
this.errors = Array.from(new Set(this.errors).add(newError));
},
onCopy() {
copy(this.itemUrl);
this.tooltipClipboard = "Copied!";
},
onCopyOut() {
this.tooltipClipboard = "Copy URL";
},
onEdit() {
this.showUrl = false;
},
assignItem(newItem, overwriteCandidates) {
if (newItem.errors) {
this.errors = newItem.errors;
}
this.item = newItem;
if (overwriteCandidates) {
this.multiselectValues.sharingCandidates = Array.from(newItem.users_shared_with);
}
if (!this.item.extra || newItem.errors.length > 0) {
this.item.extra = defaultExtra();
}
this.ready = true;
},
onChange(newSlug) {
this.showUrl = true;
const requestUrl = `${this.slugUrl}`;
axios
.put(requestUrl, {
new_slug: newSlug,
})
.then(() => (this.item.username_and_slug = `${this.itemSlugParts[0]}${newSlug}`))
.catch((error) => this.addError(error.response.data.err_msg));
},
onImportable(importable) {
if (importable) {
this.setSharing(this.actions.enable_link_access);
} else {
this.item.published = false;
this.setSharing(this.actions.disable_link_access);
}
},
onPublish(published) {
if (published) {
this.item.importable = true;
this.setSharing(this.actions.publish);
} else {
this.setSharing(this.actions.unpublish);
}
},
getSharing() {
this.ready = false;
this.dismissCountDown = 0;
axios
.get(`${getAppRoot()}api/${this.pluralNameLower}/${this.id}/sharing`)
.then((response) => this.assignItem(response.data, true))
.catch((error) => this.addError(error.response.data.err_msg));
},
setUsername() {
const Galaxy = getGalaxyInstance();
axios
.put(`${getAppRoot()}api/users/${Galaxy.user.id}/information/inputs`, {
username: this.newUsername || "",
})
.then((response) => {
this.hasUsername = true;
this.getSharing();
})
.catch((error) => this.addError(error.response.data.err_msg));
},
setSharing(action, user_id, share_option) {
let user_ids = undefined;
if (Array.isArray(user_id)) {
user_ids = user_id;
} else {
user_ids = user_id ? user_id.replace(/ /g, "").split(",") : undefined;
}
const data = {
user_ids: user_ids,
share_option: share_option ? share_option : undefined,
};
return axios
.put(`${getAppRoot()}api/${this.pluralNameLower}/${this.id}/${action}`, data)
.then(({ data }) => {
this.errors = [];
const userIdsSaved = user_ids && !this.permissionsChangeRequired(data) && data.errors.length === 0;
this.assignItem(data, userIdsSaved);
if (userIdsSaved) {
this.dismissCountDown = 3;
}
})
.catch((error) => this.addError(error.response.data.err_msg));
},
searchChanged(searchValue, exposedUsers) {
this.multiselectValues.currentUserSearch = searchValue;
if (!exposedUsers) {
this.multiselectValues.userOptions = [{ email: searchValue }];
} else if (searchValue.length < 3) {
this.threeCharactersEntered = false;
this.multiselectValues.userOptions = [];
} else {
this.threeCharactersEntered = true;
axios
.get(`${getAppRoot()}api/users?f_email=${searchValue}`)
.then((response) => {
this.multiselectValues.userOptions = response.data.filter(
({ email }) =>
!this.multiselectValues.sharingCandidates.map(({ email }) => email).includes(email)
);
})
.catch((error) => this.addError(error.response.data.err_msg));
}
},
},
};
</script>
<style scoped>
.sharing_icon {
margin-top: 0.15rem;
}
.share_with_view {
margin: 1rem 1rem;
}
.share_with_title {
text-align: center;
padding-top: 1.1rem;
}
.multiselect-users {
font-weight: normal;
}
.multiselect-users::v-deep .multiselect__option--highlight {
background: #dee2e6;
color: #2c3143;
}
.multiselect__tag {
background: #dee2e6;
color: #2c3143;
}
.multiselect__tag-icon:after {
color: white;
}
.multiselect__tag-icon:focus,
.multiselect__tag-icon:hover {
background: #132c40;
}
.success-alert {
margin: 0.3rem 2rem 0.9rem;
}
.share-with-card-buttons {
margin: 0.5rem 0;
float: right;
}
</style>
+7 -7
View File
@@ -17,7 +17,7 @@ import decodeUriComponent from "decode-uri-component";
import Router from "layout/router";
import ToolForm from "components/Tool/ToolForm";
import FormGeneric from "components/Form/FormGeneric";
import Sharing from "components/Sharing.vue";
import Sharing from "components/Sharing/Sharing.vue";
import UserPreferences from "components/User/UserPreferences.vue";
import DatasetList from "components/Dataset/DatasetList.vue";
import { getUserPreferencesModel } from "components/User/UserPreferencesModel";
@@ -192,8 +192,8 @@ export const getAnalysisRouter = (Galaxy) => {
show_visualizations_sharing: function () {
this._display_vue_helper(Sharing, {
id: QueryStringParsing.get("id"),
plural_name: "Visualizations",
model_class: "Visualization",
pluralName: "Visualizations",
modelClass: "Visualization",
});
},
@@ -246,8 +246,8 @@ export const getAnalysisRouter = (Galaxy) => {
show_histories_sharing: function () {
this._display_vue_helper(Sharing, {
id: QueryStringParsing.get("id"),
plural_name: "Histories",
model_class: "History",
pluralName: "Histories",
modelClass: "History",
});
},
@@ -318,8 +318,8 @@ export const getAnalysisRouter = (Galaxy) => {
show_pages_sharing: function () {
this._display_vue_helper(Sharing, {
id: QueryStringParsing.get("id"),
plural_name: "Pages",
model_class: "Page",
pluralName: "Pages",
modelClass: "Page",
});
},
+3 -1
View File
@@ -4,7 +4,9 @@ import { Toast } from "ui/toast";
export function copy(text, notificationText) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
Toast.info(notificationText);
if (notificationText) {
Toast.info(notificationText);
}
});
} else {
prompt("Copy to clipboard: Ctrl+C, Enter", text);
+1
View File
@@ -206,6 +206,7 @@ class ConfigSerializer(base.ModelSerializer):
'default_panel_view': _use_config,
'upload_from_form_button': _use_config,
'release_doc_base_url': _use_config,
'expose_user_email': _use_config,
'user_library_import_dir_available': lambda config, key, **context: bool(config.get('user_library_import_dir')),
'welcome_directory': _use_config,
}
+100 -6
View File
@@ -8,13 +8,20 @@ import glob
import logging
import os
from typing import (
cast,
List,
Optional,
Set,
Tuple,
Union,
)
from pydantic import (
BaseModel,
Field,
)
from sqlalchemy import (
and_,
asc,
desc,
false,
@@ -60,6 +67,37 @@ from galaxy.util import restore_text
log = logging.getLogger(__name__)
class HDABasicInfo(BaseModel):
id: EncodedDatabaseIdField
name: str
class ShareHistoryExtra(sharable.ShareWithExtra):
can_change: List[HDABasicInfo] = Field(
[],
title="Can Change",
description=(
"A collection of datasets that are not accessible by one or more of the target users "
"and that can be made accessible for others by the user sharing the history."
),
)
cannot_change: List[HDABasicInfo] = Field(
[],
title="Cannot Change",
description=(
"A collection of datasets that are not accessible by one or more of the target users "
"and that cannot be made accessible for others by the user sharing the history."
),
)
accessible_count: int = Field(
0,
title="Accessible Count",
description=(
"The number of datasets in the history that are public or accessible by all the target users."
),
)
class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMixin, SortableManager):
model_class = model.History
@@ -249,6 +287,68 @@ class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMix
job, _ = history_exp_tool.execute(trans, incoming=params, history=history, set_output_hid=True)
return job
def get_sharing_extra_information(
self, trans, item, users: Set[model.User], errors: Set[str], option: Optional[sharable.SharingOptions] = None
) -> Optional[sharable.ShareWithExtra]:
"""Returns optional extra information about the datasets of the history that can be accessed by the users."""
extra = ShareHistoryExtra()
history = cast(model.History, item)
if history.empty:
errors.add("You cannot share an empty history.")
return extra
owner = trans.user
owner_roles = owner.all_roles()
can_change_dict = {}
cannot_change_dict = {}
share_anyway = option is not None and option == sharable.SharingOptions.no_changes
datasets = history.activatable_datasets
total_dataset_count = len(datasets)
for user in users:
if self.is_history_shared_with(history, user):
continue
user_roles = user.all_roles()
# TODO: Handle this is a more performant way
# Only deal with datasets that have not been purged
for hda in datasets:
if trans.app.security_agent.can_access_dataset(user_roles, hda.dataset):
continue
# The user with which we are sharing the history does not have access permission on the current dataset
owner_can_manage_dataset = (
trans.app.security_agent.can_manage_dataset(owner_roles, hda.dataset)
and not hda.dataset.library_associations
)
if option and owner_can_manage_dataset:
if option == sharable.SharingOptions.make_accessible_to_shared:
trans.app.security_agent.privately_share_dataset(hda.dataset, users=[owner, user])
elif option == sharable.SharingOptions.make_public:
trans.app.security_agent.make_dataset_public(hda.dataset)
else:
hda_id = trans.security.encode_id(hda.id)
hda_info = HDABasicInfo(id=hda_id, name=hda.name)
if owner_can_manage_dataset:
can_change_dict[hda_id] = hda_info
else:
cannot_change_dict[hda_id] = hda_info
extra.can_change = list(can_change_dict.values())
extra.cannot_change = list(cannot_change_dict.values())
extra.accessible_count = total_dataset_count - len(extra.can_change) - len(extra.cannot_change)
if not extra.accessible_count and not extra.can_change and not share_anyway:
errors.add("The history you are sharing do not contain any datasets that can be accessed by the users with which you are sharing.")
extra.can_share = not errors and (extra.accessible_count == total_dataset_count or option is not None)
return extra
def is_history_shared_with(self, history, user) -> bool:
return bool(self.session().query(self.user_share_model).filter(
and_(
self.user_share_model.table.c.user_id == user.id,
self.user_share_model.table.c.history_id == history.id,
)
).first())
class HistoryExportView:
@@ -955,12 +1055,6 @@ class HistoriesService(ServiceBase):
fasta_hdas=[LabelValuePair(label=f'{hda.hid}: {hda.name}', value=trans.security.encode_id(hda.id)) for hda in fasta_hdas],
)
def sharing(self, trans, id: EncodedDatabaseIdField, payload: Optional[sharable.SharingPayload] = None) -> sharable.SharingStatus:
"""Allows to publish or share with other users the given resource (by id) and returns the current sharing
status of the resource.
"""
return self.shareable_service.sharing(trans, id, payload)
def _serialize_history(
self,
trans,
-6
View File
@@ -286,12 +286,6 @@ class PagesService:
trans.response.set_content_type("application/pdf")
return internal_galaxy_markdown_to_pdf(trans, internal_galaxy_markdown, 'page')
def sharing(self, trans, id: EncodedDatabaseIdField, payload: Optional[sharable.SharingPayload] = None) -> sharable.SharingStatus:
"""Allows to publish or share with other users the given resource (by id) and returns the current sharing
status of the resource.
"""
return self.shareable_service.sharing(trans, id, payload)
class PageManager(sharable.SharableModelManager, UsesAnnotations):
"""Provides operations for managing a Page."""
+249 -181
View File
@@ -11,17 +11,25 @@ A sharable Galaxy object:
"""
import logging
import re
from enum import Enum
from typing import (
List,
Optional,
Set,
Tuple,
Type,
Union,
)
from pydantic import (
BaseModel,
Extra,
Field,
)
from sqlalchemy import true
from sqlalchemy import (
false,
true,
)
from galaxy import exceptions
from galaxy.managers import (
@@ -32,13 +40,128 @@ from galaxy.managers import (
taggable,
users
)
from galaxy.model import UserShareAssociation
from galaxy.model import (
User,
UserShareAssociation,
)
from galaxy.schema.fields import EncodedDatabaseIdField
from galaxy.structured_app import MinimalManagerApp
from galaxy.util import ready_name_for_url
log = logging.getLogger(__name__)
UserIdentifier = Union[EncodedDatabaseIdField, str]
class SharingOptions(str, Enum):
"""Options for sharing resources that may have restricted access to all or part of their contents."""
make_public = "make_public"
make_accessible_to_shared = "make_accessible_to_shared"
no_changes = "no_changes"
class ShareWithExtra(BaseModel):
can_share: bool = Field(
False,
title="Can Share",
description="Indicates whether the resource can be directly shared or requires further actions.",
)
class Config:
extra = Extra.allow
class ShareWithPayload(BaseModel):
user_ids: List[UserIdentifier] = Field(
...,
title="User Identifiers",
description=(
"A collection of encoded IDs (or email addresses) of users "
"that this resource will be shared with."
),
)
share_option: Optional[SharingOptions] = Field(
None,
title="Share Option",
description=(
"User choice for sharing resources which its contents may be restricted:\n"
" - None: The user did not choose anything yet or no option is needed.\n"
f" - {SharingOptions.make_public}: The contents of the resource will be made publicly accessible.\n"
f" - {SharingOptions.make_accessible_to_shared}: This will automatically create a new `sharing role` allowing protected contents to be accessed only by the desired users.\n"
f" - {SharingOptions.no_changes}: This won't change the current permissions for the contents. The user which this resource will be shared may not be able to access all its contents.\n"
),
)
class SetSlugPayload(BaseModel):
new_slug: str = Field(
...,
title="New Slug",
description="The slug that will be used to access this shared item.",
)
class UserEmail(BaseModel):
id: EncodedDatabaseIdField = Field(
...,
title="User ID",
description="The encoded ID of the user.",
)
email: str = Field(
...,
title="Email",
description="The email of the user.",
)
class SharingStatus(BaseModel):
id: EncodedDatabaseIdField = Field(
...,
title="ID",
description="The encoded ID of the resource to be shared.",
)
title: str = Field(
...,
title="Title",
description="The title or name of the resource.",
)
importable: bool = Field(
...,
title="Importable",
description="Whether this resource can be published using a link.",
)
published: bool = Field(
...,
title="Published",
description="Whether this resource is currently published.",
)
users_shared_with: List[UserEmail] = Field(
[],
title="Users shared with",
description="The list of encoded ids for users the resource has been shared.",
)
username_and_slug: Optional[str] = Field(
None,
title="Username and slug",
description="The relative URL in the form of /u/{username}/{resource_single_char}/{slug}",
)
class ShareWithStatus(SharingStatus):
errors: List[str] = Field(
[],
title="Errors",
description="Collection of messages indicating that the resource was not shared with some (or all users) due to an error.",
)
extra: Optional[ShareWithExtra] = Field(
None,
title="Extra",
description=(
"Optional extra information about this shareable resource that may be of interest. "
"The contents of this field depend on the particular resource."
),
)
class SharableModelManager(base.ModelManager, secured.OwnableManagerMixin, secured.AccessibleManagerMixin,
taggable.TaggableManagerMixin, annotatable.AnnotatableManagerMixin, ratable.RatableManagerMixin):
@@ -158,14 +281,11 @@ class SharableModelManager(base.ModelManager, secured.OwnableManagerMixin, secur
query = query.filter_by(user=user)
return query.all()
def share_with(self, item, user, flush=True):
def share_with(self, item, user: User, flush: bool = True):
"""
Get or create a share for the given user (or users if `user` is a list).
Get or create a share for the given user.
"""
# precondition: user has been validated
# allow user to be a list and call recursivly
if isinstance(user, list):
return [self.share_with(item, _, flush=False) for _ in user]
# get or create
existing = self.get_share_assocs(item, user=user)
if existing:
@@ -189,12 +309,10 @@ class SharableModelManager(base.ModelManager, secured.OwnableManagerMixin, secur
self.session().flush()
return user_share_assoc
def unshare_with(self, item, user, flush=True):
def unshare_with(self, item, user: User, flush: bool = True):
"""
Delete a user share (or list of shares) from the database.
Delete a user share from the database.
"""
if isinstance(user, list):
return [self.unshare_with(item, _, flush=False) for _ in user]
# Look for and delete sharing relation for user.
user_share_assoc = self.get_share_assocs(item, user=user)[0]
self.session().delete(user_share_assoc)
@@ -233,6 +351,33 @@ class SharableModelManager(base.ModelManager, secured.OwnableManagerMixin, secur
items = self._apply_fn_filters_gen(query.all(), fn_filters)
return list(self._apply_fn_limit_offset_gen(items, limit, offset))
def get_sharing_extra_information(
self, trans, item, users: Set[User], errors: Set[str], option: Optional[SharingOptions] = None
) -> Optional[ShareWithExtra]:
"""Returns optional extra information about the shareability of the given item.
This function should be overridden in the particular manager class that wants
to provide the extra information, otherwise, it will be None by default."""
return None
def update_current_sharing_with_users(self, item, new_users_shared_with: Set[User], flush=True):
"""Updates the currently list of users this item is shared with by adding new
users and removing missing ones."""
current_shares = self.get_share_assocs(item)
currently_shared_with = {share.user for share in current_shares}
needs_adding = new_users_shared_with - currently_shared_with
for user in needs_adding:
current_shares.append(self.share_with(item, user, flush=False))
needs_removing = currently_shared_with - new_users_shared_with
for user in needs_removing:
current_shares.remove(self.unshare_with(item, user, flush=False))
if flush:
self.session().flush()
return current_shares
# .... slugs
# slugs are human readable strings often used to link to sharable resources (replacing ids)
# TODO: as validator, deserializer, etc. (maybe another object entirely?)
@@ -244,6 +389,9 @@ class SharableModelManager(base.ModelManager, secured.OwnableManagerMixin, secur
if not self.is_valid_slug(new_slug):
raise exceptions.RequestParameterInvalidException("Invalid slug", slug=new_slug)
if item.slug == new_slug:
return item
# error if slug is already in use
if self._slug_exists(user, new_slug):
raise exceptions.Conflict("Slug already exists", slug=new_slug)
@@ -439,18 +587,7 @@ class SharableModelDeserializer(base.ModelDeserializer,
"""
unencoded_ids = [self.app.security.decode_id(id_) for id_ in val]
new_users_shared_with = set(self.manager.user_manager.by_ids(unencoded_ids))
current_shares = self.manager.get_share_assocs(item)
currently_shared_with = {share.user for share in current_shares}
needs_adding = new_users_shared_with - currently_shared_with
for user in needs_adding:
current_shares.append(self.manager.share_with(item, user, flush=False))
needs_removing = currently_shared_with - new_users_shared_with
for user in needs_removing:
current_shares.remove(self.manager.unshare_with(item, user, flush=False))
self.manager.session().flush()
current_shares = self.manager.update_current_sharing_with_users(item, new_users_shared_with)
# TODO: or should this return the list of ids?
return current_shares
@@ -473,78 +610,6 @@ class SharableModelFilters(base.ModelFilterParser,
})
class SharingPayload(BaseModel):
action: str = Field( # TODO: this seems like it should be a list of actions instead of separating them by '-'
..., # Mark this field as required
title="Action",
description=(
"The name of the sharing action. "
"Can be one (or multiple values separated by '-') of the following: "
"make_accessible_via_link, make_accessible_and_publish, publish, "
"unpublish, disable_link_access, disable_link_access_and_unpublish, unshare_user"
),
)
user_id: Optional[EncodedDatabaseIdField] = Field(
None,
title="User ID",
description=(
"The ID of the user with whom this resource will be shared. "
"*Required* when the action is `unshare_user`."
),
)
class UserEmail(BaseModel):
id: EncodedDatabaseIdField = Field(
..., # Mark this field as required
title="User ID",
description="The encoded ID of the user.",
)
email: str = Field(
..., # Mark this field as required
title="Email",
description="The email of the user.",
)
class SharingStatus(BaseModel):
id: EncodedDatabaseIdField = Field(
..., # Mark this field as required
title="ID",
description="The encoded ID of the resource to be shared.",
)
title: str = Field(
..., # Mark this field as required
title="Title",
description="The title or name of the resource.",
)
importable: bool = Field(
..., # Mark this field as required
title="Importable",
description="Whether this resource can be published using a link.",
)
published: bool = Field(
..., # Mark this field as required
title="Published",
description="Whether this resource is currently published.",
)
users_shared_with: List[UserEmail] = Field(
[],
title="Users shared with",
description="The list of encoded ids for users the resource has been shared.",
)
username_and_slug: Optional[str] = Field(
None,
title="Username and slug",
description="The relative URL in the form of /u/{username}/{resource_single_char}/{slug}",
)
skipped: Optional[bool] = Field(
None,
title="Skipped",
description="Indicates that some of the resources within this object were not published due to an error.",
)
class SlugBuilder:
"""Builder for creating slugs out of items."""
@@ -596,97 +661,100 @@ class ShareableService:
def __init__(self, manager: SharableModelManager, serializer: SharableModelSerializer) -> None:
self.manager = manager
self.serializer = serializer
self.slug_builder = SlugBuilder()
def sharing(self, trans, id: EncodedDatabaseIdField, payload: Optional[SharingPayload] = None) -> SharingStatus:
"""Allows to publish or share with other users the given resource (by id) and returns the current sharing
status of the resource.
def set_slug(self, trans, id: EncodedDatabaseIdField, payload: SetSlugPayload):
item = self._get_item_by_id(trans, id)
self.manager.set_slug(item, payload.new_slug, trans.user)
:param id: The encoded ID of the resource to share.
:type id: EncodedDatabaseIdField
:param payload: The options to share this resource, defaults to None
:type payload: Optional[sharable.SharingPayload], optional
:return: The current sharing status of the resource.
:rtype: sharable.SharingStatus
"""
skipped = False
def sharing(self, trans, id: EncodedDatabaseIdField) -> SharingStatus:
"""Gets the current sharing status of the item with the given id."""
item = self._get_item_by_id(trans, id)
return self._get_sharing_status(trans, item)
def enable_link_access(self, trans, id: EncodedDatabaseIdField) -> SharingStatus:
item = self._get_item_by_id(trans, id)
self.manager.make_importable(item)
return self._get_sharing_status(trans, item)
def disable_link_access(self, trans, id: EncodedDatabaseIdField) -> SharingStatus:
item = self._get_item_by_id(trans, id)
self.manager.make_non_importable(item)
return self._get_sharing_status(trans, item)
def publish(self, trans, id: EncodedDatabaseIdField) -> SharingStatus:
item = self._get_item_by_id(trans, id)
self.manager.publish(item)
return self._get_sharing_status(trans, item)
def unpublish(self, trans, id: EncodedDatabaseIdField) -> SharingStatus:
item = self._get_item_by_id(trans, id)
self.manager.unpublish(item)
return self._get_sharing_status(trans, item)
def share_with_users(self, trans, id: EncodedDatabaseIdField, payload: ShareWithPayload) -> ShareWithStatus:
item = self._get_item_by_id(trans, id)
users, errors = self._get_users(trans, payload.user_ids)
extra = self._share_with_options(trans, item, users, errors, payload.share_option)
base_status = self._get_sharing_status(trans, item)
status = ShareWithStatus.parse_obj(base_status)
status.extra = extra
status.errors.extend(errors)
return status
def _share_with_options(
self,
trans,
item,
users: Set[User],
errors: Set[str],
share_option: Optional[SharingOptions] = None,
):
extra = self.manager.get_sharing_extra_information(trans, item, users, errors, share_option)
if not extra or extra.can_share:
self.manager.update_current_sharing_with_users(item, users)
extra = None
return extra
def _get_item_by_id(self, trans, id: EncodedDatabaseIdField):
class_name = self.manager.model_class.__name__
item = base.get_object(trans, id, class_name, check_ownership=True, check_accessible=True, deleted=False)
actions = []
if payload:
actions += payload.action.split("-")
for action in actions:
if action == "make_accessible_via_link":
self._make_item_accessible(trans.sa_session, item)
if hasattr(item, "has_possible_members") and item.has_possible_members:
skipped = self._make_members_public(trans, item)
elif action == "make_accessible_and_publish":
self._make_item_accessible(trans.sa_session, item)
if hasattr(item, "has_possible_members") and item.has_possible_members:
skipped = self._make_members_public(trans, item)
item.published = True
elif action == "publish":
if item.importable:
item.published = True
if hasattr(item, "has_possible_members") and item.has_possible_members:
skipped = self._make_members_public(trans, item)
else:
raise exceptions.MessageException(f"{class_name} not importable.")
elif action == "disable_link_access":
item.importable = False
elif action == "unpublish":
item.published = False
elif action == "disable_link_access_and_unpublish":
item.importable = item.published = False
elif action == "unshare_user":
if payload is None or payload.user_id is None:
raise exceptions.MessageException(f"Missing required user_id to perform {action}")
user = trans.sa_session.query(trans.app.model.User).get(trans.app.security.decode_id(payload.user_id))
class_name_lc = class_name.lower()
ShareAssociation = getattr(trans.app.model, f"{class_name}UserShareAssociation")
usas = trans.sa_session.query(ShareAssociation).filter_by(**{"user": user, class_name_lc: item}).all()
if not usas:
raise exceptions.MessageException(f"{class_name} was not shared with user.")
for usa in usas:
trans.sa_session.delete(usa)
trans.sa_session.add(item)
trans.sa_session.flush()
if item.importable and not item.slug:
self._make_item_accessible(trans.sa_session, item)
item_dict = self.serializer.serialize_to_view(item,
return item
def _get_sharing_status(self, trans, item):
status = self.serializer.serialize_to_view(item,
user=trans.user, trans=trans, default_view="sharing")
item_dict["users_shared_with"] = [{"id": self.manager.app.security.encode_id(a.user.id), "email": a.user.email} for a in item.users_shared_with]
if skipped:
item_dict["skipped"] = True
return SharingStatus.parse_obj(item_dict)
status["users_shared_with"] = [{"id": self.manager.app.security.encode_id(a.user.id), "email": a.user.email} for a in item.users_shared_with]
return SharingStatus.parse_obj(status)
def _is_valid_slug(self, slug):
""" Returns true if slug is valid. """
return base.is_valid_slug(slug)
def _get_users(self, trans, emails_or_ids: Optional[List] = None) -> Tuple[Set[User], Set[str]]:
if emails_or_ids is None:
raise exceptions.MessageException("Missing required user IDs or emails")
send_to_users: Set[User] = set()
send_to_err: Set[str] = set()
for email_or_id in set(emails_or_ids):
email_or_id = email_or_id.strip()
if not email_or_id:
continue
def _make_item_accessible(self, sa_session, item):
""" Makes item accessible--viewable and importable--and sets item's slug.
Does not flush/commit changes, however. Item must have name, user,
importable, and slug attributes. """
item.importable = True
self.slug_builder.create_item_slug(sa_session, item)
send_to_user = None
if '@' in email_or_id:
email_address = email_or_id
send_to_user = self.manager.user_manager.by_email(email_address,
filters=[User.table.c.deleted == false()])
else:
try:
decoded_user_id = trans.security.decode_id(email_or_id)
send_to_user = self.manager.user_manager.by_id(decoded_user_id)
if send_to_user.deleted:
send_to_user = None
except exceptions.MalformedId:
send_to_user = None
def _make_members_public(self, trans, item):
""" Make the non-purged datasets in history public
Performs pemissions check.
"""
# TODO eventually we should handle more classes than just History
skipped = False
for hda in item.activatable_datasets:
dataset = hda.dataset
if not trans.app.security_agent.dataset_is_public(dataset):
if trans.app.security_agent.can_manage_dataset(trans.user.all_roles(), dataset):
try:
trans.app.security_agent.make_dataset_public(hda.dataset)
except Exception:
log.warning("Unable to make dataset with id: %s public", dataset.id)
skipped = True
else:
log.warning("User without permissions tried to make dataset with id: %s public", dataset.id)
skipped = True
return skipped
if not send_to_user:
send_to_err.add(f"{email_or_id} is not a valid Galaxy user.")
elif send_to_user == trans.user:
send_to_err.add("You cannot share resources with yourself.")
else:
send_to_users.add(send_to_user)
return send_to_users, send_to_err
-8
View File
@@ -5,11 +5,9 @@ Visualizations are saved configurations/variables used to
reproduce a specific view in a Galaxy visualization.
"""
import logging
from typing import Optional
from galaxy import model
from galaxy.managers import sharable
from galaxy.schema.fields import EncodedDatabaseIdField
from galaxy.structured_app import MinimalManagerApp
log = logging.getLogger(__name__)
@@ -89,9 +87,3 @@ class VisualizationsService:
self.shareable_service = sharable.ShareableService(self.manager, self.serializer)
# TODO: add the rest of the API actions here and call them directly from the API controller
def sharing(self, trans, id: EncodedDatabaseIdField, payload: Optional[sharable.SharingPayload] = None) -> sharable.SharingStatus:
"""Allows to publish or share with other users the given resource (by id) and returns the current sharing
status of the resource.
"""
return self.shareable_service.sharing(trans, id, payload)
+10 -4
View File
@@ -1667,17 +1667,23 @@ class NavigatesGalaxy(HasDriver):
assert text == expected, f"Tooltip text [{text}] was not expected text [{expected}]."
def assert_error_message(self, contains=None):
element = self.components._.messages["error"]
return self.assert_message(element, contains=contains)
self.components._.messages.error.wait_for_visible()
elements = self.find_elements(self.components._.messages.selectors.error)
return self.assert_message(elements, contains=contains)
def assert_warning_message(self, contains=None):
element = self.components._.messages["warning"]
return self.assert_message(element, contains=contains)
def assert_message(self, element, contains=None):
element = element.wait_for_visible()
assert element, "No error message found, one expected."
if contains is not None:
if type(element) == list:
assert any([contains in el.text for el in element]), \
f"{contains} was not found in {[el.text for el in element]}"
return
element = element.wait_for_visible()
text = element.text
if contains not in text:
message = f"Text [{contains}] expected inside of [{text}] but not found."
+6 -2
View File
@@ -394,8 +394,12 @@ histories:
sharing:
selectors:
unshare_user_button: '.unshare_user'
share_with_a_user_button: '#share_with_a_user'
unshare_user_button: '.share_with_view .multiselect__tag-icon'
user_email_input: '.user-email-input-form'
submit_sharing_with: '.submit-sharing-with'
share_with_collapse: '.share-with-collapse'
share_with_multiselect: '.share_with_view > .multiselect'
share_with_input: '.share_with_view input'
make_accessible: '.make-accessible label'
make_publishable: '.make-publishable label'
labels:
+166 -8
View File
@@ -6,6 +6,13 @@ API operations on a history.
import logging
from typing import Optional
from fastapi import (
Body,
Path,
Response,
status,
)
from galaxy import (
util
)
@@ -13,8 +20,12 @@ from galaxy.managers import (
histories,
sharable,
)
from galaxy.managers.context import ProvidesUserContext
from galaxy.schema import FilterQueryParams
from galaxy.schema.fields import OrderParamField
from galaxy.schema.fields import (
EncodedDatabaseIdField,
OrderParamField,
)
from galaxy.schema.schema import (
CreateHistoryPayload,
ExportHistoryArchivePayload,
@@ -29,10 +40,116 @@ from galaxy.web import (
expose_api_raw,
)
from galaxy.webapps.galaxy.api.configuration import parse_serialization_params
from . import BaseGalaxyAPIController, depends
from . import (
BaseGalaxyAPIController,
depends,
DependsOnTrans,
Router,
)
log = logging.getLogger(__name__)
router = Router(tags=['histories'])
HistoryIdPathParam: EncodedDatabaseIdField = Path(
...,
title="History ID",
description="The encoded database identifier of the History."
)
@router.cbv
class FastAPIHistories:
service: histories.HistoriesService = depends(histories.HistoriesService)
@router.get(
'/api/histories/{id}/sharing',
summary="Get the current sharing status of the given item.",
)
def sharing(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
) -> sharable.SharingStatus:
"""Return the sharing status of the item."""
return self.service.shareable_service.sharing(trans, id)
@router.put(
'/api/histories/{id}/enable_link_access',
summary="Makes this item accessible by a URL link.",
)
def enable_link_access(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
) -> sharable.SharingStatus:
"""Makes this item accessible by a URL link and return the current sharing status."""
return self.service.shareable_service.enable_link_access(trans, id)
@router.put(
'/api/histories/{id}/disable_link_access',
summary="Makes this item inaccessible by a URL link.",
)
def disable_link_access(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
) -> sharable.SharingStatus:
"""Makes this item inaccessible by a URL link and return the current sharing status."""
return self.service.shareable_service.disable_link_access(trans, id)
@router.put(
'/api/histories/{id}/publish',
summary="Makes this item public and accessible by a URL link.",
)
def publish(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
) -> sharable.SharingStatus:
"""Makes this item publicly available by a URL link and return the current sharing status."""
return self.service.shareable_service.publish(trans, id)
@router.put(
'/api/histories/{id}/unpublish',
summary="Removes this item from the published list.",
)
def unpublish(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
) -> sharable.SharingStatus:
"""Removes this item from the published list and return the current sharing status."""
return self.service.shareable_service.unpublish(trans, id)
@router.put(
'/api/histories/{id}/share_with_users',
summary="Share this item with specific users.",
)
def share_with_users(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
payload: sharable.ShareWithPayload = Body(...)
) -> sharable.ShareWithStatus:
"""Shares this item with specific users and return the current sharing status."""
return self.service.shareable_service.share_with_users(trans, id, payload)
@router.put(
'/api/histories/{id}/slug',
summary="Set a new slug for this shared item.",
status_code=status.HTTP_204_NO_CONTENT,
)
def set_slug(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = HistoryIdPathParam,
payload: sharable.SetSlugPayload = Body(...),
):
"""Sets a new slug to access this item by URL. The new slug must be unique."""
self.service.shareable_service.set_slug(trans, id, payload)
return Response(status_code=status.HTTP_204_NO_CONTENT)
class HistoryFilterQueryParams(FilterQueryParams):
order: Optional[str] = OrderParamField(default_order="create_time-dsc")
@@ -367,11 +484,52 @@ class HistoriesController(BaseGalaxyAPIController):
return self.service.get_custom_builds_metadata(trans, id)
@expose_api
def sharing(self, trans, id, payload=None, **kwd):
def sharing(self, trans, id, **kwd):
"""
* GET/POST /api/pages/{id}/sharing
View/modify sharing options for the page with the given id.
* GET /api/histories/{id}/sharing
"""
if payload:
payload = sharable.SharingPayload(**payload)
return self.service.sharing(trans, id, payload)
return self.service.shareable_service.sharing(trans, id)
@expose_api
def enable_link_access(self, trans, id, **kwd):
"""
* PUT /api/histories/{id}/enable_link_access
"""
return self.service.shareable_service.enable_link_access(trans, id)
@expose_api
def disable_link_access(self, trans, id, **kwd):
"""
* PUT /api/histories/{id}/disable_link_access
"""
return self.service.shareable_service.disable_link_access(trans, id)
@expose_api
def publish(self, trans, id, **kwd):
"""
* PUT /api/histories/{id}/publish
"""
return self.service.shareable_service.publish(trans, id)
@expose_api
def unpublish(self, trans, id, **kwd):
"""
* PUT /api/histories/{id}/unpublish
"""
return self.service.shareable_service.unpublish(trans, id)
@expose_api
def share_with_users(self, trans, id, payload, **kwd):
"""
* PUT /api/histories/{id}/share_with_users
"""
payload = sharable.ShareWithPayload(**payload)
return self.service.shareable_service.share_with_users(trans, id, payload)
@expose_api
def set_slug(self, trans, id, payload, **kwd):
"""
* PUT /api/histories/{id}/slug
"""
payload = sharable.SetSlugPayload(**payload)
self.service.shareable_service.set_slug(trans, id, payload)
+129 -19
View File
@@ -8,6 +8,7 @@ from fastapi import (
Body,
Path,
Query,
Response,
status,
)
from starlette.responses import StreamingResponse
@@ -20,7 +21,12 @@ from galaxy.managers.pages import (
PageSummary,
PageSummaryList,
)
from galaxy.managers.sharable import SharingPayload, SharingStatus
from galaxy.managers.sharable import (
SetSlugPayload,
ShareWithPayload,
ShareWithStatus,
SharingStatus,
)
from galaxy.schema.fields import EncodedDatabaseIdField
from galaxy.web import (
expose_api,
@@ -47,7 +53,7 @@ DeletedQueryParam: bool = Query(
PageIdPathParam: EncodedDatabaseIdField = Path(
..., # Required
title="Page ID",
description="The encoded indentifier of the Page."
description="The encoded database identifier of the Page."
)
@@ -135,28 +141,91 @@ class FastAPIPages:
@router.get(
'/api/pages/{id}/sharing',
summary="Get sharing the status of the given Page.",
summary="Get the current sharing status of the given Page.",
)
def get_sharing(
def sharing(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
) -> SharingStatus:
"""Return the sharing status of the Page."""
return self.service.sharing(trans, id)
"""Return the sharing status of the item."""
return self.service.shareable_service.sharing(trans, id)
@router.post(
'/api/pages/{id}/sharing',
summary="Set sharing options for the given Page.",
@router.put(
'/api/pages/{id}/enable_link_access',
summary="Makes this item accessible by a URL link.",
)
def post_sharing(
def enable_link_access(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
payload: SharingPayload = Body(...),
) -> SharingStatus:
"""Return the sharing status of the Page after the changes."""
return self.service.sharing(trans, id, payload)
"""Makes this item accessible by a URL link and return the current sharing status."""
return self.service.shareable_service.enable_link_access(trans, id)
@router.put(
'/api/pages/{id}/disable_link_access',
summary="Makes this item inaccessible by a URL link.",
)
def disable_link_access(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
) -> SharingStatus:
"""Makes this item inaccessible by a URL link and return the current sharing status."""
return self.service.shareable_service.disable_link_access(trans, id)
@router.put(
'/api/pages/{id}/publish',
summary="Makes this item public and accessible by a URL link.",
)
def publish(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
) -> SharingStatus:
"""Makes this item publicly available by a URL link and return the current sharing status."""
return self.service.shareable_service.publish(trans, id)
@router.put(
'/api/pages/{id}/unpublish',
summary="Removes this item from the published list.",
)
def unpublish(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
) -> SharingStatus:
"""Removes this item from the published list and return the current sharing status."""
return self.service.shareable_service.unpublish(trans, id)
@router.put(
'/api/pages/{id}/share_with_users',
summary="Share this item with specific users.",
)
def share_with_users(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
payload: ShareWithPayload = Body(...)
) -> ShareWithStatus:
"""Shares this item with specific users and return the current sharing status."""
return self.service.shareable_service.share_with_users(trans, id, payload)
@router.put(
'/api/pages/{id}/slug',
summary="Set a new slug for this shared item.",
status_code=status.HTTP_204_NO_CONTENT,
)
def set_slug(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = PageIdPathParam,
payload: SetSlugPayload = Body(...),
):
"""Sets a new slug to access this item by URL. The new slug must be unique."""
self.service.shareable_service.set_slug(trans, id, payload)
return Response(status_code=status.HTTP_204_NO_CONTENT)
class PagesController(BaseGalaxyAPIController):
@@ -242,11 +311,52 @@ class PagesController(BaseGalaxyAPIController):
return self.service.show_pdf(trans, id)
@expose_api
def sharing(self, trans, id, payload=None, **kwd):
def sharing(self, trans, id, **kwd):
"""
* GET/POST /api/pages/{id}/sharing
View/modify sharing options for the page with the given id.
* GET /api/pages/{id}/sharing
"""
if payload:
payload = SharingPayload(**payload)
return self.service.sharing(trans, id, payload)
return self.service.shareable_service.sharing(trans, id)
@expose_api
def enable_link_access(self, trans, id, **kwd):
"""
* PUT /api/pages/{id}/enable_link_access
"""
return self.service.shareable_service.enable_link_access(trans, id)
@expose_api
def disable_link_access(self, trans, id, **kwd):
"""
* PUT /api/pages/{id}/disable_link_access
"""
return self.service.shareable_service.disable_link_access(trans, id)
@expose_api
def publish(self, trans, id, **kwd):
"""
* PUT /api/pages/{id}/publish
"""
return self.service.shareable_service.publish(trans, id)
@expose_api
def unpublish(self, trans, id, **kwd):
"""
* PUT /api/pages/{id}/unpublish
"""
return self.service.shareable_service.unpublish(trans, id)
@expose_api
def share_with_users(self, trans, id, payload, **kwd):
"""
* PUT /api/pages/{id}/share_with_users
"""
payload = ShareWithPayload(**payload)
return self.service.shareable_service.share_with_users(trans, id, payload)
@expose_api
def set_slug(self, trans, id, payload, **kwd):
"""
* PUT /api/pages/{id}/slug
"""
payload = SetSlugPayload(**payload)
self.service.shareable_service.set_slug(trans, id, payload)
+168 -8
View File
@@ -7,24 +7,143 @@ may change often.
import json
import logging
from fastapi import (
Body,
Path,
Response,
status,
)
from galaxy import (
exceptions,
util,
web
)
from galaxy.managers.sharable import SharingPayload
from galaxy.managers.context import ProvidesUserContext
from galaxy.managers.sharable import (
SetSlugPayload,
ShareWithPayload,
ShareWithStatus,
SharingStatus,
)
from galaxy.managers.visualizations import VisualizationsService
from galaxy.model.item_attrs import UsesAnnotations
from galaxy.schema.fields import EncodedDatabaseIdField
from galaxy.web import expose_api
from galaxy.webapps.base.controller import (
UsesVisualizationMixin
)
from galaxy.webapps.base.webapp import GalaxyWebTransaction
from . import BaseGalaxyAPIController, depends
from . import (
BaseGalaxyAPIController,
depends,
DependsOnTrans,
Router,
)
log = logging.getLogger(__name__)
router = Router(tags=['visualizations'])
VisualizationIdPathParam: EncodedDatabaseIdField = Path(
...,
title="Visualization ID",
description="The encoded database identifier of the Visualization."
)
@router.cbv
class FastAPIVisualizations:
service: VisualizationsService = depends(VisualizationsService)
@router.get(
'/api/visualizations/{id}/sharing',
summary="Get the current sharing status of the given Page.",
)
def sharing(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
) -> SharingStatus:
"""Return the sharing status of the item."""
return self.service.shareable_service.sharing(trans, id)
@router.put(
'/api/visualizations/{id}/enable_link_access',
summary="Makes this item accessible by a URL link.",
)
def enable_link_access(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
) -> SharingStatus:
"""Makes this item accessible by a URL link and return the current sharing status."""
return self.service.shareable_service.enable_link_access(trans, id)
@router.put(
'/api/visualizations/{id}/disable_link_access',
summary="Makes this item inaccessible by a URL link.",
)
def disable_link_access(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
) -> SharingStatus:
"""Makes this item inaccessible by a URL link and return the current sharing status."""
return self.service.shareable_service.disable_link_access(trans, id)
@router.put(
'/api/visualizations/{id}/publish',
summary="Makes this item public and accessible by a URL link.",
)
def publish(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
) -> SharingStatus:
"""Makes this item publicly available by a URL link and return the current sharing status."""
return self.service.shareable_service.publish(trans, id)
@router.put(
'/api/visualizations/{id}/unpublish',
summary="Removes this item from the published list.",
)
def unpublish(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
) -> SharingStatus:
"""Removes this item from the published list and return the current sharing status."""
return self.service.shareable_service.unpublish(trans, id)
@router.put(
'/api/visualizations/{id}/share_with_users',
summary="Share this item with specific users.",
)
def share_with_users(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
payload: ShareWithPayload = Body(...)
) -> ShareWithStatus:
"""Shares this item with specific users and return the current sharing status."""
return self.service.shareable_service.share_with_users(trans, id, payload)
@router.put(
'/api/visualizations/{id}/slug',
summary="Set a new slug for this shared item.",
status_code=status.HTTP_204_NO_CONTENT,
)
def set_slug(
self,
trans: ProvidesUserContext = DependsOnTrans,
id: EncodedDatabaseIdField = VisualizationIdPathParam,
payload: SetSlugPayload = Body(...),
):
"""Sets a new slug to access this item by URL. The new slug must be unique."""
self.service.shareable_service.set_slug(trans, id, payload)
return Response(status_code=status.HTTP_204_NO_CONTENT)
class VisualizationsController(BaseGalaxyAPIController, UsesVisualizationMixin, UsesAnnotations):
"""
@@ -152,14 +271,55 @@ class VisualizationsController(BaseGalaxyAPIController, UsesVisualizationMixin,
return rval
@expose_api
def sharing(self, trans, id, payload=None, **kwd):
def sharing(self, trans, id, **kwd):
"""
* GET/POST /api/pages/{id}/sharing
View/modify sharing options for the page with the given id.
* GET /api/visualizations/{id}/sharing
"""
if payload:
payload = SharingPayload(**payload)
return self.service.sharing(trans, id, payload)
return self.service.shareable_service.sharing(trans, id)
@expose_api
def enable_link_access(self, trans, id, **kwd):
"""
* PUT /api/visualizations/{id}/enable_link_access
"""
return self.service.shareable_service.enable_link_access(trans, id)
@expose_api
def disable_link_access(self, trans, id, **kwd):
"""
* PUT /api/visualizations/{id}/disable_link_access
"""
return self.service.shareable_service.disable_link_access(trans, id)
@expose_api
def publish(self, trans, id, **kwd):
"""
* PUT /api/visualizations/{id}/publish
"""
return self.service.shareable_service.publish(trans, id)
@expose_api
def unpublish(self, trans, id, **kwd):
"""
* PUT /api/visualizations/{id}/unpublish
"""
return self.service.shareable_service.unpublish(trans, id)
@expose_api
def share_with_users(self, trans, id, payload, **kwd):
"""
* PUT /api/visualizations/{id}/share_with_users
"""
payload = ShareWithPayload(**payload)
return self.service.shareable_service.share_with_users(trans, id, payload)
@expose_api
def set_slug(self, trans, id, payload, **kwd):
"""
* PUT /api/visualizations/{id}/slug
"""
payload = SetSlugPayload(**payload)
self.service.shareable_service.set_slug(trans, id, payload)
def _validate_and_parse_payload(self, payload):
"""
+21 -3
View File
@@ -479,7 +479,13 @@ def populate_api_routes(webapp, app):
webapp.mapper.connect('/api/genomes/{id}/indexes', controller='genomes', action='indexes')
webapp.mapper.connect('/api/genomes/{id}/sequences', controller='genomes', action='sequences')
webapp.mapper.resource('visualization', 'visualizations', path_prefix='/api')
webapp.mapper.connect('/api/visualizations/{id}/sharing', action='sharing', controller="visualizations", conditions=dict(method=["GET", "POST"]))
webapp.mapper.connect('/api/visualizations/{id}/sharing', action='sharing', controller="visualizations", conditions=dict(method=["GET"]))
webapp.mapper.connect('/api/visualizations/{id}/enable_link_access', action='enable_link_access', controller="visualizations", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/visualizations/{id}/disable_link_access', action='disable_link_access', controller="visualizations", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/visualizations/{id}/publish', action='publish', controller="visualizations", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/visualizations/{id}/unpublish', action='unpublish', controller="visualizations", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/visualizations/{id}/share_with_users', action='share_with_users', controller="visualizations", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/visualizations/{id}/slug', action='set_slug', controller="visualizations", conditions=dict(method=["PUT"]))
webapp.mapper.resource('plugins', 'plugins', path_prefix='/api')
webapp.mapper.connect('/api/workflows/build_module', action='build_module', controller="workflows")
webapp.mapper.connect('/api/workflows/menu', action='get_workflow_menu', controller="workflows", conditions=dict(method=["GET"]))
@@ -490,7 +496,13 @@ def populate_api_routes(webapp, app):
webapp.mapper.connect('/api/licenses/{id}', controller='licenses', action='get', conditions=dict(method="GET"))
webapp.mapper.resource_with_deleted('history', 'histories', path_prefix='/api')
webapp.mapper.connect('/api/histories/{history_id}/citations', action='citations', controller="histories")
webapp.mapper.connect('/api/histories/{id}/sharing', action='sharing', controller="histories", conditions=dict(method=["GET", "POST"]))
webapp.mapper.connect('/api/histories/{id}/sharing', action='sharing', controller="histories", conditions=dict(method=["GET"]))
webapp.mapper.connect('/api/histories/{id}/enable_link_access', action='enable_link_access', controller="histories", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/histories/{id}/disable_link_access', action='disable_link_access', controller="histories", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/histories/{id}/publish', action='publish', controller="histories", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/histories/{id}/unpublish', action='unpublish', controller="histories", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/histories/{id}/share_with_users', action='share_with_users', controller="histories", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/histories/{id}/slug', action='set_slug', controller="histories", conditions=dict(method=["PUT"]))
webapp.mapper.connect(
'dynamic_tool_confs',
'/api/configuration/dynamic_tool_confs',
@@ -529,7 +541,13 @@ def populate_api_routes(webapp, app):
webapp.mapper.resource('search', 'search', path_prefix='/api')
webapp.mapper.connect('/api/pages/{id}.pdf', action='show_pdf', controller="pages", conditions=dict(method=["GET"]))
webapp.mapper.resource('page', 'pages', path_prefix="/api")
webapp.mapper.connect('/api/pages/{id}/sharing', action='sharing', controller="pages", conditions=dict(method=["GET", "POST"]))
webapp.mapper.connect('/api/pages/{id}/sharing', action='sharing', controller="pages", conditions=dict(method=["GET"]))
webapp.mapper.connect('/api/pages/{id}/enable_link_access', action='enable_link_access', controller="pages", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/pages/{id}/disable_link_access', action='disable_link_access', controller="pages", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/pages/{id}/publish', action='publish', controller="pages", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/pages/{id}/unpublish', action='unpublish', controller="pages", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/pages/{id}/share_with_users', action='share_with_users', controller="pages", conditions=dict(method=["PUT"]))
webapp.mapper.connect('/api/pages/{id}/slug', action='set_slug', controller="pages", conditions=dict(method=["PUT"]))
webapp.mapper.resource('revision', 'revisions',
path_prefix='/api/pages/{page_id}',
controller='page_revisions',
@@ -2,7 +2,6 @@ import logging
from markupsafe import escape
from sqlalchemy import (
and_,
false,
null,
true
@@ -13,7 +12,6 @@ from sqlalchemy.orm import (
undefer
)
import galaxy.util
from galaxy import exceptions
from galaxy import model
from galaxy import web
@@ -26,7 +24,6 @@ from galaxy.model.item_attrs import (
from galaxy.structured_app import StructuredApp
from galaxy.util import (
listify,
Params,
parse_int,
sanitize_text,
string_as_bool,
@@ -717,79 +714,6 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt
raise exceptions.InternalServerError('An error occurred and the dataset is NOT private.')
return {'message': f"Success, requested permissions have been changed in {'all histories' if all_histories else history.name}."}
@web.expose
@web.require_login("share histories with other users")
def share(self, trans, id=None, email="", **kwd):
# If a history contains both datasets that can be shared and others that cannot be shared with the desired user,
# then the entire history is shared, and the protected datasets will be visible, but inaccessible ( greyed out )
# in the copyd history
params = Params(kwd)
user = trans.get_user()
# TODO: we have too many error messages floating around in here - we need
# to incorporate the messaging system used by the libraries that will display
# a message on any page.
err_msg = galaxy.util.restore_text(params.get('err_msg', ''))
if not email:
if not id:
# Default to the current history
id = trans.security.encode_id(trans.history.id)
id = listify(id)
send_to_err = err_msg
histories = []
for history_id in id:
history_id = self.decode_id(history_id)
history = self.history_manager.get_owned(history_id, trans.user, current_history=trans.history)
histories.append(history)
return trans.fill_template("/history/share.mako",
histories=histories,
email=email,
send_to_err=send_to_err)
histories = self._get_histories(trans, id)
send_to_users, send_to_err = self._get_users(trans, user, email)
if not send_to_users:
if not send_to_err:
send_to_err += f"{email} is not a valid Galaxy user. {err_msg}"
return trans.fill_template("/history/share.mako",
histories=histories,
email=email,
send_to_err=send_to_err)
if params.get('share_button', False):
# The user has not yet made a choice about how to share, so dictionaries will be built for display
can_change, cannot_change, no_change_needed, unique_no_change_needed, send_to_err = \
self._populate_restricted(trans, user, histories, send_to_users, None, send_to_err, unique=True)
send_to_err += err_msg
if cannot_change and not no_change_needed and not can_change:
send_to_err = "The histories you are sharing do not contain any datasets that can be accessed by the users with which you are sharing."
return trans.fill_template("/history/share.mako",
histories=histories,
email=email,
send_to_err=send_to_err)
if can_change or cannot_change:
return trans.fill_template("/history/share.mako",
histories=histories,
email=email,
send_to_err=send_to_err,
can_change=can_change,
cannot_change=cannot_change,
no_change_needed=unique_no_change_needed)
if no_change_needed:
return self._share_histories(trans, user, send_to_err, histories=no_change_needed)
elif not send_to_err:
# User seems to be sharing an empty history
send_to_err = "You cannot share an empty history. "
return trans.fill_template("/history/share.mako",
histories=histories,
email=email,
send_to_err=send_to_err)
@web.expose
def adjust_hidden(self, trans, id=None, **kwd):
""" THIS METHOD IS A TEMPORARY ADDITION. It'll allow us to fix the
@@ -804,248 +728,8 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt
trans.history.unhide_datasets()
trans.sa_session.flush()
@web.expose
@web.require_login("share restricted histories with other users")
def share_restricted(self, trans, id=None, email="", **kwd):
if 'action' in kwd:
action = kwd['action']
else:
err_msg = "Select an action. "
return trans.response.send_redirect(url_for(controller='history',
action='share',
id=id,
email=email,
err_msg=err_msg,
share_button=True))
user = trans.get_user()
user_roles = user.all_roles()
histories = self._get_histories(trans, id)
send_to_users, send_to_err = self._get_users(trans, user, email)
send_to_err = ''
# The user has made a choice, so dictionaries will be built for sharing
can_change, cannot_change, no_change_needed, unique_no_change_needed, send_to_err = \
self._populate_restricted(trans, user, histories, send_to_users, action, send_to_err)
# Now that we've populated the can_change, cannot_change, and no_change_needed dictionaries,
# we'll populate the histories_for_sharing dictionary from each of them.
histories_for_sharing = {}
if no_change_needed:
# Don't need to change anything in cannot_change, so populate as is
histories_for_sharing, send_to_err = \
self._populate(trans, histories_for_sharing, no_change_needed, send_to_err)
if cannot_change:
# Can't change anything in cannot_change, so populate as is
histories_for_sharing, send_to_err = \
self._populate(trans, histories_for_sharing, cannot_change, send_to_err)
# The action here is either 'public' or 'private', so we'll continue to populate the
# histories_for_sharing dictionary from the can_change dictionary.
for send_to_user, history_dict in can_change.items():
for history in history_dict:
# Make sure the current history has not already been shared with the current send_to_user
if trans.sa_session.query(trans.app.model.HistoryUserShareAssociation) \
.filter(and_(trans.app.model.HistoryUserShareAssociation.table.c.user_id == send_to_user.id,
trans.app.model.HistoryUserShareAssociation.table.c.history_id == history.id)) \
.count() > 0:
send_to_err += f"History ({history.name}) already shared with user ({send_to_user.email})"
else:
# Only deal with datasets that have not been purged
for hda in history.activatable_datasets:
# If the current dataset is not public, we may need to perform an action on it to
# make it accessible by the other user.
if not trans.app.security_agent.can_access_dataset(send_to_user.all_roles(), hda.dataset):
# The user with which we are sharing the history does not have access permission on the current dataset
if trans.app.security_agent.can_manage_dataset(user_roles, hda.dataset) and not hda.dataset.library_associations:
# The current user has authority to change permissions on the current dataset because
# they have permission to manage permissions on the dataset and the dataset is not associated
# with a library.
if action == "private":
trans.app.security_agent.privately_share_dataset(hda.dataset, users=[user, send_to_user])
elif action == "public":
trans.app.security_agent.make_dataset_public(hda.dataset)
# Populate histories_for_sharing with the history after performing any requested actions on
# its datasets to make them accessible by the other user.
if send_to_user not in histories_for_sharing:
histories_for_sharing[send_to_user] = [history]
elif history not in histories_for_sharing[send_to_user]:
histories_for_sharing[send_to_user].append(history)
return self._share_histories(trans, user, send_to_err, histories=histories_for_sharing)
def _get_histories(self, trans, ids):
if not ids:
# Default to the current history
ids = trans.security.encode_id(trans.history.id)
ids = listify(ids)
histories = []
for history_id in ids:
history_id = self.decode_id(history_id)
history = self.history_manager.get_owned(history_id, trans.user, current_history=trans.history)
histories.append(history)
return histories
def _get_users(self, trans, user, emails_or_ids):
send_to_users = []
send_to_err = ""
for string in listify(emails_or_ids):
string = string.strip()
if not string:
continue
send_to_user = None
if '@' in string:
email_address = string
send_to_user = self.user_manager.by_email(email_address,
filters=[trans.app.model.User.table.c.deleted == false()])
else:
try:
decoded_user_id = self.decode_id(string)
send_to_user = self.user_manager.by_id(decoded_user_id)
if send_to_user.deleted:
send_to_user = None
# TODO: in an ideal world, we would let this bubble up to web.expose which would handle it
except exceptions.MalformedId:
send_to_user = None
if not send_to_user:
send_to_err += f"{string} is not a valid Galaxy user. "
elif send_to_user == user:
send_to_err += "You cannot send histories to yourself. "
else:
send_to_users.append(send_to_user)
return send_to_users, send_to_err
def _populate(self, trans, histories_for_sharing, other, send_to_err):
# This method will populate the histories_for_sharing dictionary with the users and
# histories in other, eliminating histories that have already been shared with the
# associated user. No security checking on datasets is performed.
# If not empty, the histories_for_sharing dictionary looks like:
# { userA: [ historyX, historyY ], userB: [ historyY ] }
# other looks like:
# { userA: {historyX : [hda, hda], historyY : [hda]}, userB: {historyY : [hda]} }
for send_to_user, history_dict in other.items():
for history in history_dict:
# Make sure the current history has not already been shared with the current send_to_user
if trans.sa_session.query(trans.app.model.HistoryUserShareAssociation) \
.filter(and_(trans.app.model.HistoryUserShareAssociation.table.c.user_id == send_to_user.id,
trans.app.model.HistoryUserShareAssociation.table.c.history_id == history.id)) \
.count() > 0:
send_to_err += f"History ({history.name}) already shared with user ({send_to_user.email})"
else:
# Build the dict that will be used for sharing
if send_to_user not in histories_for_sharing:
histories_for_sharing[send_to_user] = [history]
elif history not in histories_for_sharing[send_to_user]:
histories_for_sharing[send_to_user].append(history)
return histories_for_sharing, send_to_err
def _populate_restricted(self, trans, user, histories, send_to_users, action, send_to_err, unique=False):
# The user may be attempting to share histories whose datasets cannot all be accessed by other users.
# If this is the case, the user sharing the histories can:
# 1) action=='public': choose to make the datasets public if he is permitted to do so
# 2) action=='private': automatically create a new "sharing role" allowing protected
# datasets to be accessed only by the desired users
# This method will populate the can_change, cannot_change and no_change_needed dictionaries, which
# are used for either displaying to the user, letting them make 1 of the choices above, or sharing
# after the user has made a choice. They will be used for display if 'unique' is True, and will look
# like: {historyX : [hda, hda], historyY : [hda] }
# For sharing, they will look like:
# { userA: {historyX : [hda, hda], historyY : [hda]}, userB: {historyY : [hda]} }
can_change = {}
cannot_change = {}
no_change_needed = {}
unique_no_change_needed = {}
user_roles = user.all_roles()
for history in histories:
for send_to_user in send_to_users:
# Make sure the current history has not already been shared with the current send_to_user
if trans.sa_session.query(trans.app.model.HistoryUserShareAssociation) \
.filter(and_(trans.app.model.HistoryUserShareAssociation.table.c.user_id == send_to_user.id,
trans.app.model.HistoryUserShareAssociation.table.c.history_id == history.id)) \
.count() > 0:
send_to_err += f"History ({history.name}) already shared with user ({send_to_user.email})"
else:
# Only deal with datasets that have not been purged
for hda in history.activatable_datasets:
if trans.app.security_agent.can_access_dataset(send_to_user.all_roles(), hda.dataset):
# The no_change_needed dictionary is a special case. If both of can_change
# and cannot_change are empty, no_change_needed will used for sharing. Otherwise
# unique_no_change_needed will be used for displaying, so we need to populate both.
# Build the dictionaries for display, containing unique histories only
if history not in unique_no_change_needed:
unique_no_change_needed[history] = [hda]
else:
unique_no_change_needed[history].append(hda)
# Build the dictionaries for sharing
if send_to_user not in no_change_needed:
no_change_needed[send_to_user] = {}
if history not in no_change_needed[send_to_user]:
no_change_needed[send_to_user][history] = [hda]
else:
no_change_needed[send_to_user][history].append(hda)
else:
# The user with which we are sharing the history does not have access permission on the current dataset
if trans.app.security_agent.can_manage_dataset(user_roles, hda.dataset):
# The current user has authority to change permissions on the current dataset because
# they have permission to manage permissions on the dataset.
# NOTE: ( gvk )There may be problems if the dataset also has an ldda, but I don't think so
# because the user with which we are sharing will not have the "manage permission" permission
# on the dataset in their history. Keep an eye on this though...
if unique:
# Build the dictionaries for display, containing unique histories only
if history not in can_change:
can_change[history] = [hda]
else:
can_change[history].append(hda)
else:
# Build the dictionaries for sharing
if send_to_user not in can_change:
can_change[send_to_user] = {}
if history not in can_change[send_to_user]:
can_change[send_to_user][history] = [hda]
else:
can_change[send_to_user][history].append(hda)
else:
if action in ["private", "public"]:
# The user has made a choice, so 'unique' doesn't apply. Don't change stuff
# that the user doesn't have permission to change
continue
if unique:
# Build the dictionaries for display, containing unique histories only
if history not in cannot_change:
cannot_change[history] = [hda]
else:
cannot_change[history].append(hda)
else:
# Build the dictionaries for sharing
if send_to_user not in cannot_change:
cannot_change[send_to_user] = {}
if history not in cannot_change[send_to_user]:
cannot_change[send_to_user][history] = [hda]
else:
cannot_change[send_to_user][history].append(hda)
return can_change, cannot_change, no_change_needed, unique_no_change_needed, send_to_err
def _share_histories(self, trans, user, send_to_err, histories=None):
# histories looks like: { userA: [ historyX, historyY ], userB: [ historyY ] }
histories = histories or {}
if not histories:
send_to_err += "No users have been specified or no histories can be sent without changing permissions or associating a sharing role. "
return trans.response.send_redirect(web.url_for(f"/histories/list?status=error&message={send_to_err}"))
else:
shared_histories = []
for send_to_user, send_to_user_histories in histories.items():
for history in send_to_user_histories:
share = trans.app.model.HistoryUserShareAssociation()
share.history = history
share.user = send_to_user
trans.sa_session.add(share)
self.slug_builder.create_item_slug(trans.sa_session, history)
trans.sa_session.flush()
if history not in shared_histories:
shared_histories.append(history)
return trans.response.send_redirect(web.url_for(f"/histories/sharing?id={trans.security.encode_id(shared_histories[0].id)}"))
# ......................................................................... actions/orig. async
@web.expose
def purge_deleted_datasets(self, trans):
count = 0
@@ -1,6 +1,5 @@
from markupsafe import escape
from sqlalchemy import (
and_,
desc,
false,
true
@@ -445,46 +444,6 @@ class PageController(BaseUIController, SharableMixin,
"""
return trans.fill_template("page/editor.mako", id=id)
@web.expose
@web.require_login("use Galaxy pages")
def share(self, trans, id, email="", use_panels=False):
""" Handle sharing with an individual user. """
msg = mtype = None
page = trans.sa_session.query(model.Page).get(self.decode_id(id))
if email:
other = trans.sa_session.query(model.User) \
.filter(and_(model.User.table.c.email == email,
model.User.table.c.deleted == false())) \
.first()
if not other:
mtype = "error"
msg = f"User '{escape(email)}' does not exist"
elif other == trans.get_user():
mtype = "error"
msg = ("You cannot share a page with yourself")
elif trans.sa_session.query(model.PageUserShareAssociation) \
.filter_by(user=other, page=page).count() > 0:
mtype = "error"
msg = f"Page already shared with '{escape(email)}'"
else:
share = model.PageUserShareAssociation()
share.page = page
share.user = other
session = trans.sa_session
session.add(share)
self.slug_builder.create_item_slug(session, page)
session.flush()
page_title = escape(page.title)
other_email = escape(other.email)
trans.set_message(f"Page '{page_title}' shared with user '{other_email}'")
return trans.response.send_redirect(url_for(f"/pages/sharing?id={id}"))
return trans.fill_template("/ind_share_base.mako",
message=msg,
messagetype=mtype,
item=page,
email=email,
use_panels=use_panels)
@web.expose
@web.require_login()
def display(self, trans, id):
@@ -9,7 +9,6 @@ from paste.httpexceptions import (
HTTPNotFound
)
from sqlalchemy import (
and_,
desc,
false,
or_,
@@ -414,46 +413,6 @@ class VisualizationController(BaseUIController, SharableMixin, UsesVisualization
message="""Visualization "%s" has been imported. <br>You can <a href="%s">start using this visualization</a> or %s."""
% (visualization.title, web.url_for('/visualizations/list'), referer_message), use_panels=True)
@web.expose
@web.require_login("share Galaxy visualizations")
def share(self, trans, id=None, email="", use_panels=False):
""" Handle sharing a visualization with a particular user. """
msg = mtype = None
visualization = self.get_visualization(trans, id, check_ownership=True)
if email:
other = trans.sa_session.query(model.User) \
.filter(and_(model.User.table.c.email == email,
model.User.table.c.deleted == false())) \
.first()
if not other:
mtype = "error"
msg = f"User '{escape(email)}' does not exist"
elif other == trans.get_user():
mtype = "error"
msg = ("You cannot share a visualization with yourself")
elif trans.sa_session.query(model.VisualizationUserShareAssociation) \
.filter_by(user=other, visualization=visualization).count() > 0:
mtype = "error"
msg = f"Visualization already shared with '{escape(email)}'"
else:
share = model.VisualizationUserShareAssociation()
share.visualization = visualization
share.user = other
session = trans.sa_session
session.add(share)
self.slug_builder.create_item_slug(session, visualization)
session.flush()
viz_title = escape(visualization.title)
other_email = escape(other.email)
trans.set_message(f"Visualization '{viz_title}' shared with user '{other_email}'")
return trans.response.send_redirect(web.url_for(f"/visualizations/sharing?id={id}"))
return trans.fill_template("/ind_share_base.mako",
message=msg,
messagetype=mtype,
item=visualization,
email=email,
use_panels=use_panels)
@web.expose
def display_by_username_and_slug(self, trans, username, slug):
""" Display visualization based on a username and slug. """
+114 -60
View File
@@ -7,102 +7,156 @@ from galaxy_test.base.api import UsesApiTestCaseMixin
class SharingApiTests(UsesApiTestCaseMixin):
""" Includes some tests for the sharing functionality of a particular resource type."""
# The api_name has to be set to the appropiate value in the class using these tests.
# The api_name has to be set to the appropriate value in the class using these tests.
api_name: str
def create(self, name: str) -> str:
"""Creates a shareable resource with the given name and returns it's ID.
:param name: The name of the shareable resource to create.
:type name: str
:return: The ID of the resource.
:rtype: str
"""
raise SkipTest("Abstract")
def test_get_sharing_info(self):
def test_sharing_get_status(self):
resource_id = self.create("resource-to-share")
sharing_response = self._get_resource_sharing_info(resource_id)
sharing_response = self._get_resource_sharing_status(resource_id)
self._assert_has_keys(sharing_response, "title", "importable", "id", "username_and_slug", "published", "users_shared_with")
def test_sharing_make_accessible_via_link(self):
resource_id = self.create("resource-to-make-accessible-via-link")
def test_sharing_access(self):
resource_id = self.create("resource-to-enable-link-access")
sharing_response = self._get_resource_sharing_info(resource_id)
sharing_response = self._get_resource_sharing_status(resource_id)
assert sharing_response["importable"] is False
payload = {"action": "make_accessible_via_link"}
sharing_response = self._set_resource_sharing(resource_id, payload)
sharing_response = self._set_resource_sharing(resource_id, "enable_link_access")
assert sharing_response["importable"] is True
def test_sharing_make_accessible_and_publish(self):
sharing_response = self._set_resource_sharing(resource_id, "disable_link_access")
assert sharing_response["importable"] is False
def test_sharing_publish(self):
resource_id = self.create("resource-to-publish")
sharing_response = self._get_resource_sharing_info(resource_id)
sharing_response = self._get_resource_sharing_status(resource_id)
assert sharing_response["importable"] is False
assert sharing_response["published"] is False
payload = {"action": "make_accessible_and_publish"}
sharing_response = self._set_resource_sharing(resource_id, payload)
sharing_response = self._set_resource_sharing(resource_id, "publish")
assert sharing_response["importable"] is True
assert sharing_response["published"] is True
def test_sharing_publish_not_accessible_raises_400(self):
resource_id = self.create("resource-to-publish-not-accesible")
sharing_response = self._get_resource_sharing_info(resource_id)
assert sharing_response["importable"] is False
payload = {"action": "publish"}
sharing_response = self._set_resource_sharing(resource_id, payload, expect_response_status=400)
def test_sharing_disable_link_access(self):
resource_id = self.create("resource-to-disable-link-access")
payload = {"action": "make_accessible_via_link"}
sharing_response = self._set_resource_sharing(resource_id, payload)
assert sharing_response["importable"] is True
payload = {"action": "disable_link_access"}
sharing_response = self._set_resource_sharing(resource_id, payload)
assert sharing_response["importable"] is False
def test_sharing_unpublish(self):
resource_id = self.create("resource-to-unpublish")
sharing_response = self._get_resource_sharing_info(resource_id)
payload = {"action": "make_accessible_and_publish"}
sharing_response = self._set_resource_sharing(resource_id, payload)
assert sharing_response["importable"] is True
assert sharing_response["published"] is True
payload = {"action": "unpublish"}
sharing_response = self._set_resource_sharing(resource_id, payload)
sharing_response = self._set_resource_sharing(resource_id, "unpublish")
assert sharing_response["importable"] is True
assert sharing_response["published"] is False
def test_sharing_disable_link_access_and_unpublish(self):
resource_id = self.create("resource-to-disable-link-access-and-unpublish")
def test_sharing_without_user(self):
resource_id = self.create("resource-to-share-with-empty")
sharing_response = self._get_resource_sharing_info(resource_id)
sharing_response = self._get_resource_sharing_status(resource_id)
assert not sharing_response["users_shared_with"]
payload = {"action": "make_accessible_and_publish"}
sharing_response = self._set_resource_sharing(resource_id, payload)
assert sharing_response["importable"] is True
assert sharing_response["published"] is True
payload = {"user_ids": []}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert not sharing_response["users_shared_with"]
payload = {"action": "disable_link_access_and_unpublish"}
sharing_response = self._set_resource_sharing(resource_id, payload)
assert sharing_response["importable"] is False
assert sharing_response["published"] is False
def test_sharing_with_user_id(self):
target_user = self._setup_user("target@user.com")
target_user_id = target_user["id"]
def _get_resource_sharing_info(self, resource_id: str):
resource_id = self.create("resource-to-share-user-id")
sharing_response = self._get_resource_sharing_status(resource_id)
assert not sharing_response["users_shared_with"]
payload = {"user_ids": [target_user_id]}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert sharing_response["users_shared_with"]
assert sharing_response["users_shared_with"][0]["id"] == target_user_id
payload = {"user_ids": []}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert not sharing_response["users_shared_with"]
def test_sharing_with_user_email(self):
target_user = self._setup_user("target@user.com")
target_user_email = target_user["email"]
resource_id = self.create("resource-to-share-user-email")
sharing_response = self._get_resource_sharing_status(resource_id)
assert not sharing_response["users_shared_with"]
payload = {"user_ids": [target_user_email]}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert sharing_response["users_shared_with"]
assert sharing_response["users_shared_with"][0]["email"] == target_user_email
payload = {"user_ids": []}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert not sharing_response["users_shared_with"]
def test_update_sharing_with_users(self):
target_user_list = ["target01@user.com", "target02@user.com"]
additional_user_list = ["add01@user.com", "add02@user.com"]
all_user_emails = target_user_list + additional_user_list
for email in all_user_emails:
self._setup_user(email)
resource_id = self.create("resource-to-share-and-update")
payload = {"user_ids": all_user_emails}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert sharing_response["users_shared_with"]
assert len(sharing_response["users_shared_with"]) == len(all_user_emails)
# We just keep target users so additional users should be removed
payload = {"user_ids": target_user_list}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert len(sharing_response["users_shared_with"]) == len(target_user_list)
assert additional_user_list not in sharing_response["users_shared_with"]
def test_sharing_with_invalid_user(self):
invalid_user_email = "unknown@user.com"
resource_id = self.create("resource-to-share-user-unknown")
sharing_response = self._get_resource_sharing_status(resource_id)
assert not sharing_response["users_shared_with"]
payload = {"user_ids": [invalid_user_email]}
sharing_response = self._set_resource_sharing(resource_id, action="share_with_users", payload=payload)
assert not sharing_response["users_shared_with"]
assert sharing_response["errors"]
assert invalid_user_email in sharing_response["errors"][0]
def test_set_slug(self):
resource_id = self.create("resource-to-set-slug")
other_resource_id = self.create("other-resource-to-set-slug")
response = self._set_slug(resource_id, "new-slug")
self._assert_status_code_is_ok(response)
# Slugs must be unique for the same user/resource
response = self._set_slug(other_resource_id, "new-slug")
self._assert_status_code_is(response, 409)
# Other users cannot change the slug if they don't own the resource
with self._different_user():
response = self._set_slug(resource_id, "another-slug")
self._assert_status_code_is(response, 403)
def _get_resource_sharing_status(self, resource_id: str):
sharing_response = self._get(f"{self.api_name}/{resource_id}/sharing")
self._assert_status_code_is(sharing_response, 200)
return sharing_response.json()
def _set_resource_sharing(self, resource_id: str, payload: Any, expect_response_status: int = 200):
sharing_response = self._post(f"{self.api_name}/{resource_id}/sharing", data=payload, json=True)
def _set_resource_sharing(self, resource_id: str, action: str, payload: Any = None, expect_response_status: int = 200):
sharing_response = self._put(f"{self.api_name}/{resource_id}/{action}", data=payload, json=True)
self._assert_status_code_is(sharing_response, expect_response_status)
return sharing_response.json()
def _set_slug(self, resource_id: str, new_slug: str):
payload = {"new_slug": new_slug}
response = self._put(f"{self.api_name}/{resource_id}/slug", data=payload, json=True)
return response
+132 -7
View File
@@ -32,13 +32,7 @@ class BaseHistories:
return create_response
class HistoriesApiTestCase(ApiTestCase, BaseHistories, SharingApiTests):
api_name = "histories"
def create(self, name: str) -> str:
response_json = self._create_history(name)
return response_json["id"]
class HistoriesApiTestCase(ApiTestCase, BaseHistories):
def test_create_history(self):
# Create a history.
@@ -425,3 +419,134 @@ class ImportExportHistoryTestCase(ApiTestCase, BaseHistories):
if elements_checker is not None:
elements_checker(imported_collection_metadata["elements"])
class SharingHistoryTestCase(ApiTestCase, BaseHistories, SharingApiTests):
"""Tests specific for the particularities of sharing Histories."""
api_name = "histories"
def create(self, name: str) -> str:
response_json = self._create_history(name)
history_id = response_json["id"]
# History to share cannot be empty
populator = DatasetPopulator(self.galaxy_interactor)
populator.new_dataset(history_id)
return history_id
def setUp(self):
super().setUp()
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
def test_sharing_with_private_datasets(self):
history_id = self.dataset_populator.new_history()
hda = self.dataset_populator.new_dataset(history_id)
hda_id = hda["id"]
self.dataset_populator.make_private(history_id, hda_id)
with self._different_user():
target_user_id = self.dataset_populator.user_id()
payload = {"user_ids": [target_user_id]}
sharing_response = self._share_history_with_payload(history_id, payload)
# If no share_option is provided, the extra field will contain the
# datasets that need to be accessible before sharing
assert sharing_response["extra"]
assert sharing_response["extra"]["can_share"] is False
assert sharing_response["extra"]["can_change"][0]["id"] == hda_id
assert not sharing_response["users_shared_with"]
# Now we provide the share_option
payload = {
"user_ids": [target_user_id],
"share_option": "make_accessible_to_shared"
}
sharing_response = self._share_history_with_payload(history_id, payload)
assert sharing_response["users_shared_with"]
assert sharing_response["users_shared_with"][0]["id"] == target_user_id
def test_sharing_without_manage_permissions(self):
history_id = self.dataset_populator.new_history()
hda = self.dataset_populator.new_dataset(history_id)
hda_id = hda["id"]
owner_role_id = self.dataset_populator.user_private_role_id()
with self._different_user():
target_user_id = self.dataset_populator.user_id()
with self._different_user("alice@test.com"):
alice_role_id = self.dataset_populator.user_private_role_id()
# We have one dataset that we cannot manage
payload = {"access": [owner_role_id], "manage": [alice_role_id]}
update_response = self._update_permissions(history_id, hda_id, payload)
self._assert_status_code_is(update_response, 200)
# We will get an error if none of the datasets can be made accessible
payload = {"user_ids": [target_user_id]}
sharing_response = self._share_history_with_payload(history_id, payload)
assert sharing_response["extra"]
assert sharing_response["extra"]["can_share"] is False
assert sharing_response["extra"]["cannot_change"][0]["id"] == hda_id
assert sharing_response["errors"]
assert not sharing_response["users_shared_with"]
# Trying to change the permissions when sharing should fail
# because we don't have manage permissions
payload = {
"user_ids": [target_user_id],
"share_option": "make_public"
}
sharing_response = self._share_history_with_payload(history_id, payload)
assert sharing_response["extra"]
assert sharing_response["extra"]["can_share"] is False
assert sharing_response["errors"]
assert not sharing_response["users_shared_with"]
# we can share if we don't try to make any permission changes
payload = {
"user_ids": [target_user_id],
"share_option": "no_changes"
}
sharing_response = self._share_history_with_payload(history_id, payload)
assert not sharing_response["errors"]
assert sharing_response["users_shared_with"]
assert sharing_response["users_shared_with"][0]["id"] == target_user_id
def test_sharing_empty_not_allowed(self):
history_id = self.dataset_populator.new_history()
with self._different_user():
target_user_id = self.dataset_populator.user_id()
payload = {"user_ids": [target_user_id]}
sharing_response = self._share_history_with_payload(history_id, payload)
assert sharing_response["extra"]["can_share"] is False
assert sharing_response["errors"]
assert "empty" in sharing_response["errors"][0]
def test_sharing_with_duplicated_users(self):
history_id = self.create("HistoryToShareWithDuplicatedUser")
with self._different_user():
target_user_id = self.dataset_populator.user_id()
# Ignore repeated users in the same request
payload = {"user_ids": [target_user_id, target_user_id]}
sharing_response = self._share_history_with_payload(history_id, payload)
assert sharing_response["users_shared_with"]
assert len(sharing_response["users_shared_with"]) == 1
assert sharing_response["users_shared_with"][0]["id"] == target_user_id
def _share_history_with_payload(self, history_id, payload):
sharing_response = self._put(f"histories/{history_id}/share_with_users", data=payload, json=True)
self._assert_status_code_is(sharing_response, 200)
return sharing_response.json()
def _update_permissions(self, history_id: str, dataset_id: str, payload):
url = f"histories/{history_id}/contents/{dataset_id}/permissions"
update_url = self._api_url(url, **{"use_admin_key": True})
update_response = put(update_url, json=payload)
return update_response
@@ -28,9 +28,11 @@ class HistorySharingTestCase(SeleniumTestCase):
user1_email, user2_email, history_id = self.setup_two_users_with_one_shared_history()
self.submit_login(user1_email, retries=VALID_LOGIN_RETRIES)
self.navigate_to_history_share_page()
self.components.histories.sharing.share_with_collapse.wait_for_and_click()
unshare_user_button = self.components.histories.sharing.unshare_user_button
unshare_user_button.wait_for_and_click()
self.components.histories.sharing.submit_sharing_with.wait_for_and_click()
self.navigate_to_history_share_page()
unshare_user_button.assert_absent()
@@ -66,7 +68,7 @@ class HistorySharingTestCase(SeleniumTestCase):
user1_email = self._get_random_email()
self.register(user1_email)
self.share_history_with_user(user_email=user1_email)
self.assert_error_message(contains='You cannot send histories to yourself')
self.assert_error_message(contains='You cannot share resources with yourself')
def setup_two_users_with_one_shared_history(self, share_by_id=False):
user1_email = self._get_random_email()
@@ -84,6 +86,7 @@ class HistorySharingTestCase(SeleniumTestCase):
self.wait_for_history()
history_id = self.current_history_id()
if share_by_id:
self.share_history_with_user(user_email=user2_email, assert_valid=True)
else:
@@ -96,10 +99,6 @@ class HistorySharingTestCase(SeleniumTestCase):
self.home()
self.click_history_option("Share or Publish")
def navigate_to_history_user_share_page(self):
self.navigate_to_history_share_page()
self.components.histories.sharing.share_with_a_user_button.wait_for_and_click()
def share_history_with_user(self, user_id=None, user_email=None, assert_valid=False, screenshot=False):
"""Share the current history with a target user by ID or email.
@@ -107,18 +106,20 @@ class HistorySharingTestCase(SeleniumTestCase):
is also specified. The ``user_email`` however is always used to check
the result if ``assert_valid`` is True.
"""
self.navigate_to_history_user_share_page()
form_selector = "form#share"
form = self.wait_for_selector(form_selector)
# If expose_user_info is on would fill form out with this
# line, in future dispatch on actual select2 div present or not.
# self.select2_set_value(form_selector, email)
self.fill(form, {"email": user_id or user_email})
self.navigate_to_history_share_page()
self.components.histories.sharing.share_with_collapse.wait_for_and_click()
self.components.histories.sharing.share_with_multiselect.wait_for_and_click()
self.components.histories.sharing.share_with_input.wait_for_and_send_keys(user_id or user_email)
if screenshot:
self.screenshot("history_sharing_user")
self.click_submit(form)
# first click to add the item
self.components.histories.sharing.submit_sharing_with.wait_for_and_click()
# second click to save the sharing preferences
self.components.histories.sharing.submit_sharing_with.wait_for_and_click()
if assert_valid:
self.assert_no_error_message()
xpath = f'//td[contains(text(), "{user_email}")]'
xpath = f'//span[contains(text(), "{user_email}")]'
self.wait_for_xpath_visible(xpath)
-287
View File
@@ -1,287 +0,0 @@
<% _=n_ %>
<%inherit file="/base.mako"/>
<%def name="title()">Share histories</%def>
<div class="toolForm">
<div class="toolFormTitle">Share ${len( histories)} histories</div>
<div class="toolFormBody">
%if not can_change and not cannot_change and not no_change_needed:
## We are sharing histories that contain only public datasets
<form name='share' id='share' action="${h.url_for( controller='history', action='share' )}" method="post" >
<div class="form-title-row"><b>Histories to be shared:</b></div>
<div class="form-row" style="padding-left: 2em;">
<table width="100%">
<thead>
<th>${_('History Name')}</th>
<th>${_('Number of Datasets')}</th>
</thead>
<tbody>
%for history in histories:
<tr>
<td>
<input type="hidden" name="id" value="${trans.security.encode_id( history.id )}">
${ util.unicodify( history.name ) | h }
</td>
<td>
%if len( history.datasets ) < 1:
<div class="warningmark">${_('This history contains no data.')}</div>
%else:
${len(history.datasets)}
%endif
</td>
</tr>
%endfor
</tbody>
</table>
</div>
<div style="clear: both"></div>
<div class="form-row">
<% existing_emails = ','.join([ d.user.email for d in history.users_shared_with ]) %>
<label>Galaxy user emails with which to share histories</label>
%if trans.app.config.expose_user_email or trans.app.config.expose_user_name or trans.user_is_admin:
<input type="hidden" id="email_select" name="email" value="${ existing_emails }" style="float: left; width: 250px; margin-right: 10px;">
</input>
%else:
<input type="text" name="email" value="${ existing_emails }" size="40">
</input>
%endif
<div class="toolParamHelp" style="clear: both;">
Enter a Galaxy user email address or a comma-separated list of addresses if sharing with multiple users
</div>
</div>
%if send_to_err:
<div style="clear: both"></div>
<div class="form-row">
<div class="alert alert-danger">${send_to_err}</div>
</div>
%endif
<div style="clear: both"></div>
<div class="form-row">
<input type="submit" name="share_button" value="Submit">
</div>
</form>
<script type="text/javascript">
// stolen from templates/admin/impersonate.mako
/* This should be ripped out and made generic at some point for the
* various API bindings available, and once the API can filter list
* queries (term, below) */
var user_id = "${trans.security.encode_id(trans.user.id)}";
var history_id = "${trans.security.encode_id( history.id )}";
function item_to_label(item){
var text = "";
if(typeof(item.username) === "string" && typeof(item.email) === "string"){
text = item.username + " <" + item.email + ">";
}else if(typeof(item.username) === "string"){
text = item.username;
}else{
text = item.email;
}
return text;
//return "id:" + item.id + "|e:" + item.email + "|u:" + item.username;
}
$("#email_select").select2({
placeholder: "Select a user",
multiple: true,
initSelection: function(element, callback) {
var data = [
// Must be here to loop across the users that this has been shared with.
%for i, association in enumerate( history.users_shared_with ):
<% shared_with = association.user %>
{
email: "${ shared_with.email }",
id: "${trans.security.encode_id(shared_with.id)}",
text: item_to_label({"email": "${ shared_with.email }", "username": "${ shared_with.username }" })
},
%endfor
];
callback(data);
},
tokenSeparators: [',', ' '],
// Required for initSelection
id: function(object) {
return object.id;
},
ajax: {
url: "${h.url_for(controller="/api/users", action="index")}",
data: function (term) {
return {
f_any: term,
};
},
dataType: 'json',
quietMillis: 250,
results: function (data) {
var results = [];
// For every user returned by the API call,
$.each(data, function(index, item){
// If they aren't the requesting user, add to the
// list that will populate the select
if(item.id != "${trans.security.encode_id(trans.user.id)}"){
if(item.email !== undefined){
results.push({
id: item.id,
name: item.username,
text: item_to_label(item),
});
}
}
});
return {
results: results
};
}
},
createSearchChoice: function(term, data) {
// Check for a user with a matching email.
var matches = _.filter(data, function(user){
return user.text.indexOf(term) > -1;
});
// If there aren't any users with matching object labels, then
// display a "default" entry with whatever text they're entering.
// id is set to term as that will be used in
if(matches.length == 0){
return {id: term, text:term};
}else{
// No extra needed
}
}
});
</script>
%else:
## We are sharing restricted histories
%if no_change_needed or can_change:
<form name='share_restricted' id=share_restricted' action="${h.url_for( controller='history', action='share_restricted' )}" method="post">
%if send_to_err:
<div style="clear: both"></div>
<div class="form-row">
<div class="alert alert-danger">${send_to_err}</div>
</div>
%endif
## Needed for rebuilding dicts
<input type="hidden" name="email" value="${email}" size="40">
%for history in histories:
<input type="hidden" name="id" value="${trans.security.encode_id( history.id )}">
%endfor
%if no_change_needed:
## no_change_needed looks like: {historyX : [hda, hda], historyY : [hda] }
<div style="clear: both"></div>
<div class="form-row">
<div class="donemessage">
The following datasets can be shared with ${email} with no changes
</div>
</div>
%for history, hdas in no_change_needed.items():
<div class="form-row">
<label>History</label>
${util.unicodify( history.name )}
</div>
<div style="clear: both"></div>
<div class="form-row">
<label>Datasets</label>
</div>
%for hda in hdas:
<div class="form-row">
${util.unicodify( hda.name )}
%if hda.deleted:
(deleted)
%endif
</div>
%endfor
%endfor
%endif
%if can_change:
## can_change looks like: {historyX : [hda, hda], historyY : [hda] }
<div style="clear: both"></div>
<div class="form-row">
<div class="warningmessage">
The following datasets can be shared with ${email} by updating their permissions
</div>
</div>
%for history, hdas in can_change.items():
<div class="form-row">
<label>History</label>
${util.unicodify( history.name )}
</div>
<div style="clear: both"></div>
<div class="form-row">
<label>Datasets</label>
</div>
%for hda in hdas:
<div class="form-row">
${util.unicodify( hda.name )}
%if hda.deleted:
(deleted)
%endif
</div>
%endfor
%endfor
%endif
%if cannot_change:
## cannot_change looks like: {historyX : [hda, hda], historyY : [hda] }
<div style="clear: both"></div>
<div class="form-row">
<div class="alert alert-danger">
The following datasets cannot be shared with ${email} because you are not authorized to
change the permissions on them
</div>
</div>
%for history, hdas in cannot_change.items():
<div class="form-row">
<label>History</label>
${util.unicodify( history.name )}
</div>
<div style="clear: both"></div>
<div class="form-row">
<label>Datasets</label>
</div>
%for hda in hdas:
<div class="form-row">
${util.unicodify( hda.name )}
%if hda.deleted:
(deleted)
%endif
</div>
%endfor
%endfor
%endif
<div class="toolFormTitle"></div>
<div class="form-row">
<label>How would you like to proceed?</label>
</div>
%if can_change:
<div class="form-row">
<input type="radio" name="action" value="public"> Make datasets public so anyone can access them
%if cannot_change:
(where possible)
%endif
</div>
<div class="form-row">
%if no_change_needed:
<input type="radio" name="action" value="private"> Make datasets private to me and the user(s) with whom I am sharing
%else:
<input type="radio" name="action" value="private" checked> Make datasets private to me and the user(s) with whom I am sharing
%endif
%if cannot_change:
(where possible)
%endif
</div>
%endif
%if no_change_needed:
<div class="form-row">
<input type="radio" name="action" value="share_anyway" checked> Share anyway
%if can_change:
(don't change any permissions)
%endif
</div>
%endif
<div class="form-row">
<input type="submit" name="share_restricted_button" value="Go"><br/>
</div>
</form>
%endif
%endif
</div>
</div>