Skip to content

Commit ddc8f93

Browse files
authored
Merge pull request #374 from VariantEffect/feature/bencap/288/data-collections
Data collections
2 parents af5606f + 2b9acf3 commit ddc8f93

26 files changed

+2244
-47
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Collections data model
2+
3+
Revision ID: c404b6719110
4+
Revises: 2b6f40ea2fb6
5+
Create Date: 2024-10-15 12:57:29.682271
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "c404b6719110"
15+
down_revision = "2b6f40ea2fb6"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.create_table(
23+
"collections",
24+
sa.Column("id", sa.Integer(), nullable=False),
25+
sa.Column("urn", sa.String(length=64), nullable=True),
26+
sa.Column("private", sa.Boolean(), nullable=False),
27+
sa.Column("name", sa.String(), nullable=False),
28+
sa.Column("badge_name", sa.String(), nullable=True),
29+
sa.Column("description", sa.String(), nullable=True),
30+
sa.Column("created_by_id", sa.Integer(), nullable=True),
31+
sa.Column("modified_by_id", sa.Integer(), nullable=True),
32+
sa.Column("creation_date", sa.Date(), nullable=False),
33+
sa.Column("modification_date", sa.Date(), nullable=False),
34+
sa.ForeignKeyConstraint(
35+
["created_by_id"],
36+
["users.id"],
37+
),
38+
sa.ForeignKeyConstraint(
39+
["modified_by_id"],
40+
["users.id"],
41+
),
42+
sa.PrimaryKeyConstraint("id"),
43+
)
44+
op.create_index(op.f("ix_collections_created_by_id"), "collections", ["created_by_id"], unique=False)
45+
op.create_index(op.f("ix_collections_modified_by_id"), "collections", ["modified_by_id"], unique=False)
46+
op.create_index(op.f("ix_collections_urn"), "collections", ["urn"], unique=True)
47+
48+
op.create_table(
49+
"collection_user_associations",
50+
sa.Column("collection_id", sa.Integer(), nullable=False),
51+
sa.Column("user_id", sa.Integer(), nullable=False),
52+
sa.Column(
53+
"contribution_role",
54+
sa.Enum(
55+
"admin",
56+
"editor",
57+
"viewer",
58+
name="contributionrole",
59+
native_enum=False,
60+
create_constraint=True,
61+
length=32,
62+
),
63+
nullable=False,
64+
),
65+
sa.ForeignKeyConstraint(
66+
["collection_id"],
67+
["collections.id"],
68+
),
69+
sa.ForeignKeyConstraint(
70+
["user_id"],
71+
["users.id"],
72+
),
73+
sa.PrimaryKeyConstraint("collection_id", "user_id"),
74+
)
75+
76+
op.create_table(
77+
"collection_experiments",
78+
sa.Column("collection_id", sa.Integer(), nullable=False),
79+
sa.Column("experiment_id", sa.Integer(), nullable=False),
80+
sa.ForeignKeyConstraint(
81+
["collection_id"],
82+
["collections.id"],
83+
),
84+
sa.ForeignKeyConstraint(
85+
["experiment_id"],
86+
["experiments.id"],
87+
),
88+
sa.PrimaryKeyConstraint("collection_id", "experiment_id"),
89+
)
90+
91+
op.create_table(
92+
"collection_score_sets",
93+
sa.Column("collection_id", sa.Integer(), nullable=False),
94+
sa.Column("score_set_id", sa.Integer(), nullable=False),
95+
sa.ForeignKeyConstraint(
96+
["collection_id"],
97+
["collections.id"],
98+
),
99+
sa.ForeignKeyConstraint(
100+
["score_set_id"],
101+
["scoresets.id"],
102+
),
103+
sa.PrimaryKeyConstraint("collection_id", "score_set_id"),
104+
)
105+
# ### end Alembic commands ###
106+
107+
108+
def downgrade():
109+
# ### commands auto generated by Alembic - please adjust! ###
110+
op.drop_table("collection_score_sets")
111+
op.drop_table("collection_experiments")
112+
op.drop_table("collection_user_associations")
113+
op.drop_index(op.f("ix_collections_urn"), table_name="collections")
114+
op.drop_index(op.f("ix_collections_modified_by_id"), table_name="collections")
115+
op.drop_index(op.f("ix_collections_created_by_id"), table_name="collections")
116+
op.drop_table("collections")
117+
# ### end Alembic commands ###

