diff --git a/docs/changelog.md b/docs/changelog.md index ded803c..6586c4b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. +## [0.10.0] -- 2024-07-18 +- Added user delete method +- Added project history and restoring projects + + ## [0.9.0] -- 2024-06-25 - Introduced new sample ordering with linked list [#133](https://github.com/pepkit/pepdbagent/issues/133) - Efficiency improvements of project update function diff --git a/pepdbagent/_version.py b/pepdbagent/_version.py index 3e2f46a..61fb31c 100644 --- a/pepdbagent/_version.py +++ b/pepdbagent/_version.py @@ -1 +1 @@ -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/pepdbagent/const.py b/pepdbagent/const.py index 055397a..aae67f6 100644 --- a/pepdbagent/const.py +++ b/pepdbagent/const.py @@ -20,3 +20,5 @@ LAST_UPDATE_DATE_KEY = "last_update_date" PEPHUB_SAMPLE_ID_KEY = "ph_id" + +MAX_HISTORY_SAMPLES_NUMBER = 2000 diff --git a/pepdbagent/db_utils.py b/pepdbagent/db_utils.py index 1d0e822..ea36bd8 100644 --- a/pepdbagent/db_utils.py +++ b/pepdbagent/db_utils.py @@ -1,10 +1,12 @@ import datetime +import enum import logging from typing import List, Optional from sqlalchemy import ( TIMESTAMP, BigInteger, + Enum, FetchedValue, ForeignKey, Result, @@ -119,6 +121,10 @@ class Projects(Base): namespace_mapping: Mapped["User"] = relationship("User", back_populates="projects_mapping") + history_mapping: Mapped[List["HistoryProjects"]] = relationship( + back_populates="project_mapping", cascade="all, delete-orphan" + ) # TODO: check if cascade is correct + __table_args__ = (UniqueConstraint("namespace", "name", "tag"),) @@ -131,7 +137,6 @@ class Samples(Base): id: Mapped[int] = mapped_column(primary_key=True) sample: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue()) - row_number: Mapped[int] # TODO: should be removed project_id = mapped_column(ForeignKey("projects.id", ondelete="CASCADE")) project_mapping: Mapped["Projects"] = relationship(back_populates="samples_mapping") sample_name: Mapped[Optional[str]] = mapped_column() @@ -245,6 +250,52 @@ class ViewSampleAssociation(Base): view: Mapped["Views"] = relationship(back_populates="samples") +class HistoryProjects(Base): + + __tablename__ = "project_history" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE")) + user: Mapped[str] = mapped_column(ForeignKey("users.namespace", ondelete="SET NULL")) + update_time: Mapped[datetime.datetime] = mapped_column( + TIMESTAMP(timezone=True), default=deliver_update_date + ) + project_yaml: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue()) + + project_mapping: Mapped["Projects"] = relationship( + "Projects", back_populates="history_mapping" + ) + sample_changes_mapping: Mapped[List["HistorySamples"]] = relationship( + back_populates="history_project_mapping", cascade="all, delete-orphan" + ) + + +class UpdateTypes(enum.Enum): + """ + Enum for the type of update + """ + + UPDATE = "update" + INSERT = "insert" + DELETE = "delete" + + +class HistorySamples(Base): + + __tablename__ = "sample_history" + + id: Mapped[int] = mapped_column(primary_key=True) + history_id: Mapped[int] = mapped_column(ForeignKey("project_history.id", ondelete="CASCADE")) + guid: Mapped[str] = mapped_column(nullable=False) + parent_guid: Mapped[Optional[str]] = mapped_column(nullable=True) + sample_json: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue()) + change_type: Mapped[UpdateTypes] = mapped_column(Enum(UpdateTypes), nullable=False) + + history_project_mapping: Mapped["HistoryProjects"] = relationship( + "HistoryProjects", back_populates="sample_changes_mapping" + ) + + class BaseEngine: """ A class with base methods, that are used in several classes. e.g. fetch_one or fetch_all diff --git a/pepdbagent/exceptions.py b/pepdbagent/exceptions.py index 741de6d..64e5278 100644 --- a/pepdbagent/exceptions.py +++ b/pepdbagent/exceptions.py @@ -107,3 +107,13 @@ def __init__(self, msg=""): class NamespaceNotFoundError(PEPDatabaseAgentError): def __init__(self, msg=""): super().__init__(f"""Project does not exist. {msg}""") + + +class HistoryNotFoundError(PEPDatabaseAgentError): + def __init__(self, msg=""): + super().__init__(f"""History does not exist. {msg}""") + + +class UserNotFoundError(PEPDatabaseAgentError): + def __init__(self, msg=""): + super().__init__(f"""User does not exist. {msg}""") diff --git a/pepdbagent/models.py b/pepdbagent/models.py index 52b8fa6..c138d18 100644 --- a/pepdbagent/models.py +++ b/pepdbagent/models.py @@ -1,4 +1,5 @@ # file with pydantic models +import datetime from typing import Dict, List, Optional, Union from peppy.const import CONFIG_KEY, SAMPLE_RAW_DICT_KEY, SUBSAMPLE_RAW_LIST_KEY @@ -224,3 +225,24 @@ class NamespaceStats(BaseModel): namespace: Union[str, None] = None projects_updated: Dict[str, int] = None projects_created: Dict[str, int] = None + + +class HistoryChangeModel(BaseModel): + """ + Model for history change + """ + + change_id: int + change_date: datetime.datetime + user: str + + +class HistoryAnnotationModel(BaseModel): + """ + History annotation model + """ + + namespace: str + name: str + tag: str = DEFAULT_TAG + history: List[HistoryChangeModel] diff --git a/pepdbagent/modules/project.py b/pepdbagent/modules/project.py index 1f07a3c..c4348f1 100644 --- a/pepdbagent/modules/project.py +++ b/pepdbagent/modules/project.py @@ -17,16 +17,39 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from pepdbagent.const import DEFAULT_TAG, DESCRIPTION_KEY, NAME_KEY, PEPHUB_SAMPLE_ID_KEY, PKG_NAME -from pepdbagent.db_utils import BaseEngine, Projects, Samples, Subsamples, User +from pepdbagent.const import ( + DEFAULT_TAG, + DESCRIPTION_KEY, + MAX_HISTORY_SAMPLES_NUMBER, + NAME_KEY, + PEPHUB_SAMPLE_ID_KEY, + PKG_NAME, +) +from pepdbagent.db_utils import ( + BaseEngine, + HistoryProjects, + HistorySamples, + Projects, + Samples, + Subsamples, + UpdateTypes, + User, +) from pepdbagent.exceptions import ( + HistoryNotFoundError, PEPDatabaseAgentError, ProjectDuplicatedSampleGUIDsError, ProjectNotFoundError, ProjectUniqueNameError, SampleTableUpdateError, ) -from pepdbagent.models import ProjectDict, UpdateItems, UpdateModel +from pepdbagent.models import ( + HistoryAnnotationModel, + HistoryChangeModel, + ProjectDict, + UpdateItems, + UpdateModel, +) from pepdbagent.utils import create_digest, generate_guid, order_samples, registry_path_converter _LOGGER = logging.getLogger(PKG_NAME) @@ -127,6 +150,31 @@ def _get_samples(self, session: Session, prj_id: int, with_id: bool) -> List[Dic :param prj_id: project id :param with_id: retrieve sample with id """ + result_dict = self._get_samples_dict(prj_id, session, with_id) + + result_dict = order_samples(result_dict) + + ordered_samples_list = [sample["sample"] for sample in result_dict] + return ordered_samples_list + + @staticmethod + def _get_samples_dict(prj_id: int, session: Session, with_id: bool) -> Dict: + """ + Get not ordered samples from the project. This method is used to retrieve samples from the project + + :param prj_id: project id + :param session: open session object + :param with_id: retrieve sample with id + + :return: dictionary with samples: + {guid: + { + "sample": sample_dict, + "guid": guid, + "parent_guid": parent_guid + } + } + """ samples_results = session.scalars(select(Samples).where(Samples.project_id == prj_id)) result_dict = {} for sample in samples_results: @@ -140,10 +188,7 @@ def _get_samples(self, session: Session, prj_id: int, with_id: bool) -> List[Dic "parent_guid": sample.parent_guid, } - result_dict = order_samples(result_dict) - - ordered_samples_list = [sample["sample"] for sample in result_dict] - return ordered_samples_list + return result_dict @staticmethod def _create_select_statement(name: str, namespace: str, tag: str = DEFAULT_TAG) -> Select: @@ -481,6 +526,7 @@ def update( namespace: str, name: str, tag: str = DEFAULT_TAG, + user: str = None, ) -> None: """ Update partial parts of the record in db @@ -502,6 +548,7 @@ def update( :param namespace: project namespace :param name: project name :param tag: project tag + :param user: user that updates the project if user is not provided, user will be set as Namespace :return: None """ if self.exists(namespace=namespace, name=name, tag=tag): @@ -558,6 +605,19 @@ def update( f"pephub_sample_id '{PEPHUB_SAMPLE_ID_KEY}' is missing in samples." f"Please provide it to update samples, or use overwrite method." ) + if len(update_dict["samples"]) > MAX_HISTORY_SAMPLES_NUMBER: + _LOGGER.warning( + f"Number of samples in the project exceeds the limit of {MAX_HISTORY_SAMPLES_NUMBER}." + f"Samples won't be updated." + ) + new_history = None + else: + new_history = HistoryProjects( + project_id=found_prj.id, + user=user or namespace, + project_yaml=self.get_config(namespace, name, tag), + ) + session.add(new_history) self._update_samples( project_id=found_prj.id, @@ -565,6 +625,7 @@ def update( sample_name_key=update_dict["config"].get( SAMPLE_TABLE_INDEX_KEY, "sample_name" ), + history_sa_model=new_history, ) if "subsamples" in update_dict: @@ -591,6 +652,7 @@ def _update_samples( project_id: int, samples_list: List[Dict[str, str]], sample_name_key: str = "sample_name", + history_sa_model: Union[HistoryProjects, None] = None, ) -> None: """ Update samples in the project @@ -600,6 +662,7 @@ def _update_samples( :param project_id: project id in PEPhub database :param samples_list: list of samples to be updated :param sample_name_key: key of the sample name + :param history_sa_model: HistoryProjects object, to write to the history table :return: None """ @@ -607,6 +670,12 @@ def _update_samples( old_samples = session.scalars(select(Samples).where(Samples.project_id == project_id)) old_samples_mapping: dict = {sample.guid: sample for sample in old_samples} + + # old_child_parent_id needed because of the parent_guid is sometimes set to none in sqlalchemy mapping :( bug + old_child_parent_id: Dict[str, str] = { + child: mapping.parent_guid for child, mapping in old_samples_mapping.items() + } + old_samples_ids_set: set = set(old_samples_mapping.keys()) new_samples_ids_list: list = [ new_sample[PEPHUB_SAMPLE_ID_KEY] @@ -631,6 +700,19 @@ def _update_samples( del new_samples_ids_list, new_samples_ids_set + for remove_id in deleted_ids: + + if history_sa_model: + history_sa_model.sample_changes_mapping.append( + HistorySamples( + guid=old_samples_mapping[remove_id].guid, + parent_guid=old_child_parent_id[remove_id], + sample_json=old_samples_mapping[remove_id].sample, + change_type=UpdateTypes.DELETE, + ) + ) + session.delete(old_samples_mapping[remove_id]) + parent_id = None parent_mapping = None @@ -644,26 +726,57 @@ def _update_samples( sample=sample_value, guid=current_id, sample_name=sample_value[sample_name_key], - row_number=0, project_id=project_id, parent_mapping=parent_mapping, ) session.add(new_sample) + if history_sa_model: + history_sa_model.sample_changes_mapping.append( + HistorySamples( + guid=new_sample.guid, + parent_guid=new_sample.parent_guid, + sample_json=new_sample.sample, + change_type=UpdateTypes.INSERT, + ) + ) + else: + current_history = None if old_samples_mapping[current_id].sample != sample_value: + + if history_sa_model: + current_history = HistorySamples( + guid=old_samples_mapping[current_id].guid, + parent_guid=old_samples_mapping[current_id].parent_guid, + sample_json=old_samples_mapping[current_id].sample, + change_type=UpdateTypes.UPDATE, + ) + old_samples_mapping[current_id].sample = sample_value old_samples_mapping[current_id].sample_name = sample_value[sample_name_key] + # !bug workaround: if project was deleted and sometimes old_samples_mapping[current_id].parent_guid + # and it can cause an error in history. For this we have `old_child_parent_id` dict if old_samples_mapping[current_id].parent_guid != parent_id: + if history_sa_model: + if current_history: + current_history.parent_guid = parent_id + else: + current_history = HistorySamples( + guid=old_samples_mapping[current_id].guid, + parent_guid=old_child_parent_id[current_id], + sample_json=old_samples_mapping[current_id].sample, + change_type=UpdateTypes.UPDATE, + ) old_samples_mapping[current_id].parent_mapping = parent_mapping + if history_sa_model and current_history: + history_sa_model.sample_changes_mapping.append(current_history) + parent_id = current_id parent_mapping = new_sample or old_samples_mapping[current_id] - for remove_id in deleted_ids: - session.delete(old_samples_mapping[remove_id]) - session.commit() @staticmethod @@ -781,11 +894,10 @@ def _add_samples_to_project( :return: NoReturn """ previous_sample_guid = None - for row_number, sample in enumerate(samples): + for sample in samples: sample = Samples( sample=sample, - row_number=row_number, sample_name=sample.get(sample_table_index), parent_guid=previous_sample_guid, guid=generate_guid(), @@ -970,3 +1082,290 @@ def get_samples( .sample_table.replace({np.nan: None}) .to_dict(orient="records") ) + + def get_history(self, namespace: str, name: str, tag: str) -> HistoryAnnotationModel: + """ + Get project history annotation by providing namespace, name, and tag + + :param namespace: project namespace + :param name: project name + :param tag: project tag + + :return: project history annotation + """ + + with Session(self._sa_engine) as session: + statement = ( + select(HistoryProjects) + .where( + HistoryProjects.project_id + == select(Projects.id) + .where( + and_( + Projects.namespace == namespace, + Projects.name == name, + Projects.tag == tag, + ) + ) + .scalar_subquery() + ) + .order_by(HistoryProjects.update_time.desc()) + ) + results = session.scalars(statement) + return_results: List = [] + + if results: + for result in results: + return_results.append( + HistoryChangeModel( + change_id=result.id, + change_date=result.update_time, + user=result.user, + ) + ) + return HistoryAnnotationModel( + namespace=namespace, + name=name, + tag=tag, + history=return_results, + ) + + def get_project_from_history( + self, + namespace: str, + name: str, + tag: str, + history_id: int, + raw: bool = True, + with_id: bool = False, + ) -> Union[dict, peppy.Project]: + """ + Get project sample history annotation by providing namespace, name, and tag + + :param namespace: project namespace + :param name: project name + :param tag: project tag + :param history_id: history id + :param raw: if True, retrieve unprocessed (raw) PEP dict. [Default: True] + :param with_id: if True, retrieve samples with ids. [Default: False] + + :return: project sample history annotation + """ + + with Session(self._sa_engine) as session: + project_mapping = session.scalar( + select(Projects).where( + and_( + Projects.namespace == namespace, + Projects.name == name, + Projects.tag == tag, + ) + ) + ) + if not project_mapping: + raise ProjectNotFoundError( + f"No project found for supplied input: '{namespace}/{name}:{tag}'. " + f"Did you supply a valid namespace and project?" + ) + + sample_dict = self._get_samples_dict( + prj_id=project_mapping.id, session=session, with_id=True + ) + + main_history = session.scalar( + select(HistoryProjects) + .where( + and_( + HistoryProjects.project_id == project_mapping.id, + HistoryProjects.id == history_id, + ) + ) + .order_by(HistoryProjects.update_time.desc()) + ) + if not main_history: + raise HistoryNotFoundError( + f"No history found for supplied input: '{namespace}/{name}:{tag}'. " + f"Did you supply a valid history id?" + ) + + changes_mappings = session.scalars( + select(HistoryProjects) + .where( + and_( + HistoryProjects.project_id == project_mapping.id, + ) + ) + .order_by(HistoryProjects.update_time.desc()) + ) + + # Changes mapping is a ordered list from most early to latest changes + # We have to loop through each change and apply it to the sample list + # It should be done before we found the history_id that user is looking for + project_config = None + + for result in changes_mappings: + sample_dict = self._apply_history_changes(sample_dict, result) + + if result.id == history_id: + project_config = result.project_yaml + break + + samples_list = order_samples(sample_dict) + ordered_samples_list = [sample["sample"] for sample in samples_list] + + if not with_id: + for sample in ordered_samples_list: + try: + del sample[PEPHUB_SAMPLE_ID_KEY] + except KeyError: + pass + + if raw: + return { + CONFIG_KEY: project_config or project_mapping.config, + SAMPLE_RAW_DICT_KEY: ordered_samples_list, + SUBSAMPLE_RAW_LIST_KEY: self.get_subsamples(namespace, name, tag), + } + return peppy.Project.from_dict( + pep_dictionary={ + CONFIG_KEY: project_config or project_mapping.config, + SAMPLE_RAW_DICT_KEY: ordered_samples_list, + SUBSAMPLE_RAW_LIST_KEY: self.get_subsamples(namespace, name, tag), + } + ) + + @staticmethod + def _apply_history_changes(sample_dict: dict, change: HistoryProjects) -> dict: + """ + Apply changes from the history to the sample list + + :param sample_dict: dictionary with samples + :param change: history change + :return: updated sample list + """ + for sample_change in change.sample_changes_mapping: + sample_id = sample_change.guid + + if sample_change.change_type == UpdateTypes.UPDATE: + sample_dict[sample_id]["sample"] = sample_change.sample_json + sample_dict[sample_id]["sample"][PEPHUB_SAMPLE_ID_KEY] = sample_change.guid + sample_dict[sample_id]["parent_guid"] = sample_change.parent_guid + + elif sample_change.change_type == UpdateTypes.DELETE: + sample_dict[sample_id] = { + "sample": sample_change.sample_json, + "guid": sample_id, + "parent_guid": sample_change.parent_guid, + } + + elif sample_change.change_type == UpdateTypes.INSERT: + del sample_dict[sample_id] + + return sample_dict + + def delete_history( + self, namespace: str, name: str, tag: str, history_id: Union[int, None] = None + ) -> None: + """ + Delete history from the project + + :param namespace: project namespace + :param name: project name + :param tag: project tag + :param history_id: history id. If none is provided, all history will be deleted + + :return: None + """ + with Session(self._sa_engine) as session: + project_mapping = session.scalar( + select(Projects).where( + and_( + Projects.namespace == namespace, + Projects.name == name, + Projects.tag == tag, + ) + ) + ) + if not project_mapping: + raise ProjectNotFoundError( + f"No project found for supplied input: '{namespace}/{name}:{tag}'. " + f"Did you supply a valid namespace and project?" + ) + + if history_id is None: + session.execute( + delete(HistoryProjects).where(HistoryProjects.project_id == project_mapping.id) + ) + session.commit() + return None + + history_mapping = session.scalar( + select(HistoryProjects).where( + and_( + HistoryProjects.project_id == project_mapping.id, + HistoryProjects.id == history_id, + ) + ) + ) + if not history_mapping: + raise HistoryNotFoundError( + f"No history found for supplied input: '{namespace}/{name}:{tag}'. " + f"Did you supply a valid history id?" + ) + + session.delete(history_mapping) + session.commit() + + def restore( + self, + namespace: str, + name: str, + tag: str, + history_id: int, + user: str = None, + ) -> None: + """ + Restore project to the specific history state + + :param namespace: project namespace + :param name: project name + :param tag: project tag + :param history_id: history id + :param user: user that restores the project if user is not provided, user will be set as Namespace + + :return: None + """ + + restore_project = self.get_project_from_history( + namespace=namespace, + name=name, + tag=tag, + history_id=history_id, + raw=True, + with_id=True, + ) + self.update( + update_dict={"project": peppy.Project.from_dict(restore_project)}, + namespace=namespace, + name=name, + tag=tag, + user=user or namespace, + ) + + def clean_history(self, days: int = 90) -> None: + """ + Delete all history data that is older then 3 month, or specific number of days + + :param days: number of days to keep history data + :return: None + """ + + with Session(self._sa_engine) as session: + session.execute( + delete(HistoryProjects).where( + HistoryProjects.update_time + < (datetime.datetime.now() - datetime.timedelta(days=days)) + ) + ) + session.commit() + _LOGGER.info("History was cleaned successfully!") diff --git a/pepdbagent/modules/sample.py b/pepdbagent/modules/sample.py index 7e8d89a..90e216b 100644 --- a/pepdbagent/modules/sample.py +++ b/pepdbagent/modules/sample.py @@ -234,7 +234,6 @@ def add( else: sample_mapping = Samples( sample=sample_dict, - row_number=0, project_id=project_mapping.id, sample_name=sample_name, guid=generate_guid(), @@ -314,7 +313,7 @@ def delete( parent_mapping = sample_mapping.parent_mapping child_mapping = sample_mapping.child_mapping session.delete(sample_mapping) - if parent_mapping: + if child_mapping: child_mapping.parent_mapping = parent_mapping project_mapping.number_of_samples -= 1 project_mapping.last_update_date = datetime.datetime.now(datetime.timezone.utc) diff --git a/pepdbagent/modules/user.py b/pepdbagent/modules/user.py index eea8f84..125c7c1 100644 --- a/pepdbagent/modules/user.py +++ b/pepdbagent/modules/user.py @@ -7,7 +7,11 @@ from pepdbagent.const import PKG_NAME from pepdbagent.db_utils import BaseEngine, Projects, Stars, User -from pepdbagent.exceptions import ProjectAlreadyInFavorites, ProjectNotInFavorites +from pepdbagent.exceptions import ( + ProjectAlreadyInFavorites, + ProjectNotInFavorites, + UserNotFoundError, +) from pepdbagent.models import AnnotationList, AnnotationModel _LOGGER = logging.getLogger(PKG_NAME) @@ -214,3 +218,17 @@ def exists( return True else: return False + + def delete(self, namespace: str) -> None: + """ + Delete user from the database with all related data + + :param namespace: user namespace + :return: None + """ + if not self.exists(namespace): + raise UserNotFoundError + + with Session(self._sa_engine) as session: + session.execute(delete(User).where(User.namespace == namespace)) + session.commit() diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index d86dcfc..cba088f 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,6 +1,6 @@ sqlalchemy>=2.0.0 logmuse>=0.2.7 -peppy>=0.40.0 +peppy>=0.40.4 ubiquerg>=0.6.2 coloredlogs>=15.0.1 pytest-mock diff --git a/tests/test_annotation.py b/tests/test_annotation.py index e6778e3..9e5603e 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -1,5 +1,7 @@ import datetime + import pytest + from pepdbagent.exceptions import FilterError, ProjectNotFoundError from .utils import PEPDBAgentContextManager diff --git a/tests/test_namespace.py b/tests/test_namespace.py index 432a9c2..196f6e8 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -1,4 +1,5 @@ import pytest + from pepdbagent.exceptions import ProjectAlreadyInFavorites, ProjectNotInFavorites from .utils import PEPDBAgentContextManager @@ -62,7 +63,7 @@ def test_namespace_stats(self): ) class TestFavorites: """ - Test function within user class + Test method related to favorites """ def test_add_projects_to_favorites(self): @@ -155,3 +156,40 @@ def test_annotation_favorite_number(self, namespace, name): assert prj_annot.stars_number == 1 else: assert prj_annot.stars_number == 0 + + +@pytest.mark.skipif( + not PEPDBAgentContextManager().db_setup(), + reason="DB is not setup", +) +class TestUser: + """ + Test methods within user class + """ + + def test_create_user(self): + with PEPDBAgentContextManager(add_data=True) as agent: + + user = agent.user.create_user("test_user") + + assert agent.user.exists("test_user") + + def test_delete_user(self): + with PEPDBAgentContextManager(add_data=True) as agent: + + test_user = "test_user" + agent.user.create_user(test_user) + assert agent.user.exists(test_user) + agent.user.delete(test_user) + assert not agent.user.exists(test_user) + + def test_delete_user_deletes_projects(self): + with PEPDBAgentContextManager(add_data=True) as agent: + + test_user = "namespace1" + + assert agent.user.exists(test_user) + agent.user.delete(test_user) + assert not agent.user.exists(test_user) + results = agent.namespace.get(query=test_user) + assert len(results.results) == 0 diff --git a/tests/test_project.py b/tests/test_project.py index 26cf017..426f026 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -19,7 +19,7 @@ class TestProject: def test_create_project(self): with PEPDBAgentContextManager(add_data=False) as agent: prj = peppy.Project(list_of_available_peps()["namespace3"]["subtables"]) - agent.project.create(prj, namespace="test", name="imply", overwrite=True) + agent.project.create(prj, namespace="test", name="imply", overwrite=False) assert True def test_create_project_from_dict(self): @@ -66,9 +66,9 @@ def test_get_config(self, namespace, name): tag="default", ) ff = peppy.Project(get_path_to_example_file(namespace, name)) - ff.description = description - ff.name = name - assert kk == ff.config + ff["_original_config"]["description"] = description + ff["_original_config"]["name"] = name + assert kk == ff["_original_config"] @pytest.mark.parametrize( "namespace, name", diff --git a/tests/test_project_history.py b/tests/test_project_history.py new file mode 100644 index 0000000..332e1ca --- /dev/null +++ b/tests/test_project_history.py @@ -0,0 +1,305 @@ +import peppy +import pytest + +from pepdbagent.const import PEPHUB_SAMPLE_ID_KEY +from pepdbagent.exceptions import HistoryNotFoundError + +from .utils import PEPDBAgentContextManager + + +@pytest.mark.skipif( + not PEPDBAgentContextManager().db_setup(), + reason="DB is not setup", +) +class TestProjectHistory: + """ + Test project methods + """ + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_get_add_history_all_annotation(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + prj["_sample_dict"][0]["sample_name"] = "new_sample_name" + + del prj["_sample_dict"][1] + del prj["_sample_dict"][2] + new_sample1 = { + "sample_name": "new_sample", + "protocol": "new_protocol", + PEPHUB_SAMPLE_ID_KEY: None, + } + new_sample2 = { + "sample_name": "new_sample2", + "protocol": "new_protocol2", + PEPHUB_SAMPLE_ID_KEY: None, + } + + prj["_sample_dict"].append(new_sample1.copy()) + prj["_sample_dict"].append(new_sample2.copy()) + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + project_history = agent.project.get_history(namespace, name, tag="default") + + assert len(project_history.history) == 1 + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_get_add_history_all_project(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj_init = agent.project.get(namespace, name, tag="default", raw=False) + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + # prj["_sample_dict"][0]["sample_name"] = "new_sample_name" + + del prj["_sample_dict"][1] + del prj["_sample_dict"][2] + new_sample1 = { + "sample_name": "new_sample", + "protocol": "new_protocol", + PEPHUB_SAMPLE_ID_KEY: None, + } + new_sample2 = { + "sample_name": "new_sample2", + "protocol": "new_protocol2", + PEPHUB_SAMPLE_ID_KEY: None, + } + + prj["_sample_dict"].append(new_sample1.copy()) + prj["_sample_dict"].append(new_sample2.copy()) + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + history_prj = agent.project.get_project_from_history( + namespace, name, tag="default", history_id=1, raw=False + ) + assert prj_init == history_prj + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_get_history_multiple_changes(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + del prj["_sample_dict"][1] + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + new_sample1 = { + "sample_name": "new_sample", + "protocol": "new_protocol", + PEPHUB_SAMPLE_ID_KEY: None, + } + prj["_sample_dict"].append(new_sample1.copy()) + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + history = agent.project.get_history(namespace, name, tag="default") + + assert len(history.history) == 2 + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_get_project_incorrect_history_id(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj = agent.project.get(namespace, name, tag="default", with_id=True) + del prj["_sample_dict"][1] + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + with pytest.raises(HistoryNotFoundError): + agent.project.get_project_from_history( + namespace, "amendments2", tag="default", history_id=1, raw=False + ) + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_get_history_none(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + history_annot = agent.project.get_history(namespace, name, tag="default") + assert len(history_annot.history) == 0 + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_delete_all_history(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + del prj["_sample_dict"][1] + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + new_sample1 = { + "sample_name": "new_sample", + "protocol": "new_protocol", + PEPHUB_SAMPLE_ID_KEY: None, + } + prj["_sample_dict"].append(new_sample1.copy()) + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + history = agent.project.get_history(namespace, name, tag="default") + + assert len(history.history) == 2 + + agent.project.delete_history(namespace, name, tag="default", history_id=None) + + history = agent.project.get_history(namespace, name, tag="default") + assert len(history.history) == 0 + + project_exists = agent.project.exists(namespace, name, tag="default") + assert project_exists + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_delete_one_history(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + del prj["_sample_dict"][1] + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + new_sample1 = { + "sample_name": "new_sample", + "protocol": "new_protocol", + PEPHUB_SAMPLE_ID_KEY: None, + } + prj["_sample_dict"].append(new_sample1.copy()) + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + history = agent.project.get_history(namespace, name, tag="default") + + assert len(history.history) == 2 + + agent.project.delete_history(namespace, name, tag="default", history_id=1) + + history = agent.project.get_history(namespace, name, tag="default") + + assert len(history.history) == 1 + assert history.history[0].change_id == 2 + + @pytest.mark.parametrize( + "namespace, name, sample_name", + [ + ["namespace1", "amendments1", "pig_0h"], + ], + ) + def test_restore_project(self, namespace, name, sample_name): + with PEPDBAgentContextManager(add_data=True) as agent: + prj_org = agent.project.get(namespace, name, tag="default", with_id=False) + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + del prj["_sample_dict"][1] + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + prj = agent.project.get(namespace, name, tag="default", with_id=True) + + new_sample1 = { + "sample_name": "new_sample", + "protocol": "new_protocol", + PEPHUB_SAMPLE_ID_KEY: None, + } + prj["_sample_dict"].append(new_sample1.copy()) + + agent.project.update( + namespace=namespace, + name=name, + tag="default", + update_dict={"project": peppy.Project.from_dict(prj)}, + ) + + agent.project.restore(namespace, name, tag="default", history_id=1) + + restored_project = agent.project.get(namespace, name, tag="default", with_id=False) + + assert prj_org == restored_project diff --git a/tests/test_samples.py b/tests/test_samples.py index 0e4de49..e8a6862 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -1,5 +1,6 @@ import peppy import pytest + from pepdbagent.exceptions import SampleNotFoundError from .utils import PEPDBAgentContextManager diff --git a/tests/test_updates.py b/tests/test_updates.py index 73854c9..790b313 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1,8 +1,9 @@ import peppy import pytest from peppy.exceptions import IllegalStateException -from pepdbagent.exceptions import ProjectDuplicatedSampleGUIDsError, SampleTableUpdateError + from pepdbagent.const import PEPHUB_SAMPLE_ID_KEY +from pepdbagent.exceptions import ProjectDuplicatedSampleGUIDsError, SampleTableUpdateError from .utils import PEPDBAgentContextManager diff --git a/tests/utils.py b/tests/utils.py index 6e3e7f4..8ddc820 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,7 @@ import os -import peppy import warnings + +import peppy from sqlalchemy.exc import OperationalError from pepdbagent import PEPDatabaseAgent