Merge branch 'release_22.05' into dev

This commit is contained in:
Nicola Soranzo
2022-07-11 13:46:43 +01:00
21 changed files with 108 additions and 107 deletions
@@ -84,7 +84,7 @@
<DatasetDetails
v-if="expandDataset"
:dataset="item"
:show-highlight="isHistoryItem && isHistPanel"
:show-highlight="isHistoryItem"
@edit="onEdit"
@toggleHighlights="toggleHighlights" />
</b-collapse>
@@ -119,8 +119,7 @@ export default {
highlight: { type: String, default: null },
id: { type: Number, required: true },
isDataset: { type: Boolean, default: true },
isHistoryItem: { type: Boolean, default: true },
isHistPanel: { type: Boolean, default: false },
isHistoryItem: { type: Boolean, default: false },
item: { type: Object, required: true },
name: { type: String, required: true },
selected: { type: Boolean, default: false },
@@ -6,7 +6,6 @@
:name="item.element_identifier"
:is-dataset="item.element_type == 'hda'"
:expand-dataset="expandDataset"
:is-history-item="false"
@update:expand-dataset="expandDataset = $event"
@view-collection="viewCollection = !viewCollection" />
<GenericItem v-if="viewCollection" :item-id="item.object.id" :item-src="item.object.history_content_type" />
@@ -3,6 +3,7 @@
<loading-span v-if="loading" message="Loading dataset" />
<div v-else>
<ContentItem
is-history-item
:id="item.hid"
:item="item"
:name="item.name"
@@ -10,9 +11,9 @@
:is-dataset="item.history_content_type == 'dataset'"
@update:expand-dataset="expandDataset = $event"
@view-collection="viewCollection = !viewCollection"
@delete="onDelete"
@undelete="onUndelete"
@unhide="onUnhide" />
@delete="onDelete(item)"
@undelete="onUndelete(item)"
@unhide="onUnhide(item)" />
<div v-if="viewCollection">
<div v-for="(collectionItem, collectionIndex) in item.elements" :key="collectionIndex">
<GenericElement :item="collectionItem" />
@@ -28,7 +28,6 @@
:name="item.element_identifier"
:expand-dataset="isExpanded(item)"
:is-dataset="item.element_type == 'hda'"
:is-history-item="false"
@update:expand-dataset="setExpanded(item, $event)"
@view-collection="onViewSubCollection" />
</template>
@@ -100,12 +100,12 @@
<template v-slot:item="{ item, currentOffset }">
<ContentItem
v-if="!invisible[item.hid]"
is-history-item
:id="item.hid"
:item="item"
:name="item.name"
:expand-dataset="isExpanded(item)"
:is-dataset="isDataset(item)"
is-hist-panel
:highlight="getHighlight(item)"
:selected="isSelected(item)"
:selectable="showSelection"
+11 -10
View File
@@ -57,10 +57,11 @@ export class TagService {
if (!tag.valid) {
throw new Error("Invalid tag");
}
const url = `${getAppRoot()}tag/add_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&new_tag=${
tag.text
}`;
const response = await axios.get(url);
const url = `${getAppRoot()}tag/add_tag_async`;
const config = {
params: { item_id: id, item_class: itemClass, context: context, new_tag: tag.text },
};
const response = await axios.get(url, config);
if (response.status !== 200) {
throw new Error(`Unable to save tag: ${tag}`);
}
@@ -75,10 +76,9 @@ export class TagService {
async delete(rawTag) {
const { id, itemClass, context } = this;
const tag = createTag(rawTag);
const url = `${getAppRoot()}tag/remove_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&tag_name=${
tag.text
}`;
const response = await axios.get(url);
const url = `${getAppRoot()}tag/remove_tag_async`;
const config = { params: { item_id: id, item_class: itemClass, context: context, tag_name: tag.text } };
const response = await axios.get(url, config);
if (response.status !== 200) {
throw new Error(`Unable to delete tag: ${tag}`);
}
@@ -92,8 +92,9 @@ export class TagService {
*/
async autocomplete(searchText) {
const { id, itemClass } = this;
const url = `${getAppRoot()}tag/tag_autocomplete_data?item_id=${id}&item_class=${itemClass}&q=${searchText}`;
const response = await axios.get(url);
const url = `${getAppRoot()}tag/tag_autocomplete_data`;
const config = { params: { item_id: id, item_class: itemClass, q: searchText } };
const response = await axios.get(url, config);
if (response.status !== 200) {
throw new Error(`Unable to retrieve autocomplete tags for search string: ${searchText}`);
}
+17 -47
View File
@@ -2,9 +2,6 @@ import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { TagService } from "./tagService";
import { createTag } from "./model";
//import { interval } from "rxjs";
//import { take, takeUntil } from "rxjs/operators";
// test response
import autocompleteResponse from "./testData/autocompleteResponse.txt";
describe("Tags/tagService.js", () => {
@@ -15,6 +12,12 @@ describe("Tags/tagService.js", () => {
context: "",
debounceInterval: 50, // shorter value than default for unit tests
};
const testLabel = "fo0bar123";
const expectedParams = {
item_id: 123,
item_class: "fooClass",
context: "",
};
const svc = new TagService(svcParams);
beforeEach(() => {
@@ -26,43 +29,43 @@ describe("Tags/tagService.js", () => {
});
describe("save", () => {
const testLabel = "fo0bar123";
const testTag = createTag(testLabel);
const { id, itemClass, context } = svcParams;
const expectedSaveUrl = `/tag/add_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&new_tag=${testLabel}`;
const expectedSaveUrl = `/tag/add_tag_async`;
it("should save a string tag", async () => {
const savedTag = await svc.save(testLabel);
expect(savedTag.text).toBe(testLabel);
expect(axiosMock.history.get[0].url).toBe(expectedSaveUrl);
expect(axiosMock.history.get[0].params).toEqual({ ...expectedParams, new_tag: testLabel });
});
it("should save an object tag", async () => {
const savedTag = await svc.save(testTag);
expect(savedTag.text).toBe(testLabel);
expect(axiosMock.history.get[0].url).toBe(expectedSaveUrl);
expect(axiosMock.history.get[0].params).toEqual({ ...expectedParams, new_tag: testLabel });
});
// TODO: test error conditions
});
describe("delete", () => {
const testLabel = "fo0bar123";
const testTag = createTag(testLabel);
const { id, itemClass, context } = svcParams;
const expectedDeleteUrl = `/tag/remove_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&tag_name=${testLabel}`;
const expectedDeleteUrl = `/tag/remove_tag_async`;
it("should delete a text tag", async () => {
const result = await svc.delete(testTag);
expect(result).toBeTruthy();
expect(axiosMock.history.get[0].url).toBe(expectedDeleteUrl);
expect(axiosMock.history.get[0].params).toEqual({ ...expectedParams, tag_name: testLabel });
});
it("should delete an object tag", async () => {
const result = await svc.delete(testTag);
expect(result).toBeTruthy();
expect(axiosMock.history.get[0].url).toBe(expectedDeleteUrl);
expect(axiosMock.history.get[0].params).toEqual({ ...expectedParams, tag_name: testLabel });
});
// TODO: test error conditions
@@ -70,8 +73,7 @@ describe("Tags/tagService.js", () => {
describe("autocomplete", () => {
const searchString = "foo";
const { id, itemClass } = svcParams;
const expectedSearchUrl = `/tag/tag_autocomplete_data?item_id=${id}&item_class=${itemClass}&q=${searchString}`;
const expectedSearchUrl = `/tag/tag_autocomplete_data`;
const checkAutocompleteResult = (result) => {
expect(result).toBeTruthy();
@@ -83,46 +85,14 @@ describe("Tags/tagService.js", () => {
// straight ajax request, unused in practice, but it's easier to
// test this call if we just expose it
const { ...searchParams } = expectedParams;
delete searchParams.context;
it("ajax call should return tag objects", async () => {
const result = await svc.autocomplete(searchString);
expect(axiosMock.history.get[0].url).toBe(expectedSearchUrl);
expect(axiosMock.history.get[0].params).toEqual({ ...searchParams, q: searchString });
checkAutocompleteResult(result);
});
/*
// hit the search input with multiple entries, only one ajax call
// should result because of debouncing
it("should debounce autocomplete search inputs", (done) => {
// stub ajax request to return the success response if the
// searchString is the expected input
// take first emission, debouncing should guarantee the first
// emission is the last thing we sent, should be searchString
const timer$ = interval(100).pipe(take(1));
const option$ = svc.autocompleteOptions.pipe(takeUntil(timer$));
const nextHandler = jest.fn();
option$.subscribe({
next: nextHandler,
error: (err) => console.warn("error", err),
complete: () => {
expect(nextHandler).toHaveBeenCalledTimes(1);
expect(axiosMock.history.get.length).toBe(1);
done();
},
});
// spam a bunch of key inputs followed by the correct input
const spamCount = 2 + Math.floor(Math.random() * 10);
for (let i = 0; i < spamCount; i++) {
const spamVal = new String(Math.random());
svc.autocompleteSearchText = spamVal;
}
svc.autocompleteSearchText = searchString;
});
*/
});
});
+8 -2
View File
@@ -86,6 +86,9 @@ export default {
isLast() {
return this.currentIndex === this.steps.length - 1;
},
hasBegun() {
return this.currentIndex >= 1;
},
},
beforeDestroy() {
window.removeEventListener("keyup", this.handleKeyup);
@@ -105,12 +108,15 @@ export default {
}
},
loginRequired(user) {
return this.requirements.indexOf("logged_in") >= 0 && user.isAnonymous;
return !this.hasBegun && this.requirements.indexOf("logged_in") >= 0 && user.isAnonymous;
},
adminRequired(user) {
return this.requirements.indexOf("admin") >= 0 && !user.is_admin;
return !this.hasBegun && this.requirements.indexOf("admin") >= 0 && !user.is_admin;
},
newHistoryRequired(history) {
if (this.hasBegun) {
return false;
}
const hasNewNistoryRequirement = this.requirements.indexOf("new_history") >= 0;
if (!hasNewNistoryRequirement) {
return false;
+1 -1
View File
@@ -4,7 +4,7 @@ import { mountVueComponent } from "utils/mountVueComponent";
import Tour from "./Tour";
// delays and maximum number of attempts to wait for element
const attempts = 100;
const attempts = 200;
const delay = 200;
// return tour data
+1 -1
View File
@@ -75,7 +75,7 @@ var DatasetAssociation = Backbone.Model.extend(BASE_MVC.LoggableMixin).extend(
display: `datasets/${id}/display/?preview=True`,
edit: `datasets/edit?dataset_id=${id}`,
download: `api/datasets/${id}/display${this._downloadQueryParameters()}`,
report_error: `dataset/errors?id=${id}`,
report_error: `datasets/error?dataset_id=${id}`,
rerun: `tool_runner/rerun?id=${id}`,
show_params: `datasets/${id}/details`,
visualization: "visualization",
+2 -1
View File
@@ -1,6 +1,7 @@
import json
import logging
import typing
from datetime import datetime
from boltons.iterutils import remap
from pydantic import (
@@ -107,7 +108,7 @@ class JobManager:
def build_and_apply_filters(query, objects, filter_func):
if objects is not None:
if isinstance(objects, str):
if isinstance(objects, str) or isinstance(objects, datetime):
query = query.filter(filter_func(objects))
elif isinstance(objects, list):
t = []
+2 -2
View File
@@ -500,7 +500,7 @@ class GalaxyRBACAgent(RBACAgent):
def item_permission_map_for_add(self, trans, user_roles, libitems):
return self.allow_action_on_libitems(trans, user_roles, self.permitted_actions.LIBRARY_ADD, libitems)
def can_access_dataset(self, user_roles, dataset):
def can_access_dataset(self, user_roles, dataset: galaxy.model.Dataset):
# SM: dataset_is_public will access dataset.actions, which is a
# backref that causes a query to be made to DatasetPermissions
retval = self.dataset_is_public(dataset) or self.allow_action(
@@ -1122,7 +1122,7 @@ class GalaxyRBACAgent(RBACAgent):
if not dataset.purged and not self.dataset_is_public(dataset):
self.make_dataset_public(dataset)
def dataset_is_public(self, dataset):
def dataset_is_public(self, dataset: galaxy.model.Dataset):
"""
A dataset is considered public if there are no "access" actions
associated with it. Any other actions ( 'manage permissions',
+5 -3
View File
@@ -653,9 +653,11 @@ class DiskObjectStore(ConcreteObjectStore):
if entire_dir and (extra_dir or obj_dir):
shutil.rmtree(path)
return True
if self._exists(obj, **kwargs):
os.remove(path)
return True
os.remove(path)
return True
except FileNotFoundError:
# Absolutely possible that a delete request races, but that's "fine".
return True
except OSError as ex:
log.critical(f"{self.__get_filename(obj, **kwargs)} delete error {ex}")
return False
+2 -2
View File
@@ -1112,8 +1112,8 @@ class JobIndexQueryPayload(Model):
user_id: Optional[DecodedDatabaseIdField] = None
tool_ids: Optional[List[str]] = None
tool_ids_like: Optional[List[str]] = None
date_range_min: Optional[str] = None
date_range_max: Optional[str] = None
date_range_min: Optional[datetime] = None
date_range_max: Optional[datetime] = None
history_id: Optional[DecodedDatabaseIdField] = None
workflow_id: Optional[DecodedDatabaseIdField] = None
invocation_id: Optional[DecodedDatabaseIdField] = None
+5 -2
View File
@@ -120,7 +120,10 @@ from galaxy.util.bunch import Bunch
from galaxy.util.dictifiable import Dictifiable
from galaxy.util.expressions import ExpressionContext
from galaxy.util.form_builder import SelectField
from galaxy.util.json import safe_loads
from galaxy.util.json import (
safe_loads,
swap_inf_nan,
)
from galaxy.util.rules_dsl import RuleSet
from galaxy.util.template import (
fill_template,
@@ -2461,7 +2464,7 @@ class Tool(Dictifiable):
"enctype": self.enctype,
}
)
return tool_model
return swap_inf_nan(tool_model)
def populate_model(self, request_context, inputs, state_inputs, group_inputs, other_values=None):
"""
+8 -4
View File
@@ -9,6 +9,7 @@ from collections.abc import (
Mapping,
Sequence,
)
from decimal import Decimal
from ..util import unicodify
@@ -22,7 +23,8 @@ from_json_string = json.loads
def swap_inf_nan(val):
"""
This takes an arbitrary object and preps it for jsonifying safely, templating Inf/NaN.
This takes an arbitrary object and preps it for jsonifying safely, templating Inf/NaN and
casting Decimal instances as strings.
"""
if isinstance(val, str):
# basestring first, because it's a sequence and would otherwise get caught below.
@@ -40,6 +42,8 @@ def swap_inf_nan(val):
return "__-Infinity__"
else:
return val
elif isinstance(val, Decimal):
return str(val)
else:
return val
@@ -63,12 +67,12 @@ def safe_dumps(obj, **kwargs):
"""
This is a wrapper around dumps that encodes Infinity and NaN values. It's a
fairly rare case (which will be low in request volume). Basically, we tell
json.dumps to blow up if it encounters Infinity/NaN, and we 'fix' it before
re-encoding.
json.dumps to blow up if it encounters Infinity/NaN, or Decimal values
and we 'fix' it before re-encoding.
"""
try:
dumped = json.dumps(obj, allow_nan=False, **kwargs)
except ValueError:
except (ValueError, TypeError):
obj = swap_inf_nan(obj)
dumped = json.dumps(obj, allow_nan=False, **kwargs)
if kwargs.get("escape_closing_tags", True):
+5 -4
View File
@@ -5,6 +5,7 @@ API operations on a jobs.
"""
import logging
from datetime import datetime
from typing import (
Any,
Dict,
@@ -98,13 +99,13 @@ ToolIdLikeQueryParam: Optional[str] = Query(
description="Limit listing of jobs to those that match one of the included tool ID sql-like patterns. If none, all are returned",
)
DateRangeMinQueryParam: Optional[str] = Query(
DateRangeMinQueryParam: Optional[datetime] = Query(
default=None,
title="Date Range Minimum",
description="Limit listing of jobs to those that are updated after specified date (e.g. '2014-01-01')",
)
DateRangeMaxQueryParam: Optional[str] = Query(
DateRangeMaxQueryParam: Optional[datetime] = Query(
default=None,
title="Date Range Maximum",
description="Limit listing of jobs to those that are updated before specified date (e.g. '2014-01-01')",
@@ -186,8 +187,8 @@ class FastAPIJobs:
view: JobIndexViewEnum = ViewQueryParam,
tool_id: Optional[str] = ToolIdQueryParam,
tool_id_like: Optional[str] = ToolIdLikeQueryParam,
date_range_min: Optional[str] = DateRangeMinQueryParam,
date_range_max: Optional[str] = DateRangeMaxQueryParam,
date_range_min: Optional[datetime] = DateRangeMinQueryParam,
date_range_max: Optional[datetime] = DateRangeMaxQueryParam,
history_id: Optional[EncodedDatabaseIdField] = HistoryIdQueryParam,
workflow_id: Optional[EncodedDatabaseIdField] = WorkflowIdQueryParam,
invocation_id: Optional[EncodedDatabaseIdField] = InvocationIdQueryParam,
@@ -10,7 +10,6 @@ from markupsafe import escape
from galaxy import (
datatypes,
model,
util,
web,
)
@@ -88,16 +87,6 @@ class DatasetInterface(BaseUIController, UsesAnnotations, UsesItemRatings, UsesE
roles, dataset_association.dataset
)
@web.expose
def errors(self, trans, id):
hda = trans.sa_session.query(model.HistoryDatasetAssociation).get(self.decode_id(id))
if not hda or not self._can_access_dataset(trans, hda):
return trans.show_error_message(
"Either this dataset does not exist or you do not have permission to access it."
)
return trans.fill_template("dataset/errors.mako", hda=hda)
@web.expose
def stdout(self, trans, dataset_id=None, **kwargs):
trans.response.set_content_type("text/plain")
@@ -713,6 +702,10 @@ class DatasetInterface(BaseUIController, UsesAnnotations, UsesItemRatings, UsesE
**kwds,
):
"""Access to external display applications"""
if None in [app_name, link_name]:
return trans.show_error_message("A display application name and link name must be provided.")
app_name = unquote_plus(app_name)
link_name = unquote_plus(link_name)
# Build list of parameters to pass in to display application logic (app_kwds)
app_kwds = {}
for name, value in dict(kwds).items(): # clone kwds because we remove stuff as we go.
@@ -734,10 +727,6 @@ class DatasetInterface(BaseUIController, UsesAnnotations, UsesItemRatings, UsesE
else:
user_roles = []
# Decode application name and link name
app_name = unquote_plus(app_name)
link_name = unquote_plus(link_name)
if None in [app_name, link_name]:
return trans.show_error_message("A display application name and link name must be provided.")
if self._can_access_dataset(trans, data, additional_roles=user_roles):
msg = []
preparable_steps = []
@@ -106,8 +106,9 @@ class RootController(controller.JSAppLauncher, UsesAnnotations):
if "authz_method" in kwd:
authz_method = kwd["authz_method"]
if data:
current_user_roles = trans.get_current_user_roles()
if authz_method == "rbac" and trans.app.security_agent.can_access_dataset(current_user_roles, data):
if authz_method == "rbac" and trans.app.security_agent.can_access_dataset(
trans.get_current_user_roles(), data.dataset
):
trans.response.set_content_type(data.get_mime())
trans.log_event(f"Formatted dataset id {str(id)} for display at {display_app}")
return data.as_display_type(display_app, **kwd)
+2 -2
View File
@@ -82,14 +82,14 @@ class JobsApiTestCase(ApiTestCase, TestsTools):
@uses_test_history(require_new=True)
def test_index_date_filter(self, history_id):
self.__history_with_new_dataset(history_id)
two_weeks_ago = (datetime.datetime.utcnow() - datetime.timedelta(14)).isoformat()
last_week = (datetime.datetime.utcnow() - datetime.timedelta(7)).isoformat()
next_week = (datetime.datetime.utcnow() + datetime.timedelta(7)).isoformat()
today = datetime.datetime.utcnow().isoformat()
tomorrow = (datetime.datetime.utcnow() + datetime.timedelta(1)).isoformat()
self.__history_with_new_dataset(history_id)
jobs = self.__jobs_index(data={"date_range_min": today[0:10], "date_range_max": tomorrow[0:10]})
jobs = self.__jobs_index(data={"date_range_min": today, "date_range_max": tomorrow})
assert len(jobs) > 0
today_job_id = jobs[0]["id"]
+25
View File
@@ -0,0 +1,25 @@
from decimal import Decimal
import pytest
from galaxy.util.json import (
safe_dumps,
swap_inf_nan,
)
@pytest.mark.parametrize(
"val,expected_val",
[
(float("inf"), "__Infinity__"),
(float("-inf"), "__-Infinity__"),
(float("NaN"), "__NaN__"),
(Decimal("1"), "1"),
],
)
def test_swap_inf_nan(val, expected_val):
assert swap_inf_nan(val) == expected_val
def test_safe_dumps():
assert safe_dumps({"a": Decimal("0.1")}) == """{"a": "0.1"}"""