src/mavedb/lib/experiments.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from mavedb.models.contributor import Contributor
99
from mavedb.models.controlled_keyword import ControlledKeyword
1010
from mavedb.models.experiment import Experiment
11-
from mavedb.models.experiment_controlled_keyword import ExperimentControlledKeywordAssociation
11+
from mavedb.models.experiment_controlled_keyword import (
12+
ExperimentControlledKeywordAssociation,
13+
)
1214
from mavedb.models.publication_identifier import PublicationIdentifier
1315
from mavedb.models.score_set import ScoreSet
1416
from mavedb.models.user import User
@@ -117,6 +119,9 @@ def search_experiments(
117119
items = []
118120

119121
save_to_logging_context({"matching_resources": len(items)})
120-
logger.debug(msg="Experiment search yielded {len(items)} matching resources.", extra=logging_context())
122+
logger.debug(
123+
msg="Experiment search yielded {len(items)} matching resources.",
124+
extra=logging_context(),
125+
)
121126

122127
return items

src/mavedb/lib/permissions.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from mavedb.db.base import Base
66
from mavedb.lib.authentication import UserData
77
from mavedb.lib.logging.context import logging_context, save_to_logging_context
8+
from mavedb.models.collection import Collection
9+
from mavedb.models.enums.contribution_role import ContributionRole
810
from mavedb.models.enums.user_role import UserRole
911
from mavedb.models.experiment import Experiment
1012
from mavedb.models.experiment_set import ExperimentSet
@@ -15,6 +17,7 @@
1517

1618

1719
class Action(Enum):
20+
LOOKUP = "lookup"
1821
READ = "read"
1922
UPDATE = "update"
2023
DELETE = "delete"
@@ -23,6 +26,7 @@ class Action(Enum):
2326
SET_SCORES = "set_scores"
2427
ADD_ROLE = "add_role"
2528
PUBLISH = "publish"
29+
ADD_BADGE = "add_badge"
2630

2731

2832
class PermissionResponse:
@@ -33,9 +37,15 @@ def __init__(self, permitted: bool, http_code: int = 403, message: Optional[str]
3337

3438
save_to_logging_context({"permission_message": self.message, "access_permitted": self.permitted})
3539
if self.permitted:
36-
logger.debug(msg="Access to the requested resource is permitted.", extra=logging_context())
40+
logger.debug(
41+
msg="Access to the requested resource is permitted.",
42+
extra=logging_context(),
43+
)
3744
else:
38-
logger.debug(msg="Access to the requested resource is not permitted.", extra=logging_context())
45+
logger.debug(
46+
msg="Access to the requested resource is not permitted.",
47+
extra=logging_context(),
48+
)
3949

4050

4151
class PermissionException(Exception):
@@ -59,6 +69,7 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) ->
5969
user_is_owner = False
6070
user_is_self = False
6171
user_may_edit = False
72+
user_may_view_private = False
6273
active_roles = user_data.active_roles if user_data else []
6374

6475
if isinstance(item, ExperimentSet) or isinstance(item, Experiment) or isinstance(item, ScoreSet):
@@ -72,6 +83,27 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) ->
7283

7384
save_to_logging_context({"resource_is_published": published})
7485

86+
if isinstance(item, Collection):
87+
assert item.private is not None
88+
private = item.private
89+
published = item.private is False
90+
user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False
91+
admin_user_ids = set()
92+
editor_user_ids = set()
93+
viewer_user_ids = set()
94+
for user_association in item.user_associations:
95+
if user_association.contribution_role == ContributionRole.admin:
96+
admin_user_ids.add(user_association.user_id)
97+
elif user_association.contribution_role == ContributionRole.editor:
98+
editor_user_ids.add(user_association.user_id)
99+
elif user_association.contribution_role == ContributionRole.viewer:
100+
viewer_user_ids.add(user_association.user_id)
101+
user_is_admin = user_is_owner or (user_data is not None and user_data.user.id in admin_user_ids)
102+
user_may_edit = user_is_admin or (user_data is not None and user_data.user.id in editor_user_ids)
103+
user_may_view_private = user_may_edit or (user_data is not None and (user_data.user.id in viewer_user_ids))
104+
105+
save_to_logging_context({"resource_is_published": published})
106+
75107
if isinstance(item, User):
76108
user_is_self = item.id == user_data.user.id if user_data is not None else False
77109
user_may_edit = user_is_self
@@ -254,7 +286,109 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) ->
254286
else:
255287
raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)")
256288

