*Provide sample and run through the validation steps, but without saving them. Allows all the samples to be evaluated for validity first so potential errors can be addressed.
*Provide sample and run through the validation steps, but without saving them. Allows all the samples to be evaluated for validity first so potential errors can be addressed.
\ No newline at end of file
diff --git a/SampleService.spec b/SampleService.spec
index 3e5a28be..cf1d18ab 100644
--- a/SampleService.spec
+++ b/SampleService.spec
@@ -372,6 +372,7 @@ module SampleService {
node_id node;
boolean update;
boolean as_admin;
+ list labels;
user as_user;
} CreateDataLinkParams;
@@ -487,6 +488,31 @@ module SampleService {
*/
funcdef expire_data_link(ExpireDataLinkParams params) returns() authentication required;
+ /* label_data_links parameters.
+
+ links - the the links to be labeled.
+ add_labels - the labels to be added to the links.
+ remove_labels - the labels to be removed from the links.
+ as_admin - run the method as a service administrator. The user must have full
+ administration permissions.
+ as_user - label the links as a different user. Ignored if as_admin is not true. Neither
+ the administrator nor the impersonated user need have permissions to the link if a
+ new version is saved.
+ */
+ typedef structure {
+ list links;
+ list add_labels;
+ list remove_labels;
+ boolean as_admin;
+ user as_user;
+ } LabelDataLinksParams;
+
+ /* Label data links.
+
+ The user must have write permissions for the Workspace object.
+ */
+ funcdef label_data_links(LabelDataLinksParams params) returns() authentication required;
+
/* get_data_links_from_sample parameters.
id - the sample ID.
diff --git a/lib/SampleService/SampleServiceClient.py b/lib/SampleService/SampleServiceClient.py
index ecc37820..71d9ce79 100644
--- a/lib/SampleService/SampleServiceClient.py
+++ b/lib/SampleService/SampleServiceClient.py
@@ -646,6 +646,53 @@ def expire_data_link(self, params, context=None):
return self._client.call_method('SampleService.expire_data_link',
[params], self._service_ver, context)
+ def label_data_links(self, params, context=None):
+ """
+ Label data links.
+ The user must have write permissions for the Workspace object.
+ :param params: instance of type "LabelDataLinksParams"
+ (label_data_links parameters. links - the the links to be labeled.
+ add_labels - the labels to be added to the links. remove_labels -
+ the labels to be removed from the links. as_admin - run the method
+ as a service administrator. The user must have full administration
+ permissions. as_user - label the links as a different user.
+ Ignored if as_admin is not true. Neither the administrator nor the
+ impersonated user need have permissions to the link if a new
+ version is saved.) -> structure: parameter "links" of list of type
+ "DataLink" (A data link from a KBase workspace object to a sample.
+ upa - the workspace UPA of the linked object. dataid - the dataid
+ of the linked data, if any, within the object. If omitted the
+ entire object is linked to the sample. id - the sample id. version
+ - the sample version. node - the sample node. createdby - the user
+ that created the link. created - the time the link was created.
+ expiredby - the user that expired the link, if any. expired - the
+ time the link was expired, if at all.) -> structure: parameter
+ "linkid" of type "link_id" (A link ID. Must be globally unique.
+ Always assigned by the Sample service. Typically only of use to
+ service admins.), parameter "upa" of type "ws_upa" (A KBase
+ Workspace service Unique Permanent Address (UPA). E.g. 5/6/7 where
+ 5 is the workspace ID, 6 the object ID, and 7 the object
+ version.), parameter "dataid" of type "data_id" (An id for a unit
+ of data within a KBase Workspace object. A single object may
+ contain many data units. A dataid is expected to be unique within
+ a single object. Must be less than 255 characters.), parameter
+ "id" of type "sample_id" (A Sample ID. Must be globally unique.
+ Always assigned by the Sample service.), parameter "version" of
+ type "version" (The version of a sample. Always > 0.), parameter
+ "node" of type "node_id" (A SampleNode ID. Must be unique within a
+ Sample and be less than 255 characters.), parameter "createdby" of
+ type "user" (A user's username.), parameter "created" of type
+ "timestamp" (A timestamp in epoch milliseconds.), parameter
+ "expiredby" of type "user" (A user's username.), parameter
+ "expired" of type "timestamp" (A timestamp in epoch
+ milliseconds.), parameter "add_labels" of list of String,
+ parameter "remove_labels" of list of String, parameter "as_admin"
+ of type "boolean" (A boolean value, 0 for false, 1 for true.),
+ parameter "as_user" of type "user" (A user's username.)
+ """
+ return self._client.call_method('SampleService.label_data_links',
+ [params], self._service_ver, context)
+
def get_data_links_from_sample(self, params, context=None):
"""
Get data links to Workspace objects originating from a sample.
diff --git a/lib/SampleService/SampleServiceImpl.py b/lib/SampleService/SampleServiceImpl.py
index c27c9c54..3627115a 100644
--- a/lib/SampleService/SampleServiceImpl.py
+++ b/lib/SampleService/SampleServiceImpl.py
@@ -32,6 +32,7 @@
from SampleService.impl_methods import (
update_samples_acls as _update_samples_acls
)
+from SampleService.core.workspace import DataUnitID
_CTX_USER = 'user_id'
_CTX_TOKEN = 'token'
@@ -708,7 +709,7 @@ def create_data_link(self, ctx, params):
# ctx is the context object
# return variables are: results
#BEGIN create_data_link
- duid, sna, update = _create_data_link_params(params)
+ duid, sna, update, labels = _create_data_link_params(params)
as_admin, user = _get_admin_request_from_object(params, 'as_admin', 'as_user')
_check_admin(
self._user_lookup, ctx[_CTX_TOKEN], _AdminPermission.FULL,
@@ -887,6 +888,69 @@ def expire_data_link(self, ctx, params):
#END expire_data_link
pass
+ def label_data_links(self, ctx, params):
+ """
+ Label data links.
+ The user must have write permissions for the Workspace object.
+ :param params: instance of type "LabelDataLinksParams"
+ (label_data_links parameters. links - the the links to be labeled.
+ add_labels - the labels to be added to the links. remove_labels -
+ the labels to be removed from the links. as_admin - run the method
+ as a service administrator. The user must have full administration
+ permissions. as_user - label the links as a different user.
+ Ignored if as_admin is not true. Neither the administrator nor the
+ impersonated user need have permissions to the link if a new
+ version is saved.) -> structure: parameter "links" of list of type
+ "DataLink" (A data link from a KBase workspace object to a sample.
+ upa - the workspace UPA of the linked object. dataid - the dataid
+ of the linked data, if any, within the object. If omitted the
+ entire object is linked to the sample. id - the sample id. version
+ - the sample version. node - the sample node. createdby - the user
+ that created the link. created - the time the link was created.
+ expiredby - the user that expired the link, if any. expired - the
+ time the link was expired, if at all.) -> structure: parameter
+ "linkid" of type "link_id" (A link ID. Must be globally unique.
+ Always assigned by the Sample service. Typically only of use to
+ service admins.), parameter "upa" of type "ws_upa" (A KBase
+ Workspace service Unique Permanent Address (UPA). E.g. 5/6/7 where
+ 5 is the workspace ID, 6 the object ID, and 7 the object
+ version.), parameter "dataid" of type "data_id" (An id for a unit
+ of data within a KBase Workspace object. A single object may
+ contain many data units. A dataid is expected to be unique within
+ a single object. Must be less than 255 characters.), parameter
+ "id" of type "sample_id" (A Sample ID. Must be globally unique.
+ Always assigned by the Sample service.), parameter "version" of
+ type "version" (The version of a sample. Always > 0.), parameter
+ "node" of type "node_id" (A SampleNode ID. Must be unique within a
+ Sample and be less than 255 characters.), parameter "createdby" of
+ type "user" (A user's username.), parameter "created" of type
+ "timestamp" (A timestamp in epoch milliseconds.), parameter
+ "expiredby" of type "user" (A user's username.), parameter
+ "expired" of type "timestamp" (A timestamp in epoch
+ milliseconds.), parameter "add_labels" of list of String,
+ parameter "remove_labels" of list of String, parameter "as_admin"
+ of type "boolean" (A boolean value, 0 for false, 1 for true.),
+ parameter "as_user" of type "user" (A user's username.)
+ """
+ # ctx is the context object
+ #BEGIN label_data_links
+ as_admin, user = _get_admin_request_from_object(params, 'as_admin', 'as_user')
+ _check_admin(
+ self._user_lookup, ctx[_CTX_TOKEN], _AdminPermission.FULL,
+ # pretty annoying to test ctx.log_info is working, do it manually
+ 'label_data_links', ctx.log_info, as_user=user, skip_check=not as_admin)
+
+ duids = [DataUnitID(dl.get('ws_upa'), dl.get('data_id')) for dl in params.get('links')]
+
+ self._samples.label_data_links(
+ user if user else _UserID(ctx[_CTX_USER]),
+ duids,
+ params.get('add_labels',[]),
+ params.get('remove_labels',[]),
+ )
+ #END label_data_links
+ pass
+
def get_data_links_from_sample(self, ctx, params):
"""
Get data links to Workspace objects originating from a sample.
diff --git a/lib/SampleService/core/api_translation.py b/lib/SampleService/core/api_translation.py
index 14b73fde..ebc02e4a 100644
--- a/lib/SampleService/core/api_translation.py
+++ b/lib/SampleService/core/api_translation.py
@@ -524,6 +524,7 @@ def create_data_link_params(params: Dict[str, Any]) -> Tuple[DataUnitID, SampleN
upa - workspace object UPA
dataid - ID of the data within the workspace object
update - whether the link should be updated
+ labels - list of labels to add to the link
:param params: the parameters.
:returns: a tuple consisting of:
@@ -541,6 +542,7 @@ def create_data_link_params(params: Dict[str, Any]) -> Tuple[DataUnitID, SampleN
_cast(str, _check_string_int(params, 'node', True))
)
duid = get_data_unit_id_from_object(params)
+ labels = params.get('labels', [])
return (duid, sna, bool(params.get('update')))
diff --git a/lib/SampleService/core/data_link.py b/lib/SampleService/core/data_link.py
index a8895c71..ab20afcb 100644
--- a/lib/SampleService/core/data_link.py
+++ b/lib/SampleService/core/data_link.py
@@ -4,6 +4,7 @@
'''
from __future__ import annotations
+from typing import Optional, List
import datetime
import uuid
@@ -14,6 +15,7 @@
from SampleService.core.user import UserID
from SampleService.core.workspace import DataUnitID
+_VALID_CONTROLLED_LABELS = ['canonical']
class DataLink:
'''
@@ -25,6 +27,7 @@ class DataLink:
:ivar created_by: the user that created the link.
:ivar expired: the expiration time or None if the link is not expired.
:ivar expired_by: the user that expired the link or None if the link is not expired.
+ :ivar controlled_labels: Controlled vocabulary labels for this link.
'''
def __init__(
@@ -35,7 +38,8 @@ def __init__(
created: datetime.datetime,
created_by: UserID,
expired: datetime.datetime = None,
- expired_by: UserID = None):
+ expired_by: UserID = None,
+ controlled_labels: Optional[List[str]] = None):
'''
Create the link. If expired is provided expired_by must also be provided. If expired
is falsy expired_by is ignored.
@@ -47,6 +51,7 @@ def __init__(
:param created_by: the user that created the link.
:param expired: the expiration time for the link or None if the link is not expired.
:param expired_by: the user that expired the link or None if the link is not expired.
+ :param controlled_labels: Controlled vocabulary labels for this link.
'''
# may need to make this non ws specific. YAGNI for now.
self.id = _not_falsy(id_, 'id_')
@@ -56,6 +61,7 @@ def __init__(
self.created_by = _not_falsy(created_by, 'created_by')
self.expired = None
self.expired_by = None
+ self.controlled_labels = DataLink.validate_controlled_labels(controlled_labels)
if expired:
self.expired = _check_timestamp(expired, 'expired')
if expired < created:
@@ -93,3 +99,20 @@ def __eq__(self, other):
def __hash__(self):
return hash((self.id, self.duid, self.sample_node_address,
self.created, self.created_by, self.expired, self.expired_by))
+
+ @staticmethod
+ def validate_controlled_labels(labels: List[str] | None):
+ '''
+ Validate the controlled vocabulary labels.
+
+ :param labels: the labels to validate.
+ :returns: the validated labels.
+ '''
+ if not labels:
+ return []
+ normalized = [label.strip().lower() for label in labels]
+ bad_labels = [label for label in normalized if label not in _VALID_CONTROLLED_LABELS]
+ if bad_labels:
+ raise ValueError(f'invalid controlled vocabulary labels: {bad_labels}.'+
+ f'Valid labels are: {_VALID_CONTROLLED_LABELS}')
+ return labels
diff --git a/lib/SampleService/core/samples.py b/lib/SampleService/core/samples.py
index 777e0359..c2af7831 100644
--- a/lib/SampleService/core/samples.py
+++ b/lib/SampleService/core/samples.py
@@ -352,6 +352,7 @@ def create_data_link(
user: UserID,
duid: DataUnitID,
sna: SampleNodeAddress,
+ labels: List[str],
update: bool = False,
as_admin: bool = False) -> DataLink:
'''
@@ -367,6 +368,7 @@ def create_data_link(
:param user: the user creating the link.
:param duid: the data unit to link the the sample.
:param sna: the sample node to link to the data unit.
+ :param labels: the labels to apply to the link.
:param update: True to expire any extant link if it does not link to the provided sample.
If False and a link from the data unit already exists, link creation will fail.
:param as_admin: allow link creation to proceed if user does not have
@@ -388,7 +390,7 @@ def create_data_link(
_not_falsy(sna, 'sna').sampleid, user, _SampleAccessType.ADMIN, as_admin=as_admin)
wsperm = _WorkspaceAccessType.NONE if as_admin else _WorkspaceAccessType.WRITE
self._ws.has_permission(user, wsperm, upa=duid.upa)
- dl = DataLink(self._uuid_gen(), duid, sna, self._now(), user)
+ dl = DataLink(self._uuid_gen(), duid, sna, self._now(), user, controlled_labels=labels)
expired_id = self._storage.create_data_link(dl, update=update)
if self._kafka:
self._kafka.notify_new_link(dl.id)
@@ -396,6 +398,29 @@ def create_data_link(
self._kafka.notify_expired_link(expired_id)
return dl
+ def label_data_links(self, user: UserID, duids: List[DataUnitID], add_labels: List[str], remove_labels: List[str], as_admin: bool = False) -> None:
+ '''
+ Label a list of data links. The user must have admin access to the sample,
+ since labeling data grants permissions: once labeled, if a user
+ has access to the data unit, the user also has access to the sample.
+
+ :param user: the user labeling the links.
+ :param links: the links to label.
+ :param as_admin: allow label creation to proceed if user does not
+ '''
+ _not_falsy(user, 'user')
+ _not_falsy(duids, 'duids')
+ wsperm = _WorkspaceAccessType.NONE if as_admin else _WorkspaceAccessType.WRITE
+
+ # check permissions on the links' data objects
+ # as a set so we dont check permissions on the same workspace twice
+ required_workspaces = set(duid.upa.wsid for duid in duids)
+ for ws_id in required_workspaces:
+ self._ws.has_permission(user, wsperm, workspace_id=ws_id)
+
+ self._storage.label_data_links(duids, add_labels, remove_labels)
+
+
def expire_data_link(self, user: UserID, duid: DataUnitID, as_admin: bool = False) -> None:
'''
Expire a data link, ensuring that it will not show up in link queries without an effective
diff --git a/lib/SampleService/core/storage/arango_sample_storage.py b/lib/SampleService/core/storage/arango_sample_storage.py
index 0009a8c9..616bde75 100644
--- a/lib/SampleService/core/storage/arango_sample_storage.py
+++ b/lib/SampleService/core/storage/arango_sample_storage.py
@@ -159,6 +159,7 @@
_FLD_LINK_CREATED_BY = 'createby'
_FLD_LINK_EXPIRED = 'expired'
_FLD_LINK_EXPIRED_BY = 'expireby'
+_FLD_LINK_CONTROLLED_LABELS = 'clabels'
# see https://www.arangodb.com/2018/07/time-traveling-with-graph-databases/
_ARANGO_MAX_INTEGER = 2**53 - 1
@@ -1364,7 +1365,8 @@ def _create_link_doc(self, link: DataLink, samplever: UUID):
# recording the integer version saves looking it up in the version doc and it's
# immutable so denormalization is ok here
_FLD_LINK_SAMPLE_INT_VERSION: sna.version,
- _FLD_LINK_SAMPLE_NODE: sna.node
+ _FLD_LINK_SAMPLE_NODE: sna.node,
+ _FLD_LINK_CONTROLLED_LABELS: link.controlled_labels,
}
def _get_link_doc_from_link_id(self, id_):
@@ -1388,6 +1390,42 @@ def _get_link_doc_from_duid(self, duid):
raise _NoSuchLinkError(str(duid))
return linkdoc
+ def label_data_links(
+ self,
+ duids: List[DataUnitID],
+ add_labels: List[str],
+ remove_labels: List[str]) -> List[DataLink]:
+ '''Set or remove labels from a data link.'''
+ # validate labels to be added
+ normalized_add_labels = DataLink.validate_controlled_labels(add_labels)
+ # create transaction
+ tdb = self._db.begin_transaction(
+ read=self._col_data_link.name,
+ write=self._col_data_link.name)
+ try:
+ tdlc = tdb.collection(self._col_data_link.name)
+ linkdocs = []
+ for duid in duids:
+ linkdoc = self._get_link_doc_from_duid(duid)
+ labels = linkdoc[_FLD_LINK_CONTROLLED_LABELS]
+ # add and remove labels from doc
+ for rem in remove_labels:
+ if rem in labels:
+ labels.remove(rem)
+ for add in normalized_add_labels:
+ if add not in labels:
+ labels.append(add)
+ # update the link doc (in transaction)
+ tdlc.update(linkdoc)
+ linkdocs.append(linkdoc)
+ # nothing thrown, so commit the transaction
+ self._commit_transaction(tdb)
+ return linkdocs
+ finally:
+ # rollback if an exception was thrown
+ self._abort_transaction(tdb)
+
+
def expire_data_link(
self,
expired: datetime.datetime,
@@ -1509,7 +1547,8 @@ def _doc_to_link(self, doc) -> DataLink:
self._timestamp_to_datetime(self._timestamp_milliseconds_to_seconds(doc[_FLD_LINK_CREATED])),
UserID(doc[_FLD_LINK_CREATED_BY]),
None if ex == _ARANGO_MAX_INTEGER else self._timestamp_to_datetime(self._timestamp_milliseconds_to_seconds(ex)),
- UserID(doc[_FLD_LINK_EXPIRED_BY]) if doc[_FLD_LINK_EXPIRED_BY] else None
+ UserID(doc[_FLD_LINK_EXPIRED_BY]) if doc[_FLD_LINK_EXPIRED_BY] else None,
+ doc.get(_FLD_LINK_CONTROLLED_LABELS, [])
)
def _doc_to_dataunit_id(self, doc) -> DataUnitID:
diff --git a/test/core/storage/arango_sample_storage_test.py b/test/core/storage/arango_sample_storage_test.py
index af9e8337..1a350fa9 100644
--- a/test/core/storage/arango_sample_storage_test.py
+++ b/test/core/storage/arango_sample_storage_test.py
@@ -1733,7 +1733,8 @@ def test_create_and_get_data_link(samplestorage):
'created': 500000,
'createby': 'usera',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
link2 = samplestorage._col_data_link.get('42_42_42_bc7324de86d54718dd0dc29c55c6d53a')
@@ -1755,7 +1756,8 @@ def test_create_and_get_data_link(samplestorage):
'created': 600000,
'createby': 'userb',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
link3 = samplestorage._col_data_link.get('5_89_32_3735ce9bbe59e7ec245da484772f9524')
@@ -1777,7 +1779,8 @@ def test_create_and_get_data_link(samplestorage):
'created': 700000,
'createby': 'u',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
link4 = samplestorage._col_data_link.get('5_89_32_bc7324de86d54718dd0dc29c55c6d53a')
@@ -1799,7 +1802,8 @@ def test_create_and_get_data_link(samplestorage):
'created': 800000,
'createby': 'userd',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
# test get method
@@ -1901,7 +1905,8 @@ def test_creaate_data_link_with_update_no_extant_link(samplestorage):
'created': 500000,
'createby': 'usera',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
link2 = samplestorage._col_data_link.get('5_89_32_bc7324de86d54718dd0dc29c55c6d53a')
@@ -1923,7 +1928,8 @@ def test_creaate_data_link_with_update_no_extant_link(samplestorage):
'created': 550000,
'createby': 'user',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
# test get method
@@ -2021,7 +2027,8 @@ def test_create_data_link_with_update_noop(samplestorage):
'created': 500000,
'createby': 'usera',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
link2 = samplestorage._col_data_link.get('5_89_32_bc7324de86d54718dd0dc29c55c6d53a')
@@ -2043,7 +2050,8 @@ def test_create_data_link_with_update_noop(samplestorage):
'created': 550000,
'createby': 'user',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
# test get method
@@ -2138,7 +2146,8 @@ def test_create_data_link_with_update(samplestorage):
'created': 500000,
'createby': 'usera',
'expired': 599999,
- 'expireby': 'userb'
+ 'expireby': 'userb',
+ 'clabels': []
}
link2 = samplestorage._col_data_link.get('5_89_32_bc7324de86d54718dd0dc29c55c6d53a_550.0')
@@ -2160,7 +2169,8 @@ def test_create_data_link_with_update(samplestorage):
'created': 550000,
'createby': 'user',
'expired': 699999,
- 'expireby': 'userc'
+ 'expireby': 'userc',
+ 'clabels': []
}
link3 = samplestorage._col_data_link.get('5_89_32')
@@ -2182,7 +2192,8 @@ def test_create_data_link_with_update(samplestorage):
'created': 600000,
'createby': 'userb',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
link4 = samplestorage._col_data_link.get('5_89_32_bc7324de86d54718dd0dc29c55c6d53a')
@@ -2204,7 +2215,8 @@ def test_create_data_link_with_update(samplestorage):
'created': 700000,
'createby': 'userc',
'expired': 9007199254740991,
- 'expireby': None
+ 'expireby': None,
+ 'clabels': []
}
# test get method. Expired, so DUID won't work here
@@ -2954,7 +2966,8 @@ def _expire_and_get_data_link_via_duid(samplestorage, expired, dataid, expectedm
'created': -100000,
'createby': 'userb',
'expired': expired * 1000,
- 'expireby': 'yay'
+ 'expireby': 'yay',
+ 'clabels': []
}
assert samplestorage.get_data_link(lid) == DataLink(
@@ -3025,7 +3038,8 @@ def _expire_and_get_data_link_via_id(samplestorage, expired, dataid, expectedmd5
'created': 5000,
'createby': 'usera',
'expired': expired * 1000,
- 'expireby': 'user'
+ 'expireby': 'user',
+ 'clabels': []
}
link = samplestorage.get_data_link(lid)