From ec7de1934d5ad7cd804981c8eff023da9b5223c3 Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Wed, 18 Sep 2024 23:34:09 -0400 Subject: [PATCH] feat: add queue repository and unit tests --- src/dioptra/restapi/db/repository/errors.py | 4 + src/dioptra/restapi/db/repository/queues.py | 263 ++++++++ src/dioptra/restapi/db/repository/utils.py | 544 +++++++++++++++-- src/dioptra/restapi/db/shared_errors.py | 117 ++++ src/dioptra/restapi/db/unit_of_work.py | 2 + src/dioptra/restapi/errors.py | 18 + src/dioptra/restapi/v1/queues/errors.py | 4 +- src/dioptra/restapi/v1/queues/service.py | 219 +++---- tests/unit/restapi/conftest.py | 8 + tests/unit/restapi/test_queue_repository.py | 644 ++++++++++++++++++++ tests/unit/restapi/test_repo_utils.py | 245 +++++++- 11 files changed, 1908 insertions(+), 160 deletions(-) create mode 100644 src/dioptra/restapi/db/repository/queues.py create mode 100644 src/dioptra/restapi/db/shared_errors.py create mode 100644 tests/unit/restapi/test_queue_repository.py diff --git a/src/dioptra/restapi/db/repository/errors.py b/src/dioptra/restapi/db/repository/errors.py index 77f4e3b49..bd2d2d2c6 100644 --- a/src/dioptra/restapi/db/repository/errors.py +++ b/src/dioptra/restapi/db/repository/errors.py @@ -27,3 +27,7 @@ class UsernameNotAvailableError(Exception): class UserEmailNotAvailableError(Exception): """The email address is not available.""" + + +class QueueAlreadyExistsError(Exception): + """The queue name already exists.""" diff --git a/src/dioptra/restapi/db/repository/queues.py b/src/dioptra/restapi/db/repository/queues.py new file mode 100644 index 000000000..519ce2f4b --- /dev/null +++ b/src/dioptra/restapi/db/repository/queues.py @@ -0,0 +1,263 @@ +# This Software (Dioptra) is being made available as a public service by the +# National Institute of Standards and Technology (NIST), an Agency of the United +# States Department of Commerce. This software was developed in part by employees of +# NIST and in part by NIST contractors. Copyright in portions of this software that +# were developed by NIST contractors has been licensed or assigned to NIST. Pursuant +# to Title 17 United States Code Section 105, works of NIST employees are not +# subject to copyright protection in the United States. However, NIST may hold +# international copyright in software created by its employees and domestic +# copyright (or licensing rights) in portions of software that were assigned or +# licensed to NIST. To the extent that NIST holds copyright in this software, it is +# being made available under the Creative Commons Attribution 4.0 International +# license (CC BY 4.0). The disclaimers of the CC BY 4.0 license apply to all parts +# of the software developed or licensed by NIST. +# +# ACCESS THE FULL CC BY 4.0 LICENSE HERE: +# https://creativecommons.org/licenses/by/4.0/legalcode +""" +The queue repository: data operations related to queues +""" + +from collections.abc import Iterable, Sequence + +import sqlalchemy as sa + +from dioptra.restapi.db.models import Group, Queue, Resource +from dioptra.restapi.db.repository.errors import QueueAlreadyExistsError +from dioptra.restapi.db.repository.utils import ( + CompatibleSession, + DeletionPolicy, + S, + apply_resource_deletion_policy, + assert_group_exists, + assert_resource_does_not_exist, + assert_resource_exists, + assert_snapshot_does_not_exist, + assert_user_exists, + assert_user_in_group, + delete_resource, + get_group_id, + get_resource_id, +) +from dioptra.restapi.db.shared_errors import ResourceError + + +class QueueRepository: + def __init__(self, session: CompatibleSession[S]): + self.session = session + + def create(self, queue: Queue) -> None: + """ + Create a new queue resource. This creates both the resource and the + initial snapshot. + + Args: + queue: The queue to create + """ + + # Consistency rules: + # - Latest-snapshot queue names must be unique within the owning group + # - Queue snapshots must be of queue resources + # - For now, the snapshot creator must be a member of the group who + # owns the resource. I think this will become more complicated when + # we implement shares and permissions. + + assert_resource_does_not_exist(self.session, queue, DeletionPolicy.ANY) + assert_snapshot_does_not_exist(self.session, queue) + assert_user_exists(self.session, queue.creator, DeletionPolicy.NOT_DELETED) + assert_group_exists( + self.session, queue.resource.owner, DeletionPolicy.NOT_DELETED + ) + assert_user_in_group(self.session, queue.creator, queue.resource.owner) + + check_name = self.get_by_name( + queue.name, queue.resource.owner, DeletionPolicy.ANY + ) + if check_name: + raise QueueAlreadyExistsError("Queue name already exists") + + if queue.resource.resource_type != "queue": + raise Exception( + f'Queue resource type must be "queue": {queue.resource.resource_type}' + ) + + self.session.add(queue) + + def create_snapshot(self, queue: Queue) -> None: + """ + Create a new queue snapshot. + + Args: + queue: A Queue object with the desired snapshot settings + """ + # Consistency rules: + # - Latest-snapshot queue names must be unique within the owning group + # - Queue snapshots must be of queue resources + # - Snapshot timestamps must be monotonically increasing(?) + # - For now, the snapshot creator must be a member of the group who + # owns the resource. I think this will become more complicated when + # we implement shares and permissions. + + assert_resource_exists(self.session, queue, DeletionPolicy.NOT_DELETED) + assert_snapshot_does_not_exist(self.session, queue) + assert_user_exists(self.session, queue.creator, DeletionPolicy.NOT_DELETED) + assert_user_in_group(self.session, queue.creator, queue.resource.owner) + + if queue.resource.resource_type != "queue": + raise Exception( + f'Queue resource type must be "queue": {queue.resource.resource_type}' + ) + + # In case the name is changing in this snapshot, ensure uniqueness with + # respect to the owning group. We must allow repeated queue names + # within the same resource (e.g. if the name does not change across + # snapshots), so the requirement only applies with respect to other + # queue resources in the same group. So reusing get_by_name() would + # not work here. + queue_id = get_resource_id(queue) + sub_stmt: sa.Select = ( + sa.select(sa.literal_column("1")) + .select_from(Queue) + .join(Resource) + .where( + Queue.name == queue.name, + # Dunno why mypy has trouble with this expression... could + # also add a cast, but what to cast it to? + Resource.owner == queue.resource.owner, # type: ignore + Queue.resource_id != queue_id, + ) + ) + + exists_stmt = sa.select(sub_stmt.exists()) + exists = self.session.scalar(exists_stmt) + if exists: + raise QueueAlreadyExistsError("Queue name in use: " + queue.name) + + # Assume that the new snapshot's created_on timestamp is later than the + # current latest timestamp? + + self.session.add(queue) + + def delete(self, queue: Queue | int) -> None: + """ + Delete a queue. No-op if the queue is already deleted. + + Args: + queue: A Queue object or resource_id primary key value identifying + a queue resource + """ + + try: + delete_resource(self.session, queue) + except ResourceError as e: + # if an integer ID was passed, the exception here won't include + # the resource type, so ensure it's set + if not e.resource_type: + e.resource_type = "queue" + raise + + def get( + self, + resource_ids: int | Iterable[int], + deletion_policy: DeletionPolicy = DeletionPolicy.NOT_DELETED, + ) -> Queue | Sequence[Queue] | None: + """ + Get the latest snapshot of the given queue resource. + + Args: + resource_ids: An ID or iterable of IDs of queue resource IDs + deletion_policy: Whether to look at deleted queues, non-deleted + queue, or all queues + + Returns: + A Queue/list of Queue objects, or None/empty list if none were + found with the given ID(s) + """ + + if isinstance(resource_ids, int): + resource_id_check = Queue.resource_id == resource_ids + else: + resource_id_check = Queue.resource_id.in_(resource_ids) + + # Extra join with Resource is important: the query produces incorrect + # results without it! + stmt = ( + sa.select(Queue) + .join(Resource) + .where( + resource_id_check, + Queue.resource_snapshot_id == Resource.latest_snapshot_id, + ) + ) + stmt = apply_resource_deletion_policy(stmt, deletion_policy) + + queues: Queue | Sequence[Queue] | None + if isinstance(resource_ids, int): + queues = self.session.scalar(stmt) + else: + queues = self.session.scalars(stmt).all() + + return queues + + def get_snapshot( + self, + snapshot_id: int, + deletion_policy: DeletionPolicy = DeletionPolicy.NOT_DELETED, + ) -> Queue | None: + """ + Get a given queue snapshot. + + Args: + snapshot_id: The ID of a queue snapshot + deletion_policy: Whether to look at deleted queues, non-deleted + queue, or all queues + + Returns: + A Queue object, or None if one was not found with the given ID + """ + stmt = sa.select(Queue).where(Queue.resource_snapshot_id == snapshot_id) + stmt = apply_resource_deletion_policy(stmt, deletion_policy) + + queue = self.session.scalar(stmt) + return queue + + def get_by_name( + self, + name: str, + group: Group | int, + deletion_policy: DeletionPolicy = DeletionPolicy.NOT_DELETED, + ) -> Queue | None: + """ + Get a queue by name. This returns the latest version (snapshot) of + the queue. + + Args: + name: A queue name + group: A group/group ID, to disambiguate same-named queues across + groups + deletion_policy: Whether to look at deleted queues, non-deleted + queue, or all queues + + Returns: + A queue, or None if one was not found + """ + + assert_group_exists(self.session, group, DeletionPolicy.NOT_DELETED) + + group_id = get_group_id(group) + + stmt = ( + sa.select(Queue) + .join(Resource) + .where( + Queue.resource_snapshot_id == Resource.latest_snapshot_id, + Resource.group_id == group_id, + Queue.name == name, + ) + ) + + stmt = apply_resource_deletion_policy(stmt, deletion_policy) + + queue = self.session.scalar(stmt) + + return queue diff --git a/src/dioptra/restapi/db/repository/utils.py b/src/dioptra/restapi/db/repository/utils.py index d5a4ab04e..b7ad9e0bd 100644 --- a/src/dioptra/restapi/db/repository/utils.py +++ b/src/dioptra/restapi/db/repository/utils.py @@ -18,14 +18,32 @@ import typing import sqlalchemy as sa -from sqlalchemy.orm import Session, scoped_session - -from dioptra.restapi.db.models import Group, GroupLock, User, UserLock -from dioptra.restapi.db.models.constants import GroupLockTypes, UserLockTypes +from sqlalchemy.orm import Session, aliased, scoped_session + +from dioptra.restapi.db.models import ( + Group, + GroupLock, + GroupMember, + Resource, + ResourceLock, + ResourceSnapshot, + User, + UserLock, +) +from dioptra.restapi.db.models.constants import ( + group_lock_types, + resource_lock_types, + user_lock_types, +) from dioptra.restapi.db.repository.errors import ( UserEmailNotAvailableError, UsernameNotAvailableError, ) +from dioptra.restapi.db.shared_errors import ( + ResourceDeletedError, + ResourceExistsError, + ResourceNotFoundError, +) # General ORM-using code ought to be compatible with "plain" SQLAlchemy or # flask_sqlalchemy's ORM sessions (the latter are of generic type @@ -60,6 +78,65 @@ class DeletionPolicy(enum.Enum): DELETED = enum.auto() +def get_group_id(group: Group | int) -> int | None: + """ + Helper for APIs which allow a Group domain object or group_id integer + primary key value. This normalizes the value to the group_id value, or + None (if a Group object was passed with a null .group_id attribute). + + Args: + group: A group object, group_id integer primary key value + + Returns: + A group ID or None + """ + if isinstance(group, int): + group_id = group + else: + group_id = group.group_id + + return group_id + + +def get_resource_id(resource: Resource | ResourceSnapshot | int) -> int | None: + """ + Helper for APIs which allow a Resource/ResourceSnapshot object or + resource_id integer primary key value. This normalizes the value to the + resource_id value, or None (if an object was passed with a null + .resource_id attribute). + + Args: + resource: A resource, snapshot, or resource_id integer primary key + value + + Returns: + A resource ID or None + """ + if isinstance(resource, int): + resource_id = resource + + else: + # This hack should not in theory be necessary. But when creating a + # snapshot, SQLAlchemy doesn't seem to set foreign key attributes when + # setting the "resource" relationship attribute. That means that when + # creating a snapshot via an existing resource, the snapshot's + # .resource_id attribute may still be null, whereas + # .resource.resource_id is non-null. For us, it means we can't trust a + # null .resource_id attribute on a snapshot. It may mean there is no + # corresponding resource, or it may mean SQLAlchemy just didn't set the + # attribute. So if 'resource' is a snapshot with a null .resource_id, + # make a second attempt to get a resource ID via .resource.resource_id. + resource_id = resource.resource_id + if ( + resource_id is None + and isinstance(resource, ResourceSnapshot) + and resource.resource + ): + resource_id = resource.resource.resource_id + + return resource_id + + def user_exists(session: CompatibleSession[S], user: User) -> ExistenceResult: """ Check whether the given user exists in the database, and if so, whether @@ -89,7 +166,7 @@ def user_exists(session: CompatibleSession[S], user: User) -> ExistenceResult: if not row: exists = ExistenceResult.DOES_NOT_EXIST - elif row[1] == UserLockTypes.DELETE: + elif row[1] == user_lock_types.DELETE: exists = ExistenceResult.DELETED else: exists = ExistenceResult.EXISTS @@ -97,26 +174,29 @@ def user_exists(session: CompatibleSession[S], user: User) -> ExistenceResult: return exists -def group_exists(session: CompatibleSession[S], group: Group) -> ExistenceResult: +def group_exists(session: CompatibleSession[S], group: Group | int) -> ExistenceResult: """ Check whether the given group exists in the database, and if so, whether it was deleted or not. Args: session: An SQLAlchemy session - group: A Group object + group: A Group object or group_id integer primary key value Returns: One of the ExistenceResult enum values """ - if group.group_id is None: + + group_id = get_group_id(group) + + if group_id is None: exists = ExistenceResult.DOES_NOT_EXIST else: # May as well get existence + deletion status in one query stmt = ( sa.select(Group.group_id, GroupLock.group_lock_type) .outerjoin(GroupLock) - .where(Group.group_id == group.group_id) + .where(Group.group_id == group_id) ) results = session.execute(stmt) # will need to change if a group may have multiple lock types @@ -124,7 +204,58 @@ def group_exists(session: CompatibleSession[S], group: Group) -> ExistenceResult if not row: exists = ExistenceResult.DOES_NOT_EXIST - elif row[1] == GroupLockTypes.DELETE: + elif row[1] == group_lock_types.DELETE: + exists = ExistenceResult.DELETED + else: + exists = ExistenceResult.EXISTS + + return exists + + +def resource_exists( + session: CompatibleSession[S], resource: Resource | ResourceSnapshot | int +) -> ExistenceResult: + """ + Check whether the given resource exists in the database, and if so, whether + it was deleted or not. + + Args: + session: An SQLAlchemy session + resource: A resource, snapshot (something with a .resource_id + attribute we can use to identify a resource), or resource_id + integer primary key value + + Returns: + One of the ExistenceResult enum values + """ + + resource_id = get_resource_id(resource) + + if resource_id is None: + exists = ExistenceResult.DOES_NOT_EXIST + else: + stmt = ( + sa.select(ResourceLock.resource_lock_type) + .select_from(Resource) + .outerjoin(ResourceLock) + .where(Resource.resource_id == resource_id) + # Note: using "IN ('delete', NULL)" as a shortcut operator doesn't + # work here, since IN operates via '=', and '=' doesn't behave as + # expected with nulls. + .where( + sa.or_( + ResourceLock.resource_lock_type == resource_lock_types.DELETE, + ResourceLock.resource_lock_type == None, # noqa: E711 + ) + ) + ) + + # This really ought to only produce at most one value + locks = session.scalars(stmt).all() + + if not locks: + exists = ExistenceResult.DOES_NOT_EXIST + elif resource_lock_types.DELETE in locks: exists = ExistenceResult.DELETED else: exists = ExistenceResult.EXISTS @@ -132,6 +263,42 @@ def group_exists(session: CompatibleSession[S], group: Group) -> ExistenceResult return exists +def snapshot_exists(session: CompatibleSession[S], snapshot: ResourceSnapshot) -> bool: + """ + Check whether the given snapshot exists in the database. Snapshots can't + be individually deleted (only the resources), so a deletion check is not + applicable here. + + Args: + session: An SQLAlchemy session + snapshot: Any snapshot object + + Returns: + True if the snapshot exists; False if not + """ + + if snapshot.resource_snapshot_id is None: + exists = False + + else: + sub_stmt: sa.Select = ( + sa.select(sa.literal_column("1")) + .select_from(ResourceSnapshot) + .where( + ResourceSnapshot.resource_snapshot_id == snapshot.resource_snapshot_id + ) + ) + exists_stmt = sa.select(sub_stmt.exists()) + + exists = session.scalar(exists_stmt) + + # For mypy. I think a "select exists(....)" should always return true + # or false. + assert exists is not None + + return exists + + def assert_user_exists( session: CompatibleSession[S], user: User, deletion_policy: DeletionPolicy ) -> None: @@ -159,20 +326,11 @@ def assert_user_exists( user_id = "" if user.user_id is None else user.user_id user_name = "" if user.username is None else user.username - if existence_result == ExistenceResult.DOES_NOT_EXIST: - raise Exception(f"User does not exist: {user_name}/{user_id}") - - elif existence_result == ExistenceResult.EXISTS: - if deletion_policy == DeletionPolicy.DELETED: - raise Exception(f"User exists, not deleted: {user_name}/{user_id}") - - elif existence_result == ExistenceResult.DELETED: - if deletion_policy == DeletionPolicy.NOT_DELETED: - raise Exception(f"User is deleted: {user_name}/{user_id}") + _assert_exists(deletion_policy, existence_result, "User", f"{user_id}/{user_name}") def assert_group_exists( - session: CompatibleSession[S], group: Group, deletion_policy: DeletionPolicy + session: CompatibleSession[S], group: Group | int, deletion_policy: DeletionPolicy ) -> None: """ Check whether the given group exists in the database. This function accepts @@ -187,7 +345,7 @@ def assert_group_exists( Args: session: An SQLAlchemy session - group: A Group object + group: A Group object or group_id integer primary key value deletion_policy: One of the DeletionPolicy enum values Raises: @@ -195,19 +353,86 @@ def assert_group_exists( """ existence_result = group_exists(session, group) - group_id = "" if group.group_id is None else group.group_id - group_name = "" if group.name is None else group.name + group_id = get_group_id(group) + if isinstance(group, int): + obj_id = str(group_id) + else: + obj_id = f"{group_id}/{group.name}" + + _assert_exists(deletion_policy, existence_result, "Group", obj_id) + + +def assert_resource_exists( + session: CompatibleSession[S], + resource: Resource | ResourceSnapshot | int, + deletion_policy: DeletionPolicy, +) -> None: + """ + Check whether the given resource exists in the database. This function + accepts a policy value expressing the caller's preference with respect to + deleted resources: + + ANY: Check whether the resource exists in the database at all (deletion + state doesn't matter) + NOT_DELETED: Check whether the resource exists in the database and is + not deleted + DELETED: Check whether the resource exists in the database and is + deleted - if existence_result == ExistenceResult.DOES_NOT_EXIST: - raise Exception(f"Group does not exist: {group_name}/{group_id}") + Args: + session: An SQLAlchemy session + resource: A resource, snapshot, or resource_id integer primary key + value + deletion_policy: One of the DeletionPolicy enum values - elif existence_result == ExistenceResult.EXISTS: - if deletion_policy == DeletionPolicy.DELETED: - raise Exception(f"Group exists, not deleted: {group_name}/{group_id}") + Raises: + ResourceNotFoundError: if the resource is not found (even deleted) + ResourceDeletedError: if the resource exists and is deleted, an + error with respect to the NOT_DELETED policy + ResourceExistsError: if the resource exists and is not deleted, an + error with respect to the DELETED policy + """ + existence_result = resource_exists(session, resource) - elif existence_result == ExistenceResult.DELETED: - if deletion_policy == DeletionPolicy.NOT_DELETED: - raise Exception(f"Group is deleted: {group_name}/{group_id}") + resource_id = get_resource_id(resource) + if isinstance(resource, int): + resource_type = None + else: + resource_type = resource.resource_type + + if existence_result is ExistenceResult.DOES_NOT_EXIST: + raise ResourceNotFoundError(resource_id, resource_type) + + elif existence_result is ExistenceResult.EXISTS: + if deletion_policy is DeletionPolicy.DELETED: + raise ResourceExistsError(resource_id, resource_type) + + elif existence_result is ExistenceResult.DELETED: + if deletion_policy is DeletionPolicy.NOT_DELETED: + raise ResourceDeletedError(resource_id, resource_type) + + +def assert_snapshot_exists( + session: CompatibleSession[S], snapshot: ResourceSnapshot +) -> None: + """ + Check whether the given snapshot exists in the database. Snapshots can't + be individually deleted (only the resources), so deletion policy is not + applicable here. + + Args: + session: An SQLAlchemy session + snapshot: A snapshot object + + Raises: + Exception if the snapshot doesn't exist + """ + + if not snapshot_exists(session, snapshot): + snapshot_id = str(snapshot.resource_snapshot_id or "") + snapshot_type = snapshot.resource_type or "" + + raise Exception(f"{snapshot_type} snapshot not found: {snapshot_id}") def assert_user_does_not_exist( @@ -238,20 +463,13 @@ def assert_user_does_not_exist( user_id = "" if user.user_id is None else user.user_id user_name = "" if user.username is None else user.username - if existence_result is ExistenceResult.EXISTS: - if deletion_policy is not DeletionPolicy.DELETED: - raise Exception(f"User exists, not deleted: {user_name}/{user_id}") - - elif existence_result is ExistenceResult.DELETED: - if deletion_policy is not DeletionPolicy.NOT_DELETED: - raise Exception(f"User exists (deleted): {user_name}/{user_id}") - - # else: ExistenceResult.DOES_NOT_EXIST. deletion policy doesn't matter in - # this case; the user does not exist at all. + _assert_does_not_exist( + deletion_policy, existence_result, "User", f"{user_id}/{user_name}" + ) def assert_group_does_not_exist( - session: CompatibleSession[S], group: Group, deletion_policy: DeletionPolicy + session: CompatibleSession[S], group: Group | int, deletion_policy: DeletionPolicy ) -> None: """ Check whether the given group exists in the database. This function accepts @@ -267,7 +485,7 @@ def assert_group_does_not_exist( Args: session: An SQLAlchemy session - group: A Group object + group: A Group object or group_id integer primary key value deletion_policy: One of the DeletionPolicy enum values Raises: @@ -275,19 +493,171 @@ def assert_group_does_not_exist( """ existence_result = group_exists(session, group) - group_id = "" if group.group_id is None else group.group_id - group_name = "" if group.name is None else group.name + group_id = get_group_id(group) + if isinstance(group, int): + obj_id = str(group_id) + else: + obj_id = f"{group_id}/{group.name}" + + _assert_does_not_exist(deletion_policy, existence_result, "Group", obj_id) + + +def assert_resource_does_not_exist( + session: CompatibleSession[S], + resource: Resource | ResourceSnapshot | int, + deletion_policy: DeletionPolicy, +) -> None: + """ + Check whether the given resource exists in the database. This function + accepts a policy value expressing the caller's preference with respect to + deleted resources: + + ANY: Ensure the resource does not exist in the database at all (deletion + state doesn't matter). Same as resource_exists(...) == DOES_NOT_EXIST + NOT_DELETED: Ensure the resource doesn't exist as non-deleted (deleted is + ok). Same as resource_exists(...) != EXISTS + DELETED: Ensure the resource doesn't exist as deleted (non-deleted ok). + Same as resource_exists(...) != DELETED + + Args: + session: An SQLAlchemy session + resource: A resource, snapshot, or resource_id integer primary key + value + deletion_policy: One of the DeletionPolicy enum values + + Raises: + ResourceExistsError: if the resource is found and is not deleted, an + error with respect to policies ANY and NOT_DELETED + ResourceDeletedError: if the resource is found and is deleted, an + error with respect to policies ANY and DELETED + """ + existence_result = resource_exists(session, resource) + + resource_id = get_resource_id(resource) + if isinstance(resource, int): + resource_type = None + else: + resource_type = resource.resource_type if existence_result is ExistenceResult.EXISTS: if deletion_policy is not DeletionPolicy.DELETED: - raise Exception(f"Group exists, not deleted: {group_name}/{group_id}") + raise ResourceExistsError(resource_id, resource_type) elif existence_result is ExistenceResult.DELETED: if deletion_policy is not DeletionPolicy.NOT_DELETED: - raise Exception(f"Group exists (deleted): {group_name}/{group_id}") + raise ResourceDeletedError(resource_id, resource_type) # else: ExistenceResult.DOES_NOT_EXIST. deletion policy doesn't matter in - # this case; the group does not exist at all. + # this case; the object does not exist at all. + + +def assert_snapshot_does_not_exist( + session: CompatibleSession[S], snapshot: ResourceSnapshot +) -> None: + """ + Check whether the given snapshot exists in the database. Snapshots can't + be individually deleted (only the resources), so deletion policy is not + applicable here. + + Args: + session: An SQLAlchemy session + snapshot: A snapshot object + + Raises: + Exception: if the snapshot exists + """ + + snapshot_id = str(snapshot.resource_snapshot_id or "") + snapshot_type = snapshot.resource_type or "" + + if snapshot_exists(session, snapshot): + raise Exception(f"{snapshot_type} snapshot exists: {snapshot_id}") + + +def _assert_exists( + deletion_policy: DeletionPolicy, + existence_result: ExistenceResult, + obj_type: str, + obj_id: str, +) -> None: + """ + Common code for checking existence relative to deletion policy. + + Args: + deletion_policy: One of the DeletionPolicy enum values + existence_result: One of the ExistenceResult enum values + obj_type: Brief word(s) to describe the kind of object (e.g. a + "user", "queue", etc), used in error messages + obj_id: Brief word(s) to identify the particular object being checked, + e.g. an ID, name, etc, used in error messages + """ + if existence_result is ExistenceResult.DOES_NOT_EXIST: + raise Exception(f"{obj_type} does not exist: {obj_id}") + + elif existence_result is ExistenceResult.EXISTS: + if deletion_policy is DeletionPolicy.DELETED: + raise Exception(f"{obj_type} exists, not deleted: {obj_id}") + + elif existence_result is ExistenceResult.DELETED: + if deletion_policy is DeletionPolicy.NOT_DELETED: + raise Exception(f"{obj_type} is deleted: {obj_id}") + + +def _assert_does_not_exist( + deletion_policy: DeletionPolicy, + existence_result: ExistenceResult, + obj_type: str, + obj_id: str, +): + """ + Common code for checking non-existence relative to deletion policy. + + Args: + deletion_policy: One of the DeletionPolicy enum values + existence_result: One of the ExistenceResult enum values + obj_type: Brief word(s) to describe the kind of object (e.g. a + "user", "queue", etc), used in error messages + obj_id: Brief word(s) to identify the particular object being checked, + e.g. an ID, name, etc, used in error messages + """ + if existence_result is ExistenceResult.EXISTS: + if deletion_policy is not DeletionPolicy.DELETED: + raise Exception(f"{obj_type} exists, not deleted: {obj_id}") + + elif existence_result is ExistenceResult.DELETED: + if deletion_policy is not DeletionPolicy.NOT_DELETED: + raise Exception(f"{obj_type} exists (deleted): {obj_id}") + + # else: ExistenceResult.DOES_NOT_EXIST. deletion policy doesn't matter in + # this case; the object does not exist at all. + + +def assert_user_in_group( + session: CompatibleSession[S], user: User, group: Group +) -> None: + """ + Ensure the given user is a member of the given group. This function + assumes both already exist in the database. It also ignores the deletion + status of both. Existence/deletion status should be checked by the caller + first, if necessary. + + Args: + session: An SQLAlchemy session + user: An existing user + group: An existing group + + Raises: + Exception: if the given user is not in the given group + """ + + # Assume existence checks on user and group were already done, so they are + # known to exist. + membership = session.get(GroupMember, (user.user_id, group.group_id)) + + if not membership: + raise Exception( + f"User ({user.user_id}/{user.username}) is not in group ({group.group_id}/{group.name})" # noqa: B950 + ) def check_user_collision(session: CompatibleSession[S], user: User) -> None: @@ -321,3 +691,79 @@ def check_user_collision(session: CompatibleSession[S], user: User) -> None: raise UserEmailNotAvailableError( "User already exists with email address: " + user.email_address ) + + +def apply_resource_deletion_policy( + stmt: sa.Select, deletion_policy: DeletionPolicy +) -> sa.Select: + """ + Factored out code to add components to a SELECT statement to apply deletion + policy to it, affecting whether deleted resources are searched. This + function is intended to apply to snapshot queries; it adds a join to + Resource, which will use the foreign key relationship between Resource and + ResourceSnapshot which already exists. But it could in theory work with + any other select statement having a table which has a defined foreign key + relationship with Resource. + + Args: + stmt: A snapshot select statement to modify + deletion_policy: The policy to apply + + Returns: + A modified select statement + """ + + # Use an alias, just in case the given select statement already includes a + # join with Resource. + resource_alias = aliased(Resource) + + if deletion_policy is DeletionPolicy.NOT_DELETED: + stmt = stmt.join(resource_alias).where( + resource_alias.is_deleted == False # noqa: E712 + ) + elif deletion_policy is DeletionPolicy.DELETED: + stmt = stmt.join(resource_alias).where( + resource_alias.is_deleted == True # noqa: E712 + ) + + return stmt + + +def delete_resource( + session: CompatibleSession[S], resource: Resource | ResourceSnapshot | int +) -> None: + """ + Common routine for deleting a resource. No-op if the resource is already + deleted. + + Args: + session: An SQLAlchemy session + resource: A resource, snapshot, or resource_id integer primary key + value + + Raises: + ResourceNotFoundError: if the resource does not exist + """ + + exists_result = resource_exists(session, resource) + + if exists_result is ExistenceResult.DOES_NOT_EXIST: + resource_id = get_resource_id(resource) + resource_type = None if isinstance(resource, int) else resource.resource_type + raise ResourceNotFoundError(resource_id, resource_type) + + elif exists_result is ExistenceResult.EXISTS: + + # here, we really need the Resource object; ResourceLock's constructor + # is just designed that way. + if isinstance(resource, int): + resource_obj = session.get(Resource, resource) + elif isinstance(resource, ResourceSnapshot): + resource_obj = resource.resource + else: + resource_obj = resource + + lock = ResourceLock(resource_lock_types.DELETE, resource_obj) + session.add(lock) + + # else: exists_result is DELETED; nothing to do. diff --git a/src/dioptra/restapi/db/shared_errors.py b/src/dioptra/restapi/db/shared_errors.py new file mode 100644 index 000000000..6c77fe86f --- /dev/null +++ b/src/dioptra/restapi/db/shared_errors.py @@ -0,0 +1,117 @@ +# This Software (Dioptra) is being made available as a public service by the +# National Institute of Standards and Technology (NIST), an Agency of the United +# States Department of Commerce. This software was developed in part by employees of +# NIST and in part by NIST contractors. Copyright in portions of this software that +# were developed by NIST contractors has been licensed or assigned to NIST. Pursuant +# to Title 17 United States Code Section 105, works of NIST employees are not +# subject to copyright protection in the United States. However, NIST may hold +# international copyright in software created by its employees and domestic +# copyright (or licensing rights) in portions of software that were assigned or +# licensed to NIST. To the extent that NIST holds copyright in this software, it is +# being made available under the Creative Commons Attribution 4.0 International +# license (CC BY 4.0). The disclaimers of the CC BY 4.0 license apply to all parts +# of the software developed or licensed by NIST. +# +# ACCESS THE FULL CC BY 4.0 LICENSE HERE: +# https://creativecommons.org/licenses/by/4.0/legalcode +""" +Exception classes which are applicable across the families of endpoints, or +to general software layers below the endpoint controllers. +""" +from collections.abc import Iterable + + +class ResourceError(Exception): # Make this an ABC? + """ + Instances represent an error related to a particular resource or resources. + They capture information about the resource(s), including ID(s) and + optionally a resource type. + """ + + def __init__( + self, resource_id: int | Iterable[int] | None, resource_type: str | None = None + ) -> None: + """ + Initialize this exception object. + + Args: + resource_id: The resource primary key ID (or iterable of IDs). + Nullable to support reporting from an ORM instance which has + not been persisted to the DB, so it does not yet have an ID. + The exception info will not be very informative in that case, + since it will not identify a particular database resource. + resource_type: A resource type. Obviously a resource which does + not exist has no type (or anything else, for that matter). In + that case, this should reflect the context of the error. What + type of resource was intended? Nullable, in case it is not + known at the point the exception is originally thrown. It can + be filled in later, if desired. + + It is implied that if multiple IDs are given for resource_id, + they are all for resources of the same type. + """ + self.resource_type = resource_type + self.resource_id = resource_id + + def _get_resource_id_str(self) -> str: + """ + Helper for subclasses to get the resource IDs associated with this + error as a string useful in an error message. If there are multiple + IDs, this returns a string formatted with a comma-delimited syntax. + + Returns: + Resource ID or IDs as a string + """ + if self.resource_id is None: + resource_id_str = "" + elif isinstance(self.resource_id, int): + resource_id_str = str(self.resource_id) + else: + resource_id_str = ", ".join(str(i) for i in self.resource_id) + + return resource_id_str + + +class ResourceNotFoundError(ResourceError): + """ + Instances represent a search for a resource which found no results, not + even any deleted resources. This is an error in contexts where the + resource must exist (whether deleted or not). + """ + + def __str__(self) -> str: + resource_type = self.resource_type or "resource" + resource_id_str = self._get_resource_id_str() + + message = f"{resource_type} not found: {resource_id_str}" + return message + + +class ResourceDeletedError(ResourceError): + """ + Instances represent a search for a resource which found a deleted resource. + This is an error in contexts where the resource either must not exist at + all, or must exist and not be deleted. + """ + + def __str__(self) -> str: + resource_type = self.resource_type or "resource" + resource_id_str = self._get_resource_id_str() + + message = f"{resource_type} is deleted: {resource_id_str}" + return message + + +class ResourceExistsError(ResourceError): + """ + Instances represent a successful search for a resource, where the resource + was found to be not deleted. This is an error in contexts where either the + resource must not exist at all, or must be deleted. + """ + + def __str__(self) -> str: + resource_type = self.resource_type or "resource" + resource_id_str = self._get_resource_id_str() + + message = f"{resource_type} exists, not deleted: {resource_id_str}" + return message diff --git a/src/dioptra/restapi/db/unit_of_work.py b/src/dioptra/restapi/db/unit_of_work.py index 778ac4386..975d011a7 100644 --- a/src/dioptra/restapi/db/unit_of_work.py +++ b/src/dioptra/restapi/db/unit_of_work.py @@ -20,6 +20,7 @@ from dioptra.restapi.db.db import db from dioptra.restapi.db.repository.groups import GroupRepository +from dioptra.restapi.db.repository.queues import QueueRepository from dioptra.restapi.db.repository.users import UserRepository @@ -35,6 +36,7 @@ def __init__(self) -> None: self.session = db.session self.user_repo = UserRepository(self.session) self.group_repo = GroupRepository(self.session) + self.queue_repo = QueueRepository(self.session) def commit(self) -> None: self.session.commit() diff --git a/src/dioptra/restapi/errors.py b/src/dioptra/restapi/errors.py index ba72c03a5..e7c3a3e2a 100644 --- a/src/dioptra/restapi/errors.py +++ b/src/dioptra/restapi/errors.py @@ -22,6 +22,8 @@ from flask_restx import Api +from dioptra.restapi.db.shared_errors import ResourceDeletedError, ResourceNotFoundError + class BackendDatabaseError(Exception): """The backend database returned an unexpected response.""" @@ -104,3 +106,19 @@ def register_error_handlers(api: Api) -> None: v1.queues.errors.register_error_handlers(api) v1.tags.errors.register_error_handlers(api) v1.users.errors.register_error_handlers(api) + + # Temporary, until exception revamp is complete. These apply to all + # resource types, therefore they don't belong with any single family of + # endpoints. + api.errorhandler(ResourceNotFoundError)(_handle_resource_does_not_exist_error) + api.errorhandler(ResourceDeletedError)(_handle_resource_deleted_error) + + +def _handle_resource_does_not_exist_error(error: ResourceNotFoundError): + resource_type = error.resource_type or "resource" + return {"message": f"Not Found - The requested {resource_type} does not exist"}, 404 + + +def _handle_resource_deleted_error(error: ResourceDeletedError): + resource_type = error.resource_type or "resource" + return {"message": f"Not Found - The requested {resource_type} is deleted"}, 404 diff --git a/src/dioptra/restapi/v1/queues/errors.py b/src/dioptra/restapi/v1/queues/errors.py index 12d4a3a41..01d0e80cc 100644 --- a/src/dioptra/restapi/v1/queues/errors.py +++ b/src/dioptra/restapi/v1/queues/errors.py @@ -19,9 +19,7 @@ from flask_restx import Api - -class QueueAlreadyExistsError(Exception): - """The queue name already exists.""" +from dioptra.restapi.db.repository.errors import QueueAlreadyExistsError class QueueDoesNotExistError(Exception): diff --git a/src/dioptra/restapi/v1/queues/service.py b/src/dioptra/restapi/v1/queues/service.py index ad21273a6..1ff325d54 100644 --- a/src/dioptra/restapi/v1/queues/service.py +++ b/src/dioptra/restapi/v1/queues/service.py @@ -17,6 +17,7 @@ """The server-side functions that perform queue endpoint operations.""" from __future__ import annotations +from collections.abc import Sequence from typing import Any, Final import structlog @@ -26,13 +27,14 @@ from structlog.stdlib import BoundLogger from dioptra.restapi.db import db, models -from dioptra.restapi.db.models.constants import resource_lock_types +from dioptra.restapi.db.repository.utils import DeletionPolicy +from dioptra.restapi.db.shared_errors import ResourceDeletedError, ResourceNotFoundError +from dioptra.restapi.db.unit_of_work import UnitOfWork from dioptra.restapi.errors import BackendDatabaseError -from dioptra.restapi.v1 import utils -from dioptra.restapi.v1.groups.service import GroupIdService +from dioptra.restapi.v1 import groups, utils from dioptra.restapi.v1.shared.search_parser import construct_sql_query_filters -from .errors import QueueAlreadyExistsError, QueueDoesNotExistError, QueueSortError +from .errors import QueueDoesNotExistError, QueueSortError LOGGER: BoundLogger = structlog.stdlib.get_logger() @@ -54,21 +56,15 @@ class QueueService(object): """The service methods for registering and managing queues by their unique id.""" @inject - def __init__( - self, - queue_name_service: QueueNameService, - group_id_service: GroupIdService, - ) -> None: + def __init__(self, uow: UnitOfWork) -> None: """Initialize the queue service. All arguments are provided via dependency injection. Args: - queue_name_service: A QueueNameService object. - group_id_service: A GroupIdService object. + uow: A UnitOfWork instance """ - self._queue_name_service = queue_name_service - self._group_id_service = group_id_service + self._uow = uow def create( self, @@ -96,25 +92,28 @@ def create( """ log: BoundLogger = kwargs.get("log", LOGGER.new()) - if self._queue_name_service.get(name, group_id=group_id, log=log) is not None: - log.debug("Queue name already exists", name=name, group_id=group_id) - raise QueueAlreadyExistsError - - group = self._group_id_service.get(group_id, error_if_not_found=True) + group = self._uow.group_repo.get(group_id, DeletionPolicy.NOT_DELETED) + if not group: + raise groups.errors.GroupDoesNotExistError resource = models.Resource(resource_type="queue", owner=group) new_queue = models.Queue( name=name, description=description, resource=resource, creator=current_user ) - db.session.add(new_queue) + + try: + self._uow.queue_repo.create(new_queue) + except Exception: + self._uow.rollback() + raise if commit: - db.session.commit() - log.debug( - "Queue registration successful", - queue_id=new_queue.resource_id, - name=new_queue.name, - ) + self._uow.commit() + log.debug( + "Queue registration successful", + queue_id=new_queue.resource_id, + name=new_queue.name, + ) return utils.QueueDict(queue=new_queue, has_draft=False) @@ -230,15 +229,15 @@ class QueueIdService(object): """The service methods for registering and managing queues by their unique id.""" @inject - def __init__(self, queue_name_service: QueueNameService) -> None: + def __init__(self, uow: UnitOfWork) -> None: """Initialize the queue service. All arguments are provided via dependency injection. Args: - queue_name_service: A QueueNameService object. + uow: A UnitOfWork instance """ - self._queue_name_service = queue_name_service + self._uow = uow def get( self, @@ -257,29 +256,29 @@ def get( The queue object if found, otherwise None. Raises: - QueueDoesNotExistError: If the queue is not found and `error_if_not_found` + ResourceNotFoundError: If the queue is not found and `error_if_not_found` + is True. + ResourceDeletedError: If the queue is deleted and `error_if_not_found` is True. """ log: BoundLogger = kwargs.get("log", LOGGER.new()) log.debug("Get queue by id", queue_id=queue_id) - stmt = ( - select(models.Queue) - .join(models.Resource) - .where( - models.Queue.resource_id == queue_id, - models.Queue.resource_snapshot_id == models.Resource.latest_snapshot_id, - models.Resource.is_deleted == False, # noqa: E712 - ) - ) - queue = db.session.scalars(stmt).first() + queue = self._uow.queue_repo.get(queue_id, DeletionPolicy.ANY) + # For mypy: if we ask for a single queue ID, we get at most a single + # Queue back. + assert queue is None or isinstance(queue, models.Queue) - if queue is None: + if not queue: if error_if_not_found: - log.debug("Queue not found", queue_id=queue_id) - raise QueueDoesNotExistError - - return None + raise ResourceNotFoundError(queue_id, "queue") + else: + return None + elif queue.resource.is_deleted: + if error_if_not_found: + raise ResourceDeletedError(queue_id, "queue") + else: + return None drafts_stmt = ( select(models.DraftResource.draft_resource_id) @@ -318,26 +317,29 @@ def modify( The updated queue object. Raises: - QueueDoesNotExistError: If the queue is not found and `error_if_not_found` + ResourceNotFoundError: If the queue is not found and `error_if_not_found` + is True. + ResourceDeletedError: If the queue is deleted and `error_if_not_found` is True. QueueAlreadyExistsError: If the queue name already exists. """ log: BoundLogger = kwargs.get("log", LOGGER.new()) - queue_dict = self.get(queue_id, error_if_not_found=error_if_not_found, log=log) - - if queue_dict is None: - return None + queue = self._uow.queue_repo.get(queue_id, DeletionPolicy.ANY) + # For mypy: if we ask for a single queue ID, we get at most a single + # Queue back. + assert queue is None or isinstance(queue, models.Queue) - queue = queue_dict["queue"] - group_id = queue.resource.group_id - if ( - name != queue.name - and self._queue_name_service.get(name, group_id=group_id, log=log) - is not None - ): - log.debug("Queue name already exists", name=name, group_id=group_id) - raise QueueAlreadyExistsError + if not queue: + if error_if_not_found: + raise ResourceNotFoundError(queue_id, "queue") + else: + return None + elif queue.resource.is_deleted: + if error_if_not_found: + raise ResourceDeletedError(queue_id, "queue") + else: + return None new_queue = models.Queue( name=name, @@ -345,16 +347,21 @@ def modify( resource=queue.resource, creator=current_user, ) - db.session.add(new_queue) + try: + self._uow.queue_repo.create_snapshot(new_queue) + except Exception: + self._uow.rollback() + raise if commit: - db.session.commit() - log.debug( - "Queue modification successful", - queue_id=queue_id, - name=name, - description=description, - ) + self._uow.commit() + + log.debug( + "Queue modification successful", + queue_id=queue_id, + name=name, + description=description, + ) return utils.QueueDict(queue=new_queue, has_draft=False) @@ -368,24 +375,14 @@ def delete(self, queue_id: int, **kwargs) -> dict[str, Any]: A dictionary reporting the status of the request. Raises: - QueueDoesNotExistError: If the queue is not found. + ResourceNotFoundError: If the queue is not found. """ log: BoundLogger = kwargs.get("log", LOGGER.new()) - stmt = select(models.Resource).filter_by( - resource_id=queue_id, resource_type=RESOURCE_TYPE, is_deleted=False - ) - queue_resource = db.session.scalars(stmt).first() - - if queue_resource is None: - raise QueueDoesNotExistError + with self._uow: + # No-op if already deleted + self._uow.queue_repo.delete(queue_id) - deleted_resource_lock = models.ResourceLock( - resource_lock_type=resource_lock_types.DELETE, - resource=queue_resource, - ) - db.session.add(deleted_resource_lock) - db.session.commit() log.debug("Queue deleted", queue_id=queue_id) return {"status": "Success", "queue_id": queue_id} @@ -394,6 +391,17 @@ def delete(self, queue_id: int, **kwargs) -> dict[str, Any]: class QueueIdsService(object): """The service methods for retrieving queues from a list of ids.""" + @inject + def __init__(self, uow: UnitOfWork): + """Initialize the queue IDs service. + + All arguments are provided via dependency injection. + + Args: + uow: A UnitOfWork instance + """ + self._uow = uow + def get( self, queue_ids: list[int], @@ -404,43 +412,50 @@ def get( Args: queue_ids: The unique ids of the queues. - error_if_not_found: If True, raise an error if the queue is not found. + error_if_not_found: If True, raise an error if any queues are not found. Defaults to False. Returns: - The queue object if found, otherwise None. + The queue objects if found, otherwise None. Raises: - QueueDoesNotExistError: If the queue is not found and `error_if_not_found` + ResourceNotFoundError: If any queues are not found and `error_if_not_found` is True. """ log: BoundLogger = kwargs.get("log", LOGGER.new()) log.debug("Get queue by id", queue_ids=queue_ids) - stmt = ( - select(models.Queue) - .join(models.Resource) - .where( - models.Queue.resource_id.in_(tuple(queue_ids)), - models.Queue.resource_snapshot_id == models.Resource.latest_snapshot_id, - models.Resource.is_deleted == False, # noqa: E712 - ) - ) - queues = list(db.session.scalars(stmt).all()) + # More complex situation here where some queues could be deleted and + # some may not exist at all. For now, just treat both as not existing. + queues = self._uow.queue_repo.get(queue_ids, DeletionPolicy.NOT_DELETED) + # For mypy: if we request a list of IDs, we get a Sequence of Queues + # back. + assert isinstance(queues, Sequence) if len(queues) != len(queue_ids) and error_if_not_found: queue_ids_missing = set(queue_ids) - set( queue.resource_id for queue in queues ) log.debug("Queue not found", queue_ids=list(queue_ids_missing)) - raise QueueDoesNotExistError + raise ResourceNotFoundError(queue_ids_missing, "queue") - return queues + return list(queues) class QueueNameService(object): """The service methods for managing queues by their name.""" + @inject + def __init__(self, uow: UnitOfWork): + """Initialize the queue name service. + + All arguments are provided via dependency injection. + + Args: + uow: A UnitOfWork instance + """ + self._uow = uow + def get( self, name: str, @@ -452,7 +467,7 @@ def get( Args: name: The name of the queue. - group_id: The the group id of the queue. + group_id: The group id of the queue. error_if_not_found: If True, raise an error if the queue is not found. Defaults to False. @@ -466,17 +481,9 @@ def get( log: BoundLogger = kwargs.get("log", LOGGER.new()) log.debug("Get queue by name", queue_name=name, group_id=group_id) - stmt = ( - select(models.Queue) - .join(models.Resource) - .where( - models.Queue.name == name, - models.Resource.group_id == group_id, - models.Resource.is_deleted == False, # noqa: E712 - models.Resource.latest_snapshot_id == models.Queue.resource_snapshot_id, - ) + queue = self._uow.queue_repo.get_by_name( + name, group_id, DeletionPolicy.NOT_DELETED ) - queue = db.session.scalars(stmt).first() if queue is None: if error_if_not_found: diff --git a/tests/unit/restapi/conftest.py b/tests/unit/restapi/conftest.py index bdba4c642..92ac85c50 100644 --- a/tests/unit/restapi/conftest.py +++ b/tests/unit/restapi/conftest.py @@ -38,6 +38,7 @@ from dioptra.restapi.db import db as restapi_db from dioptra.restapi.db.repository.groups import GroupRepository +from dioptra.restapi.db.repository.queues import QueueRepository from dioptra.restapi.db.repository.users import UserRepository from dioptra.restapi.db.unit_of_work import UnitOfWork from dioptra.restapi.v1.shared.request_scope import request @@ -252,6 +253,13 @@ def user_repo(db: SQLAlchemy) -> UserRepository: return repo +@pytest.fixture +def queue_repo(db: SQLAlchemy) -> QueueRepository: + repo = QueueRepository(db.session) + + return repo + + @pytest.fixture def uow() -> UnitOfWork: return UnitOfWork() diff --git a/tests/unit/restapi/test_queue_repository.py b/tests/unit/restapi/test_queue_repository.py new file mode 100644 index 000000000..782bd173f --- /dev/null +++ b/tests/unit/restapi/test_queue_repository.py @@ -0,0 +1,644 @@ +# This Software (Dioptra) is being made available as a public service by the +# National Institute of Standards and Technology (NIST), an Agency of the United +# States Department of Commerce. This software was developed in part by employees of +# NIST and in part by NIST contractors. Copyright in portions of this software that +# were developed by NIST contractors has been licensed or assigned to NIST. Pursuant +# to Title 17 United States Code Section 105, works of NIST employees are not +# subject to copyright protection in the United States. However, NIST may hold +# international copyright in software created by its employees and domestic +# copyright (or licensing rights) in portions of software that were assigned or +# licensed to NIST. To the extent that NIST holds copyright in this software, it is +# being made available under the Creative Commons Attribution 4.0 International +# license (CC BY 4.0). The disclaimers of the CC BY 4.0 license apply to all parts +# of the software developed or licensed by NIST. +# +# ACCESS THE FULL CC BY 4.0 LICENSE HERE: +# https://creativecommons.org/licenses/by/4.0/legalcode +import datetime + +import pytest + +import dioptra.restapi.db.models as m +from dioptra.restapi.db.models.constants import resource_lock_types +from dioptra.restapi.db.repository.utils import DeletionPolicy +from dioptra.restapi.db.shared_errors import ( + ResourceDeletedError, + ResourceExistsError, + ResourceNotFoundError, +) + + +@pytest.fixture +def queue_snap_setup(queue_repo, account, db, fake_data): + queues = [fake_data.queue(account.user, account.group) for _ in range(3)] + + for queue in queues: + queue_repo.create(queue) + + db.session.commit() + + # 5 versions each of 3 different queues + latest_snaps = [None] * len(queues) + for i in range(1, 6): + for j, queue in enumerate(queues): + new_snap = m.Queue( + queue.description, queue.resource, account.user, f"{queue.name}_{i}" + ) + new_snap.created_on = queue.created_on + datetime.timedelta(hours=i) + + queue_repo.create_snapshot(new_snap) + db.session.commit() + + latest_snaps[j] = new_snap + + return queues, latest_snaps + + +def test_queue_create_queue_not_exists(queue_repo, account, db, fake_data): + queue = fake_data.queue(account.user, account.group) + queue_repo.create(queue) + db.session.commit() + + check_queue = db.session.get(m.Queue, queue.resource_snapshot_id) + + assert check_queue == queue + + +def test_queue_create_queue_exists(queue_repo, account, db, fake_data): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + with pytest.raises(ResourceExistsError): + queue_repo.create(queue) + + +def test_queue_create_queue_exists_deleted(queue_repo, account, db, fake_data): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + queue_lock = m.ResourceLock(resource_lock_types.DELETE, queue.resource) + db.session.add(queue_lock) + db.session.commit() + + with pytest.raises(ResourceDeletedError): + queue_repo.create(queue) + + +def test_queue_create_user_not_exists(queue_repo, account, db, fake_data): + u2 = m.User("user2", "password2", "user2@example.org") + resource = m.Resource("queue", account.group) + queue = m.Queue("description", resource, u2, "a queue") + + with pytest.raises(Exception): + queue_repo.create(queue) + + +def test_queue_create_group_not_exist(queue_repo, account, db, fake_data): + g2 = m.Group("group2", account.user) + resource = m.Resource("queue", g2) + queue = m.Queue("description", resource, account.user, "a queue") + + with pytest.raises(Exception): + queue_repo.create(queue) + + +def test_queue_create_user_not_member(queue_repo, account, db, fake_data): + u2 = m.User("user2", "password2", "user2@example.org") + g2 = m.Group("group2", u2) + db.session.add(g2) + db.session.add(u2) + db.session.commit() + + resource = m.Resource("queue", g2) + queue = m.Queue("description", resource, account.user, "a queue") + with pytest.raises(Exception): + queue_repo.create(queue) + + +def test_queue_create_name_collision(queue_repo, account, db, fake_data): + queue1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue1) + db.session.commit() + + queue2 = fake_data.queue(account.user, account.group) + queue2.name = queue1.name + + with pytest.raises(Exception): + queue_repo.create(queue2) + + +def test_queue_create_wrong_resource_type(queue_repo, account, db, fake_data): + experiment_resource = m.Resource("experiment", account.group) + queue = m.Queue("description", experiment_resource, account.user, "name") + + with pytest.raises(Exception): + queue_repo.create(queue) + + +def test_queue_get_by_name(queue_repo, account, db, fake_data): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + check_queue = queue_repo.get_by_name(queue.name, account.group, DeletionPolicy.ANY) + assert check_queue == queue + + check_queue = queue_repo.get_by_name( + queue.name, account.group, DeletionPolicy.NOT_DELETED + ) + assert check_queue == queue + + check_queue = queue_repo.get_by_name( + queue.name, account.group, DeletionPolicy.DELETED + ) + assert not check_queue + + # Add another queue owned by another group with the same name; ensure + # get_by_name() using the same name but different group, yields the other + # queue. + account2 = fake_data.account() + db.session.add(account2.group) + db.session.commit() + + queue2 = fake_data.queue(account2.user, account2.group) + queue2.name = queue.name + db.session.add(queue2) + db.session.commit() + + check_queue = queue_repo.get_by_name( + queue.name, account2.group, DeletionPolicy.NOT_DELETED + ) + assert check_queue == queue2 + assert check_queue != queue + + +def test_queue_get_by_name_deleted(queue_repo, account, db, fake_data): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + queue_lock = m.ResourceLock(resource_lock_types.DELETE, queue.resource) + db.session.add(queue_lock) + db.session.commit() + + check_queue = queue_repo.get_by_name(queue.name, account.group, DeletionPolicy.ANY) + assert check_queue == queue + + check_queue = queue_repo.get_by_name( + queue.name, account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + queue.name, account.group, DeletionPolicy.DELETED + ) + assert check_queue == queue + + +def test_queue_get_by_name_not_exist(queue_repo, account, db, fake_data): + check_queue = queue_repo.get_by_name("foo", account.group, DeletionPolicy.ANY) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "foo", account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name("foo", account.group, DeletionPolicy.DELETED) + assert not check_queue + + # Try getting an existing queue via the wrong group + queue = fake_data.queue(account.user, account.group) + queue_repo.create(queue) + db.session.commit() + + account2 = fake_data.account() + db.session.add(account2.group) + db.session.commit() + + check_queue = queue_repo.get_by_name(queue.name, account2.group, DeletionPolicy.ANY) + assert not check_queue + + check_queue = queue_repo.get_by_name( + queue.name, account2.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + queue.name, account2.group, DeletionPolicy.DELETED + ) + assert not check_queue + + +def test_queue_get_by_name_multi_version(queue_repo, account, db, fake_data): + queuesnap1 = fake_data.queue(account.user, account.group) + # We can't rely on auto-timestamping here; the instances are created too + # quickly and can coincidentally get identical timestamps. + queuesnap1.created_on = datetime.datetime.fromisoformat( + "1992-07-22T12:17:31.410801Z" + ) + + queuesnap2 = m.Queue( + queuesnap1.description, queuesnap1.resource, queuesnap1.creator, queuesnap1.name + ) + queuesnap2.created_on = datetime.datetime.fromisoformat( + "1998-10-23T20:39:53.132405Z" + ) + + db.session.add_all([queuesnap1, queuesnap2]) + db.session.commit() + + # Should only get the latest version + queue = queue_repo.get_by_name( + queuesnap1.name, account.group, DeletionPolicy.NOT_DELETED + ) + + assert queue == queuesnap2 + + +def test_queue_get_by_name_old_name(queue_repo, account, db): + # Ensure getting a queue by an old name doesn't return an old queue + # snapshot. + queue_res = m.Resource("queue", account.group) + queue = m.Queue("desc", queue_res, account.user, "name1") + queue_repo.create(queue) + db.session.commit() + + queue_name2 = m.Queue("desc", queue_res, account.user, "name2") + queue_repo.create_snapshot(queue_name2) + db.session.commit() + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.NOT_DELETED + ) + assert check_queue == queue_name2 + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.ANY + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.ANY + ) + assert check_queue == queue_name2 + + +def test_queue_get_by_name_old_name_deleted(queue_repo, account, db): + # Ensure getting a queue by an old name doesn't return an old queue + # snapshot (deleted version). + queue_res = m.Resource("queue", account.group) + queue = m.Queue("desc", queue_res, account.user, "name1") + queue_repo.create(queue) + db.session.commit() + + queue_name2 = m.Queue("desc", queue_res, account.user, "name2") + queue_repo.create_snapshot(queue_name2) + db.session.commit() + + queue_repo.delete(queue_name2) + db.session.commit() + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.DELETED + ) + assert check_queue == queue_name2 + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.ANY + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.ANY + ) + assert check_queue == queue_name2 + + +def test_queue_get_by_name_old_name_not_exist(queue_repo, account, db): + # Ensure getting a queue by an old name doesn't return an old queue + # snapshot (does not exist version). + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.NOT_DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.DELETED + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name1", account.group, DeletionPolicy.ANY + ) + assert not check_queue + + check_queue = queue_repo.get_by_name( + "name2", account.group, DeletionPolicy.ANY + ) + assert not check_queue + + +def test_queue_create_snapshot(queue_repo, account, db, fake_data): + + queue_snap1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue_snap1) + db.session.commit() + + queue_snap2 = m.Queue( + queue_snap1.description, + queue_snap1.resource, + queue_snap1.creator, + queue_snap1.name, + ) + queue_repo.create_snapshot(queue_snap2) + db.session.commit() + + check_resource = db.session.get(m.Resource, queue_snap1.resource_id) + assert check_resource is not None + assert len(check_resource.versions) == 2 + assert queue_snap1 in check_resource.versions + assert queue_snap2 in check_resource.versions + assert queue_snap1.created_on < queue_snap2.created_on + + +def test_queue_create_snapshot_snap_exists(queue_repo, account, db, fake_data): + + queue_snap1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue_snap1) + db.session.commit() + + with pytest.raises(Exception): + queue_repo.create_snapshot(queue_snap1) + + +def test_queue_create_snapshot_resource_not_exists(queue_repo, account): + + other_resource = m.Resource("queue", account.group) + queue_snap2 = m.Queue("description", other_resource, account.user, "name") + + with pytest.raises(ResourceNotFoundError): + queue_repo.create_snapshot(queue_snap2) + + +def test_queue_create_snapshot_creator_not_member(queue_repo, account, db, fake_data): + + queue_snap1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue_snap1) + db.session.commit() + + u2 = m.User("user2", "password2", "user2@example.org") + g2 = m.Group("group2", u2) + db.session.add(g2) + db.session.add(u2) + db.session.commit() + + queue_snap2 = m.Queue( + queue_snap1.description, queue_snap1.resource, u2, queue_snap1.name + ) + + with pytest.raises(Exception): + queue_repo.create_snapshot(queue_snap2) + + +def test_queue_create_snapshot_name_collision(queue_repo, account, db, fake_data): + + queue1_snap1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue1_snap1) + db.session.commit() + + queue2_snap1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue2_snap1) + db.session.commit() + + queue2_snap2 = m.Queue( + queue2_snap1.description, + queue2_snap1.resource, + queue2_snap1.creator, + queue1_snap1.name, + ) + + with pytest.raises(Exception): + queue_repo.create_snapshot(queue2_snap2) + + # Create a queue in a different group which has the same name as a queue in + # account.group. This ought to be allowed since the groups are different. + account2 = fake_data.account() + db.session.add(account2.group) + db.session.commit() + + queue3_snap1 = fake_data.queue(account2.user, account2.group) + queue_repo.create(queue3_snap1) + db.session.commit() + + queue3_snap2 = m.Queue( + queue3_snap1.description, + queue3_snap1.resource, + queue3_snap1.creator, + queue1_snap1.name, + ) + queue_repo.create_snapshot(queue3_snap2) + db.session.commit() + + +def test_queue_create_snapshot_wrong_resource_type(queue_repo, account, db, fake_data): + + queue_snap1 = fake_data.queue(account.user, account.group) + queue_repo.create(queue_snap1) + db.session.commit() + + experiment = fake_data.experiment(account.user, account.group) + db.session.add(experiment) + db.session.commit() + + queue_snap2 = m.Queue( + queue_snap1.description, experiment.resource, account.user, experiment.name + ) + with pytest.raises(Exception): + queue_repo.create_snapshot(queue_snap2) + + +def test_queue_get_single(queue_repo, queue_snap_setup): + queues, latest_snaps = queue_snap_setup + + latest_snap = queue_repo.get(queues[0].resource_id, DeletionPolicy.NOT_DELETED) + assert latest_snap == latest_snaps[0] + + latest_snap = queue_repo.get(queues[0].resource_id, DeletionPolicy.DELETED) + assert latest_snap is None + + latest_snap = queue_repo.get(queues[0].resource_id, DeletionPolicy.ANY) + assert latest_snap == latest_snaps[0] + + +def test_queue_get_single_deleted(queue_repo, db, queue_snap_setup): + queues, latest_snaps = queue_snap_setup + + # Delete a queue + queue_lock = m.ResourceLock(resource_lock_types.DELETE, queues[0].resource) + db.session.add(queue_lock) + db.session.commit() + + latest_snap = queue_repo.get(queues[0].resource_id, DeletionPolicy.NOT_DELETED) + assert latest_snap is None + + latest_snap = queue_repo.get(queues[0].resource_id, DeletionPolicy.DELETED) + assert latest_snap == latest_snaps[0] + + latest_snap = queue_repo.get(queues[0].resource_id, DeletionPolicy.ANY) + assert latest_snap == latest_snaps[0] + + +def test_queue_get_single_not_exist(queue_repo, queue_snap_setup): + + latest_snap = queue_repo.get(999999, DeletionPolicy.NOT_DELETED) + assert latest_snap is None + + latest_snap = queue_repo.get(999999, DeletionPolicy.DELETED) + assert latest_snap is None + + latest_snap = queue_repo.get(999999, DeletionPolicy.ANY) + assert latest_snap is None + + +def test_queue_get_multi(queue_repo, queue_snap_setup): + queues, latest_snaps = queue_snap_setup + + # For these multi-return-value cases, I actually don't think the + # order of returned snapshots is guaranteed. + check_latest = queue_repo.get( + (queues[0].resource_id, queues[2].resource_id), DeletionPolicy.NOT_DELETED + ) + assert check_latest == [latest_snaps[0], latest_snaps[2]] + + check_latest = queue_repo.get( + (queues[0].resource_id, queues[2].resource_id), DeletionPolicy.DELETED + ) + assert check_latest == [] + + check_latest = queue_repo.get( + (queues[0].resource_id, queues[2].resource_id), DeletionPolicy.ANY + ) + assert check_latest == [latest_snaps[0], latest_snaps[2]] + + # Try with other iterable types (iterables of resource IDs) + check_latest = queue_repo.get( + [queues[0].resource_id, queues[2].resource_id], DeletionPolicy.ANY + ) + assert check_latest == [latest_snaps[0], latest_snaps[2]] + + check_latest = queue_repo.get( + {queues[0].resource_id, queues[2].resource_id}, DeletionPolicy.ANY + ) + assert check_latest == [latest_snaps[0], latest_snaps[2]] + + +def test_queue_get_multi_deleted(queue_repo, db, queue_snap_setup): + queues, latest_snaps = queue_snap_setup + + # ... and delete one + lock = m.ResourceLock(resource_lock_types.DELETE, queues[0].resource) + db.session.add(lock) + db.session.commit() + + # For these multi-return-value cases, I actually don't think the + # order of returned snapshots is guaranteed. + check_latest = queue_repo.get( + (queues[0].resource_id, queues[2].resource_id), DeletionPolicy.NOT_DELETED + ) + assert check_latest == [latest_snaps[2]] + + check_latest = queue_repo.get( + (queues[0].resource_id, queues[2].resource_id), DeletionPolicy.DELETED + ) + assert check_latest == [latest_snaps[0]] + + check_latest = queue_repo.get( + (queues[0].resource_id, queues[2].resource_id), DeletionPolicy.ANY + ) + assert check_latest == [latest_snaps[0], latest_snaps[2]] + + +def test_queue_get_multi_not_exist(queue_repo, queue_snap_setup): + queues, latest_snaps = queue_snap_setup + + # For these multi-return-value cases, I actually don't think the + # order of returned snapshots is guaranteed. + check_latest = queue_repo.get( + (999999, queues[2].resource_id), DeletionPolicy.NOT_DELETED + ) + assert check_latest == [latest_snaps[2]] + + check_latest = queue_repo.get((999999, 888888), DeletionPolicy.NOT_DELETED) + assert check_latest == [] + + check_latest = queue_repo.get( + (999999, queues[2].resource_id), DeletionPolicy.DELETED + ) + assert check_latest == [] + + check_latest = queue_repo.get((queues[2].resource_id, 999999), DeletionPolicy.ANY) + assert check_latest == [latest_snaps[2]] + + +def test_queue_delete(queue_repo, db, queue_snap_setup): + queues, latest_snaps = queue_snap_setup + + queue_repo.delete(queues[0]) + db.session.commit() + assert queues[0].resource.is_deleted + + # Second time should be a no-op + queue_repo.delete(queues[0]) + db.session.commit() + assert queues[0].resource.is_deleted + + +def test_queue_delete_not_exist(queue_repo, account, fake_data): + queue = fake_data.queue(account.user, account.group) + + with pytest.raises(ResourceNotFoundError): + queue_repo.delete(queue) diff --git a/tests/unit/restapi/test_repo_utils.py b/tests/unit/restapi/test_repo_utils.py index eca6da906..50e640a94 100644 --- a/tests/unit/restapi/test_repo_utils.py +++ b/tests/unit/restapi/test_repo_utils.py @@ -15,8 +15,13 @@ # ACCESS THE FULL CC BY 4.0 LICENSE HERE: # https://creativecommons.org/licenses/by/4.0/legalcode import pytest + import dioptra.restapi.db.models as models import dioptra.restapi.db.repository.utils as utils +from dioptra.restapi.db.models.constants import resource_lock_types +from dioptra.restapi.db.shared_errors import ( + ResourceDeletedError, ResourceExistsError, ResourceNotFoundError +) def test_user_exists(db, account): @@ -252,14 +257,18 @@ def test_assert_group_does_not_exist_group_not_exists(db, account): user2 = models.User("user2", "password2", "user2@example.org") group2 = models.Group("group2", user2) - utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.NOT_DELETED) + utils.assert_group_does_not_exist( + db.session, group2, utils.DeletionPolicy.NOT_DELETED + ) utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.DELETED) utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.ANY) # Also try with bad ID group2.group_id = 999999 - utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.NOT_DELETED) + utils.assert_group_does_not_exist( + db.session, group2, utils.DeletionPolicy.NOT_DELETED + ) utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.DELETED) utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.ANY) @@ -282,3 +291,235 @@ def test_assert_group_does_not_exist_group_deleted(db, account): with pytest.raises(Exception): utils.assert_group_does_not_exist(db.session, group2, utils.DeletionPolicy.ANY) + + +def test_resource_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + result = utils.resource_exists(db.session, queue) + assert result is utils.ExistenceResult.EXISTS + + +def test_resource_not_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + + result = utils.resource_exists(db.session, queue) + assert result is utils.ExistenceResult.DOES_NOT_EXIST + + +def test_resource_deleted(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + lock = models.ResourceLock("delete", queue.resource) + db.session.add(lock) + db.session.commit() + + result = utils.resource_exists(db.session, queue) + assert result is utils.ExistenceResult.DELETED + + +def test_assert_resource_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.NOT_DELETED) + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.ANY) + + with pytest.raises(ResourceExistsError): + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.DELETED) + + +def test_assert_resource_exists_not_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + + with pytest.raises(ResourceNotFoundError): + utils.assert_resource_exists( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + with pytest.raises(ResourceNotFoundError): + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.ANY) + + with pytest.raises(ResourceNotFoundError): + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.DELETED) + + # Also try with bad ID + queue.resource_snapshot_id = 999999 + + with pytest.raises(ResourceNotFoundError): + utils.assert_resource_exists( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + with pytest.raises(ResourceNotFoundError): + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.ANY) + + with pytest.raises(ResourceNotFoundError): + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.DELETED) + + +def test_assert_resource_exists_deleted(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + lock = models.ResourceLock("delete", queue.resource) + db.session.add(lock) + db.session.commit() + + with pytest.raises(ResourceDeletedError): + utils.assert_resource_exists( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.ANY) + + utils.assert_resource_exists(db.session, queue, utils.DeletionPolicy.DELETED) + + +def test_assert_resource_does_not_exist_resource_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + with pytest.raises(ResourceExistsError): + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + with pytest.raises(ResourceExistsError): + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.ANY + ) + + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.DELETED + ) + + +def test_assert_resource_does_not_exist_resource_not_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + utils.assert_resource_does_not_exist(db.session, queue, utils.DeletionPolicy.ANY) + + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.DELETED + ) + + # Also try with bad ID + queue.resource_snapshot_id = 999999 + + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + utils.assert_resource_does_not_exist(db.session, queue, utils.DeletionPolicy.ANY) + + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.DELETED + ) + + +def test_assert_resource_does_not_exist_resource_deleted(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + lock = models.ResourceLock("delete", queue.resource) + db.session.add(lock) + db.session.commit() + + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.NOT_DELETED + ) + + with pytest.raises(ResourceDeletedError): + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.ANY + ) + + with pytest.raises(ResourceDeletedError): + utils.assert_resource_does_not_exist( + db.session, queue, utils.DeletionPolicy.DELETED + ) + + +def test_snapshot_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + result = utils.snapshot_exists(db.session, queue) + assert result + + +def test_snapshot_not_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + result = utils.snapshot_exists(db.session, queue) + assert not result + + queue.resource_snapshot_id = 999999 + result = utils.snapshot_exists(db.session, queue) + assert not result + + +def test_assert_snapshot_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + utils.assert_snapshot_exists(db.session, queue) + + +def test_assert_snapshot_exists_not_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + + with pytest.raises(Exception): + utils.assert_snapshot_exists(db.session, queue) + + +def test_assert_snapshot_does_not_exist(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + with pytest.raises(Exception): + utils.assert_snapshot_does_not_exist(db.session, queue) + + +def test_assert_snapshot_does_not_exist_not_exists(db, fake_data, account): + queue = fake_data.queue(account.user, account.group) + + utils.assert_snapshot_does_not_exist(db.session, queue) + + +def test_delete_resource(db, account, fake_data): + queue = fake_data.queue(account.user, account.group) + db.session.add(queue) + db.session.commit() + + utils.delete_resource(db.session, queue) + + lock = db.session.get( + models.ResourceLock, (queue.resource_id, resource_lock_types.DELETE) + ) + assert lock + + # Should be a no-op + utils.delete_resource(db.session, queue) + + +def test_delete_resource_not_exists(db, account, fake_data): + queue = fake_data.queue(account.user, account.group) + + with pytest.raises(ResourceNotFoundError): + utils.delete_resource(db.session, queue)