+ @delete="onDelete(item)"
+ @undelete="onUndelete(item)"
+ @unhide="onUnhide(item)" />
diff --git a/client/src/components/History/CurrentCollection/CollectionPanel.vue b/client/src/components/History/CurrentCollection/CollectionPanel.vue
index 3977d89036f..025f48d74f3 100644
--- a/client/src/components/History/CurrentCollection/CollectionPanel.vue
+++ b/client/src/components/History/CurrentCollection/CollectionPanel.vue
@@ -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" />
diff --git a/client/src/components/History/CurrentHistory/HistoryPanel.vue b/client/src/components/History/CurrentHistory/HistoryPanel.vue
index f90a3965007..dbc3cf438c2 100644
--- a/client/src/components/History/CurrentHistory/HistoryPanel.vue
+++ b/client/src/components/History/CurrentHistory/HistoryPanel.vue
@@ -100,12 +100,12 @@
{
@@ -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;
- });
-
- */
});
});
diff --git a/client/src/components/Tour/Tour.vue b/client/src/components/Tour/Tour.vue
index 00e7206e6d3..244ab085d7e 100644
--- a/client/src/components/Tour/Tour.vue
+++ b/client/src/components/Tour/Tour.vue
@@ -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;
diff --git a/client/src/components/Tour/runTour.js b/client/src/components/Tour/runTour.js
index 39d06756893..9f5292e6b35 100644
--- a/client/src/components/Tour/runTour.js
+++ b/client/src/components/Tour/runTour.js
@@ -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
diff --git a/client/src/mvc/dataset/dataset-model.js b/client/src/mvc/dataset/dataset-model.js
index cb62962d54d..e7f7a9d132d 100644
--- a/client/src/mvc/dataset/dataset-model.js
+++ b/client/src/mvc/dataset/dataset-model.js
@@ -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",
diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py
index 35735c41d94..9a5ff96c317 100644
--- a/lib/galaxy/managers/jobs.py
+++ b/lib/galaxy/managers/jobs.py
@@ -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 = []
diff --git a/lib/galaxy/model/security.py b/lib/galaxy/model/security.py
index 738db0b548c..3e5690e78fc 100644
--- a/lib/galaxy/model/security.py
+++ b/lib/galaxy/model/security.py
@@ -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',
diff --git a/lib/galaxy/objectstore/__init__.py b/lib/galaxy/objectstore/__init__.py
index 62c531ffaae..2455132fa10 100644
--- a/lib/galaxy/objectstore/__init__.py
+++ b/lib/galaxy/objectstore/__init__.py
@@ -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
diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py
index 0570082cde7..abc57a90175 100644
--- a/lib/galaxy/schema/schema.py
+++ b/lib/galaxy/schema/schema.py
@@ -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
diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py
index cf7336c6b94..e43e0114236 100644
--- a/lib/galaxy/tools/__init__.py
+++ b/lib/galaxy/tools/__init__.py
@@ -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):
"""
diff --git a/lib/galaxy/util/json.py b/lib/galaxy/util/json.py
index 53d9f5cc71a..a6980f94036 100644
--- a/lib/galaxy/util/json.py
+++ b/lib/galaxy/util/json.py
@@ -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):
diff --git a/lib/galaxy/webapps/galaxy/api/jobs.py b/lib/galaxy/webapps/galaxy/api/jobs.py
index b60cb4b6799..9c33290f9f3 100644
--- a/lib/galaxy/webapps/galaxy/api/jobs.py
+++ b/lib/galaxy/webapps/galaxy/api/jobs.py
@@ -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,
diff --git a/lib/galaxy/webapps/galaxy/controllers/dataset.py b/lib/galaxy/webapps/galaxy/controllers/dataset.py
index 59da806cf5b..f07d2c66d5a 100644
--- a/lib/galaxy/webapps/galaxy/controllers/dataset.py
+++ b/lib/galaxy/webapps/galaxy/controllers/dataset.py
@@ -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 = []
diff --git a/lib/galaxy/webapps/galaxy/controllers/root.py b/lib/galaxy/webapps/galaxy/controllers/root.py
index 8efc139a326..4f14cbd52b9 100644
--- a/lib/galaxy/webapps/galaxy/controllers/root.py
+++ b/lib/galaxy/webapps/galaxy/controllers/root.py
@@ -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)
diff --git a/lib/galaxy_test/api/test_jobs.py b/lib/galaxy_test/api/test_jobs.py
index 992cf4dd89c..6f2c0324ac8 100644
--- a/lib/galaxy_test/api/test_jobs.py
+++ b/lib/galaxy_test/api/test_jobs.py
@@ -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"]
diff --git a/test/unit/util/test_json.py b/test/unit/util/test_json.py
new file mode 100644
index 00000000000..dc1a3432d50
--- /dev/null
+++ b/test/unit/util/test_json.py
@@ -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"}"""