diff --git a/pydatalab/schemas/cell.json b/pydatalab/schemas/cell.json index a41903da4..b1316261a 100644 --- a/pydatalab/schemas/cell.json +++ b/pydatalab/schemas/cell.json @@ -298,6 +298,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.", @@ -354,6 +412,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", diff --git a/pydatalab/schemas/equipment.json b/pydatalab/schemas/equipment.json index d5bd7eeb1..b49632da8 100644 --- a/pydatalab/schemas/equipment.json +++ b/pydatalab/schemas/equipment.json @@ -253,6 +253,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.", @@ -309,6 +367,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 35cb3fc64..a1c1d5bc1 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -257,6 +257,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.", @@ -313,6 +371,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index ad1446b3a..d3fa2a36e 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -311,6 +311,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.", @@ -367,6 +425,13 @@ "type": "string", "format": "email" }, + "groups": { + "title": "Groups", + "type": "array", + "items": { + "$ref": "#/definitions/Group" + } + }, "managers": { "title": "Managers", "type": "array", diff --git a/pydatalab/src/pydatalab/login.py b/pydatalab/src/pydatalab/login.py index ff5246da2..ff1242b78 100644 --- a/pydatalab/src/pydatalab/login.py +++ b/pydatalab/src/pydatalab/login.py @@ -10,7 +10,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 @@ -68,6 +68,11 @@ def identities(self) -> List[Identity]: """Returns the list of identities of the user.""" return 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 + @property def identity_types(self) -> List[IdentityType]: """Returns a list of the identity types associated with the user.""" @@ -88,6 +93,20 @@ def get_by_id_cached(user_id): return get_by_id(user_id) +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}}, + ], + "as": "groups", + } + + def get_by_id(user_id: str) -> Optional[LoginUser]: """Lookup the user database ID and create a new `LoginUser` with the relevant metadata. diff --git a/pydatalab/src/pydatalab/models/people.py b/pydatalab/src/pydatalab/models/people.py index 8fb181695..3f6080fd9 100644 --- a/pydatalab/src/pydatalab/models/people.py +++ b/pydatalab/src/pydatalab/models/people.py @@ -7,7 +7,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): @@ -98,6 +98,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: Optional[str] + """A description of the group""" + + group_admins: Optional[List[PyObjectId]] + """A list of user IDs that can manage this group.""" + + class Person(Entry): """A model that describes an individual and their digital identities.""" @@ -113,6 +137,9 @@ class Person(Entry): contact_email: Optional[EmailStr] """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: Optional[List[PyObjectId]] """A list of user IDs that can manage this person's items.""" @@ -169,3 +196,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/mongo.py b/pydatalab/src/pydatalab/mongo.py index d0195a97f..16453c65b 100644 --- a/pydatalab/src/pydatalab/mongo.py +++ b/pydatalab/src/pydatalab/mongo.py @@ -192,4 +192,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/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..167e91bb1 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.mongo import flask_mongo -from pydatalab.permissions import admin_only, get_default_permissions +from pydatalab.models.people import Group, User +from pydatalab.mongo import _get_active_mongo_client, flask_mongo +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}}, + ], + "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,85 @@ def save_role(user_id): ) return (jsonify({"status": "success"}), 200) + + +@ADMIN.route("/groups", methods=["GET"]) +def get_groups(): + return jsonify( + { + "status": "success", + "data": [json.loads(Group(**d).json()) for d in flask_mongo.db.groups.find()], + } + ), 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=["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 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..8b3aa1376 --- /dev/null +++ b/pydatalab/src/pydatalab/routes/v0_1/groups.py @@ -0,0 +1,46 @@ +import json + +from flask import Blueprint, jsonify, request + +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 diff --git a/pydatalab/src/pydatalab/routes/v0_1/items.py b/pydatalab/src/pydatalab/routes/v0_1/items.py index 4d8819aa7..ae94ac8b1 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -13,7 +13,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 flask_mongo @@ -981,43 +980,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 02c87732b..1c0dd1f08 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/users.py +++ b/pydatalab/src/pydatalab/routes/v0_1/users.py @@ -1,14 +1,22 @@ +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 USERS = Blueprint("users", __name__) +@USERS.before_request +@active_users_or_get_only +def _(): ... + + @USERS.route("/users/", methods=["PATCH"]) def save_user(user_id): request_json = request.get_json() @@ -71,3 +79,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..0704078ed 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,21 @@ 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 == 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 +120,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 +139,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/src/components/AdminDisplay.vue b/webapp/src/components/AdminDisplay.vue index c9905f2c6..eb79901dd 100644 --- a/webapp/src/components/AdminDisplay.vue +++ b/webapp/src/components/AdminDisplay.vue @@ -1,15 +1,17 @@ + + 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 @@