From f2625ed8efde6146f46e17aa752de93966e2321c Mon Sep 17 00:00:00 2001 From: Etienne Boileau Date: Wed, 15 May 2024 17:26:53 +0200 Subject: [PATCH] WIP project/permission --- server/src/scimodom/app.py | 27 ++++++- server/src/scimodom/plugins/cli.py | 81 ++++++++++++++++--- server/src/scimodom/services/permission.py | 18 ++++- server/src/scimodom/services/project.py | 73 ++++++++++++++--- server/tests/unit/services/test_permission.py | 45 +++++++++++ server/tests/unit/services/test_project.py | 31 +++++-- 6 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 server/tests/unit/services/test_permission.py diff --git a/server/src/scimodom/app.py b/server/src/scimodom/app.py index 1eacc27e..2340b281 100644 --- a/server/src/scimodom/app.py +++ b/server/src/scimodom/app.py @@ -19,6 +19,7 @@ add_annotation, add_assembly, add_project, + add_user_to_project, add_dataset, add_all, validate_dataset_title, @@ -113,12 +114,34 @@ def annotation(id): "project", epilog="Check docs at https://dieterich-lab.github.io/scimodom/." ) @click.argument("template", type=click.Path(exists=True)) - def project(template): + @click.option( + "--skip-add-user", + is_flag=True, + show_default=True, + default=False, + help="Do not add user to project.", + ) + def project(template, skip_add_user): """Add a new project to the database. TEMPLATE is the path to a project template (json). """ - add_project(template) + add_user = not skip_add_user + add_project(template, add_user=add_user) + + @app.cli.command( + "permission", epilog="Check docs at https://dieterich-lab.github.io/scimodom/." + ) + @click.argument("username", type=click.STRING) + @click.argument("smid", type=click.STRING) + def permission(username, smid): + """Force add a user to a project. + + \b + USERNAME is the user email. + SMID is the project ID to which this user is to be associated. + """ + add_user_to_project(username, smid) @app.cli.command( "dataset", epilog="Check docs at https://dieterich-lab.github.io/scimodom/." diff --git a/server/src/scimodom/plugins/cli.py b/server/src/scimodom/plugins/cli.py index 657ed9ed..593d5973 100644 --- a/server/src/scimodom/plugins/cli.py +++ b/server/src/scimodom/plugins/cli.py @@ -5,10 +5,12 @@ import traceback import click +from sqlalchemy import select from scimodom.config import Config from scimodom.database.database import get_session from scimodom.database.models import ( + Project, Dataset, Modification, DetectionTechnology, @@ -18,9 +20,10 @@ import scimodom.database.queries as queries from scimodom.services.annotation import AnnotationService from scimodom.services.assembly import AssemblyService -from scimodom.services.project import ProjectService +from scimodom.services.project import get_project_service from scimodom.services.dataset import DataService from scimodom.services.setup import get_setup_service +from scimodom.services.user import get_user_service, NoSuchUser import scimodom.utils.utils as utils @@ -97,14 +100,15 @@ def add_annotation(annotation_id: int) -> None: session.close() -def add_project(project_template: str | Path) -> None: +def add_project(project_template: str | Path, add_user: bool = True) -> None: """Provides a CLI function to add a new project. :param project_template: Path to a json file with require fields. :type project_template: str or Path + :param add_user: Associate user and newly created project + :type add_user: bool """ - session = get_session() # load project metadata project = json.load(open(project_template)) # add project @@ -115,13 +119,72 @@ def add_project(project_template: str | Path) -> None: c = click.getchar() if c not in ["y", "Y"]: return - service = ProjectService(session, project) - service.create_project() + project_service = get_project_service() + project_service.create_project(project) click.secho( - f"Successfully created. The SMID for this project is {service.get_smid()}.", + f"Successfully created. The SMID for this project is {project_service.get_smid()}.", fg="green", ) - session.close() + if add_user: + username = project["contact_email"] + click.secho(f"Adding user {username} to newly created project...", fg="green") + click.secho("Continue [y/n]?", fg="green") + c = click.getchar() + if c not in ["y", "Y"]: + return + user_service = get_user_service() + try: + user = user_service.get_user_by_email(username) + except NoSuchUser: + click.secho( + f"Unknown user {username}... Aborting!", + fg="red", + ) + else: + project_service.associate_project_to_user(user) + click.secho( + "Successfully added user to project.", + fg="green", + ) + + +def add_user_to_project(username: str, smid: str) -> None: + """Provides a CLI function to force add a user to a project. + + :param username: Username (email) + :type username: str + :param smid: SMID + :type smid: str + """ + session = get_session() + click.secho(f"Adding user {username} to {smid}...", fg="green") + click.secho("Continue [y/n]?", fg="green") + c = click.getchar() + if c not in ["y", "Y"]: + return + project_service = get_project_service() + user_service = get_user_service() + try: + user = user_service.get_user_by_email(username) + except NoSuchUser: + click.secho( + f"Unknown user {username}... Aborting!", + fg="red", + ) + else: + query = select(Project.id) + smids = session.execute(query).scalars().all() + if smid not in smids: + click.secho( + f"Unrecognised SMID {smid}... Aborting!", + fg="red", + ) + return + project_service.associate_project_to_user(user, smid=smid) + click.secho( + "Successfully added user to project.", + fg="green", + ) def add_dataset( @@ -210,8 +273,8 @@ def add_all(directory: Path, templates: list[str]) -> None: project_title = project["title"] click.secho(f"Adding {project_title}...", fg="green") try: - project_service = ProjectService(session, project) - project_service.create_project() + project_service = get_project_service() + project_service.create_project(project) smid = project_service.get_smid() metadata = _get_dataset(project, extra_cols["file"]) for filen, meta in metadata.items(): diff --git a/server/src/scimodom/services/permission.py b/server/src/scimodom/services/permission.py index 5da18378..60f7bb29 100644 --- a/server/src/scimodom/services/permission.py +++ b/server/src/scimodom/services/permission.py @@ -9,7 +9,7 @@ class PermissionService: def __init__(self, session: Session): - self._db_session = session + self._session = session def may_change_dataset(self, user: User, dataset: Dataset) -> bool: query = select(UserProjectAssociation).where( @@ -18,9 +18,23 @@ def may_change_dataset(self, user: User, dataset: Dataset) -> bool: UserProjectAssociation.project_id == dataset.project_id, ) ) - results = self._db_session.execute(query).fetchall() + results = self._session.execute(query).fetchall() return len(results) > 0 + def insert_into_user_project_association(self, user: User, project_id: str) -> None: + """Insert values into table. + + :param user: User + :type user: User + :param project_id: SMID. There is no check + on the validity of this value, this must be done + before calling this function. + :type project_id: str + """ + permission = UserProjectAssociation(user_id=user.id, project_id=project_id) + self._session.add(permission) + self._session.commit() + _cached_permission_service: Optional[PermissionService] = None diff --git a/server/src/scimodom/services/project.py b/server/src/scimodom/services/project.py index 59849f4c..a08ed04a 100644 --- a/server/src/scimodom/services/project.py +++ b/server/src/scimodom/services/project.py @@ -4,12 +4,13 @@ import json import logging from pathlib import Path -from typing import ClassVar +from typing import ClassVar, Optional from sqlalchemy.orm import Session from sqlalchemy import select, func from scimodom.config import Config +from scimodom.database.database import get_session from scimodom.database.models import ( Project, ProjectSource, @@ -18,10 +19,12 @@ DetectionTechnology, Organism, Selection, + User, ) import scimodom.database.queries as queries from scimodom.services.annotation import AnnotationService from scimodom.services.assembly import AssemblyService +from scimodom.services.permission import get_permission_service import scimodom.utils.specifications as specs import scimodom.utils.utils as utils @@ -35,12 +38,10 @@ class DuplicateProjectError(Exception): class ProjectService: - """Utility class to create a project. + """Utility class to create/manage a project. :param session: SQLAlchemy ORM session :type session: Session - :param project: Project description (json template) - :type project: dict :param SMID_LENGTH: Length of Sci-ModoM ID (SMID) :type SMID_LENGTH: int :param DATA_PATH: Data path @@ -54,14 +55,15 @@ class ProjectService: DATA_SUB_PATH: ClassVar[str] = "metadata" DATA_SUB_PATH_SUB: ClassVar[str] = "project_requests" - def __init__(self, session: Session, project: dict) -> None: + def __init__(self, session: Session) -> None: """Initializer method.""" self._session = session - self._project = project + + self._project: dict self._smid: str self._assemblies: set[tuple[int, str]] = set() - def __new__(cls, session: Session, project: dict): + def __new__(cls, session: Session): """Constructor method.""" if cls.DATA_PATH is None: msg = "Missing environment variable: DATA_PATH. Terminating!" @@ -121,8 +123,16 @@ def create_project_request(project: dict): json.dump(project, f, indent="\t") return uuid - def create_project(self, wo_assembly: bool = False) -> None: - """Project constructor.""" + def create_project(self, project: dict, wo_assembly: bool = False) -> None: + """Project constructor. + + :param project: Project description (json template) + :type project: dict + :param wo_assembly: Skip assembly set up + :type wo_assembly: bool + """ + self._project = project + self._validate_keys() self._validate_entry() self._add_selection() @@ -141,13 +151,39 @@ def create_project(self, wo_assembly: bool = False) -> None: session=self._session, taxa_id=taxid ).create_annotation() + def associate_project_to_user(self, user: User, smid: str | None = None): + """Associate a project to a user. + When called after project creation, the SMID is + available (default), else nothing is done, unless + it is passed as argument. + + :param smid: SMID. There is no check + on the validity of this value, this must be done + before calling this function. + :type smid: str + """ + if not smid: + try: + smid = self._smid + except AttributeError: + msg = "Undefined SMID. Nothing will be done." + logger.warning(msg) + return + permission_service = get_permission_service() + permission_service.insert_into_user_project_association(user, smid) + def get_smid(self) -> str: - """Return newly created SMID. + """Return newly created SMID, else + raises a ValueError. :returns: SMID :rtype: str """ - return self._smid + try: + return self._smid + except AttributeError: + msg = "Undefined SMID. This is only defined when creating a project." + raise AttributeError(msg) def _validate_keys(self) -> None: """Validate keys from project description (dictionary).""" @@ -353,3 +389,18 @@ def _write_metadata(self) -> None: parent = Path(self.DATA_PATH, self.DATA_SUB_PATH) with open(Path(parent, f"{self._smid}.json"), "w") as f: json.dump(self._project, f, indent="\t") + + +_cached_project_service: Optional[ProjectService] = None + + +def get_project_service(): + """Helper function to set up a ProjectService object by injecting its dependencies. + + :returns: Project service instance + :rtype: ProjectService + """ + global _cached_project_service + if _cached_project_service is None: + _cached_project_service = ProjectService(session=get_session()) + return _cached_project_service diff --git a/server/tests/unit/services/test_permission.py b/server/tests/unit/services/test_permission.py new file mode 100644 index 00000000..b7ac3b18 --- /dev/null +++ b/server/tests/unit/services/test_permission.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone + +import pytest +from sqlalchemy import select + +from scimodom.services.permission import PermissionService +from scimodom.database.models import ( + User, + UserState, + Project, + ProjectContact, + UserProjectAssociation, +) + + +def test_insert_user_project_association(Session): + stamp = datetime.now(timezone.utc).replace(microsecond=0) + with Session() as session, session.begin(): + contact = ProjectContact( + contact_name="contact_name", + contact_institution="contact_institution", + contact_email="contact@email", + ) + user = User(email="contact@email", state=UserState.active, password_hash="xxx") + session.add_all([contact, user]) + session.flush() + contact_id = contact.id + project = Project( + id="12345678", + title="title", + summary="summary", + contact_id=contact_id, + date_published=datetime.fromisoformat("2024-01-01"), + date_added=stamp, + ) + session.add(project) + session.flush() + smid = project.id + + service = PermissionService(Session()) + service.insert_into_user_project_association(user, smid) + + records = session.execute(select(UserProjectAssociation)).scalar() + assert records.user_id == user.id + assert records.project_id == smid diff --git a/server/tests/unit/services/test_project.py b/server/tests/unit/services/test_project.py index 7c6fb418..d4be85f3 100644 --- a/server/tests/unit/services/test_project.py +++ b/server/tests/unit/services/test_project.py @@ -149,7 +149,9 @@ def test_project_validate_keys_error( missing_key=missing_key, ) with pytest.raises(KeyError) as exc: - ProjectService(Session(), project)._validate_keys() + service = ProjectService(Session()) + service._project = project + service._validate_keys() assert str(exc.value) == f"'Keys not found: {missing_key}.'" assert exc.type == KeyError @@ -164,7 +166,9 @@ def test_project_add_selection(Session, setup, project_template, data_path): metadata_fmt="list", missing_key=None, ) - ProjectService(Session(), project)._add_selection() + service = ProjectService(Session()) + service._project = project + service._add_selection() expected_records = [(1, 1, 1, 1), (2, 1, 1, 2), (3, 2, 2, 3), (4, 2, 1, 1)] with Session() as session, session.begin(): @@ -228,7 +232,9 @@ def test_project_validate_existing_entry( ) with pytest.raises(DuplicateProjectError) as exc: - ProjectService(Session(), project)._validate_entry() + service = ProjectService(Session()) + service._project = project + service._validate_entry() assert ( str(exc.value) == f"At least one similar record exists with SMID = {smid} and title = Title. Aborting transaction!" @@ -242,7 +248,9 @@ def test_project_validate_entry(Session, project_template, data_path): metadata_fmt="list", missing_key=None, ) - assert ProjectService(Session(), project)._validate_entry() is None + service = ProjectService(Session()) + service._project = project + assert service._validate_entry() is None @pytest.mark.parametrize( @@ -267,8 +275,8 @@ def test_project_create_project( missing_date=missing_date, ) # AssemblyService tested in test_assembly.py - project_instance = ProjectService(Session(), project) - project_instance.create_project(wo_assembly=True) + project_instance = ProjectService(Session()) + project_instance.create_project(project, wo_assembly=True) project_smid = project_instance.get_smid() date_published = datetime.fromisoformat("2024-01-01") @@ -300,3 +308,14 @@ def test_project_create_project( project_template = json.load(open(Path(data_path.META_PATH, f"{smid}.json"))) assert project_template == project + + +def test_project_no_project(Session): + service = ProjectService(Session()) + with pytest.raises(AttributeError) as exc: + smid = service.get_smid() + assert ( + str(exc.value) + == "Undefined SMID. This is only defined when creating a project." + ) + assert exc.type == AttributeError