diff --git a/pydatalab/schemas/cell.json b/pydatalab/schemas/cell.json index a94011179..250bdf53e 100644 --- a/pydatalab/schemas/cell.json +++ b/pydatalab/schemas/cell.json @@ -41,6 +41,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -48,6 +56,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "cells", @@ -275,6 +290,64 @@ "name" ] }, + "Group": { + "title": "Group", + "description": "A model that describes a group of users, for the sake\nof applying group permissions.\n\nEach `Person` model can point to a given group.", + "type": "object", + "properties": { + "type": { + "title": "Type", + "default": "groups", + "const": "groups", + "type": "string" + }, + "immutable_id": { + "title": "Immutable ID", + "format": "uuid", + "type": "string" + }, + "last_modified": { + "title": "Last Modified", + "type": "string", + "format": "date-time" + }, + "relationships": { + "title": "Relationships", + "type": "array", + "items": { + "$ref": "#/definitions/TypedRelationship" + } + }, + "group_id": { + "title": "Group Id", + "minLength": 1, + "maxLength": 40, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "minLength": 1, + "maxLength": 150, + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "group_admins": { + "title": "Group Admins", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "group_id", + "display_name" + ] + }, "AccountStatus": { "title": "AccountStatus", "description": "A string enum representing the account status.", @@ -331,6 +404,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", @@ -374,6 +454,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -381,6 +469,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "collections", @@ -448,6 +543,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -455,6 +558,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "files", diff --git a/pydatalab/schemas/equipment.json b/pydatalab/schemas/equipment.json index 9d7da376a..31f7e7273 100644 --- a/pydatalab/schemas/equipment.json +++ b/pydatalab/schemas/equipment.json @@ -41,6 +41,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -48,6 +56,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "equipment", @@ -239,6 +254,64 @@ "name" ] }, + "Group": { + "title": "Group", + "description": "A model that describes a group of users, for the sake\nof applying group permissions.\n\nEach `Person` model can point to a given group.", + "type": "object", + "properties": { + "type": { + "title": "Type", + "default": "groups", + "const": "groups", + "type": "string" + }, + "immutable_id": { + "title": "Immutable ID", + "format": "uuid", + "type": "string" + }, + "last_modified": { + "title": "Last Modified", + "type": "string", + "format": "date-time" + }, + "relationships": { + "title": "Relationships", + "type": "array", + "items": { + "$ref": "#/definitions/TypedRelationship" + } + }, + "group_id": { + "title": "Group Id", + "minLength": 1, + "maxLength": 40, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "minLength": 1, + "maxLength": 150, + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "group_admins": { + "title": "Group Admins", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "group_id", + "display_name" + ] + }, "AccountStatus": { "title": "AccountStatus", "description": "A string enum representing the account status.", @@ -295,6 +368,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", @@ -338,6 +418,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -345,6 +433,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "collections", @@ -412,6 +507,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -419,6 +522,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "files", diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 602a990a9..b442edcee 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -53,6 +53,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -60,6 +68,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "samples", @@ -328,6 +343,64 @@ "name" ] }, + "Group": { + "title": "Group", + "description": "A model that describes a group of users, for the sake\nof applying group permissions.\n\nEach `Person` model can point to a given group.", + "type": "object", + "properties": { + "type": { + "title": "Type", + "default": "groups", + "const": "groups", + "type": "string" + }, + "immutable_id": { + "title": "Immutable ID", + "format": "uuid", + "type": "string" + }, + "last_modified": { + "title": "Last Modified", + "type": "string", + "format": "date-time" + }, + "relationships": { + "title": "Relationships", + "type": "array", + "items": { + "$ref": "#/definitions/TypedRelationship" + } + }, + "group_id": { + "title": "Group Id", + "minLength": 1, + "maxLength": 40, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "minLength": 1, + "maxLength": 150, + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "group_admins": { + "title": "Group Admins", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "group_id", + "display_name" + ] + }, "AccountStatus": { "title": "AccountStatus", "description": "A string enum representing the account status.", @@ -384,6 +457,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", @@ -427,6 +507,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -434,6 +522,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "collections", @@ -501,6 +596,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -508,6 +611,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "files", diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index 525d81b7f..5cba44325 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -53,6 +53,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -60,6 +68,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "starting_materials", @@ -381,6 +396,64 @@ "name" ] }, + "Group": { + "title": "Group", + "description": "A model that describes a group of users, for the sake\nof applying group permissions.\n\nEach `Person` model can point to a given group.", + "type": "object", + "properties": { + "type": { + "title": "Type", + "default": "groups", + "const": "groups", + "type": "string" + }, + "immutable_id": { + "title": "Immutable ID", + "format": "uuid", + "type": "string" + }, + "last_modified": { + "title": "Last Modified", + "type": "string", + "format": "date-time" + }, + "relationships": { + "title": "Relationships", + "type": "array", + "items": { + "$ref": "#/definitions/TypedRelationship" + } + }, + "group_id": { + "title": "Group Id", + "minLength": 1, + "maxLength": 40, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "minLength": 1, + "maxLength": 150, + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "group_admins": { + "title": "Group Admins", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "group_id", + "display_name" + ] + }, "AccountStatus": { "title": "AccountStatus", "description": "A string enum representing the account status.", @@ -437,6 +510,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", @@ -480,6 +560,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -487,6 +575,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "collections", @@ -554,6 +649,14 @@ "type": "string" } }, + "group_ids": { + "title": "Group Ids", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "creators": { "title": "Creators", "type": "array", @@ -561,6 +664,13 @@ "$ref": "#/definitions/Person" } }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "type": { "title": "Type", "default": "files", diff --git a/pydatalab/src/pydatalab/login.py b/pydatalab/src/pydatalab/login.py index 833550f67..339f2a0dd 100644 --- a/pydatalab/src/pydatalab/login.py +++ b/pydatalab/src/pydatalab/login.py @@ -9,7 +9,7 @@ from flask_login import LoginManager, UserMixin from pydatalab.models import Person -from pydatalab.models.people import AccountStatus, Identity, IdentityType +from pydatalab.models.people import AccountStatus, Group, Identity, IdentityType from pydatalab.models.utils import UserRole from pydatalab.mongo import flask_mongo @@ -72,6 +72,11 @@ def identity_types(self) -> list[IdentityType]: """Returns a list of the identity types associated with the user.""" return [_.identity_type for _ in self.person.identities] + @property + def groups(self) -> list[Group]: + """Returns the list of groups that the user is a member of.""" + return self.person.groups + def refresh(self) -> None: """Reconstruct the user object from their database entry, to be used when, e.g., a new identity has been associated with them. @@ -87,7 +92,33 @@ def get_by_id_cached(user_id): return get_by_id(user_id) +<<<<<<< HEAD def get_by_id(user_id: str) -> LoginUser | None: +======= +def groups_lookup() -> dict: + return { + "from": "groups", + "let": {"group_ids": "$group_ids"}, + "pipeline": [ + {"$match": {"$expr": {"$in": ["$_id", {"$ifNull": ["$$group_ids", []]}]}}}, + {"$addFields": {"__order": {"$indexOfArray": ["$$group_ids", "$_id"]}}}, + {"$sort": {"__order": 1}}, + { + "$project": { + "_id": 1, + "display_name": 1, + "group_id": 1, + "type": 1, + "description": 1, + } + }, + ], + "as": "groups", + } + + +def get_by_id(user_id: str) -> Optional[LoginUser]: +>>>>>>> 82a1eb81 (Add endpoints for group management) """Lookup the user database ID and create a new `LoginUser` with the relevant metadata. @@ -100,10 +131,18 @@ def get_by_id(user_id: str) -> LoginUser | None: """ - user = flask_mongo.db.users.find_one({"_id": ObjectId(user_id)}) - if not user: + user_pipeline = [ + {"$match": {"_id": ObjectId(user_id)}}, + {"$lookup": groups_lookup()}, + ] + user_cursor = flask_mongo.db.users.aggregate(user_pipeline) + users_list = list(user_cursor) + + if not users_list: return None + user = users_list[0] + role = flask_mongo.db.roles.find_one({"_id": ObjectId(user_id)}) if not role: role = "user" diff --git a/pydatalab/src/pydatalab/models/people.py b/pydatalab/src/pydatalab/models/people.py index 096250cce..be86d961d 100644 --- a/pydatalab/src/pydatalab/models/people.py +++ b/pydatalab/src/pydatalab/models/people.py @@ -6,7 +6,7 @@ from pydantic import EmailStr as PydanticEmailStr from pydatalab.models.entries import Entry -from pydatalab.models.utils import PyObjectId +from pydatalab.models.utils import HumanReadableIdentifier, PyObjectId, UserRole class IdentityType(str, Enum): @@ -97,6 +97,30 @@ class AccountStatus(str, Enum): DEACTIVATED = "deactivated" +class Group(Entry): + """A model that describes a group of users, for the sake + of applying group permissions. + + Each `Person` model can point to a given group. + + """ + + type: str = Field("groups", const=True) + """The entry type as a string.""" + + group_id: HumanReadableIdentifier + """A short, locally-unique ID for the group.""" + + display_name: DisplayName + """The chosen display name for the group""" + + description: str | None + """A description of the group""" + + group_admins: list[PyObjectId] | None + """A list of user IDs that can manage this group.""" + + class Person(Entry): """A model that describes an individual and their digital identities.""" @@ -112,6 +136,9 @@ class Person(Entry): contact_email: EmailStr | None """In the case of multiple *verified* email identities, this email will be used as the primary contact.""" + groups: list[Group] = Field(default_factory=list) + """A list of groups that this person belongs to.""" + managers: list[PyObjectId] | None """A list of user IDs that can manage this person's items.""" @@ -168,3 +195,7 @@ def new_user_from_identity( contact_email=contact_email, account_status=account_status, ) + + +class User(Person): + role: UserRole diff --git a/pydatalab/src/pydatalab/models/traits.py b/pydatalab/src/pydatalab/models/traits.py index 79f7b0537..03d072985 100644 --- a/pydatalab/src/pydatalab/models/traits.py +++ b/pydatalab/src/pydatalab/models/traits.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, root_validator -from pydatalab.models.people import Person +from pydatalab.models.people import Group, Person from pydatalab.models.utils import Constituent, InlineSubstance, PyObjectId @@ -10,9 +10,15 @@ class HasOwner(BaseModel): creator_ids: list[PyObjectId] = Field([]) """The database IDs of the user(s) who created the item.""" + group_ids: list[PyObjectId] = Field([]) + """The database IDs of the group(s) that have access to the item.""" + creators: list[Person] | None = Field(None) """Inlined info for the people associated with this item.""" + groups: list[Group] | None = Field(None) + """Inlined info for the groups associated with this item.""" + class HasRevisionControl(BaseModel): revision: int = 1 diff --git a/pydatalab/src/pydatalab/mongo.py b/pydatalab/src/pydatalab/mongo.py index cd4fba24a..170505692 100644 --- a/pydatalab/src/pydatalab/mongo.py +++ b/pydatalab/src/pydatalab/mongo.py @@ -208,4 +208,35 @@ def create_user_fts(): db.users.drop_index(user_fts_name) ret += create_user_fts() + group_fts_fields = {"display_name", "description"} + group_fts_name = "group full-text search" + group_index_name = "unique group identifiers" + + def create_group_index(group_index_name): + return db.groups.create_index( + "group_id", + unique=True, + name=group_index_name, + background=background, + ) + + try: + ret += create_group_index(group_index_name) + except pymongo.errors.OperationFailure: + db.users.drop_index(group_index_name) + ret += create_group_index(group_index_name) + + def create_group_fts(): + return db.groups.create_index( + [(k, pymongo.TEXT) for k in group_fts_fields], + name=group_fts_name, + background=background, + ) + + try: + ret += create_group_fts() + except pymongo.errors.OperationFailure: + db.users.drop_index(group_fts_name) + ret += create_group_fts() + return ret diff --git a/pydatalab/src/pydatalab/permissions.py b/pydatalab/src/pydatalab/permissions.py index 0bfd4f8e8..564ed70aa 100644 --- a/pydatalab/src/pydatalab/permissions.py +++ b/pydatalab/src/pydatalab/permissions.py @@ -6,7 +6,6 @@ from flask_login import current_user from pydatalab.config import CONFIG -from pydatalab.logger import LOGGER from pydatalab.login import UserRole from pydatalab.models.people import AccountStatus from pydatalab.mongo import get_database @@ -91,8 +90,8 @@ def get_default_permissions(user_only: bool = True, deleting: bool = False) -> d {"creator_ids": {"$exists": False}}, ] } + if current_user.is_authenticated and current_user.person is not None: - # find managed users under the given user (can later be expanded to groups) managed_users = list( get_database().users.find( {"managers": {"$in": [current_user.person.immutable_id]}}, projection={"_id": 1} @@ -100,11 +99,28 @@ def get_default_permissions(user_only: bool = True, deleting: bool = False) -> d ) if managed_users: managed_users = [u["_id"] for u in managed_users] - LOGGER.debug("Found managed users %s for user %s", managed_users, current_user.person) - user_perm: dict[str, Any] = { - "creator_ids": {"$in": [current_user.person.immutable_id] + managed_users} - } + user_group_ids = [] + if current_user.person.groups: + user_group_ids = [group.immutable_id for group in current_user.person.groups] + + groups_where_admin = list( + get_database().groups.find( + {"group_admins": {"$in": [str(current_user.person.immutable_id)]}}, {"_id": 1} + ) + ) + if groups_where_admin: + admin_group_ids = [g["_id"] for g in groups_where_admin] + user_group_ids.extend(admin_group_ids) + + user_perm_conditions = [ + {"creator_ids": {"$in": [current_user.person.immutable_id] + managed_users}} + ] + + if user_group_ids: + user_perm_conditions.append({"group_ids": {"$in": user_group_ids}}) + + user_perm: dict[str, Any] = {"$or": user_perm_conditions} if user_only: # TODO: remove this hack when permissions are refactored. Currently starting_materials and equipment # are a special case that should be group editable, so even when the route has asked to only edit this diff --git a/pydatalab/src/pydatalab/routes/v0_1/__init__.py b/pydatalab/src/pydatalab/routes/v0_1/__init__.py index 512a40163..687f9030f 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/__init__.py +++ b/pydatalab/src/pydatalab/routes/v0_1/__init__.py @@ -7,6 +7,7 @@ from .collections import COLLECTIONS from .files import FILES from .graphs import GRAPHS +from .groups import GROUPS from .healthcheck import HEALTHCHECK from .info import INFO from .items import ITEMS @@ -18,6 +19,7 @@ COLLECTIONS, REMOTES, USERS, + GROUPS, ADMIN, ITEMS, BLOCKS, diff --git a/pydatalab/src/pydatalab/routes/v0_1/admin.py b/pydatalab/src/pydatalab/routes/v0_1/admin.py index c95334073..4e544f698 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/admin.py +++ b/pydatalab/src/pydatalab/routes/v0_1/admin.py @@ -1,10 +1,14 @@ +import json + +import pymongo.errors from bson import ObjectId from flask import Blueprint, jsonify, request from flask_login import current_user from pydatalab.config import CONFIG +from pydatalab.models.people import Group, User from pydatalab.mongo import flask_mongo -from pydatalab.permissions import admin_only, get_default_permissions +from pydatalab.permissions import admin_only ADMIN = Blueprint("admins", __name__) @@ -18,7 +22,6 @@ def _(): ... def get_users(): users = flask_mongo.db.users.aggregate( [ - {"$match": get_default_permissions(user_only=True)}, { "$lookup": { "from": "roles", @@ -27,6 +30,19 @@ def get_users(): "as": "role", } }, + { + "$lookup": { + "from": "groups", + "let": {"group_ids": "$group_ids"}, + "pipeline": [ + {"$match": {"$expr": {"$in": ["$_id", {"$ifNull": ["$$group_ids", []]}]}}}, + {"$addFields": {"__order": {"$indexOfArray": ["$$group_ids", "$_id"]}}}, + {"$sort": {"__order": 1}}, + {"$project": {"_id": 1, "display_name": 1, "group_id": 1, "type": 1}}, + ], + "as": "groups", + }, + }, { "$addFields": { "role": { @@ -41,7 +57,7 @@ def get_users(): ] ) - return jsonify({"status": "success", "data": list(users)}) + return jsonify({"status": "success", "data": list(json.loads(User(**u).json()) for u in users)}) @ADMIN.route("/roles/", methods=["PATCH"]) @@ -93,3 +109,161 @@ def save_role(user_id): ) return (jsonify({"status": "success"}), 200) + + +@ADMIN.route("/admin/groups", methods=["GET"]) +def get_groups(): + groups_data = [] + for group_doc in flask_mongo.db.groups.find(): + group_doc["immutable_id"] = str(group_doc["_id"]) + + group_data = json.loads(Group(**group_doc).json()) + + group_members = list( + flask_mongo.db.users.find( + {"group_ids": group_doc["_id"]}, {"_id": 1, "display_name": 1, "contact_email": 1} + ) + ) + group_data["members"] = [ + { + "immutable_id": str(member["_id"]), + "display_name": member.get("display_name", ""), + "contact_email": member.get("contact_email", ""), + } + for member in group_members + ] + + if group_doc.get("group_admins"): + admin_ids = [ObjectId(admin_id) for admin_id in group_doc["group_admins"]] + group_admins = list( + flask_mongo.db.users.find( + {"_id": {"$in": admin_ids}}, {"_id": 1, "display_name": 1, "contact_email": 1} + ) + ) + group_data["group_admins"] = [ + { + "immutable_id": str(admin["_id"]), + "display_name": admin.get("display_name", ""), + "contact_email": admin.get("contact_email", ""), + } + for admin in group_admins + ] + + groups_data.append(group_data) + + return jsonify( + { + "status": "success", + "data": groups_data, + } + ), 200 + + +@ADMIN.route("/groups", methods=["PUT"]) +@admin_only +def create_group(): + request_json = request.get_json() + + group_json = { + "group_id": request_json.get("group_id"), + "display_name": request_json.get("display_name"), + "description": request_json.get("description"), + "group_admins": request_json.get("group_admins"), + } + try: + group = Group(**group_json) + except Exception as e: + return jsonify({"status": "error", "message": f"Invalid group data: {str(e)}"}), 400 + + try: + group_immutable_id = flask_mongo.db.groups.insert_one(group.dict()).inserted_id + except pymongo.errors.DuplicateKeyError: + return jsonify( + {"status": "error", "message": f"Group ID {group.group_id} already exists."} + ), 400 + + if group_immutable_id: + return jsonify({"status": "success", "group_immutable_id": str(group_immutable_id)}), 200 + + return jsonify({"status": "error", "message": "Unable to create group."}), 400 + + +@ADMIN.route("/groups", methods=["DELETE"]) +def delete_group(): + request_json = request.get_json() + + group_id = request_json.get("immutable_id") + if group_id is not None: + result = flask_mongo.db.groups.delete_one({"_id": ObjectId(group_id)}) + + if result.deleted_count == 1: + return jsonify({"status": "success"}), 200 + + return jsonify({"status": "error", "message": "Unable to delete group."}), 400 + + +@ADMIN.route("/groups/", methods=["PUT"]) +@admin_only +def update_group(group_immutable_id): + request_json = request.get_json() + + existing_group = flask_mongo.db.groups.find_one({"_id": ObjectId(group_immutable_id)}) + if not existing_group: + return jsonify({"status": "error", "message": "Group not found."}), 404 + + update_data = {} + + if "display_name" in request_json: + update_data["display_name"] = request_json["display_name"] + + if "description" in request_json: + update_data["description"] = request_json["description"] + + if "group_admins" in request_json: + update_data["group_admins"] = request_json["group_admins"] + + try: + temp_group_data = {**existing_group, **update_data} + temp_group_data.pop("_id", None) + Group(**temp_group_data) + except Exception as e: + return jsonify({"status": "error", "message": f"Invalid group data: {str(e)}"}), 400 + + try: + result = flask_mongo.db.groups.update_one( + {"_id": ObjectId(group_immutable_id)}, {"$set": update_data} + ) + + if result.matched_count == 0: + return jsonify({"status": "error", "message": "Group not found."}), 404 + + if result.modified_count == 0: + return jsonify({"status": "success", "message": "No changes were made."}), 200 + + return jsonify({"status": "success", "message": "Group updated successfully."}), 200 + + except Exception as e: + return jsonify({"status": "error", "message": f"Failed to update group: {str(e)}"}), 500 + + +@ADMIN.route("/groups/", methods=["PATCH"]) +def add_user_to_group(group_immutable_id): + request_json = request.get_json() + user_id = request_json.get("user_id") + + if not user_id: + return jsonify({"status": "error", "message": "No user ID provided."}), 400 + + group_exists = flask_mongo.db.groups.find_one({"_id": ObjectId(group_immutable_id)}) + if not group_exists: + return jsonify({"status": "error", "message": "Group does not exist."}), 400 + + update_user = flask_mongo.db.users.update_one( + {"_id": ObjectId(user_id)}, + {"$addToSet": {"group_ids": ObjectId(group_immutable_id)}}, + ) + + if update_user.modified_count == 1: + return jsonify({"status": "success", "message": "User added to group successfully."}), 200 + else: + return jsonify({"status": "error", "message": "Unable to add user to group."}), 400 diff --git a/pydatalab/src/pydatalab/routes/v0_1/groups.py b/pydatalab/src/pydatalab/routes/v0_1/groups.py new file mode 100644 index 000000000..a8819eab5 --- /dev/null +++ b/pydatalab/src/pydatalab/routes/v0_1/groups.py @@ -0,0 +1,75 @@ +import json + +from flask import Blueprint, jsonify, request +from flask_login import current_user + +from pydatalab.models.people import Group +from pydatalab.mongo import flask_mongo +from pydatalab.permissions import active_users_or_get_only + +GROUPS = Blueprint("groups", __name__) + + +@GROUPS.route("/search/groups", methods=["GET"]) +@active_users_or_get_only +def search_groups(): + """Perform free text search on groups and return the top results. + GET parameters: + query: String with the search terms. + nresults: Maximum number of results (default 100) + + Returns: + response list of dictionaries containing the matching groups in order of + descending match score. + """ + + query = request.args.get("query", type=str) + nresults = request.args.get("nresults", default=100, type=int) + match_obj = {"$text": {"$search": query}} + + cursor = flask_mongo.db.groups.aggregate( + [ + {"$match": match_obj}, + {"$sort": {"score": {"$meta": "textScore"}}}, + {"$limit": nresults}, + { + "$project": { + "_id": 1, + "display_name": 1, + "description": 1, + "group_id": 1, + } + }, + ] + ) + return jsonify( + {"status": "success", "data": list(json.loads(Group(**d).json()) for d in cursor)} + ), 200 + + +@GROUPS.route("/groups", methods=["GET"]) +@active_users_or_get_only +def get_user_accessible_groups(): + """Get groups that the current user can see/access.""" + + user_group_ids = ( + [group.immutable_id for group in current_user.person.groups] + if current_user.person.groups + else [] + ) + + groups_cursor = flask_mongo.db.groups.find( + { + "$or": [ + {"_id": {"$in": user_group_ids}}, + {"group_admins": {"$in": [str(current_user.person.immutable_id)]}}, + ] + } + ) + + groups_data = [] + for group_doc in groups_cursor: + group_doc["immutable_id"] = str(group_doc["_id"]) + groups_data.append(json.loads(Group(**group_doc).json())) + + return jsonify({"status": "success", "data": groups_data}), 200 diff --git a/pydatalab/src/pydatalab/routes/v0_1/items.py b/pydatalab/src/pydatalab/routes/v0_1/items.py index f9d1c1d23..061c594d1 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -14,7 +14,6 @@ from pydatalab.logger import LOGGER from pydatalab.models import ITEM_MODELS from pydatalab.models.items import Item -from pydatalab.models.people import Person from pydatalab.models.relationships import RelationshipType from pydatalab.models.utils import generate_unique_refcode from pydatalab.mongo import ITEMS_FTS_FIELDS, flask_mongo @@ -23,6 +22,27 @@ ITEMS = Blueprint("items", __name__) +def groups_lookup_for_items() -> dict: + return { + "from": "groups", + "let": {"group_ids": "$group_ids"}, + "pipeline": [ + {"$match": {"$expr": {"$in": ["$_id", {"$ifNull": ["$$group_ids", []]}]}}}, + {"$addFields": {"__order": {"$indexOfArray": ["$$group_ids", "$_id"]}}}, + {"$sort": {"__order": 1}}, + { + "$project": { + "_id": 1, + "display_name": 1, + "group_id": 1, + "type": 1, + } + }, + ], + "as": "groups", + } + + @ITEMS.before_request @active_users_or_get_only def _(): ... @@ -540,6 +560,60 @@ def _create_sample( } ] + if "additional_creators" in sample_dict and sample_dict["additional_creators"]: + additional_creator_ids = [] + for creator in sample_dict["additional_creators"]: + if isinstance(creator, dict) and "immutable_id" in creator: + additional_creator_ids.append(ObjectId(creator["immutable_id"])) + elif isinstance(creator, str): + additional_creator_ids.append(ObjectId(creator)) + + new_sample["creator_ids"] = list(dict.fromkeys(new_sample["creator_ids"])) + + seen_creators = set() + unique_creators = [] + for creator in new_sample["creators"]: + creator_key = (creator.get("display_name", ""), creator.get("contact_email", "")) + if creator_key not in seen_creators: + seen_creators.add(creator_key) + unique_creators.append(creator) + new_sample["creators"] = unique_creators + + if "share_with_groups" in sample_dict and sample_dict["share_with_groups"]: + group_ids = [] + for group in sample_dict["share_with_groups"]: + if isinstance(group, dict) and "immutable_id" in group: + group_ids.append(ObjectId(group["immutable_id"])) + elif isinstance(group, str): + group_ids.append(ObjectId(group)) + + if not CONFIG.TESTING and current_user.is_authenticated: + user_group_ids = ( + [group.immutable_id for group in current_user.person.groups] + if current_user.person.groups + else [] + ) + + groups_where_admin = list( + flask_mongo.db.groups.find( + {"group_admins": {"$in": [str(current_user.person.immutable_id)]}}, {"_id": 1} + ) + ) + admin_group_ids = [g["_id"] for g in groups_where_admin] + allowed_group_ids = user_group_ids + admin_group_ids + + if not all(gid in allowed_group_ids for gid in group_ids): + return ( + dict( + status="error", + message="You can only share with groups you belong to or administer.", + item_id=new_sample["item_id"], + ), + 403, + ) + + new_sample["group_ids"] = group_ids + # Generate a unique refcode for the sample new_sample["refcode"] = generate_unique_refcode() if generate_id_automatically: @@ -710,91 +784,137 @@ def update_item_permissions(refcode: str): request_json = request.get_json() creator_ids: list[ObjectId] = [] + group_ids: list[ObjectId] = [] if len(refcode.split(":")) != 2: refcode = f"{CONFIG.IDENTIFIER_PREFIX}:{refcode}" current_item = flask_mongo.db.items.find_one( {"refcode": refcode, **get_default_permissions(user_only=True)}, - {"_id": 1, "creator_ids": 1}, - ) # type: ignore + {"_id": 1, "creator_ids": 1, "group_ids": 1}, + ) if not current_item: - return ( - jsonify( - { - "status": "error", - "message": f"No valid item found with the given {refcode=}.", - } - ), - 401, - ) + return jsonify( + { + "status": "error", + "message": f"No valid item found with the given {refcode=}.", + } + ), 401 current_creator_ids = current_item["creator_ids"] + current_group_ids = current_item.get("group_ids", []) - if "creators" in request_json: + if "creators" in request_json and request_json["creators"] is not None: creator_ids = [ ObjectId(creator.get("immutable_id", None)) for creator in request_json["creators"] if creator.get("immutable_id", None) is not None ] - if not creator_ids: - return ( - jsonify( + if not creator_ids: + return jsonify( { "status": "error", "message": "No valid creator IDs found in the request.", } - ), - 400, - ) + ), 400 - # Validate all creator IDs are present in the database - found_ids = [d for d in flask_mongo.db.users.find({"_id": {"$in": creator_ids}}, {"_id": 1})] # type: ignore - if len(found_ids) != len(creator_ids): - return ( - jsonify( + # Validate all creator IDs are present in the database + found_ids = [ + d for d in flask_mongo.db.users.find({"_id": {"$in": creator_ids}}, {"_id": 1}) + ] + if len(found_ids) != len(creator_ids): + return jsonify( { "status": "error", "message": "One or more creator IDs not found in the database.", } - ), - 400, - ) + ), 400 - # Make sure a user cannot remove their own access to an item - current_user_id = current_user.person.immutable_id - try: - creator_ids.remove(current_user_id) - except ValueError: - pass - creator_ids.insert(0, current_user_id) - - # The first ID in the creator list takes precedence; always make sure this is included to avoid orphaned items - if current_creator_ids: - base_owner = current_creator_ids[0] + # Make sure a user cannot remove their own access to an item + current_user_id = current_user.person.immutable_id try: - creator_ids.remove(base_owner) + creator_ids.remove(current_user_id) except ValueError: pass - creator_ids.insert(0, base_owner) + creator_ids.insert(0, current_user_id) + + # The first ID in the creator list takes precedence; always make sure this is included to avoid orphaned items + if current_creator_ids: + base_owner = current_creator_ids[0] + try: + creator_ids.remove(base_owner) + except ValueError: + pass + creator_ids.insert(0, base_owner) + + if "groups" in request_json: + group_ids = [ + ObjectId(group.get("immutable_id", None)) + for group in request_json["groups"] + if group.get("immutable_id", None) is not None + ] - if set(creator_ids) == set(current_creator_ids): - # Short circuit if the creator IDs are the same + if group_ids: + found_group_ids = [ + d for d in flask_mongo.db.groups.find({"_id": {"$in": group_ids}}, {"_id": 1}) + ] + if len(found_group_ids) != len(group_ids): + return jsonify( + { + "status": "error", + "message": "One or more group IDs not found in the database.", + } + ), 400 + + user_group_ids = ( + [group.immutable_id for group in current_user.person.groups] + if current_user.person.groups + else [] + ) + + groups_where_admin = list( + flask_mongo.db.groups.find( + {"group_admins": {"$in": [str(current_user.person.immutable_id)]}}, {"_id": 1} + ) + ) + admin_group_ids = [g["_id"] for g in groups_where_admin] + allowed_group_ids = user_group_ids + admin_group_ids + + if not all(gid in allowed_group_ids for gid in group_ids): + return jsonify( + { + "status": "error", + "message": "You can only share with groups you belong to or administer.", + } + ), 403 + + update_data = {} + if ( + "creators" in request_json + and request_json["creators"] is not None + and set(creator_ids) != set(current_creator_ids) + ): + update_data["creator_ids"] = creator_ids + + if "groups" in request_json and set(group_ids) != set(current_group_ids): + update_data["group_ids"] = group_ids + + if not update_data: return jsonify({"status": "success"}), 200 - LOGGER.warning("Setting permissions for item %s to %s", refcode, creator_ids) + LOGGER.warning("Setting permissions for item %s: %s", refcode, update_data) result = flask_mongo.db.items.update_one( {"refcode": refcode, **get_default_permissions(user_only=True)}, - {"$set": {"creator_ids": creator_ids}}, + {"$set": update_data}, ) if result.modified_count != 1: return jsonify( { "status": "error", - "message": "Failed to update permissions: you cannot remove yourself or the base owner as a creator.", + "message": "Failed to update permissions.", } ), 400 @@ -878,6 +998,7 @@ def get_item_data( {"$lookup": creators_lookup()}, {"$lookup": collections_lookup()}, {"$lookup": files_lookup()}, + {"$lookup": groups_lookup_for_items()}, ], ) @@ -1106,43 +1227,3 @@ def save_item(): ) return jsonify(status="success", last_modified=updated_data["last_modified"]), 200 - - -@ITEMS.route("/search-users/", methods=["GET"]) -def search_users(): - """Perform free text search on users and return the top results. - GET parameters: - query: String with the search terms. - nresults: Maximum number of (default 100) - - Returns: - response list of dictionaries containing the matching items in order of - descending match score. - """ - - query = request.args.get("query", type=str) - nresults = request.args.get("nresults", default=100, type=int) - types = request.args.get("types", default=None) - - match_obj = {"$text": {"$search": query}} - if types is not None: - match_obj["type"] = {"$in": types} - - cursor = flask_mongo.db.users.aggregate( - [ - {"$match": match_obj}, - {"$sort": {"score": {"$meta": "textScore"}}}, - {"$limit": nresults}, - { - "$project": { - "_id": 1, - "identities": 1, - "display_name": 1, - "contact_email": 1, - } - }, - ] - ) - return jsonify( - {"status": "success", "users": list(json.loads(Person(**d).json()) for d in cursor)} - ), 200 diff --git a/pydatalab/src/pydatalab/routes/v0_1/users.py b/pydatalab/src/pydatalab/routes/v0_1/users.py index 4d7e82dc8..e73dc6278 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/users.py +++ b/pydatalab/src/pydatalab/routes/v0_1/users.py @@ -1,11 +1,13 @@ +import json + from bson import ObjectId from flask import Blueprint, jsonify, request from flask_login import current_user from pydatalab.config import CONFIG -from pydatalab.models.people import DisplayName, EmailStr +from pydatalab.models.people import DisplayName, EmailStr, Person from pydatalab.mongo import flask_mongo -from pydatalab.permissions import active_users_or_get_only +from pydatalab.permissions import active_users_or_get_only, admin_only USERS = Blueprint("users", __name__) @@ -15,6 +17,82 @@ def _(): ... +@GROUPS.before_request +@admin_only +def _(): ... + + +@GROUPS.route("/groups", methods=["PUT"]) +def create_group(): + request_json = request.get_json() + + group_json = { + "group_id": request_json.get("group_id"), + "display_name": request_json.get("display_name"), + "description": request_json.get("description"), + "group_admins": request_json.get("group_admins"), + } + try: + group = Group(**group_json) + except Exception as e: + return jsonify({"status": "error", "message": f"Invalid group data: {str(e)}"}), 400 + + try: + group_immutable_id = flask_mongo.db.groups.insert_one(group.dict()).inserted_id + except pymongo.errors.DuplicateKeyError: + return jsonify( + {"status": "error", "message": f"Group ID {group.group_id} already exists."} + ), 400 + + if group_immutable_id: + return jsonify({"status": "success", "group_immutable_id": str(group_immutable_id)}), 200 + + return jsonify({"status": "error", "message": "Unable to create group."}), 400 + + +@GROUPS.route("/groups", methods=["DELETE"]) +def delete_group(): + request_json = request.get_json() + + group_id = request_json.get("immutable_id") + if group_id is not None: + result = flask_mongo.db.groups.delete_one({"_id": ObjectId(group_id)}) + + if result.deleted_count == 1: + return jsonify({"status": "success"}), 200 + + return jsonify({"status": "error", "message": "Unable to delete group."}), 400 + + +@GROUPS.route("/groups/", methods=["PATCH"]) +def add_user_to_group(group_immutable_id): + request_json = request.get_json() + + user_id = request_json.get("user_id") + + if not user_id: + return jsonify({"status": "error", "message": "No user ID provided."}), 400 + + client = _get_active_mongo_client() + with client.start_session(causal_consistency=True) as session: + group_exists = flask_mongo.db.groups.find_one( + {"_id": ObjectId(group_immutable_id)}, session=session + ) + if not group_exists: + return jsonify({"status": "error", "message": "Group does not exist."}), 400 + + update_user = flask_mongo.db.users.update_one( + {"_id": ObjectId(user_id)}, + {"$addToSet": {"groups": group_immutable_id}}, + session=session, + ) + + if not update_user.modified_count == 1: + return jsonify({"status": "error", "message": "Unable to add user to group."}), 400 + + return jsonify({"status": "error", "message": "Unable to add user to group."}), 400 + + @USERS.route("/users/", methods=["PATCH"]) def save_user(user_id): request_json = request.get_json() @@ -77,3 +155,43 @@ def save_user(user_id): ) return (jsonify({"status": "success"}), 200) + + +@USERS.route("/search-users/", methods=["GET"]) +def search_users(): + """Perform free text search on users and return the top results. + GET parameters: + query: String with the search terms. + nresults: Maximum number of (default 100) + + Returns: + response list of dictionaries containing the matching items in order of + descending match score. + """ + + query = request.args.get("query", type=str) + nresults = request.args.get("nresults", default=100, type=int) + types = request.args.get("types", default=None) + + match_obj = {"$text": {"$search": query}} + if types is not None: + match_obj["type"] = {"$in": types} + + cursor = flask_mongo.db.users.aggregate( + [ + {"$match": match_obj}, + {"$sort": {"score": {"$meta": "textScore"}}}, + {"$limit": nresults}, + { + "$project": { + "_id": 1, + "identities": 1, + "display_name": 1, + "contact_email": 1, + } + }, + ] + ) + return jsonify( + {"status": "success", "users": list(json.loads(Person(**d).json()) for d in cursor)} + ), 200 diff --git a/pydatalab/tests/server/test_users.py b/pydatalab/tests/server/test_users.py index 64eab34e8..e373ddf40 100644 --- a/pydatalab/tests/server/test_users.py +++ b/pydatalab/tests/server/test_users.py @@ -13,6 +13,7 @@ def test_get_current_user(client): assert (resp_json := resp.json) assert resp_json["immutable_id"] == 24 * "1" assert resp_json["role"] == "user" + assert resp_json["groups"] == [] def test_get_current_user_admin(admin_client): @@ -41,7 +42,25 @@ def test_role_update_by_user(client, real_mongo_client, user_id): assert user["role"] == "manager" -def test_user_update(client, real_mongo_client, user_id, admin_user_id): +def test_list_users(admin_client, client): + resp = admin_client.get("/users") + assert resp.status_code == 200 + resp = client.get("/users") + assert resp.status_code == 403 + + +def test_list_groups(admin_client, client): + resp = admin_client.get("/groups") + assert resp.status_code == 200 + + resp = client.get("/groups") + assert resp.status_code == 200 + + resp = client.get("/admin/groups") + assert resp.status_code == 403 + + +def test_user_update(client, unauthenticated_client, real_mongo_client, user_id, admin_user_id): endpoint = f"/users/{str(user_id)}" # Test display name update user_request = {"display_name": "Test Person II"} @@ -105,6 +124,16 @@ def test_user_update(client, real_mongo_client, user_id, admin_user_id): user = real_mongo_client.get_database().users.find_one({"_id": admin_user_id}) assert user["display_name"] == "Test Admin" + # Test that differing user auth can/cannot search for users + endpoint = "/search-users/" + resp = client.get(endpoint + "?query='Test Person'") + assert resp.status_code == 200 + assert len(resp.json["users"]) == 4 + + # Test that differing user auth can/cannot search for users + resp = unauthenticated_client.get(endpoint + "?query='Test Person'") + assert resp.status_code == 401 + def test_user_update_admin(admin_client, real_mongo_client, user_id): endpoint = f"/users/{str(user_id)}" @@ -114,3 +143,53 @@ def test_user_update_admin(admin_client, real_mongo_client, user_id): assert resp.status_code == 200 user = real_mongo_client.get_database().users.find_one({"_id": user_id}) assert user["display_name"] == "Test Person" + + +def test_create_group(admin_client, client, unauthenticated_client, real_mongo_client): + from bson import ObjectId + + good_group = { + "display_name": "My New Group", + "group_id": "my-new-group", + "description": "A group for testing", + "group_admins": [], + } + + # Group ID cannot be None + bad_group = good_group.copy() + bad_group["group_id"] = None + resp = admin_client.put("/groups", json=bad_group) + assert resp.status_code == 400 + + # Successfully create group + resp = admin_client.put("/groups", json=good_group) + assert resp.status_code == 200 + group_immutable_id = ObjectId(resp.json["group_immutable_id"]) + assert real_mongo_client.get_database().groups.find_one({"_id": group_immutable_id}) + + # Group ID must be unique + resp = admin_client.put("/groups", json=good_group) + assert resp.status_code == 400 + + # Request must come from admin + # Make ID unique so that this would otherwise pass + good_group["group_id"] = "my-new-group-2" + resp = unauthenticated_client.put("/groups", json=good_group) + assert resp.status_code == 401 + assert ( + real_mongo_client.get_database().groups.find_one({"group_id": good_group["group_id"]}) + is None + ) + + # Request must come from admin + resp = client.put("/groups", json=good_group) + assert resp.status_code == 403 + assert ( + real_mongo_client.get_database().groups.find_one({"group_id": good_group["group_id"]}) + is None + ) + + # Check a user can search groups + resp = client.get("/search/groups?query=New") + assert resp.status_code == 200 + assert len(resp.json["data"]) == 1 diff --git a/webapp/cypress/e2e/batchSampleFeature.cy.js b/webapp/cypress/e2e/batchSampleFeature.cy.js index 13c2e3183..bb0768733 100644 --- a/webapp/cypress/e2e/batchSampleFeature.cy.js +++ b/webapp/cypress/e2e/batchSampleFeature.cy.js @@ -11,22 +11,16 @@ function getSubmitButton() { return cy.get("[data-testid=batch-modal-container]").contains("Submit"); } -function getBatchAddCell(row, column, additionalSelectors = "") { - return cy.get( - `[data-testid=batch-add-table] > tbody > tr:nth-of-type(${row}) > td:nth-of-type(${column}) ${additionalSelectors}`, - ); +function getBatchAddCell(row, columnName, additionalSelectors = "") { + return cy.get(`[data-testid="item-${row - 1}-${columnName}"] ${additionalSelectors}`); } -function getBatchTemplateCell(column, additionalSelectors = "") { - return cy.get( - `[data-testid=batch-add-table-template] > tbody > tr > td:nth-of-type(${column}) ${additionalSelectors}`, - ); +function getBatchTemplateCell(columnName, additionalSelectors = "") { + return cy.get(`[data-testid="template-cell-${columnName}"] ${additionalSelectors}`); } function getBatchAddError(row, additionalSelectors = "") { - return cy.get( - `[data-testid=batch-add-table] > tbody > tr:nth-of-type(${row}) + td ${additionalSelectors}`, - ); + return cy.get(`[data-testid="item-${row - 1}-error"]${additionalSelectors}`); } // Any sample ID touched by these tests should be listed here for clean-up @@ -86,11 +80,11 @@ describe("Batch sample creation", () => { it("Adds 3 valid samples", () => { cy.contains("Add batch of items").click(); getSubmitButton().should("be.disabled"); - getBatchAddCell(1, 1).type("testA"); - getBatchAddCell(2, 1).type("testB"); - getBatchAddCell(2, 2).type("this sample has a name"); + getBatchAddCell(1, "id").type("testA"); + getBatchAddCell(2, "id").type("testB"); + getBatchAddCell(2, "name").type("this sample has a name"); getSubmitButton().should("be.disabled"); - getBatchAddCell(3, 1).type("testC"); + getBatchAddCell(3, "id").type("testC"); getSubmitButton().click(); cy.get("[data-testid=batch-modal-container]").contains("a", "testA"); @@ -111,10 +105,10 @@ describe("Batch sample creation", () => { cy.findByLabelText("Number of rows:").clear().type(2); cy.get('[data-testid="batch-modal-container"]').findByText("Submit").should("be.disabled"); - getBatchAddCell(1, 1).type("baseA"); - getBatchAddCell(2, 1).type("baseB"); - getBatchAddCell(2, 2).type("the name of baseB"); - getBatchAddCell(2, 3).type("1999-12-31T01:00"); + getBatchAddCell(1, "id").type("baseA"); + getBatchAddCell(2, "id").type("baseB"); + getBatchAddCell(2, "name").type("the name of baseB"); + getBatchAddCell(2, "date").type("1999-12-31T01:00"); getSubmitButton().click(); cy.get("[data-testid=batch-modal-container]").contains("a", "baseA"); cy.get("[data-testid=batch-modal-container]").contains("a", "baseB"); @@ -130,11 +124,11 @@ describe("Batch sample creation", () => { cy.findByLabelText("Number of rows:").clear().type(4); cy.get('[data-testid="batch-modal-container"]').findByText("Submit").should("be.disabled"); - getBatchAddCell(1, 1).type("component1"); - getBatchAddCell(1, 2).type("this component has a name that is quite long"); - getBatchAddCell(2, 1).type("component2"); - getBatchAddCell(3, 1).type("component3"); - getBatchAddCell(4, 1).type("component4"); + getBatchAddCell(1, "id").type("component1"); + getBatchAddCell(1, "name").type("this component has a name"); + getBatchAddCell(2, "id").type("component2"); + getBatchAddCell(3, "id").type("component3"); + getBatchAddCell(4, "id").type("component4"); getSubmitButton().click(); cy.get("[data-testid=batch-modal-container]").contains("a", "component1"); @@ -192,17 +186,17 @@ describe("Batch sample creation", () => { it("makes samples copied from others", () => { cy.contains("Add batch of items").click(); - getBatchAddCell(1, 1).type("baseA_copy"); - getBatchAddCell(1, 2).type("a copied sample"); - getBatchAddCell(1, 4, ".vs__search").type("BaseA"); + getBatchAddCell(1, "id").type("baseA_copy"); + getBatchAddCell(1, "name").type("a copied sample"); + getBatchAddCell(1, "copy-from", ".vs__search").type("BaseA"); cy.get(".vs__dropdown-menu").contains(".badge", "baseA").click(); - getBatchAddCell(2, 1).type("baseB_copy"); - getBatchAddCell(2, 4, ".vs__search").type("BaseB"); + getBatchAddCell(2, "id").type("baseB_copy"); + getBatchAddCell(2, "copy-from", ".vs__search").type("BaseB"); cy.get(".vs__dropdown-menu").contains(".badge", "baseB").click(); - getBatchAddCell(3, 1).type("baseB_copy2"); - getBatchAddCell(3, 4, ".vs__search").type("BaseB"); + getBatchAddCell(3, "id").type("baseB_copy2"); + getBatchAddCell(3, "copy-from", ".vs__search").type("BaseB"); cy.get(".vs__dropdown-menu").contains(".badge", "baseB").click(); getSubmitButton().click(); @@ -259,54 +253,54 @@ describe("Batch sample creation", () => { cy.findByLabelText("Number of rows:").clear().type(4); // sample with two components - getBatchAddCell(1, 1).type("test101"); - getBatchAddCell(1, 2).type("sample with two components"); - getBatchAddCell(1, 5, ".vs__search").type("component1"); + getBatchAddCell(1, "id").type("test101"); + getBatchAddCell(1, "name").type("sample with two components"); + getBatchAddCell(1, "components", ".vs__search").type("component1"); cy.get(".vs__dropdown-menu").contains(".badge", "component1").click(); - getBatchAddCell(1, 5, ".vs__search").type("component2"); + getBatchAddCell(1, "components", ".vs__search").type("component2"); cy.get(".vs__dropdown-menu").contains(".badge", "component2").click(); // sample with two components, copied from a sample with no components - getBatchAddCell(2, 1).type("test102"); - getBatchAddCell(2, 2).type( + getBatchAddCell(2, "id").type("test102"); + getBatchAddCell(2, "name").type( "sample with two components, copied from a sample with no components", ); - getBatchAddCell(2, 4, ".vs__search").type("baseA"); + getBatchAddCell(2, "copy-from", ".vs__search").type("baseA"); cy.get(".vs__dropdown-menu").contains(".badge", "baseA").click(); - getBatchAddCell(2, 5, ".vs__search").type("component1"); + getBatchAddCell(2, "components", ".vs__search").type("component1"); cy.get(".vs__dropdown-menu").contains(".badge", "component1").click(); - getBatchAddCell(2, 5, ".vs__search").type("component2"); + getBatchAddCell(2, "components", ".vs__search").type("component2"); cy.get(".vs__dropdown-menu").contains(".badge", "component2").click(); // sample with one components, copied from a sample with two components - getBatchAddCell(3, 1).type("test103"); - getBatchAddCell(3, 2).type( + getBatchAddCell(3, "id").type("test103"); + getBatchAddCell(3, "name").type( "sample with one component, copied from a sample with two components", ); - getBatchAddCell(3, 4, ".vs__search").type("baseB"); + getBatchAddCell(3, "copy-from", ".vs__search").type("baseB"); cy.get(".vs__dropdown-menu").contains(".badge", "baseB").click(); - getBatchAddCell(3, 5, ".vs__search").type("component1"); + getBatchAddCell(3, "components", ".vs__search").type("component1"); cy.get(".vs__dropdown-menu").contains(".badge", "component1").click(); // sample with three components, copied from a sample with some of the same components - getBatchAddCell(4, 1).type("test104"); - getBatchAddCell(4, 2).type( + getBatchAddCell(4, "id").type("test104"); + getBatchAddCell(4, "name").type( "sample with three components, copied from a sample with some of the same components", ); - getBatchAddCell(4, 4, ".vs__search").type("baseB"); + getBatchAddCell(4, "copy-from", ".vs__search").type("baseB"); cy.get(".vs__dropdown-menu").contains(".badge", "baseB").click(); - getBatchAddCell(4, 5, ".vs__search").type("component2"); + getBatchAddCell(4, "components", ".vs__search").type("component2"); cy.get(".vs__dropdown-menu").contains(".badge", "component2").click(); - getBatchAddCell(4, 5, ".vs__search").type("component3"); + getBatchAddCell(4, "components", ".vs__search").type("component3"); cy.get(".vs__dropdown-menu").contains(".badge", "component3").click(); - getBatchAddCell(4, 5, ".vs__search").type("component4"); + getBatchAddCell(4, "components", ".vs__search").type("component4"); cy.get(".vs__dropdown-menu").contains(".badge", "component4").click(); getSubmitButton().click(); @@ -419,13 +413,14 @@ describe("Batch sample creation", () => { it("uses the template id", () => { cy.contains("Add batch of items").click(); - getBatchTemplateCell(1).type("test_{{}#{}}"); + + getBatchTemplateCell("id", "input.form-control").type("test_{{}#{}}"); // manually type names and a date - getBatchAddCell(1, 2).type("testing 1"); - getBatchAddCell(2, 2).type("testing 1,2"); - getBatchAddCell(3, 2).type("testing 1,2,3"); - getBatchAddCell(1, 3).type("1992-12-10T14:34"); + getBatchAddCell(1, "name").type("testing 1"); + getBatchAddCell(2, "name").type("testing 1,2"); + getBatchAddCell(3, "name").type("testing 1,2,3"); + getBatchAddCell(1, "date").type("1992-12-10T14:34"); getSubmitButton().click(); @@ -444,9 +439,11 @@ describe("Batch sample creation", () => { it("uses the template id, name, and date", () => { cy.contains("Add batch of items").click(); - getBatchTemplateCell(1).type("test_{{}#{}}"); - getBatchTemplateCell(2).type("this is the test sample #{{}#{}}"); - getBatchTemplateCell(3).type("1980-02-01T05:35"); + getBatchTemplateCell("id", "input.form-control").type("test_{{}#{}}"); + + getBatchTemplateCell("name", "input.form-control").type("this is the test sample #{{}#{}}"); + + getBatchTemplateCell("date", "input.form-control").type("1980-02-01T05:35"); cy.findByLabelText("start counting {#} at:").clear().type(5); @@ -467,44 +464,44 @@ describe("Batch sample creation", () => { it("uses the template id, name, date, copyFrom, and components", () => { cy.contains("Add batch of items").click(); - getBatchTemplateCell(1).type("test_{{}#{}}"); - getBatchTemplateCell(2).type("this is the test sample #{{}#{}}"); - getBatchTemplateCell(3).type("1980-02-01T23:59"); + getBatchTemplateCell("id", "input.form-control").type("test_{{}#{}}"); + getBatchTemplateCell("name", "input.form-control").type("this is the test sample #{{}#{}}"); + getBatchTemplateCell("date", "input.form-control").type("1980-02-01T23:59"); // select copyFrom sample, check that it is applied correctly - getBatchTemplateCell(4, ".vs__search").type("baseA"); + getBatchTemplateCell("copy-from", ".vs__search").type("baseA"); cy.get(".vs__dropdown-menu").contains(".badge", "baseA").click(); - getBatchAddCell(1, 4).contains("baseA"); - getBatchAddCell(2, 4).contains("baseA"); - getBatchAddCell(3, 4).contains("baseA"); + getBatchAddCell(1, "copy-from").contains("baseA"); + getBatchAddCell(2, "copy-from").contains("baseA"); + getBatchAddCell(3, "copy-from").contains("baseA"); // change the copyFrom sample, check that it is applied correctly - getBatchTemplateCell(4, ".vs__search").type("baseB"); + getBatchTemplateCell("copy-from", ".vs__search").type("baseB"); cy.get(".vs__dropdown-menu").contains(".badge", "baseB").click(); - getBatchAddCell(1, 4).contains("baseB"); - getBatchAddCell(2, 4).contains("baseB"); - getBatchAddCell(3, 4).contains("baseB"); + getBatchAddCell(1, "copy-from").contains("baseB"); + getBatchAddCell(2, "copy-from").contains("baseB"); + getBatchAddCell(3, "copy-from").contains("baseB"); // add a component, check that it is applied correctly - getBatchTemplateCell(5, ".vs__search").type("component1"); + getBatchTemplateCell("components", ".vs__search").type("component1"); cy.get(".vs__dropdown-menu").contains(".badge", "component1").click(); - getBatchAddCell(1, 5).contains("component1"); - getBatchAddCell(2, 5).contains("component1"); - getBatchAddCell(3, 5).contains("component1"); + getBatchAddCell(1, "components").contains("component1"); + getBatchAddCell(2, "components").contains("component1"); + getBatchAddCell(3, "components").contains("component1"); // add another component, check that it is applied correctly - getBatchTemplateCell(5, ".vs__search").type("component2"); + getBatchTemplateCell("components", ".vs__search").type("component2"); cy.get(".vs__dropdown-menu").contains(".badge", "component2").click(); - getBatchAddCell(1, 5).contains("component1"); - getBatchAddCell(1, 5).contains("component2"); - getBatchAddCell(2, 5).contains("component1"); - getBatchAddCell(2, 5).contains("component2"); - getBatchAddCell(3, 5).contains("component1"); - getBatchAddCell(3, 5).contains("component2"); + getBatchAddCell(1, "components").contains("component1"); + getBatchAddCell(1, "components").contains("component2"); + getBatchAddCell(2, "components").contains("component1"); + getBatchAddCell(2, "components").contains("component2"); + getBatchAddCell(3, "components").contains("component1"); + getBatchAddCell(3, "components").contains("component2"); getSubmitButton().click(); @@ -554,19 +551,19 @@ describe("Batch sample creation", () => { cy.findByLabelText("Number of rows:").clear().type(0); cy.get("[data-testid=batch-add-table] > tbody > tr").should("have.length", 0); - getBatchTemplateCell(1).type("test{{}#{}}"); - getBatchTemplateCell(2).type("name{{}#{}}"); + getBatchTemplateCell("id", "input.form-control").type("test{{}#{}}"); + getBatchTemplateCell("name", "input.form-control").type("name{{}#{}}"); - getBatchTemplateCell(4, ".vs__search").type("baseB"); + getBatchTemplateCell("copy-from", ".vs__search").type("baseB"); cy.get(".vs__dropdown-menu").contains(".badge", "baseB").click(); - getBatchTemplateCell(5, ".vs__search").type("component1"); + getBatchTemplateCell("components", ".vs__search").type("component1"); cy.get(".vs__dropdown-menu").contains(".badge", "component1").click(); - getBatchTemplateCell(5, ".vs__search").type("component3"); + getBatchTemplateCell("components", ".vs__search").type("component3"); cy.get(".vs__dropdown-menu").contains(".badge", "component3").click(); - getBatchTemplateCell(5, ".vs__search").type("component4"); + getBatchTemplateCell("components", ".vs__search").type("component4"); cy.get(".vs__dropdown-menu").contains(".badge", "component4").click(); cy.findByLabelText("Number of rows:").clear().type(100); @@ -578,43 +575,43 @@ describe("Batch sample creation", () => { cy.findByLabelText("Number of rows:").clear().type(4); cy.get("[data-testid=batch-add-table] > tbody > tr").should("have.length", 4); - getBatchAddCell(1, 1, "input").should("have.value", "test1"); - getBatchAddCell(2, 1, "input").should("have.value", "test2"); - getBatchAddCell(3, 1, "input").should("have.value", "test3"); - getBatchAddCell(4, 1, "input").should("have.value", "test4"); + getBatchAddCell(1, "id", "input").should("have.value", "test1"); + getBatchAddCell(2, "id", "input").should("have.value", "test2"); + getBatchAddCell(3, "id", "input").should("have.value", "test3"); + getBatchAddCell(4, "id", "input").should("have.value", "test4"); - getBatchAddCell(1, 2, "input").should("have.value", "name1"); - getBatchAddCell(2, 2, "input").should("have.value", "name2"); - getBatchAddCell(3, 2, "input").should("have.value", "name3"); - getBatchAddCell(4, 2, "input").should("have.value", "name4"); + getBatchAddCell(1, "name", "input").should("have.value", "name1"); + getBatchAddCell(2, "name", "input").should("have.value", "name2"); + getBatchAddCell(3, "name", "input").should("have.value", "name3"); + getBatchAddCell(4, "name", "input").should("have.value", "name4"); - getBatchAddCell(1, 4).contains("baseB"); - getBatchAddCell(2, 4).contains("baseB"); - getBatchAddCell(3, 4).contains("baseB"); - getBatchAddCell(4, 4).contains("baseB"); + getBatchAddCell(1, "copy-from").contains("baseB"); + getBatchAddCell(2, "copy-from").contains("baseB"); + getBatchAddCell(3, "copy-from").contains("baseB"); + getBatchAddCell(4, "copy-from").contains("baseB"); - getBatchAddCell(1, 5).contains("component1"); - getBatchAddCell(2, 5).contains("component1"); - getBatchAddCell(3, 5).contains("component1"); - getBatchAddCell(4, 5).contains("component1"); + getBatchAddCell(1, "components").contains("component1"); + getBatchAddCell(2, "components").contains("component1"); + getBatchAddCell(3, "components").contains("component1"); + getBatchAddCell(4, "components").contains("component1"); - getBatchAddCell(1, 5).contains("component3"); - getBatchAddCell(2, 5).contains("component3"); - getBatchAddCell(3, 5).contains("component3"); - getBatchAddCell(4, 5).contains("component3"); + getBatchAddCell(1, "components").contains("component3"); + getBatchAddCell(2, "components").contains("component3"); + getBatchAddCell(3, "components").contains("component3"); + getBatchAddCell(4, "components").contains("component3"); - getBatchAddCell(1, 5).contains("component4"); - getBatchAddCell(2, 5).contains("component4"); - getBatchAddCell(3, 5).contains("component4"); - getBatchAddCell(4, 5).contains("component4"); + getBatchAddCell(1, "components").contains("component4"); + getBatchAddCell(2, "components").contains("component4"); + getBatchAddCell(3, "components").contains("component4"); + getBatchAddCell(4, "components").contains("component4"); cy.findByLabelText("Number of rows:").clear().type(10); cy.get("[data-testid=batch-add-table] > tbody > tr").should("have.length", 10); cy.findByLabelText("Number of rows:").type("{backspace}"); cy.get("[data-testid=batch-add-table] > tbody > tr").should("have.length", 1); - getBatchAddCell(1, 1, "input").should("have.value", "test1"); - getBatchAddCell(1, 2, "input").should("have.value", "name1"); + getBatchAddCell(1, "id", "input").should("have.value", "test1"); + getBatchAddCell(1, "name", "input").should("have.value", "name1"); cy.findByLabelText("Number of rows:").clear().type(2); @@ -656,9 +653,11 @@ describe("Batch sample creation", () => { it("checks errors on the row", () => { cy.contains("Add batch of items").click(); - getBatchTemplateCell("1").type("test10{{}#{}}"); + getBatchTemplateCell("id", "input.form-control").type("test10{{}#{}}"); + cy.wait(100); getSubmitButton().should("be.disabled"); + getBatchAddError(1).should("have.text", "test101 already in use."); getBatchAddError(2).should("have.text", "test102 already in use."); getBatchAddError(3).should("have.text", "test103 already in use."); @@ -667,32 +666,32 @@ describe("Batch sample creation", () => { getSubmitButton().should("be.disabled"); getBatchAddError(4).should("have.text", "test104 already in use."); - getBatchAddCell(1, 1).type("_unique"); - getBatchTemplateCell(1, "input").should("have.value", ""); // test_id template should be cleared by modifying an item_id - getBatchAddError(1).invoke("text").invoke("trim").should("equal", ""); // expect no error for this row + getBatchAddCell(1, "id").type("_unique"); + getBatchTemplateCell("id", "input").should("have.value", ""); // test_id template should be cleared by modifying an item_id + getBatchAddError(1).should("not.exist"); // expect no error for this row getSubmitButton().should("be.disabled"); // but submit is still disabled because there are still errors - getBatchAddCell(3, 1).type("_unique"); - getBatchAddError(1).invoke("text").invoke("trim").should("equal", ""); // expect no error - getBatchAddError(3).invoke("text").invoke("trim").should("equal", ""); // expect no error + getBatchAddCell(3, "id").type("_unique"); + getBatchAddError(1).should("not.exist"); // expect no error + getBatchAddError(3).should("not.exist"); // expect no error - getBatchAddCell(2, 1).type("_unique"); - getBatchAddError(1).invoke("text").invoke("trim").should("equal", ""); // expect no error - getBatchAddError(2).invoke("text").invoke("trim").should("equal", ""); // expect no error - getBatchAddError(3).invoke("text").invoke("trim").should("equal", ""); // expect no error + getBatchAddCell(2, "id").type("_unique"); + getBatchAddError(1).should("not.exist"); // expect no error + getBatchAddError(2).should("not.exist"); // expect no error + getBatchAddError(3).should("not.exist"); // expect no error - getBatchAddCell(2, 3).type("2000-01-01T10:05"); + getBatchAddCell(2, "date").type("2000-01-01T10:05"); - getBatchAddCell(4, 1).clear(); + getBatchAddCell(4, "id").clear(); getBatchAddError(4).invoke("text").invoke("trim").should("not.equal", ""); // expect some error - getBatchAddCell(4, 1).type("test101_unique"); + getBatchAddCell(4, "id").type("test101_unique"); getBatchAddError(4).invoke("text").invoke("trim").should("not.equal", ""); // expect some error getSubmitButton().should("be.disabled"); - getBatchAddCell(4, 1).type("2"); - getBatchAddError(4).invoke("text").invoke("trim").should("equal", ""); // expect no error + getBatchAddCell(4, "id").type("2"); + getBatchAddError(4).should("not.exist"); // expect no error getSubmitButton().should("not.be.disabled"); // now all errors are fixed so submit is enabled getSubmitButton().click(); @@ -724,18 +723,18 @@ describe("Batch cell creation", () => { cy.get("[data-testid=batch-add-table] > tbody > tr").should("have.length", 4); getSubmitButton().should("be.disabled"); - getBatchAddCell(1, 1, "input").type("cell_A"); + getBatchAddCell(1, "id", "input").type("cell_A"); // set positive electrode for the first cell - getBatchAddCell(1, 5, "input.vs__search").eq(0).type("abcdef"); + getBatchAddCell(1, "components", "input.vs__search").eq(0).type("abcdef"); cy.get(".vs__dropdown-menu").contains("abcdef").click(); - getBatchAddCell(2, 1, "input").type("cell_B"); - getBatchAddCell(2, 2, "input").type("this cell has a name"); + getBatchAddCell(2, "id", "input").type("cell_B"); + getBatchAddCell(2, "name", "input").type("this cell has a name"); getSubmitButton().should("be.disabled"); - getBatchAddCell(3, 1, "input").type("cell_C"); - getBatchAddCell(3, 3, "input").type("2017-06-01T08:30"); - getBatchAddCell(4, 1, "input").type("cell_D"); + getBatchAddCell(3, "id", "input").type("cell_C"); + getBatchAddCell(3, "date", "input").type("2017-06-01T08:30"); + getBatchAddCell(4, "id", "input").type("cell_D"); getSubmitButton().click(); @@ -749,9 +748,9 @@ describe("Batch cell creation", () => { cy.contains("Add batch of items").click(); cy.findByLabelText("Number of rows:").clear().type(2); - getBatchAddCell(1, 1).type("comp1"); - getBatchAddCell(1, 2).type("comp1 name"); - getBatchAddCell(2, 1).type("comp2"); + getBatchAddCell(1, "id").type("comp1"); + getBatchAddCell(1, "name").type("comp1 name"); + getBatchAddCell(2, "id").type("comp2"); getSubmitButton().click(); cy.get("[data-testid=batch-modal-container]").contains("a", "comp1"); @@ -763,58 +762,58 @@ describe("Batch cell creation", () => { cy.get("[data-testid=batch-modal-container]").findByLabelText("Type:").select("cell"); - getBatchTemplateCell(1, "input").eq(0).type("cell_{{}#{}}"); - getBatchTemplateCell(2, "input").type("this is the test cell #{{}#{}}"); - getBatchTemplateCell(3, "input").type("1980-02-01T23:59"); + getBatchTemplateCell("id", "input.form-control").eq(0).type("cell_{{}#{}}"); + getBatchTemplateCell("name", "input.form-control").type("this is the test cell #{{}#{}}"); + getBatchTemplateCell("date", "input.form-control").type("1980-02-01T23:59"); // select copyFrom sample, check that it is applied correctly - getBatchTemplateCell(4, ".vs__search").type("cell_B"); + getBatchTemplateCell("copy-from", ".vs__search").type("cell_B"); cy.get(".vs__dropdown-menu").contains(".badge", "cell_B").click(); - getBatchAddCell(1, 4).contains("cell_B"); - getBatchAddCell(2, 4).contains("cell_B"); - getBatchAddCell(3, 4).contains("cell_B"); + getBatchAddCell(1, "copy-from").contains("cell_B"); + getBatchAddCell(2, "copy-from").contains("cell_B"); + getBatchAddCell(3, "copy-from").contains("cell_B"); // change the copyFrom sample, check that it is applied correctly - getBatchTemplateCell(4, ".vs__search").type("cell_A"); + getBatchTemplateCell("copy-from", ".vs__search").type("cell_A"); cy.get(".vs__dropdown-menu").contains(".badge", "cell_A").click(); - getBatchAddCell(1, 4).contains("cell_A"); - getBatchAddCell(2, 4).contains("cell_A"); - getBatchAddCell(3, 4).contains("cell_A"); + getBatchAddCell(1, "copy-from").contains("cell_A"); + getBatchAddCell(2, "copy-from").contains("cell_A"); + getBatchAddCell(3, "copy-from").contains("cell_A"); // add a positive electrode, check that it is applied correctly - getBatchTemplateCell(5, ".vs__search").eq(0).type("comp1"); + getBatchTemplateCell("components", ".vs__search").eq(0).type("comp1"); cy.get(".vs__dropdown-menu").contains(".badge", "comp1").click(); - getBatchAddCell(1, 5).contains("comp1"); - getBatchAddCell(2, 5).contains("comp1"); - getBatchAddCell(3, 5).contains("comp1"); + getBatchAddCell(1, "components").contains("comp1"); + getBatchAddCell(2, "components").contains("comp1"); + getBatchAddCell(3, "components").contains("comp1"); // add another component, this one tagged (i.e., not in the db) check that it is applied correctly - getBatchTemplateCell(5, ".vs__search").eq(0).type("tagged"); + getBatchTemplateCell("components", ".vs__search").eq(0).type("tagged"); cy.get(".vs__dropdown-menu").eq(0).contains("tagged").click(); - getBatchAddCell(1, 5).contains("comp1"); - getBatchAddCell(1, 5).contains("tagged"); - getBatchAddCell(2, 5).contains("comp1"); - getBatchAddCell(2, 5).contains("tagged"); - getBatchAddCell(3, 5).contains("comp1"); - getBatchAddCell(3, 5).contains("tagged"); + getBatchAddCell(1, "components").contains("comp1"); + getBatchAddCell(1, "components").contains("tagged"); + getBatchAddCell(2, "components").contains("comp1"); + getBatchAddCell(2, "components").contains("tagged"); + getBatchAddCell(3, "components").contains("comp1"); + getBatchAddCell(3, "components").contains("tagged"); // add electrolyte - getBatchTemplateCell(5, ".vs__search").eq(1).type("elyte"); + getBatchTemplateCell("components", ".vs__search").eq(1).type("elyte"); cy.get(".vs__dropdown-menu").eq(0).contains("elyte").click(); - getBatchAddCell(1, 5).contains("elyte"); - getBatchAddCell(2, 5).contains("elyte"); - getBatchAddCell(3, 5).contains("elyte"); + getBatchAddCell(1, "components").contains("elyte"); + getBatchAddCell(2, "components").contains("elyte"); + getBatchAddCell(3, "components").contains("elyte"); // add negative electrode - getBatchTemplateCell(5, ".vs__search").eq(2).type("comp2"); + getBatchTemplateCell("components", ".vs__search").eq(2).type("comp2"); cy.get(".vs__dropdown-menu").eq(0).contains(".badge", "comp2").click(); - getBatchAddCell(1, 5).contains("comp2"); - getBatchAddCell(2, 5).contains("comp2"); - getBatchAddCell(3, 5).contains("comp2"); + getBatchAddCell(1, "components").contains("comp2"); + getBatchAddCell(2, "components").contains("comp2"); + getBatchAddCell(3, "components").contains("comp2"); getSubmitButton().click(); cy.get("[data-testid=batch-modal-container]").contains("a", "cell_1"); diff --git a/webapp/src/components/AdminDisplay.vue b/webapp/src/components/AdminDisplay.vue index c9905f2c6..880e92f95 100644 --- a/webapp/src/components/AdminDisplay.vue +++ b/webapp/src/components/AdminDisplay.vue @@ -1,21 +1,52 @@ diff --git a/webapp/src/components/BatchCreateItemModal.vue b/webapp/src/components/BatchCreateItemModal.vue index a3a3593c7..28a2144f9 100644 --- a/webapp/src/components/BatchCreateItemModal.vue +++ b/webapp/src/components/BatchCreateItemModal.vue @@ -76,16 +76,18 @@ - - - - - + + + + + + + - - - - - + +
IDNameDateCopy fromComponentsIDNameDateCopy fromComponentsGroupsCreators
+ + + + +
+ + + +
@@ -200,18 +218,20 @@ - - - - - - + + + + + + + +
IDNameDateCopy fromComponentsIDNameDateCopy fromComponentsGroupsCreators
@@ -340,6 +380,8 @@ + + diff --git a/webapp/src/components/CreateItemModal.vue b/webapp/src/components/CreateItemModal.vue index f31f139ce..7cb84af72 100644 --- a/webapp/src/components/CreateItemModal.vue +++ b/webapp/src/components/CreateItemModal.vue @@ -98,6 +98,24 @@ :is="itemCreateModalAddonComponent" @starting-data-callback="(callback) => (startingDataCallback = callback)" /> +
+
+ + +
+
+ + +
+
@@ -106,6 +124,8 @@ + + diff --git a/webapp/src/components/GroupSelect.vue b/webapp/src/components/GroupSelect.vue new file mode 100644 index 000000000..871f7e4b0 --- /dev/null +++ b/webapp/src/components/GroupSelect.vue @@ -0,0 +1,104 @@ + + + diff --git a/webapp/src/components/GroupTable.vue b/webapp/src/components/GroupTable.vue new file mode 100644 index 000000000..bdb0f327d --- /dev/null +++ b/webapp/src/components/GroupTable.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/webapp/src/components/SampleInformation.vue b/webapp/src/components/SampleInformation.vue index 25f18135c..06ca67af6 100644 --- a/webapp/src/components/SampleInformation.vue +++ b/webapp/src/components/SampleInformation.vue @@ -23,13 +23,19 @@
-
+
-
+
+ +
+
+ +
+
@@ -38,6 +44,8 @@ v-model="Collections" :item_id="item_id" /> +
+
@@ -64,6 +72,7 @@ import ChemFormulaInput from "@/components/ChemFormulaInput"; import FormattedRefcode from "@/components/FormattedRefcode"; import ToggleableCollectionFormGroup from "@/components/ToggleableCollectionFormGroup"; import ToggleableCreatorsFormGroup from "@/components/ToggleableCreatorsFormGroup"; +import ToggleableGroupsFormGroup from "@/components/ToggleableGroupsFormGroup"; import TinyMceInline from "@/components/TinyMceInline"; import SynthesisInformation from "@/components/SynthesisInformation"; import TableOfContents from "@/components/TableOfContents"; @@ -79,6 +88,7 @@ export default { FormattedRefcode, ToggleableCollectionFormGroup, ToggleableCreatorsFormGroup, + ToggleableGroupsFormGroup, }, props: { item_id: { type: String, required: true }, @@ -104,6 +114,7 @@ export default { ChemForm: createComputedSetterForItemField("chemform"), DateCreated: createComputedSetterForItemField("date"), ItemCreators: createComputedSetterForItemField("creators"), + ItemGroups: createComputedSetterForItemField("groups"), Collections: createComputedSetterForItemField("collections"), }, }; diff --git a/webapp/src/components/ToggleableGroupsFormGroup.vue b/webapp/src/components/ToggleableGroupsFormGroup.vue new file mode 100644 index 000000000..1673c3157 --- /dev/null +++ b/webapp/src/components/ToggleableGroupsFormGroup.vue @@ -0,0 +1,106 @@ + + + diff --git a/webapp/src/components/UserTable.vue b/webapp/src/components/UserTable.vue index 4e4610a2c..fd499a849 100644 --- a/webapp/src/components/UserTable.vue +++ b/webapp/src/components/UserTable.vue @@ -9,7 +9,7 @@ - + {{ user.display_name }} @@ -33,7 +33,7 @@