diff --git a/src/apps/subjects/api.py b/src/apps/subjects/api.py index fcb08e13301..20cf965670e 100644 --- a/src/apps/subjects/api.py +++ b/src/apps/subjects/api.py @@ -15,7 +15,7 @@ from apps.invitations.errors import NonUniqueValue from apps.invitations.services import InvitationsService from apps.shared.domain import Response, ResponseMulti -from apps.shared.exception import NotFoundError, ValidationError +from apps.shared.exception import AccessDeniedError, NotFoundError, ValidationError from apps.shared.response import EmptyResponse from apps.shared.subjects import is_take_now_relation, is_valid_take_now_relation from apps.subjects.domain import ( @@ -33,6 +33,7 @@ from apps.users import User from apps.users.services.user import UserService from apps.workspaces.domain.constants import Role +from apps.workspaces.service.applet_access import AppletAccessService from apps.workspaces.service.check_access import CheckAccessService from apps.workspaces.service.user_access import UserAccessService from apps.workspaces.service.user_applet_access import UserAppletAccessService @@ -317,6 +318,12 @@ async def get_target_subjects_by_respondent( respondent_subject.applet_id, respondent_subject.id ) + access = await AppletAccessService(session).get_priority_access( + applet_id=respondent_subject.applet_id, user_id=user.id + ) + if not access: + raise AccessDeniedError() + assignment_service = ActivityAssignmentService(session) assignment_subject_ids = await assignment_service.get_target_subject_ids_by_respondent( respondent_subject_id=respondent_subject_id, activity_or_flow_ids=[activity_or_flow_id] @@ -351,7 +358,12 @@ class SubjectInfo(TypedDict): # Find the respondent subject in the list of subjects respondent_target_subject: TargetSubjectByRespondentResponse | None = None + is_super_reviewer = access.role in Role.super_reviewers() for subject in subjects: + can_view_data = is_super_reviewer or ( + access.role == Role.REVIEWER and str(subject.id) in access.meta.get("subjects", []) + ) + target_subject = TargetSubjectByRespondentResponse( secret_user_id=subject.secret_user_id, nickname=subject.nickname, @@ -363,6 +375,7 @@ class SubjectInfo(TypedDict): last_name=subject.last_name, submission_count=subject_info[subject.id]["submission_count"], currently_assigned=subject_info[subject.id]["currently_assigned"], + team_member_can_view_data=can_view_data, ) if subject.id == respondent_subject_id: diff --git a/src/apps/subjects/domain.py b/src/apps/subjects/domain.py index 73fc0efa7f6..37a949a4567 100644 --- a/src/apps/subjects/domain.py +++ b/src/apps/subjects/domain.py @@ -91,6 +91,7 @@ class SubjectReadResponse(SubjectUpdateRequest): class TargetSubjectByRespondentResponse(SubjectReadResponse): submission_count: int = 0 currently_assigned: bool = False + team_member_can_view_data: bool = False class SubjectRelation(InternalModel): diff --git a/src/apps/subjects/tests/tests.py b/src/apps/subjects/tests/tests.py index bcb98ec27f4..c9bd552c684 100644 --- a/src/apps/subjects/tests/tests.py +++ b/src/apps/subjects/tests/tests.py @@ -890,6 +890,7 @@ async def test_get_target_subjects_by_respondent_no_assignments_or_submissions( assert subject_result["id"] == str(lucy_applet_one_subject.id) assert subject_result["submissionCount"] == 0 assert subject_result["currentlyAssigned"] is True + assert subject_result["teamMemberCanViewData"] is True async def test_get_target_subjects_by_respondent_manual_assignment( self, @@ -944,6 +945,7 @@ async def test_get_target_subjects_by_respondent_manual_assignment( assert subject_result["id"] == str(tom_applet_one_subject.id) assert subject_result["submissionCount"] == 0 assert subject_result["currentlyAssigned"] is True + assert subject_result["teamMemberCanViewData"] is True async def test_get_target_subjects_by_respondent_excludes_deleted_assignment( self, @@ -1067,15 +1069,17 @@ async def test_get_target_subjects_by_respondent_multiple_assignments( assert tom_result["id"] == str(tom_applet_one_subject.id) assert tom_result["submissionCount"] == 0 assert tom_result["currentlyAssigned"] is True + assert tom_result["teamMemberCanViewData"] is True shell_account_result = result[1] assert shell_account_result["id"] == str(applet_one_shell_account.id) assert shell_account_result["submissionCount"] == 0 assert shell_account_result["currentlyAssigned"] is True + assert shell_account_result["teamMemberCanViewData"] is True @pytest.mark.parametrize("subject_type", ["target", "respondent"]) - async def test_get_target_subjects_by_respondent_via_submission( + async def test_get_target_subjects_by_respondent_via_submission_as_owner( self, client, tom: User, @@ -1130,3 +1134,161 @@ async def test_get_target_subjects_by_respondent_via_submission( assert tom_result["id"] == str(tom_applet_one_subject.id) assert tom_result["submissionCount"] == 1 assert tom_result["currentlyAssigned"] is False + assert tom_result["teamMemberCanViewData"] is True + + async def test_get_target_subjects_by_respondent_via_submission_as_reviewer( + self, + client, + tom: User, + pit: User, + tom_applet_one_subject: Subject, + applet_one_shell_account: Subject, + applet_one_pit_reviewer: AppletFull, + answer_create_payload: dict, + session: AsyncSession, + ): + activity = applet_one_pit_reviewer.activities[0] + + # Turn off auto-assignment + activity_service = ActivityService(session, tom.id) + await activity_service.remove_applet_activities(applet_one_pit_reviewer.id) + await activity_service.update_create( + applet_one_pit_reviewer.id, + [ + ActivityUpdate( + **activity.dict(exclude={"auto_assign"}), + auto_assign=False, + ) + ], + ) + + # Assign pit as a reviewer to tom + await UserAppletAccessService(session, tom.id, applet_one_pit_reviewer.id).set_subjects_for_review( + reviewer_id=pit.id, applet_id=applet_one_pit_reviewer.id, subjects=[tom_applet_one_subject.id] + ) + + filtered_answer_create_payload = {k: v for k, v in answer_create_payload.items() if k != "submit_id"} + + # Self-report answer + await AnswerService(session, tom.id).create_answer( + AppletAnswerCreate( + **filtered_answer_create_payload, + submit_id=str(uuid.uuid4()), + input_subject_id=tom_applet_one_subject.id, + source_subject_id=tom_applet_one_subject.id, + target_subject_id=tom_applet_one_subject.id, + ) + ) + + # Multi-informant answer + await AnswerService(session, tom.id).create_answer( + AppletAnswerCreate( + **filtered_answer_create_payload, + submit_id=str(uuid.uuid4()), + input_subject_id=tom_applet_one_subject.id, + source_subject_id=tom_applet_one_subject.id, + target_subject_id=applet_one_shell_account.id, + ) + ) + + client.login(pit) + + url = self.subject_target_by_respondent_url.format( + respondent_subject_id=tom_applet_one_subject.id, activity_or_flow_id=str(activity.id) + ) + response = await client.get(url) + assert response.status_code == http.HTTPStatus.OK + + result = response.json()["result"] + + assert len(result) == 2 + + tom_result = result[0] + + assert tom_result["id"] == str(tom_applet_one_subject.id) + assert tom_result["submissionCount"] == 1 + assert tom_result["currentlyAssigned"] is False + assert tom_result["teamMemberCanViewData"] is True + + shell_account_result = result[1] + + assert shell_account_result["id"] == str(applet_one_shell_account.id) + assert shell_account_result["submissionCount"] == 1 + assert shell_account_result["currentlyAssigned"] is False + assert shell_account_result["teamMemberCanViewData"] is False + + async def test_get_target_subjects_by_respondent_via_submission_as_coordinator( + self, + client, + tom: User, + bob: User, + tom_applet_one_subject: Subject, + applet_one_shell_account: Subject, + applet_one_bob_coordinator: AppletFull, + answer_create_payload: dict, + session: AsyncSession, + ): + activity = applet_one_bob_coordinator.activities[0] + + # Turn off auto-assignment + activity_service = ActivityService(session, tom.id) + await activity_service.remove_applet_activities(applet_one_bob_coordinator.id) + await activity_service.update_create( + applet_one_bob_coordinator.id, + [ + ActivityUpdate( + **activity.dict(exclude={"auto_assign"}), + auto_assign=False, + ) + ], + ) + + filtered_answer_create_payload = {k: v for k, v in answer_create_payload.items() if k != "submit_id"} + + # Self-report answer + await AnswerService(session, tom.id).create_answer( + AppletAnswerCreate( + **filtered_answer_create_payload, + submit_id=str(uuid.uuid4()), + input_subject_id=tom_applet_one_subject.id, + source_subject_id=tom_applet_one_subject.id, + target_subject_id=tom_applet_one_subject.id, + ) + ) + + # Multi-informant answer + await AnswerService(session, tom.id).create_answer( + AppletAnswerCreate( + **filtered_answer_create_payload, + submit_id=str(uuid.uuid4()), + input_subject_id=tom_applet_one_subject.id, + source_subject_id=tom_applet_one_subject.id, + target_subject_id=applet_one_shell_account.id, + ) + ) + + client.login(bob) + + url = self.subject_target_by_respondent_url.format( + respondent_subject_id=tom_applet_one_subject.id, activity_or_flow_id=str(activity.id) + ) + response = await client.get(url) + assert response.status_code == http.HTTPStatus.OK + + result = response.json()["result"] + + assert len(result) == 2 + + tom_result = result[0] + + assert tom_result["id"] == str(tom_applet_one_subject.id) + assert tom_result["submissionCount"] == 1 + assert tom_result["currentlyAssigned"] is False + assert tom_result["teamMemberCanViewData"] is False + + shell_account_result = result[1] + + assert shell_account_result["id"] == str(applet_one_shell_account.id) + assert shell_account_result["submissionCount"] == 1 + assert shell_account_result["currentlyAssigned"] is False + assert shell_account_result["teamMemberCanViewData"] is False diff --git a/src/apps/workspaces/service/applet_access.py b/src/apps/workspaces/service/applet_access.py new file mode 100644 index 00000000000..2c7261242e0 --- /dev/null +++ b/src/apps/workspaces/service/applet_access.py @@ -0,0 +1,30 @@ +import uuid + +from apps.applets.domain import UserAppletAccess +from apps.workspaces.crud.applet_access import AppletAccessCRUD + +__all__ = ["AppletAccessService"] + + +class AppletAccessService: + def __init__(self, session): + self.session = session + + async def get_priority_access(self, applet_id: uuid.UUID, user_id: uuid.UUID) -> UserAppletAccess | None: + """ + Get the user's access to an applet with the most permissions. Returns accesses in this order: + 1. Owner + 2. Manager + 3. Coordinator + 4. Editor + 5. Reviewer + 6. Respondent + :param applet_id: + :param user_id: + :return: + """ + schema = await AppletAccessCRUD(self.session).get_priority_access(applet_id, user_id) + if not schema: + return None + + return UserAppletAccess.from_orm(schema)