289+
elif isinstance(item, Collection):
290+
if action == Action.READ:
291+
if user_may_view_private or not private:
292+
return PermissionResponse(True)
293+
# Roles which may perform this operation.
294+
elif roles_permitted(active_roles, [UserRole.admin]):
295+
return PermissionResponse(True)
296+
elif private:
297+
# Do not acknowledge the existence of a private entity.
298+
return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found")
299+
elif user_data is None or user_data.user is None:
300+
return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'")
301+
else:
302+
return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'")
303+
elif action == Action.UPDATE:
304+
if user_may_edit:
305+
return PermissionResponse(True)
306+
# Roles which may perform this operation.
307+
elif roles_permitted(active_roles, [UserRole.admin]):
308+
return PermissionResponse(True)
309+
elif private and not user_may_view_private:
310+
# Do not acknowledge the existence of a private entity.
311+
return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found")
312+
elif user_data is None or user_data.user is None:
313+
return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'")
314+
else:
315+
return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'")
316+
elif action == Action.DELETE:
317+
# A collection may be deleted even if it has been published, as long as it is not an official collection.
318+
if user_is_owner:
319+
return PermissionResponse(
320+
not item.badge_name,
321+
403,
322+
f"insufficient permissions for URN '{item.urn}'",
323+
)
324+
# MaveDB admins may delete official collections.
325+
elif roles_permitted(active_roles, [UserRole.admin]):
326+
return PermissionResponse(True)
327+
elif private and not user_may_view_private:
328+
# Do not acknowledge the existence of a private entity.
329+
return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found")
330+
else:
331+
return PermissionResponse(False)
332+
elif action == Action.PUBLISH:
333+
if user_is_admin:
334+
return PermissionResponse(True)
335+
elif roles_permitted(active_roles, []):
336+
return PermissionResponse(True)
337+
elif private and not user_may_view_private:
338+
# Do not acknowledge the existence of a private entity.
339+
return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found")
340+
else:
341+
return PermissionResponse(False)
342+
elif action == Action.ADD_SCORE_SET:
343+
# Whether the collection is private or public, only permitted users can add a score set to a collection.
344+
if user_may_edit or roles_permitted(active_roles, [UserRole.admin]):
345+
return PermissionResponse(True)
346+
elif private and not user_may_view_private:
347+
return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found")
348+
else:
349+
return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'")
350+
elif action == Action.ADD_EXPERIMENT:
351+
# Only permitted users can add an experiment to an existing collection.
352+
return PermissionResponse(
353+
user_may_edit or roles_permitted(active_roles, [UserRole.admin]),
354+
404 if private and not user_may_view_private else 403,
355+
(
356+
f"collection with URN '{item.urn}' not found"
357+
if private and not user_may_view_private
358+
else f"insufficient permissions for URN '{item.urn}'"
359+
),
360+
)
361+
elif action == Action.ADD_ROLE:
362+
# Both collection admins and MaveDB admins can add a user to a collection role
363+
if user_is_admin or roles_permitted(active_roles, [UserRole.admin]):
364+
return PermissionResponse(True)
365+
else:
366+
return PermissionResponse(False, 403, "Insufficient permissions to add user role.")
367+
# only MaveDB admins may add a badge name to a collection, which makes the collection considered "official"
368+
elif action == Action.ADD_BADGE:
369+
# Roles which may perform this operation.
370+
if roles_permitted(active_roles, [UserRole.admin]):
371+
return PermissionResponse(True)
372+
elif private:
373+
# Do not acknowledge the existence of a private entity.
374+
return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found")
375+
elif user_data is None or user_data.user is None:
376+
return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'")
377+
else:
378+
return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'")
379+
else:
380+
raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)")
381+
257382
elif isinstance(item, User):
383+
if action == Action.LOOKUP:
384+
# any existing user can look up any mavedb user by Orcid ID
385+
# lookup differs from read because lookup means getting the first name, last name, and orcid ID of the user,
386+
# while read means getting an admin view of the user's details
387+
if user_data is not None and user_data.user is not None:
388+
return PermissionResponse(True)
389+
else:
390+
# TODO is this inappropriately acknowledging the existence of the user?
391+
return PermissionResponse(False, 401, "Insufficient permissions for user lookup.")
258392
if action == Action.READ:
259393
if user_is_self:
260394
return PermissionResponse(True)

src/mavedb/lib/urns.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import re
33
import string
4+
from uuid import uuid4
45

56
from sqlalchemy import func
67
from sqlalchemy.orm import Session
@@ -130,3 +131,14 @@ def generate_score_set_urn(db: Session, experiment: Experiment):
130131
max_suffix_number = suffix_number
131132
next_suffix_number = max_suffix_number + 1
132133
return f"{experiment_urn}-{next_suffix_number}"
134+
135+
136+
def generate_collection_urn():
137+
"""
138+
Generate a new URN for a collection.
139+
140+
Collection URNs include a 16-digit UUID.
141+
142+
:return: A new collection URN
143+
"""
144+
return f"urn:mavedb:collection-{uuid4()}"

src/mavedb/lib/validation/urn_re.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
MAVEDB_VARIANT_URN_PATTERN = rf"{MAVEDB_SCORE_SET_URN_PATTERN}#[1-9]\d*"
2525
MAVEDB_VARIANT_URN_RE = re.compile(MAVEDB_VARIANT_URN_PATTERN)
2626

27+
# Collection URN
28+
MAVEDB_COLLECTION_URN_PATTERN = r"urn:mavedb:collection-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
29+
MAVEDB_COLLECTION_URN_RE = re.compile(MAVEDB_COLLECTION_URN_PATTERN)
30+
2731
# Any URN
2832
MAVEDB_ANY_URN_PATTERN = "|".join(
2933
[

src/mavedb/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
__all__ = [
22
"access_key",
3+
"collection",
34
"controlled_keyword",
45
"doi_identifier",
56
"ensembl_identifier",

0 commit comments

Comments
 (0)