From 2ecbff76d064804d285156249898020de004fb84 Mon Sep 17 00:00:00 2001 From: iwankrshkin <140184081+iwankrshkin@users.noreply.github.com> Date: Wed, 26 Jun 2024 13:25:10 +0400 Subject: [PATCH] feat: Summary submissions support (M2-5560) (#1466) * feature: flow assessments and notes (M2-6584, M2-6585) (#1304) * fix: Perform the Reviewers Assessment for Activity Flow Allow to perform the Reviewers Assessment for the entire Activity Flow * fix: Add unittests for flow's assessments * fix: Add appletMeta to applet retrieve appletMeta.hasAssessment attribute added to determine the existence of an assessment in an applet * fix: Add notes to submission New feature allows add notes for entire flow by submission id * fix: Add submission id in data export Added reviewedSubmissionId to data export * fix: Fix comments * fix: Changed field name in export * fix: Add index * fix: Fix assessment retrieve * fix: Fix assessment retrieve rights * fix: Submission rights check * fix: Add submission reviews list * fix: Note rights error * fix: Method name * wip: Split assessments list and retrieve for activity and flow methods (M2-6584) * wip[flow assessments]: fixes after merge (M2-6584) * wip[flow assessments]: fix reviewed_flow_submit_id * wip[flow assessments]: fix reviews for flow * wip[flow assessments]: remove case from query * wip[flow assessments]: fix comments * feature(flow's latest report): Latest report (combined) support for flows (#1372) * fix(flow-submission): Fix flow submission endpoints filtering out incomplete submissions(M2-6616) (#1378) * fix(notes): Add user.id to note list (#1406) * fix(responses): Fix for emptyIdentifiers filter (#1418) * fix(summary): Fix getting versions, identifiers, submissions for deleted flows (m2-7030) (#1453) * fix(summary): Fix getting versions, identifiers, submissions for fdeleted flows * (wip): Using id instead id_version for join --------- Co-authored-by: vshvechko --- src/apps/activities/errors.py | 4 + src/apps/activity_flows/crud/flow_history.py | 18 +- src/apps/activity_flows/service/flow.py | 4 +- src/apps/answers/api.py | 230 ++++++- src/apps/answers/crud/answer_items.py | 70 ++- src/apps/answers/crud/answers.py | 69 +- src/apps/answers/crud/notes.py | 28 +- src/apps/answers/db/schemas.py | 5 +- src/apps/answers/domain/answers.py | 15 +- src/apps/answers/router.py | 119 +++- src/apps/answers/service.py | 257 ++++++-- src/apps/answers/tests/conftest.py | 181 +++++- src/apps/answers/tests/test_answers.py | 593 +++++++++++++++++- .../answers/tests/test_answers_arbitrary.py | 122 +++- src/apps/applets/api/applets.py | 13 +- src/apps/applets/crud/applets.py | 8 + src/apps/applets/domain/applet.py | 5 + src/apps/applets/service/applet.py | 3 + src/apps/applets/tests/conftest.py | 16 + src/apps/applets/tests/test_applet.py | 8 + .../2024_06_21_11_24-flow_assessments.py | 68 ++ .../2024_05_15_11_24-flow_assessments.py | 43 ++ 22 files changed, 1728 insertions(+), 151 deletions(-) create mode 100644 src/infrastructure/database/migrations/versions/2024_06_21_11_24-flow_assessments.py create mode 100644 src/infrastructure/database/migrations_arbitrary/versions/2024_05_15_11_24-flow_assessments.py diff --git a/src/apps/activities/errors.py b/src/apps/activities/errors.py index 3abcd1b5c90..0ea893884c7 100644 --- a/src/apps/activities/errors.py +++ b/src/apps/activities/errors.py @@ -252,3 +252,7 @@ class FlowItemActivityKeyNotFoundError(ValidationError): class MultiSelectNoneOptionError(ValidationError): message = _("No more than 1 none option is not allowed for multiselect.") + + +class FlowDoesNotExist(NotFoundError): + message = _("Flow does not exist.") diff --git a/src/apps/activity_flows/crud/flow_history.py b/src/apps/activity_flows/crud/flow_history.py index bddd122f86f..062d55ce27d 100644 --- a/src/apps/activity_flows/crud/flow_history.py +++ b/src/apps/activity_flows/crud/flow_history.py @@ -1,7 +1,7 @@ import uuid from pydantic import parse_obj_as -from sqlalchemy import any_, select +from sqlalchemy import and_, any_, select from sqlalchemy.orm import Query, joinedload from apps.activities.db.schemas import ActivityHistorySchema @@ -100,14 +100,20 @@ async def get_last_histories_by_applet(self, applet_id: uuid.UUID) -> list[Activ db_result = await self._execute(query) return db_result.scalars().all() - async def get_versions_data(self, flow_id: uuid.UUID) -> list[Version]: + async def get_versions_data(self, applet_id: uuid.UUID, flow_id: uuid.UUID) -> list[Version]: query: Query = ( select( AppletHistorySchema.version, AppletHistorySchema.created_at, ) .select_from(ActivityFlowHistoriesSchema) - .join(AppletHistorySchema, AppletHistorySchema.id_version == ActivityFlowHistoriesSchema.applet_id) + .join( + AppletHistorySchema, + and_( + AppletHistorySchema.id_version == ActivityFlowHistoriesSchema.applet_id, + AppletHistorySchema.id == applet_id, + ), + ) .where(ActivityFlowHistoriesSchema.id == flow_id) .order_by(AppletHistorySchema.created_at) ) @@ -115,3 +121,9 @@ async def get_versions_data(self, flow_id: uuid.UUID) -> list[Version]: data = result.all() return parse_obj_as(list[Version], data) + + async def get_list_by_id(self, id_: uuid.UUID) -> list[ActivityFlowHistoriesSchema]: + query: Query = select(ActivityFlowHistoriesSchema) + query = query.where(ActivityFlowHistoriesSchema.id == id_) + result = await self._execute(query) + return result.scalars().all() diff --git a/src/apps/activity_flows/service/flow.py b/src/apps/activity_flows/service/flow.py index 0f05a676877..2928c24d6f1 100644 --- a/src/apps/activity_flows/service/flow.py +++ b/src/apps/activity_flows/service/flow.py @@ -285,5 +285,5 @@ async def get_info_by_applet_id(self, applet_id: uuid.UUID, language: str) -> li flow_map[schema.activity_flow_id].activity_ids.append(schema.activity_id) return flows - async def get_versions(self, flow_id: uuid.UUID) -> list[Version]: - return await FlowsHistoryCRUD(self.session).get_versions_data(flow_id) + async def get_versions(self, applet_id: uuid.UUID, flow_id: uuid.UUID) -> list[Version]: + return await FlowsHistoryCRUD(self.session).get_versions_data(applet_id, flow_id) diff --git a/src/apps/answers/api.py b/src/apps/answers/api.py index bc24eb030a5..0afdcc93305 100644 --- a/src/apps/answers/api.py +++ b/src/apps/answers/api.py @@ -8,7 +8,6 @@ from pydantic import parse_obj_as from apps.activities.services import ActivityHistoryService -from apps.activity_flows.service.flow import FlowService from apps.answers.deps.preprocess_arbitrary import get_answer_session, get_arbitraries_map from apps.answers.domain import ( ActivitySubmissionResponse, @@ -199,7 +198,7 @@ async def applet_activity_answers_list( answers = await service.get_activity_answers(applet_id, activity_id, **filters) answers_ids = [answer.answer_id for answer in answers if answer.answer_id is not None] - answer_reviews = await service.get_assessments_count(answers_ids) + answer_reviews = await service.get_answer_assessments_count(answers_ids) result = [] for answer in answers: review_count = answer_reviews.get(answer.answer_id, ReviewsCount()) @@ -216,19 +215,21 @@ async def applet_flow_submissions_list( answer_session=Depends(get_answer_session), ) -> PublicFlowSubmissionsResponse: await AppletService(session, user.id).exist_by_id(applet_id) - flow = await FlowService(session=session).get_by_id(flow_id) - if not flow or flow.applet_id != applet_id: - raise NotFoundError("Flow not found") await CheckAccessService(session, user.id).check_answer_review_access(applet_id) - submissions, total = await AnswerService(session, user.id, answer_session).get_flow_submissions( - flow_id, query_params + applet_id, flow_id, query_params ) + answer_service = AnswerService(session, user.id, answer_session) + submission_ids = [s.submit_id for s in submissions.submissions] + submission_reviews = await answer_service.get_submission_assessment_count(submission_ids) + for submission in submissions.submissions: + review_count = submission_reviews.get(submission.submit_id, ReviewsCount()) + submission.review_count = review_count return PublicFlowSubmissionsResponse(result=submissions, count=total) -async def summary_latest_report_retrieve( +async def summary_activity_latest_report_retrieve( applet_id: uuid.UUID, activity_id: uuid.UUID, subject_id: uuid.UUID, @@ -255,6 +256,33 @@ async def summary_latest_report_retrieve( return FastApiResponse() +async def summary_flow_latest_report_retrieve( + applet_id: uuid.UUID, + flow_id: uuid.UUID, + subject_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +) -> FastApiResponse: + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_answer_review_access(applet_id) + subject = await SubjectsService(session, user.id).get_if_soft_exist(subject_id) + if not subject: + raise NotFoundError(f"Subject {subject_id} not found.") + + report = await AnswerService(session, user.id, answer_session).get_flow_summary_latest_report( + applet_id, flow_id, subject_id + ) + if report: + return FastApiResponse( + base64.b64decode(report.pdf.encode()), + headers={ + "Content-Disposition": f'attachment; filename="{report.email.attachment}.pdf"' # noqa + }, + ) + return FastApiResponse() + + async def applet_submit_date_list( applet_id: uuid.UUID, user: User = Depends(get_current_user), @@ -342,6 +370,30 @@ async def applet_answer_assessment_delete( await service.delete_assessment(assessment_id) +async def applet_submission_delete( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + assessment_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +) -> None: + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_answer_review_access(applet_id) + service = AnswerService(session=session, user_id=user.id, arbitrary_session=answer_session) + answer = await service.get_submission_last_answer(submission_id) + if not answer: + raise NotFoundError() + assessment = await service.get_answer_assessment_by_id(assessment_id, answer.id) + if not assessment: + raise NotFoundError + elif assessment.respondent_id != user.id: + raise AccessDeniedError + async with atomic(session): + async with atomic(answer_session): + await service.delete_assessment(assessment_id) + + async def applet_activity_assessment_retrieve( applet_id: uuid.UUID, answer_id: uuid.UUID, @@ -357,6 +409,23 @@ async def applet_activity_assessment_retrieve( ) +async def applet_submission_assessment_retrieve( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +) -> Response[AssessmentAnswerPublic]: + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_answer_review_access(applet_id) + answer = await AnswerService(session, user.id, answer_session).get_assessment_by_submit_id(applet_id, submission_id) + if not answer: + raise NotFoundError() + return Response( + result=AssessmentAnswerPublic.from_orm(answer), + ) + + async def applet_activity_identifiers_retrieve( applet_id: uuid.UUID, activity_id: uuid.UUID, @@ -381,14 +450,14 @@ async def applet_flow_identifiers_retrieve( answer_session=Depends(get_answer_session), ) -> ResponseMulti[Identifier]: filters = IdentifiersQueryParams(**query_params.filters) - await AppletService(session, user.id).exist_by_id(applet_id) - flow = await FlowService(session=session).get_by_id(flow_id) - if not flow or flow.applet_id != applet_id: - raise NotFoundError("Flow not found") + applet_service = AppletService(session, user.id) + await applet_service.exist_by_id(applet_id) await CheckAccessService(session, user.id).check_answer_review_access(applet_id) if not (target_subject_id := filters.target_subject_id): raise ValidationError("targetSubjectId missed") - identifiers = await AnswerService(session, user.id, answer_session).get_flow_identifiers(flow_id, target_subject_id) + identifiers = await AnswerService(session, user.id, answer_session).get_flow_identifiers( + applet_id, flow_id, target_subject_id + ) return ResponseMulti(result=identifiers, count=len(identifiers)) @@ -422,7 +491,108 @@ async def applet_activity_assessment_create( await AnswerService(session, user.id, answer_session).create_assessment_answer(applet_id, answer_id, schema) -async def note_add( +async def applet_flow_assessment_create( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + schema: AssessmentAnswerCreate = Body(...), + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +): + async with atomic(session): + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_answer_review_access(applet_id) + async with atomic(answer_session): + service = AnswerService(session, user.id, answer_session) + answer = await service.get_submission_last_answer(submission_id) + if answer: + await service.create_assessment_answer(applet_id, answer.id, schema, submission_id) + else: + raise NotFoundError() + + +async def submission_note_add( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + flow_id: uuid.UUID, + schema: AnswerNote = Body(...), + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +): + async with atomic(session): + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_note_crud_access(applet_id) + async with atomic(answer_session): + await AnswerService(session, user.id, answer_session).add_submission_note( + applet_id, submission_id, flow_id, schema.note + ) + return + + +async def submission_note_list( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + flow_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), + query_params: QueryParams = Depends(parse_query_params(BaseQueryParams)), + answer_session=Depends(get_answer_session), +) -> ResponseMulti[AnswerNoteDetailPublic]: + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_note_crud_access(applet_id) + notes = await AnswerService(session, user.id, answer_session).get_submission_note_list( + applet_id, submission_id, flow_id, query_params.page, query_params.limit + ) + count = await AnswerService(session, user.id, answer_session).get_submission_notes_count( + submission_id, flow_id, query_params.page, query_params.limit + ) + return ResponseMulti( + result=[AnswerNoteDetailPublic.from_orm(note) for note in notes], + count=count, + ) + + +async def submission_note_edit( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + flow_id: uuid.UUID, + note_id: uuid.UUID, + schema: AnswerNote = Body(...), + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +): + async with atomic(session): + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_note_crud_access(applet_id) + async with atomic(answer_session): + await AnswerService(session, user.id, answer_session).edit_submission_note( + applet_id, submission_id, note_id, schema.note + ) + return + + +async def submission_note_delete( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + flow_id: uuid.UUID, + note_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +): + async with atomic(session): + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_note_crud_access(applet_id) + async with atomic(answer_session): + await AnswerService(session, user.id, answer_session).delete_submission_note( + applet_id, submission_id, note_id + ) + return + + +async def answer_note_add( applet_id: uuid.UUID, answer_id: uuid.UUID, activity_id: uuid.UUID, @@ -435,13 +605,13 @@ async def note_add( await AppletService(session, user.id).exist_by_id(applet_id) await CheckAccessService(session, user.id).check_note_crud_access(applet_id) async with atomic(answer_session): - await AnswerService(session, user.id, answer_session).add_note( + await AnswerService(session, user.id, answer_session).add_answer_note( applet_id, answer_id, activity_id, schema.note ) return -async def note_list( +async def answer_note_list( applet_id: uuid.UUID, answer_id: uuid.UUID, activity_id: uuid.UUID, @@ -453,7 +623,7 @@ async def note_list( await AppletService(session, user.id).exist_by_id(applet_id) await CheckAccessService(session, user.id).check_note_crud_access(applet_id) notes = await AnswerService(session, user.id, answer_session).get_note_list( - applet_id, answer_id, activity_id, query_params + applet_id, answer_id, activity_id, query_params.page, query_params.limit ) count = await AnswerService(session, user.id, answer_session).get_notes_count(answer_id, activity_id) return ResponseMulti( @@ -462,7 +632,7 @@ async def note_list( ) -async def note_edit( +async def answer_note_edit( applet_id: uuid.UUID, answer_id: uuid.UUID, activity_id: uuid.UUID, @@ -476,13 +646,13 @@ async def note_edit( await AppletService(session, user.id).exist_by_id(applet_id) await CheckAccessService(session, user.id).check_note_crud_access(applet_id) async with atomic(answer_session): - await AnswerService(session, user.id, answer_session).edit_note( + await AnswerService(session, user.id, answer_session).edit_answer_note( applet_id, answer_id, activity_id, note_id, schema.note ) return -async def note_delete( +async def answer_note_delete( applet_id: uuid.UUID, answer_id: uuid.UUID, activity_id: uuid.UUID, @@ -495,7 +665,7 @@ async def note_delete( await AppletService(session, user.id).exist_by_id(applet_id) await CheckAccessService(session, user.id).check_note_crud_access(applet_id) async with atomic(answer_session): - await AnswerService(session, user.id, answer_session).delete_note( + await AnswerService(session, user.id, answer_session).delete_answer_note( applet_id, answer_id, activity_id, note_id ) return @@ -634,3 +804,21 @@ async def answers_existence_check( ) return Response[AnswerExistenceResponse](result=AnswerExistenceResponse(exists=is_exist)) + + +async def applet_submission_reviews_retrieve( + applet_id: uuid.UUID, + submission_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), + answer_session=Depends(get_answer_session), +) -> ResponseMulti[AnswerReviewPublic]: + await AppletService(session, user.id).exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_answer_review_access(applet_id) + reviews = await AnswerService(session, user.id, answer_session).get_reviews_by_submission_id( + applet_id, submission_id + ) + return ResponseMulti( + result=[AnswerReviewPublic.from_orm(review) for review in reviews], + count=len(reviews), + ) diff --git a/src/apps/answers/crud/answer_items.py b/src/apps/answers/crud/answer_items.py index 5ba22bdcbfe..067795a1943 100644 --- a/src/apps/answers/crud/answer_items.py +++ b/src/apps/answers/crud/answer_items.py @@ -18,14 +18,7 @@ class _ActivityAnswerFilter(Filtering): versions = FilterField(AnswerSchema.version, Comparisons.IN) target_subject_id = FilterField(AnswerSchema.target_subject_id) - - # TODO can be removed? - def prepare_identifiers(self, value: str | list[str]) -> list[str] | None: - if not value: - return None - if isinstance(value, str): - return value.split(",") - return value + empty_identifiers = FilterField(AnswerItemSchema.identifier, method_name="filter_empty_identifiers") # TODO can be removed? def prepare_versions(self, value: str | list[str]) -> list[str]: @@ -36,13 +29,15 @@ def prepare_versions(self, value: str | list[str]) -> list[str]: def filter_by_identifiers(self, field, values: list | str): if isinstance(values, str): values = values.split(",") - return field.in_(values) - def prepare_empty_identifiers(self, value: bool): + if isinstance(values, list): + values = list(filter(None.__ne__, values)) + if values: + return field.in_(values) + + def filter_empty_identifiers(self, field, value: bool): if not value: return AnswerItemSchema.identifier.isnot(None) - else: - return AnswerItemSchema.identifier.is_(None) class AnswerItemsCRUD(BaseCRUD[AnswerItemSchema]): @@ -86,11 +81,23 @@ async def get_respondent_submits_by_answer_ids(self, answer_ids: list[uuid.UUID] db_result = await self._execute(query) return db_result.scalars().all() - async def get_assessment(self, answer_id: uuid.UUID, user_id: uuid.UUID) -> AnswerItemSchema | None: + async def get_assessment( + self, answer_id: uuid.UUID, user_id: uuid.UUID, submit_id: uuid.UUID | None = None + ) -> AnswerItemSchema | None: + """ + Return assessment for activity if not passed `submit_id` + Otherwise returns assessment for submissions + """ query: Query = select(AnswerItemSchema) - query = query.where(AnswerItemSchema.answer_id == answer_id) - query = query.where(AnswerItemSchema.respondent_id == user_id) - query = query.where(AnswerItemSchema.is_assessment.is_(True)) + query = query.where( + AnswerItemSchema.answer_id == answer_id, + AnswerItemSchema.respondent_id == user_id, + AnswerItemSchema.is_assessment.is_(True), + ) + if submit_id: + query = query.where(AnswerItemSchema.reviewed_flow_submit_id == submit_id) + else: + query = query.where(AnswerItemSchema.reviewed_flow_submit_id.is_(None)) db_result = await self._execute(query) return db_result.scalars().first() @@ -105,11 +112,21 @@ async def get_answer_assessment(self, answer_item_id: uuid.UUID, answer_id: uuid async def assessment_hard_delete(self, answer_item_id: uuid.UUID): await super()._delete(id=answer_item_id, is_assessment=True) - async def get_reviews_by_answer_id(self, answer_id: uuid.UUID, activity_items: list) -> list[AnswerItemSchema]: + async def get_reviews_by_answer_id(self, answer_id: uuid.UUID) -> list[AnswerItemSchema]: query: Query = select(AnswerItemSchema) query = query.where(AnswerItemSchema.answer_id == answer_id) - query = query.where(AnswerItemSchema.is_assessment.is_(True)) + query = query.where( + AnswerItemSchema.is_assessment.is_(True), AnswerItemSchema.reviewed_flow_submit_id.is_(None) + ) + + db_result = await self._execute(query) + return db_result.scalars().all() # noqa + async def get_reviews_by_submit_id(self, submission_id: uuid.UUID) -> list[AnswerItemSchema]: + query: Query = select(AnswerItemSchema) + query = query.where( + AnswerItemSchema.reviewed_flow_submit_id == submission_id, AnswerItemSchema.is_assessment.is_(True) + ) db_result = await self._execute(query) return db_result.scalars().all() # noqa @@ -190,7 +207,22 @@ async def delete_assessment(self, assessment_id: uuid.UUID): async def get_reviewers_by_answers(self, answer_ids: list[uuid.UUID]) -> list[tuple[uuid.UUID, list[uuid.UUID]]]: query: Query = select(AnswerItemSchema.answer_id, func.array_agg(AnswerItemSchema.respondent_id)) - query = query.where(AnswerItemSchema.answer_id.in_(answer_ids), AnswerItemSchema.is_assessment.is_(True)) + query = query.where( + AnswerItemSchema.answer_id.in_(answer_ids), + AnswerItemSchema.is_assessment.is_(True), + AnswerItemSchema.reviewed_flow_submit_id.is_(None), + ) query = query.group_by(AnswerItemSchema.answer_id) db_result = await self._execute(query) return db_result.all() # noqa + + async def get_reviewers_by_submission( + self, submission_ids: list[uuid.UUID] + ) -> list[tuple[uuid.UUID, list[uuid.UUID]]]: + query: Query = select(AnswerItemSchema.reviewed_flow_submit_id, func.array_agg(AnswerItemSchema.respondent_id)) + query = query.where( + AnswerItemSchema.is_assessment.is_(True), AnswerItemSchema.reviewed_flow_submit_id.in_(submission_ids) + ) + query = query.group_by(AnswerItemSchema.reviewed_flow_submit_id) + db_result = await self._execute(query) + return db_result.all() # noqa diff --git a/src/apps/answers/crud/answers.py b/src/apps/answers/crud/answers.py index bb81efaeed1..2541ba64af2 100644 --- a/src/apps/answers/crud/answers.py +++ b/src/apps/answers/crud/answers.py @@ -6,7 +6,7 @@ from pydantic import parse_obj_as from sqlalchemy import Text, and_, case, column, delete, func, null, or_, select, text, update from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Query, contains_eager +from sqlalchemy.orm import Query, aliased, contains_eager from sqlalchemy.sql import Values from sqlalchemy.sql.elements import BooleanClauseList @@ -134,6 +134,7 @@ async def get_flow_submission_data( AnswerSchema.submit_id, AnswerSchema.flow_history_id, AnswerSchema.applet_id, AnswerSchema.version ) .order_by(created_at) + .having(func.bool_or(AnswerSchema.is_flow_completed.is_(True))) # completed submissions only ) _filters = _AnswerListFilter().get_clauses(**filters) @@ -148,7 +149,7 @@ async def get_flow_submission_data( return parse_obj_as(list[FlowSubmissionInfo], data) async def get_flow_submissions( - self, flow_id: uuid.UUID, *, page=None, limit=None, **filters + self, applet_id: uuid.UUID, flow_id: uuid.UUID, *, page=None, limit=None, **filters ) -> tuple[list[FlowSubmission], int]: created_at = func.max(AnswerItemSchema.created_at) query = ( @@ -194,7 +195,10 @@ async def get_flow_submissions( # fmt: on ) .join(AnswerSchema.answer_item) - .where(AnswerSchema.id_from_history_id(AnswerSchema.flow_history_id) == str(flow_id)) + .where( + AnswerSchema.id_from_history_id(AnswerSchema.flow_history_id) == str(flow_id), + AnswerSchema.applet_id == applet_id, + ) .group_by( AnswerSchema.submit_id, AnswerSchema.flow_history_id, AnswerSchema.applet_id, AnswerSchema.version ) @@ -333,7 +337,7 @@ async def get_applet_answers( flow_history_id.label("flow_history_id"), AnswerItemSchema.created_at, reviewed_answer_id.label("reviewed_answer_id"), - reviewed_answer_id.label("reviewed_answer_id"), + AnswerItemSchema.reviewed_flow_submit_id, AnswerSchema.client, AnswerItemSchema.tz_offset, AnswerItemSchema.scheduled_event_id, @@ -353,7 +357,6 @@ async def get_applet_answers( query = query.order_by(AnswerItemSchema.created_at.desc()) query = paging(query, page, limit) - coro_data, coro_count = ( self._execute(query), self._execute(query_count), @@ -429,22 +432,40 @@ async def get_versions_by_activity_id(self, activity_id: uuid.UUID) -> list[Vers return results - async def get_latest_answer( + async def get_latest_activity_answer( self, applet_id: uuid.UUID, - activity_id: Collection[str], - subject_id: uuid.UUID, + activity_history_ids: Collection[str], + target_subject_id: uuid.UUID, ) -> AnswerSchema | None: query: Query = select(AnswerSchema) query = query.where(AnswerSchema.applet_id == applet_id) - query = query.where(AnswerSchema.activity_history_id.in_(activity_id)) - query = query.where(AnswerSchema.target_subject_id == subject_id) + query = query.where(AnswerSchema.activity_history_id.in_(activity_history_ids)) + query = query.where(AnswerSchema.target_subject_id == target_subject_id) query = query.order_by(AnswerSchema.created_at.desc()) query = query.limit(1) db_result = await self._execute(query) return db_result.scalars().first() + async def get_latest_flow_answer( + self, + applet_id: uuid.UUID, + flow_history_ids: Collection[str], + target_subject_id: uuid.UUID, + ) -> AnswerSchema: + query: Query = select(AnswerSchema) + query = query.where( + AnswerSchema.applet_id == applet_id, + AnswerSchema.flow_history_id.in_(flow_history_ids), + AnswerSchema.target_subject_id == target_subject_id, + AnswerSchema.is_flow_completed.is_(True), + ) + query = query.order_by(AnswerSchema.created_at.desc()) + query = query.limit(1) + db_result = await self._execute(query) + return db_result.scalars().first() + async def get_by_submit_id( self, submit_id: uuid.UUID, answer_id: uuid.UUID | None = None ) -> list[AnswerSchema] | None: @@ -743,7 +764,18 @@ async def delete_by_subject(self, subject_id: uuid.UUID): ) await self._execute(query) - async def get_flow_identifiers(self, flow_id: uuid.UUID, target_subject_id: uuid.UUID) -> list[IdentifierData]: + async def get_flow_identifiers( + self, applet_id: uuid.UUID, flow_id: uuid.UUID, target_subject_id: uuid.UUID + ) -> list[IdentifierData]: + completed_submission = aliased(AnswerSchema, name="completed_submission") + is_submission_completed = ( + select(completed_submission.submit_id) + .where( + completed_submission.submit_id == AnswerSchema.submit_id, + completed_submission.is_flow_completed.is_(True), + ) + .exists() + ) query = ( select( AnswerItemSchema.identifier, @@ -754,9 +786,11 @@ async def get_flow_identifiers(self, flow_id: uuid.UUID, target_subject_id: uuid .select_from(AnswerSchema) .join(AnswerSchema.answer_item) .where( + AnswerSchema.applet_id == applet_id, AnswerSchema.id_from_history_id(AnswerSchema.flow_history_id) == str(flow_id), AnswerSchema.target_subject_id == target_subject_id, AnswerItemSchema.identifier.isnot(None), + is_submission_completed, ) .group_by( AnswerItemSchema.identifier, @@ -796,3 +830,16 @@ async def replace_answers_subject(self, subject_id_from: uuid.UUID, subject_id_t ) await self._execute(query) + + async def get_last_answer_in_flow( + self, submit_id: uuid.UUID, flow_id: uuid.UUID | None = None + ) -> AnswerSchema | None: + query = select(AnswerSchema) + query = query.where( + AnswerSchema.submit_id == submit_id, + AnswerSchema.is_flow_completed.is_(True), + ) + if flow_id: + query = query.where(AnswerSchema.flow_history_id.like(f"{flow_id}_%")) + result = await self._execute(query) + return result.scalar_one_or_none() diff --git a/src/apps/answers/crud/notes.py b/src/apps/answers/crud/notes.py index a8caeb81ac2..e99ff98db45 100644 --- a/src/apps/answers/crud/notes.py +++ b/src/apps/answers/crud/notes.py @@ -9,7 +9,6 @@ from apps.answers.domain import AnswerNoteDetail from apps.answers.errors import AnswerNoteNotFoundError from apps.shared.paging import paging -from apps.shared.query_params import QueryParams from apps.users import UserSchema from infrastructure.database.crud import BaseCRUD @@ -21,20 +20,26 @@ async def save(self, schema: AnswerNoteSchema) -> AnswerNoteSchema: return await self._create(schema) async def get_by_answer_id( - self, - answer_id: uuid.UUID, - activity_id: uuid.UUID, - query_params: QueryParams, + self, answer_id: uuid.UUID, activity_id: uuid.UUID, page: int, limit: int ) -> list[AnswerNoteSchema]: query: Query = select(AnswerNoteSchema) query = query.where(AnswerNoteSchema.answer_id == answer_id) query = query.where(AnswerNoteSchema.activity_id == activity_id) query = query.order_by(AnswerNoteSchema.created_at.desc()) - query = paging(query, query_params.page, query_params.limit) + query = paging(query, page, limit) db_result = await self._execute(query) return db_result.scalars().all() # noqa + async def get_by_submission_id(self, flow_submit_id: uuid.UUID, activity_flow_id: uuid.UUID, page: int, limit: int): + query: Query = select(AnswerNoteSchema) + query = query.where(AnswerNoteSchema.flow_submit_id == flow_submit_id) + query = query.where(AnswerNoteSchema.activity_flow_id == activity_flow_id) + query = query.order_by(AnswerNoteSchema.created_at.desc()) + query = paging(query, page, limit) + db_result = await self._execute(query) + return db_result.scalars().all() # noqa + async def get_count_by_answer_id(self, answer_id: uuid.UUID, activity_id: uuid.UUID) -> int: query: Query = select(count(AnswerNoteSchema.id)) query = query.where(AnswerNoteSchema.answer_id == answer_id) @@ -42,6 +47,16 @@ async def get_count_by_answer_id(self, answer_id: uuid.UUID, activity_id: uuid.U db_result = await self._execute(query) return db_result.scalars().first() or 0 + async def get_count_by_submission_id( + self, flow_submit_id: uuid.UUID, activity_flow_id: uuid.UUID, page: int, limit: int + ) -> int: + query: Query = select(count(AnswerNoteSchema.id)) + query = query.where(AnswerNoteSchema.flow_submit_id == flow_submit_id) + query = query.where(AnswerNoteSchema.activity_flow_id == activity_flow_id) + query = paging(query, page, limit) + db_result = await self._execute(query) + return db_result.scalars().first() or 0 + async def get_by_id(self, note_id) -> AnswerNoteSchema: note = await self._get("id", note_id) if not note: @@ -65,6 +80,7 @@ async def map_users_and_notes( AnswerNoteDetail( id=note.id, user=dict( + id=note_user.id, first_name=note_user.first_name, last_name=note_user.last_name, ), diff --git a/src/apps/answers/db/schemas.py b/src/apps/answers/db/schemas.py index 921c438d58b..061db2f3097 100644 --- a/src/apps/answers/db/schemas.py +++ b/src/apps/answers/db/schemas.py @@ -65,7 +65,9 @@ class AnswerNoteSchema(Base): __tablename__ = "answer_notes" answer_id = Column(UUID(as_uuid=True), index=True) - activity_id = Column(UUID(as_uuid=True)) + flow_submit_id = Column(UUID(as_uuid=True), nullable=True, index=True) + activity_id = Column(UUID(as_uuid=True), nullable=True) + activity_flow_id = Column(UUID(as_uuid=True), nullable=True) note = Column(StringEncryptedType(Unicode, get_key)) user_id = Column(UUID(as_uuid=True), nullable=True, index=True) @@ -92,6 +94,7 @@ class AnswerItemSchema(Base): migrated_data = Column(JSONB()) assessment_activity_id = Column(Text(), nullable=True, index=True) tz_offset = Column(Integer, nullable=True, comment="Local timezone offset in minutes") + reviewed_flow_submit_id = Column(UUID(as_uuid=True), nullable=True, index=True) @hybrid_property def is_identifier_encrypted(self): diff --git a/src/apps/answers/domain/answers.py b/src/apps/answers/domain/answers.py index da32efad11f..83d46f97b25 100644 --- a/src/apps/answers/domain/answers.py +++ b/src/apps/answers/domain/answers.py @@ -109,6 +109,7 @@ class AssessmentAnswerCreate(InternalModel): item_ids: list[uuid.UUID] reviewer_public_key: str assessment_version_id: str + reviewed_flow_submit_id: uuid.UUID | None class AnswerDate(InternalModel): @@ -277,6 +278,11 @@ def generate_summary(cls, value, values): return value +class ReviewsCount(PublicModel): + mine: int = 0 + other: int = 0 + + class FlowSubmission(PublicModel): submit_id: uuid.UUID flow_history_id: str @@ -286,6 +292,7 @@ class FlowSubmission(PublicModel): end_datetime: datetime.datetime | None = None is_completed: bool | None = None answers: list[ActivityAnswer] + review_count: ReviewsCount = ReviewsCount() class FlowSubmissionsDetails(PublicModel): @@ -372,11 +379,6 @@ def generate_summary(cls, value, values): return value -class ReviewsCount(PublicModel): - mine: int = 0 - other: int = 0 - - class AppletActivityAnswer(InternalModel): answer_id: uuid.UUID version: str | None @@ -462,6 +464,7 @@ class AnswerNote(InternalModel): class NoteOwner(InternalModel): + id: uuid.UUID first_name: str last_name: str @@ -474,6 +477,7 @@ class AnswerNoteDetail(InternalModel): class NoteOwnerPublic(PublicModel): + id: uuid.UUID first_name: str last_name: str @@ -510,6 +514,7 @@ class UserAnswerDataBase(BaseModel): flow_history_id: str | None flow_name: str | None reviewed_answer_id: uuid.UUID | str | None + reviewed_flow_submit_id: uuid.UUID | str | None created_at: datetime.datetime migrated_data: dict | None = None client: ClientMeta | None = None diff --git a/src/apps/answers/router.py b/src/apps/answers/router.py index a9bf0f070d0..86639f1c997 100644 --- a/src/apps/answers/router.py +++ b/src/apps/answers/router.py @@ -2,6 +2,10 @@ from starlette import status from apps.answers.api import ( + answer_note_add, + answer_note_delete, + answer_note_edit, + answer_note_list, answers_existence_check, applet_activity_answer_retrieve, applet_activity_answers_list, @@ -14,21 +18,26 @@ applet_answers_export, applet_completed_entities, applet_flow_answer_retrieve, + applet_flow_assessment_create, applet_flow_identifiers_retrieve, applet_flow_submissions_list, + applet_submission_assessment_retrieve, + applet_submission_delete, + applet_submission_reviews_retrieve, applet_submit_date_list, applets_completed_entities, create_anonymous_answer, create_answer, - note_add, - note_delete, - note_edit, - note_list, review_activity_list, review_flow_list, + submission_note_add, + submission_note_delete, + submission_note_edit, + submission_note_list, summary_activity_flow_list, + summary_activity_latest_report_retrieve, summary_activity_list, - summary_latest_report_retrieve, + summary_flow_latest_report_retrieve, ) from apps.answers.domain import ( ActivitySubmissionResponse, @@ -182,7 +191,17 @@ **DEFAULT_OPENAPI_RESPONSE, **AUTHENTICATION_ERROR_RESPONSES, }, -)(summary_latest_report_retrieve) +)(summary_activity_latest_report_retrieve) + + +router.post( + "/applet/{applet_id}/flows/{flow_id}/subjects/{subject_id}/latest_report", + status_code=status.HTTP_200_OK, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(summary_flow_latest_report_retrieve) router.get( "/applet/{applet_id}/dates", @@ -225,6 +244,7 @@ )(applet_answer_reviews_retrieve) router.get( + # TODO: Change 'assessment' to assessments "/applet/{applet_id}/answers/{answer_id}/assessment", status_code=status.HTTP_200_OK, responses={ @@ -235,11 +255,13 @@ )(applet_activity_assessment_retrieve) router.delete( + # TODO: Change 'assessment' to assessments "/applet/{applet_id}/answers/{answer_id}/assessment/{assessment_id}", status_code=status.HTTP_204_NO_CONTENT, )(applet_answer_assessment_delete) router.post( + # TODO: Change 'assessment' to assessments "/applet/{applet_id}/answers/{answer_id}/assessment", status_code=status.HTTP_201_CREATED, responses={ @@ -248,6 +270,43 @@ }, )(applet_activity_assessment_create) +router.post( + "/applet/{applet_id}/submissions/{submission_id}/assessments", + status_code=status.HTTP_201_CREATED, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(applet_flow_assessment_create) + + +router.get( + "/applet/{applet_id}/submissions/{submission_id}/assessments", + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": Response[AssessmentAnswerPublic]}, + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(applet_submission_assessment_retrieve) + +router.delete( + "/applet/{applet_id}/submissions/{submission_id}/assessments/{assessment_id}", + status_code=status.HTTP_204_NO_CONTENT, +)(applet_submission_delete) + + +router.get( + "/applet/{applet_id}/submissions/{submission_id}/reviews", + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": ResponseMulti[AnswerReviewPublic]}, + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(applet_submission_reviews_retrieve) + + router.post( "/applet/{applet_id}/answers/{answer_id}/activities/{activity_id}/notes", status_code=status.HTTP_201_CREATED, @@ -255,7 +314,7 @@ **DEFAULT_OPENAPI_RESPONSE, **AUTHENTICATION_ERROR_RESPONSES, }, -)(note_add) +)(answer_note_add) router.get( "/applet/{applet_id}/answers/{answer_id}/activities/{activity_id}/notes", @@ -265,7 +324,7 @@ **DEFAULT_OPENAPI_RESPONSE, **AUTHENTICATION_ERROR_RESPONSES, }, -)(note_list) +)(answer_note_list) router.put( "/applet/{applet_id}/answers/{answer_id}/activities/" "{activity_id}/notes/{note_id}", @@ -275,7 +334,7 @@ **DEFAULT_OPENAPI_RESPONSE, **AUTHENTICATION_ERROR_RESPONSES, }, -)(note_edit) +)(answer_note_edit) router.delete( "/applet/{applet_id}/answers/{answer_id}/activities/" "{activity_id}/notes/{note_id}", @@ -285,7 +344,47 @@ **DEFAULT_OPENAPI_RESPONSE, **AUTHENTICATION_ERROR_RESPONSES, }, -)(note_delete) +)(answer_note_delete) + + +router.post( + "/applet/{applet_id}/submissions/{submission_id}/flows/{flow_id}/notes", + status_code=status.HTTP_201_CREATED, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(submission_note_add) + +router.get( + "/applet/{applet_id}/submissions/{submission_id}/flows/{flow_id}/notes", + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": Response[AnswerNoteDetailPublic]}, + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(submission_note_list) + +router.put( + "/applet/{applet_id}/submissions/{submission_id}/flows/{flow_id}/notes/{note_id}", + # noqa: E501 + status_code=status.HTTP_200_OK, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(submission_note_edit) + +router.delete( + "/applet/{applet_id}/submissions/{submission_id}/flows/{flow_id}/notes/{note_id}", + # noqa: E501 + status_code=status.HTTP_204_NO_CONTENT, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(submission_note_delete) router.get( "/applet/{applet_id}/data", diff --git a/src/apps/answers/service.py b/src/apps/answers/service.py index 306f88ccfd5..83eeed0aef4 100644 --- a/src/apps/answers/service.py +++ b/src/apps/answers/service.py @@ -18,8 +18,9 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key from apps.activities.crud import ActivitiesCRUD, ActivityHistoriesCRUD, ActivityItemHistoriesCRUD +from apps.activities.db.schemas import ActivityItemHistorySchema from apps.activities.domain.activity_history import ActivityHistoryFull -from apps.activities.errors import ActivityDoeNotExist, ActivityHistoryDoeNotExist +from apps.activities.errors import ActivityDoeNotExist, ActivityHistoryDoeNotExist, FlowDoesNotExist from apps.activity_flows.crud import FlowsCRUD, FlowsHistoryCRUD from apps.alerts.crud.alert import AlertCRUD from apps.alerts.db.schemas import AlertSchema @@ -530,11 +531,18 @@ async def _validate_answer_access( pk = self._generate_history_id(answer_schema.version) await ActivityHistoriesCRUD(self.session).get_by_id(pk(activity_id)) + async def _validate_submission_access(self, applet_id: uuid.UUID, submission_id: uuid.UUID): + answer_schema = await AnswersCRUD(self.answer_session).get_last_answer_in_flow(submission_id) + if not answer_schema: + raise AnswerNotFoundError() + await self._validate_applet_activity_access(applet_id, answer_schema.target_subject_id) + async def get_flow_submission( self, applet_id: uuid.UUID, flow_id: uuid.UUID, submit_id: uuid.UUID, + is_completed: bool | None = None, ) -> FlowSubmissionDetails: allowed_subjects = await self._get_allowed_subjects(applet_id) @@ -554,7 +562,7 @@ async def get_flow_submission( answer_result: list[ActivityAnswer] = [] - is_completed = False + is_flow_completed = False for answer in answers: if answer.flow_history_id and answer.is_flow_completed: is_completed = True @@ -575,6 +583,11 @@ async def get_flow_submission( ) ) activity_hist_ids.add(answer.activity_history_id) + if answer.is_flow_completed: + is_flow_completed = True + + if is_completed and is_completed != is_flow_completed: + raise AnswerNotFoundError() flow_history_id = answers[0].flow_history_id assert flow_history_id @@ -591,14 +604,14 @@ async def get_flow_submission( created_at=max([a.created_at for a in answer_result]), end_datetime=max([a.end_datetime for a in answer_result]), answers=answer_result, - is_completed=is_completed, + is_completed=is_flow_completed, ), flow=flows[0], ) return submission - async def add_note( + async def add_answer_note( self, applet_id: uuid.UUID, answer_id: uuid.UUID, @@ -616,15 +629,11 @@ async def add_note( return note_schema async def get_note_list( - self, - applet_id: uuid.UUID, - answer_id: uuid.UUID, - activity_id: uuid.UUID, - query_params: QueryParams, + self, applet_id: uuid.UUID, answer_id: uuid.UUID, activity_id: uuid.UUID, page: int, limit: int ) -> list[AnswerNoteDetail]: await self._validate_answer_access(applet_id, answer_id, activity_id) notes_crud = AnswerNotesCRUD(self.session) - note_schemas = await notes_crud.get_by_answer_id(answer_id, activity_id, query_params) + note_schemas = await notes_crud.get_by_answer_id(answer_id, activity_id, page, limit) user_ids = set(map(lambda n: n.user_id, note_schemas)) users_crud = UsersCRUD(self.session) users = await users_crud.get_by_ids(user_ids) @@ -634,7 +643,12 @@ async def get_note_list( async def get_notes_count(self, answer_id: uuid.UUID, activity_id: uuid.UUID) -> int: return await AnswerNotesCRUD(self.session).get_count_by_answer_id(answer_id, activity_id) - async def edit_note( + async def get_submission_notes_count( + self, answer_id: uuid.UUID, activity_id: uuid.UUID, page: int, limit: int + ) -> int: + return await AnswerNotesCRUD(self.session).get_count_by_submission_id(answer_id, activity_id, page, limit) + + async def edit_answer_note( self, applet_id: uuid.UUID, answer_id: uuid.UUID, @@ -646,7 +660,7 @@ async def edit_note( await self._validate_note_access(note_id) await AnswerNotesCRUD(self.session).update_note_by_id(note_id, note) - async def delete_note( + async def delete_answer_note( self, applet_id: uuid.UUID, answer_id: uuid.UUID, @@ -662,12 +676,8 @@ async def _validate_note_access(self, note_id: uuid.UUID): if note.user_id != self.user_id: raise AnswerNoteAccessDeniedError() - async def get_assessment_by_answer_id(self, applet_id: uuid.UUID, answer_id: uuid.UUID) -> AssessmentAnswer: + async def _get_full_assessment_info(self, applet_id: uuid.UUID, assessment_answer: AnswerItemSchema | None): assert self.user_id - - await self._validate_answer_access(applet_id, answer_id) - assessment_answer = await AnswerItemsCRUD(self.answer_session).get_assessment(answer_id, self.user_id) - items_crud = ActivityItemHistoriesCRUD(self.session) last = items_crud.get_applets_assessments(applet_id) if assessment_answer: @@ -712,6 +722,28 @@ async def get_assessment_by_answer_id(self, applet_id: uuid.UUID, answer_id: uui ) return answer + async def get_assessment_by_answer_id(self, applet_id: uuid.UUID, answer_id: uuid.UUID) -> AssessmentAnswer: + assert self.user_id + await self._validate_answer_access(applet_id, answer_id) + assessment_answer = await AnswerItemsCRUD(self.answer_session).get_assessment(answer_id, self.user_id) + assessment_answer_model = await self._get_full_assessment_info(applet_id, assessment_answer) + return assessment_answer_model + + async def get_assessment_by_submit_id(self, applet_id: uuid.UUID, submit_id: uuid.UUID) -> AssessmentAnswer | None: + assert self.user_id + await self._validate_submission_access(applet_id, submit_id) + answer = await self.get_submission_last_answer(submit_id) + if answer: + assessment_answer = await AnswerItemsCRUD(self.answer_session).get_assessment( + answer.id, self.user_id, submit_id + ) + else: + # Submission without answer on assessments + assessment_answer = None + + assessment_answer_model = await self._get_full_assessment_info(applet_id, assessment_answer) + return assessment_answer_model + async def get_reviews_by_answer_id(self, applet_id: uuid.UUID, answer_id: uuid.UUID) -> list[AnswerReview]: assert self.user_id @@ -724,35 +756,28 @@ async def get_reviews_by_answer_id(self, applet_id: uuid.UUID, answer_id: uuid.U activity_versions = [t[1] for t in reviewer_activity_version] activity_items = await ActivityItemHistoriesCRUD(self.session).get_by_activity_id_versions(activity_versions) - reviews = await AnswerItemsCRUD(self.answer_session).get_reviews_by_answer_id(answer_id, activity_items) + reviews = await AnswerItemsCRUD(self.answer_session).get_reviews_by_answer_id(answer_id) + results = await self._prepare_answer_reviews(reviews, activity_items, current_role) + return results - user_ids = [rev.respondent_id for rev in reviews] - users = await UsersCRUD(self.session).get_by_ids(user_ids) - results = [] - for schema in reviews: - user = next(filter(lambda u: u.id == schema.respondent_id, users), None) - current_activity_items = list( - filter( - lambda i: i.activity_id == schema.assessment_activity_id, - activity_items, - ) - ) - if not user: - continue + async def get_reviews_by_submission_id(self, applet_id: uuid.UUID, submit_id: uuid.UUID) -> list[AnswerReview]: + assert self.user_id - can_view = await self.can_view_current_review(user.id, current_role) - results.append( - AnswerReview( - id=schema.id, - reviewer_public_key=schema.user_public_key if can_view else None, - answer=schema.answer if can_view else None, - item_ids=schema.item_ids, - items=current_activity_items, - reviewer=dict(id=user.id, first_name=user.first_name, last_name=user.last_name), - created_at=schema.created_at, - updated_at=schema.updated_at, - ) - ) + await self._validate_submission_access(applet_id, submit_id) + answer = await self.get_submission_last_answer(submit_id) + if not answer: + return [] + + current_role = await AppletAccessCRUD(self.session).get_applets_priority_role(applet_id, self.user_id) + reviewer_activity_version = await AnswerItemsCRUD(self.answer_session).get_assessment_activity_id(answer.id) + if not reviewer_activity_version: + return [] + + activity_versions = [t[1] for t in reviewer_activity_version] + activity_items = await ActivityItemHistoriesCRUD(self.session).get_by_activity_id_versions(activity_versions) + + reviews = await AnswerItemsCRUD(self.answer_session).get_reviews_by_submit_id(submit_id) + results = await self._prepare_answer_reviews(reviews, activity_items, current_role) return results async def create_assessment_answer( @@ -760,11 +785,12 @@ async def create_assessment_answer( applet_id: uuid.UUID, answer_id: uuid.UUID, schema: AssessmentAnswerCreate, + submit_id: uuid.UUID | None = None, ): assert self.user_id await self._validate_answer_access(applet_id, answer_id) - assessment = await AnswerItemsCRUD(self.answer_session).get_assessment(answer_id, self.user_id) + assessment = await AnswerItemsCRUD(self.answer_session).get_assessment(answer_id, self.user_id, submit_id) if assessment: await AnswerItemsCRUD(self.answer_session).update( AnswerItemSchema( @@ -780,6 +806,7 @@ async def create_assessment_answer( start_datetime=datetime.datetime.utcnow(), end_datetime=datetime.datetime.utcnow(), assessment_activity_id=schema.assessment_version_id, + reviewed_flow_submit_id=submit_id, ) ) else: @@ -797,6 +824,7 @@ async def create_assessment_answer( created_at=now, updated_at=now, assessment_activity_id=schema.assessment_version_id, + reviewed_flow_submit_id=submit_id, ) ) @@ -952,8 +980,12 @@ async def get_activity_identifiers( results.append(Identifier(identifier=identifier, user_public_key=key, last_answer_date=answer_date)) return results - async def get_flow_identifiers(self, flow_id: uuid.UUID, target_subject_id: uuid.UUID) -> list[Identifier]: - identifier_data = await AnswersCRUD(self.answer_session).get_flow_identifiers(flow_id, target_subject_id) + async def get_flow_identifiers( + self, applet_id: uuid.UUID, flow_id: uuid.UUID, target_subject_id: uuid.UUID + ) -> list[Identifier]: + identifier_data = await AnswersCRUD(self.answer_session).get_flow_identifiers( + applet_id, flow_id, target_subject_id + ) result = [ Identifier( identifier=row.identifier, @@ -1013,11 +1045,12 @@ async def get_activity_answers( async def get_flow_submissions( self, + applet_id: uuid.UUID, flow_id: uuid.UUID, filters: QueryParams, ) -> tuple[FlowSubmissionsDetails, int]: submissions, total = await AnswersCRUD(self.answer_session).get_flow_submissions( - flow_id, page=filters.page, limit=filters.limit, is_completed=True, **filters.filters + applet_id, flow_id, page=filters.page, limit=filters.limit, is_completed=True, **filters.filters ) flow_history_ids = {s.flow_history_id for s in submissions} flows = [] @@ -1026,7 +1059,7 @@ async def get_flow_submissions( return FlowSubmissionsDetails(submissions=submissions, flows=flows), total - async def get_assessments_count(self, answer_ids: list[uuid.UUID]) -> dict[uuid.UUID, ReviewsCount]: + async def get_answer_assessments_count(self, answer_ids: list[uuid.UUID]) -> dict[uuid.UUID, ReviewsCount]: answer_reviewers_t = await AnswerItemsCRUD(self.answer_session).get_reviewers_by_answers(answer_ids) answer_reviewers: dict[uuid.UUID, ReviewsCount] = {} for answer_id, reviewers in answer_reviewers_t: @@ -1034,6 +1067,14 @@ async def get_assessments_count(self, answer_ids: list[uuid.UUID]) -> dict[uuid. answer_reviewers[answer_id] = ReviewsCount(mine=mine, other=len(reviewers) - mine) return answer_reviewers + async def get_submission_assessment_count(self, submission_ids: list[uuid.UUID]) -> dict[uuid.UUID, ReviewsCount]: + answer_reviewers_t = await AnswerItemsCRUD(self.answer_session).get_reviewers_by_submission(submission_ids) + answer_reviewers: dict[uuid.UUID, ReviewsCount] = {} + for submission_id, reviewers in answer_reviewers_t: + mine = 1 if self.user_id in reviewers else 0 + answer_reviewers[submission_id] = ReviewsCount(mine=mine, other=len(reviewers) - mine) + return answer_reviewers + async def get_summary_latest_report( self, applet_id: uuid.UUID, @@ -1050,17 +1091,31 @@ async def get_summary_latest_report( raise activity_error_exception act_versions = set(map(lambda act_hst: act_hst.id_version, activity_hsts)) - answer = await AnswersCRUD(self.answer_session).get_latest_answer(applet_id, act_versions, subject_id) + answer = await AnswersCRUD(self.answer_session).get_latest_activity_answer(applet_id, act_versions, subject_id) if not answer: return None service = ReportServerService(self.session, arbitrary_session=self.answer_session) - is_single_flow = await service.is_flows_single_report(answer.id) - if is_single_flow: - report = await service.create_report(answer.submit_id) - else: - report = await service.create_report(answer.submit_id, answer.id) + report = await service.create_report(answer.submit_id, answer.id) + return report + async def get_flow_summary_latest_report( + self, applet_id: uuid.UUID, flow_id: uuid.UUID, subject_id: uuid.UUID + ) -> ReportServerResponse | None: + await self._is_report_server_configured(applet_id) + flow_hist_crud = FlowsHistoryCRUD(self.session) + flow_histories = await flow_hist_crud.get_list_by_id(flow_id) + if not flow_histories: + flow_not_exist_ex = FlowDoesNotExist() + flow_not_exist_ex.message = f"No such activity flow with id=${flow_id}" + raise flow_not_exist_ex + flow_versions = set(map(lambda f: f.id_version, flow_histories)) + answer_service = AnswersCRUD(self.answer_session) + answer = await answer_service.get_latest_flow_answer(applet_id, flow_versions, subject_id) + if not answer: + return None + service = ReportServerService(self.session, arbitrary_session=self.answer_session) + report = await service.create_report(answer.submit_id) return report async def _is_report_server_configured(self, applet_id: uuid.UUID): @@ -1429,6 +1484,98 @@ async def can_view_current_review(self, reviewer_id: uuid.UUID, role: Role | Non async def replace_answer_subject(self, sabject_id_from: uuid.UUID, subject_id_to: uuid.UUID): await AnswersCRUD(self.answer_session).replace_answers_subject(sabject_id_from, subject_id_to) + async def get_submission_last_answer( + self, submit_id: uuid.UUID, flow_id: uuid.UUID | None = None + ) -> AnswerSchema | None: + return await AnswersCRUD(self.answer_session).get_last_answer_in_flow(submit_id, flow_id) + + async def add_submission_note( + self, + applet_id: uuid.UUID, + submission_id: uuid.UUID, + flow_id: uuid.UUID, + note: str, + ): + answer = await self.get_submission_last_answer(submission_id) + if not answer: + raise AnswerNotFoundError() + await self._validate_applet_activity_access(applet_id, answer.respondent_id) + schema = AnswerNoteSchema( + answer_id=answer.id, note=note, user_id=self.user_id, activity_flow_id=flow_id, flow_submit_id=submission_id + ) + note_schema = await AnswerNotesCRUD(self.session).save(schema) + return note_schema + + async def get_submission_note_list( + self, + applet_id: uuid.UUID, + submission_id: uuid.UUID, + flow_id: uuid.UUID, + page: int, + limit: int, + ) -> list[AnswerNoteDetail]: + await self._validate_submission_access(applet_id, submission_id) + notes_crud = AnswerNotesCRUD(self.session) + note_schemas = await notes_crud.get_by_submission_id(submission_id, flow_id, page, limit) + user_ids = set(map(lambda n: n.user_id, note_schemas)) + users_crud = UsersCRUD(self.session) + users = await users_crud.get_by_ids(user_ids) + notes = await notes_crud.map_users_and_notes(note_schemas, users) + return notes + + async def edit_submission_note( + self, + applet_id: uuid.UUID, + submission_id: uuid.UUID, + note_id: uuid.UUID, + note: str, + ): + await self._validate_submission_access(applet_id, submission_id) + await self._validate_note_access(note_id) + await AnswerNotesCRUD(self.session).update_note_by_id(note_id, note) + + async def delete_submission_note( + self, + applet_id: uuid.UUID, + submission_id: uuid.UUID, + note_id: uuid.UUID, + ): + await self._validate_submission_access(applet_id, submission_id) + await self._validate_note_access(note_id) + await AnswerNotesCRUD(self.session).delete_note_by_id(note_id) + + async def _prepare_answer_reviews( + self, reviews: list[AnswerItemSchema], activity_items: list[ActivityItemHistorySchema], role: Role | None + ) -> list[AnswerReview]: + user_ids = [rev.respondent_id for rev in reviews] + users = await UsersCRUD(self.session).get_by_ids(user_ids) + results = [] + for schema in reviews: + user = next(filter(lambda u: u.id == schema.respondent_id, users), None) + current_activity_items = list( + filter( + lambda i: i.activity_id == schema.assessment_activity_id, + activity_items, + ) + ) + if not user: + continue + + can_view = await self.can_view_current_review(user.id, role) + results.append( + AnswerReview( + id=schema.id, + reviewer_public_key=schema.user_public_key if can_view else None, + answer=schema.answer if can_view else None, + item_ids=schema.item_ids, + items=current_activity_items, + reviewer=dict(id=user.id, first_name=user.first_name, last_name=user.last_name), + created_at=schema.created_at, + updated_at=schema.updated_at, + ) + ) + return results + class ReportServerService: def __init__(self, session, arbitrary_session=None): diff --git a/src/apps/answers/tests/conftest.py b/src/apps/answers/tests/conftest.py index b94d0721ecf..c0c5546de03 100644 --- a/src/apps/answers/tests/conftest.py +++ b/src/apps/answers/tests/conftest.py @@ -124,7 +124,12 @@ def client_meta() -> ClientMeta: @pytest.fixture -async def applet_with_flow(session: AsyncSession, applet_minimal_data: AppletCreate, tom: User) -> AppletFull: +async def applet_with_flow( + session: AsyncSession, + applet_minimal_data: AppletCreate, + tom: User, + applet_report_configuration_data: AppletReportConfigurationBase, +) -> AppletFull: data = applet_minimal_data.copy(deep=True) data.display_name = "applet with flow" @@ -148,6 +153,9 @@ async def applet_with_flow(session: AsyncSession, applet_minimal_data: AppletCre ], ), ] + data.report_server_ip = applet_report_configuration_data.report_server_ip + data.report_public_key = applet_report_configuration_data.report_public_key + data.report_recipients = applet_report_configuration_data.report_recipients applet_create = AppletCreate(**data.dict()) srv = AppletService(session, tom.id) applet = await srv.create(applet_create, applet_id=uuid.uuid4()) @@ -369,7 +377,7 @@ def note_create_data() -> AnswerNote: async def answer_note( session: AsyncSession, tom: User, answer: AnswerSchema, note_create_data: AnswerNote ) -> AnswerNoteSchema: - return await AnswerService(session, tom.id).add_note( + return await AnswerService(session, tom.id).add_answer_note( answer.applet_id, answer.id, uuid.UUID(answer.activity_history_id.split("_")[0]), note=note_create_data.note ) @@ -434,7 +442,7 @@ async def answer_note_arbitrary( answer_arbitrary: AnswerSchema, note_create_data: AnswerNote, ) -> AnswerNoteSchema: - return await AnswerService(session, tom.id, arbitrary_session).add_note( + return await AnswerService(session, tom.id, arbitrary_session).add_answer_note( answer_arbitrary.applet_id, answer_arbitrary.id, uuid.UUID(answer_arbitrary.activity_history_id.split("_")[0]), @@ -477,6 +485,159 @@ async def editor_user_reviewer_applet_one(user: UserSchema, session: AsyncSessio await srv.add_role(user.id, Role.EDITOR) +@pytest.fixture +async def applet_with_reviewable_flow( + session: AsyncSession, applet_minimal_data: AppletCreate, tom: User +) -> AppletFull: + data = applet_minimal_data.copy(deep=True) + data.display_name = "applet with reviewable flow" + + second_activity = data.activities[0].copy(deep=True) + second_activity.name = data.activities[0].name + " second" + second_activity.key = uuid.uuid4() + + third_activity = data.activities[0].copy(deep=True) + third_activity.name = data.activities[0].name + " third" + third_activity.key = uuid.uuid4() + third_activity.is_reviewable = True + + data.activities.append(second_activity) + data.activities.append(third_activity) + + data.activity_flows = [ + FlowCreate( + name="flow", + description={Language.ENGLISH: "description"}, + items=[ + FlowItemCreate(activity_key=data.activities[0].key), + FlowItemCreate(activity_key=data.activities[1].key), + ], + ), + ] + applet_create = AppletCreate(**data.dict()) + srv = AppletService(session, tom.id) + applet = await srv.create(applet_create, applet_id=uuid.uuid4()) + return applet + + +@pytest.fixture +def answers_reviewable_submission_create( + applet_with_reviewable_flow: AppletFull, answer_item_create: ItemAnswerCreate, client_meta: ClientMeta +) -> list[AppletAnswerCreate]: + item_create = answer_item_create.copy(deep=True) + activities = [] + flow_items = applet_with_reviewable_flow.activity_flows[0].items + flow_activity_ids = list(map(lambda x: x.activity_id, flow_items)) + submit_id = uuid.uuid4() + for activity_id in flow_activity_ids: + activity = next(filter(lambda x: x.id == activity_id, applet_with_reviewable_flow.activities)) + item_create.item_ids = [i.id for i in activity.items] + answer_create_data = AppletAnswerCreate( + applet_id=applet_with_reviewable_flow.id, + version=applet_with_reviewable_flow.version, + submit_id=submit_id, + activity_id=activity.id, + answer=item_create, + created_at=datetime.datetime.utcnow().replace(microsecond=0), + client=client_meta, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + ) + activities.append(answer_create_data) + return activities + + +@pytest.fixture +async def answers_reviewable_submission( + session: AsyncSession, + tom: User, + answers_reviewable_submission_create: list[AppletAnswerCreate], +) -> list[AnswerSchema]: + srv = AnswerService(session, tom.id) + answer_schemas = [] + size_t = len(answers_reviewable_submission_create) + for i in range(size_t): + answer_data = answers_reviewable_submission_create[i] + if i == (size_t - 1): + answer_data.is_flow_completed = True + answer = await srv.create_answer(answer_data) + answer_schemas.append(answer) + return answer_schemas + + +@pytest.fixture +async def answers_reviewable_submission_arbitrary( + session: AsyncSession, + arbitrary_session: AsyncSession, + tom: User, + answers_reviewable_submission_create: list[AppletAnswerCreate], +) -> list[AnswerSchema]: + srv = AnswerService(session, tom.id, arbitrary_session) + answer_schemas = [] + size_t = len(answers_reviewable_submission_create) + for i in range(size_t): + answer_data = answers_reviewable_submission_create[i] + if i == (size_t - 1): + answer_data.is_flow_completed = True + answer = await srv.create_answer(answer_data) + answer_schemas.append(answer) + return answer_schemas + + +@pytest.fixture +def assessment_submission_create( + tom: User, answers_reviewable_submission: list[AnswerSchema], applet_with_reviewable_flow: AppletFull +) -> AssessmentAnswerCreate: + assessment_activity = next(i for i in applet_with_reviewable_flow.activities if i.is_reviewable) + last_flow_answer: AnswerSchema = next(filter(lambda a: a.is_flow_completed, answers_reviewable_submission)) + return AssessmentAnswerCreate( + answer="assessment answer", + item_ids=[(i.id) for i in assessment_activity.items], + assessment_version_id=f"{assessment_activity.id}_{applet_with_reviewable_flow.version}", + reviewer_public_key=str(tom.id), + reviewed_flow_submit_id=last_flow_answer.submit_id, + ) + + +@pytest.fixture +async def assessment_for_submission( + session: AsyncSession, + tom: User, + answers_reviewable_submission: list[AnswerSchema], + assessment_submission_create: AssessmentAnswerCreate, +) -> None: + last_flow_answer: AnswerSchema = next(filter(lambda a: a.is_flow_completed, answers_reviewable_submission)) + srv = AnswerService(session, tom.id) + await srv.create_assessment_answer(last_flow_answer.applet_id, last_flow_answer.id, assessment_submission_create) + + +@pytest.fixture +async def assessment_for_submission_arbitrary( + session: AsyncSession, + arbitrary_session: AsyncSession, + tom: User, + answers_reviewable_submission_arbitrary: list[AnswerSchema], + assessment_submission_create: AssessmentAnswerCreate, +) -> None: + last_flow_answer: AnswerSchema = next( + filter(lambda a: a.is_flow_completed, answers_reviewable_submission_arbitrary) + ) + srv = AnswerService(session, tom.id, arbitrary_session=arbitrary_session) + await srv.create_assessment_answer(last_flow_answer.applet_id, last_flow_answer.id, assessment_submission_create) + + +@pytest.fixture +async def submission_note( + session: AsyncSession, tom: User, answers_reviewable_submission: list[AnswerSchema], note_create_data: AnswerNote +) -> AnswerNoteSchema: + last_flow_answer: AnswerSchema = next(filter(lambda a: a.is_flow_completed, answers_reviewable_submission)) + return await AnswerService(session, tom.id).add_submission_note( + last_flow_answer.applet_id, + last_flow_answer.submit_id, + uuid.UUID(last_flow_answer.flow_history_id.split("_")[0]), + note=note_create_data.note, + ) + + @pytest.fixture async def applet__activity_turned_into_assessment( session: AsyncSession, tom: User, applet_data: AppletCreate @@ -560,3 +721,17 @@ async def applet__with_ordered_activities(session: AsyncSession, tom: User, appl data.activities = activities updated_applet = await srv.update(applet.id, data) return updated_applet + + +@pytest.fixture +async def answer_ident_series( + session: AsyncSession, tom: User, answer_create: AppletAnswerCreate +) -> list[AnswerSchema]: + srv = AnswerService(session, tom.id) + answers = [] + for ident in ("Ident1", "Ident2", None): + answer_create.submit_id = uuid.uuid4() + answer_create.answer.identifier = ident + answer = await srv.create_answer(answer_create) + answers.append(answer) + return answers diff --git a/src/apps/answers/tests/test_answers.py b/src/apps/answers/tests/test_answers.py index 9c88b415224..8e1b9700192 100644 --- a/src/apps/answers/tests/test_answers.py +++ b/src/apps/answers/tests/test_answers.py @@ -89,6 +89,22 @@ async def lucy_manager_in_applet_with_reviewable_activity(session, tom, lucy, ap return lucy +@pytest.fixture +async def lucy_manager_in_applet_with_reviewable_flow(session, tom, lucy, applet_with_reviewable_flow) -> User: + await UserAppletAccessCRUD(session).save( + UserAppletAccessSchema( + user_id=lucy.id, + applet_id=applet_with_reviewable_flow.id, + role=Role.MANAGER, + owner_id=tom.id, + invitor_id=tom.id, + meta=dict(), + nickname=str(uuid.uuid4()), + ) + ) + return lucy + + @pytest.fixture def tom_answer_assessment_create_data(tom, applet_with_reviewable_activity) -> AssessmentAnswerCreate: activity_assessment_id = applet_with_reviewable_activity.activities[1].id @@ -174,6 +190,31 @@ async def tom_answer_activity_flow(session: AsyncSession, tom: User, applet_with ) +@pytest.fixture +async def tom_answer_activity_flow_incomplete( + session: AsyncSession, tom: User, applet_with_flow: AppletFull +) -> AnswerSchema: + answer_service = AnswerService(session, tom.id) + return await answer_service.create_answer( + AppletAnswerCreate( + applet_id=applet_with_flow.id, + version=applet_with_flow.version, + submit_id=uuid.uuid4(), + flow_id=applet_with_flow.activity_flows[0].id, + is_flow_completed=False, + activity_id=applet_with_flow.activities[0].id, + answer=ItemAnswerCreate( + item_ids=[applet_with_flow.activities[0].items[0].id], + start_time=datetime.datetime.utcnow(), + end_time=datetime.datetime.utcnow(), + user_public_key=str(tom.id), + identifier="encrypted_identifier", + ), + client=ClientMeta(app_id=f"{uuid.uuid4()}", app_version="1.1", width=984, height=623), + ) + ) + + @pytest.fixture def applet_with_flow_answer_create(applet_with_flow: AppletFull) -> list[AppletAnswerCreate]: submit_id = uuid.uuid4() @@ -350,6 +391,41 @@ async def tom_answer(tom: User, session: AsyncSession, answer_create: AppletAnsw return await AnswerService(session, tom.id).create_answer(answer_create) +@pytest.fixture +async def submission_assessment_answer( + tom: User, + session: AsyncSession, + assessment_submission_create: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, +) -> AnswerItemSchema | None: + service = AnswerService(session, tom.id, session) + assert assessment_submission_create.reviewed_flow_submit_id + answer = await service.get_submission_last_answer(assessment_submission_create.reviewed_flow_submit_id) + assert answer + submission_id = assessment_submission_create.reviewed_flow_submit_id + answer_service = AnswerService(session, tom.id) + await answer_service.create_assessment_answer( + applet_with_reviewable_flow.id, answer.id, assessment_submission_create, submission_id + ) + return await AnswerItemsCRUD(session).get_assessment(answer.id, tom.id) + + +@pytest.fixture +async def submission_answer( + client: TestClient, + tom: User, + answers_reviewable_submission: list[AnswerSchema], +): + return next(filter(lambda a: a.is_flow_completed, answers_reviewable_submission)) + + +@pytest.fixture +async def tom_applet_with_flow_subject( + session: AsyncSession, tom: User, applet_with_flow: AppletFull +) -> Subject | None: + return await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet_with_flow.id) + + @pytest.fixture async def tom_answer_activity_flow_not_completed( session: AsyncSession, tom: User, applet_with_flow: AppletFull @@ -471,13 +547,27 @@ class TestAnswerActivityItems(BaseTest): applet_submit_dates_url = "/answers/applet/{applet_id}/dates" activity_answer_url = "/answers/applet/{applet_id}/activities/{activity_id}/answers/{answer_id}" - flow_submission_url = "/answers/applet/{applet_id}/flows/{flow_id}/submissions/{submit_id}" + flow_submission_url = f"{flow_submissions_url}/{{submit_id}}" assessment_answers_url = "/answers/applet/{applet_id}/answers/{answer_id}/assessment" + assessment_submissions_url = "/answers/applet/{applet_id}/submissions/{submission_id}/assessments" + assessment_submissions_retrieve_url = "/answers/applet/{applet_id}/submissions/{submission_id}/assessments" + assessment_submission_delete_url = ( + "/answers/applet/{applet_id}/submissions/{submission_id}/assessments/{assessment_id}" + ) + submission_reviews_url = "/answers/applet/{applet_id}/submissions/{submission_id}/reviews" + answer_reviews_url = "/answers/applet/{applet_id}/answers/{answer_id}/reviews" answer_notes_url = "/answers/applet/{applet_id}/answers/{answer_id}/activities/{activity_id}/notes" answer_note_detail_url = "/answers/applet/{applet_id}/answers/{answer_id}/activities/{activity_id}/notes/{note_id}" - latest_report_url = "/answers/applet/{applet_id}/activities/{activity_id}/subjects/{subject_id}/latest_report" + submission_notes_url = "/answers/applet/{applet_id}/submissions/{submission_id}/flows/{flow_id}/notes" + submission_note_detail_url = ( + "/answers/applet/{applet_id}/submissions/{submission_id}/flows/{flow_id}/notes/{note_id}" + ) + latest_activity_report_url = ( + "/answers/applet/{applet_id}/activities/{activity_id}/subjects/{subject_id}/latest_report" + ) + latest_flow_report_url = "/answers/applet/{applet_id}/flows/{flow_id}/subjects/{subject_id}/latest_report" check_existence_url = "/answers/check-existence" assessment_delete_url = "/answers/applet/{applet_id}/answers/{answer_id}/assessment/{assessment_id}" @@ -667,7 +757,7 @@ async def test_get_latest_summary( client.login(tom) response = await client.post( - self.latest_report_url.format( + self.latest_activity_report_url.format( applet_id=str(applet.id), activity_id=str(applet.activities[0].id), subject_id=str(tom_applet_subject.id), @@ -1014,6 +1104,7 @@ async def test_add_note(self, client: TestClient, tom: User, answer: AnswerSchem assert note["note"] == note_create_data.note assert note["user"]["firstName"] == tom.first_name assert note["user"]["lastName"] == tom.last_name + assert note["user"]["id"] == str(tom.id) # Just check that other columns in place assert note["id"] assert note["createdAt"] @@ -1091,7 +1182,7 @@ async def test_answers_export( "version", "submitId", "scheduledDatetime", "startDatetime", "endDatetime", "legacyProfileId", "migratedDate", "relation", "sourceSubjectId", "targetSubjectId", "client", - "tzOffset", "scheduledEventId", + "tzOffset", "scheduledEventId", "reviewedFlowSubmitId" } # Comment for now, wtf is it # assert int(answer['startDatetime'] * 1000) == answer_item_create.start_time @@ -1195,6 +1286,32 @@ async def test_get_flow_identifiers( assert data[i]["identifier"] == _answer.answer.identifier assert data[i]["userPublicKey"] == _answer.answer.user_public_key + async def test_get_flow_identifiers_incomplete_submission( + self, + mock_kiq_report, + client, + tom: User, + applet_with_flow: AppletFull, + applet_with_flow_answer_create: list[AppletAnswerCreate], + tom_answer_activity_flow_incomplete, + session, + ): + applet = applet_with_flow + client.login(tom) + + tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet.id) + assert tom_subject + + identifier_url = self.flow_identifiers_url.format( + applet_id=applet.id, flow_id=applet_with_flow.activity_flows[0].id + ) + response = await client.get(identifier_url, dict(targetSubjectId=tom_subject.id)) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 0 + assert data["result"] == [] + # TODO: Move to another place, not needed any answer for test async def test_get_all_activity_versions_for_applet(self, client: TestClient, tom: User, applet: AppletFull): client.login(tom) @@ -1757,6 +1874,40 @@ async def test_review_flows_one_answer( assert set(data[0]["answerDates"][0].keys()) == {"submitId", "createdAt", "endDatetime"} assert len(data[1]["answerDates"]) == 0 + async def test_review_flows_one_answer_incomplete_submission( + self, + mock_kiq_report, + client, + tom: User, + applet_with_flow: AppletFull, + tom_answer_activity_flow_incomplete, + session, + ): + client.login(tom) + url = self.review_flows_url.format(applet_id=applet_with_flow.id) + + tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet_with_flow.id) + assert tom_subject + + response = await client.get( + url, + dict( + targetSubjectId=tom_subject.id, + createdDate=datetime.datetime.utcnow().date(), + ), + ) + assert response.status_code == 200 + data = response.json() + assert "result" in data + data = data["result"] + assert len(data) == len(applet_with_flow.activity_flows) + assert set(data[0].keys()) == {"id", "name", "answerDates", "lastAnswerDate"} + for i, row in enumerate(data): + assert row["id"] == str(applet_with_flow.activity_flows[i].id) + assert row["name"] == applet_with_flow.activity_flows[i].name + assert len(data[0]["answerDates"]) == 0 + assert len(data[1]["answerDates"]) == 0 + async def test_review_flows_multiple_answers( self, mock_kiq_report, @@ -1798,7 +1949,6 @@ async def test_flow_submission(self, client, tom: User, applet_with_flow: Applet assert "result" in data data = data["result"] assert set(data.keys()) == {"flow", "submission", "summary"} - assert data["submission"]["isCompleted"] is True assert len(data["submission"]["answers"]) == len(applet_with_flow.activity_flows[0].items) answer_data = data["submission"]["answers"][0] @@ -1835,6 +1985,24 @@ async def test_flow_submission(self, client, tom: User, applet_with_flow: Applet assert data["summary"]["identifier"]["identifier"] == "encrypted_identifier" # fmt: on + async def test_flow_submission_incomplete( + self, client, tom: User, applet_with_flow: AppletFull, tom_answer_activity_flow_incomplete + ): + client.login(tom) + url = self.flow_submission_url.format( + applet_id=applet_with_flow.id, + flow_id=applet_with_flow.activity_flows[0].id, + submit_id=tom_answer_activity_flow_incomplete.submit_id, + ) + response = await client.get(url) + assert response.status_code == 200 + data = response.json() + assert "result" in data + data = data["result"] + assert set(data.keys()) == {"flow", "submission", "summary"} + assert data["submission"]["isCompleted"] is False + assert len(data["submission"]["answers"]) == len(applet_with_flow.activity_flows[0].items) + async def test_flow_submission_no_flow( self, client, tom: User, applet_with_flow: AppletFull, tom_answer_activity_no_flow ): @@ -1918,6 +2086,7 @@ async def test_get_flow_submissions( "endDatetime", "flowHistoryId", "isCompleted", + "reviewCount", "submitId", "version", } @@ -1946,6 +2115,33 @@ async def test_get_flow_submissions( # fmt: on assert flow_data["idVersion"] == tom_answer_activity_flow.flow_history_id + async def test_get_flow_submissions_incomplete( + self, + mock_kiq_report, + client, + tom: User, + applet_with_flow: AppletFull, + tom_answer_activity_flow_incomplete, + session, + ): + client.login(tom) + url = self.flow_submissions_url.format( + applet_id=applet_with_flow.id, + flow_id=applet_with_flow.activity_flows[0].id, + ) + + tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet_with_flow.id) + assert tom_subject + response = await client.get(url, dict(targetSubjectId=tom_subject.id)) + assert response.status_code == 200 + data = response.json() + assert set(data.keys()) == {"result", "count"} + assert data["count"] == 0 + data = data["result"] + assert set(data.keys()) == {"flows", "submissions"} + + assert len(data["submissions"]) == 0 + @pytest.mark.parametrize( "query_params", ( @@ -2268,3 +2464,390 @@ async def test_summary_activity_order_with_deleted( not_deleted_ids = [str(a.id) for a in activities_applet] deleted_activities = list(filter(lambda a: a["id"] not in not_deleted_ids, activities_payload)) assert sorted(deleted_activities, key=lambda x: x["name"]) == deleted_activities + + async def test_applet_assessment_create_for_submission( + self, + client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + ): + assert assessment_submission_create.reviewed_flow_submit_id + client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + flow_id = str(applet_with_reviewable_flow.activity_flows[0].id) + submission_id = str(assessment_submission_create.reviewed_flow_submit_id) + response = await client.post( + self.assessment_submissions_url.format(applet_id=applet_id, flow_id=flow_id, submission_id=submission_id), + data=assessment_submission_create, + ) + assert response.status_code == http.HTTPStatus.CREATED + answer = await AnswersCRUD(session).get_last_answer_in_flow( + assessment_submission_create.reviewed_flow_submit_id + ) + assert answer + assessment_for_flow = await AnswerItemsCRUD(session).get_assessment( + answer.id, tom.id, assessment_submission_create.reviewed_flow_submit_id + ) + assert assessment_for_flow + assert assessment_for_flow.reviewed_flow_submit_id == assessment_submission_create.reviewed_flow_submit_id + + assessment_for_act = await AnswerItemsCRUD(session).get_assessment(answer.id, tom.id) + assert assessment_for_act + assert assessment_for_act.reviewed_flow_submit_id is None + + async def test_applet_assessment_retrive_for_submission( + self, + client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + ): + assert assessment_submission_create.reviewed_flow_submit_id + client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + submission_id = str(assessment_submission_create.reviewed_flow_submit_id) + response = await client.get( + self.assessment_submissions_retrieve_url.format( + applet_id=applet_id, + submission_id=submission_id, + ) + ) + assert response.status_code == http.HTTPStatus.OK + + async def test_applet_assessment_retrive_for_submission_if_no_assessment_answer( + self, + client: TestClient, + tom: User, + session: AsyncSession, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + ): + assert assessment_submission_create.reviewed_flow_submit_id + client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + submission_id = str(assessment_submission_create.reviewed_flow_submit_id) + response = await client.get( + self.assessment_submissions_retrieve_url.format( + applet_id=applet_id, + submission_id=submission_id, + ) + ) + assert response.status_code == http.HTTPStatus.OK + payload = response.json() + assert payload["result"]["answer"] is None + assert payload["result"]["items"] is not None + + async def test_applet_assessment_delete_for_submission( + self, + client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + ): + assert assessment_submission_create.reviewed_flow_submit_id + client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + submission_id = str(assessment_submission_create.reviewed_flow_submit_id) + response = await client.delete( + self.assessment_submission_delete_url.format( + applet_id=applet_id, submission_id=submission_id, assessment_id=submission_assessment_answer.id + ) + ) + assert response.status_code == http.HTTPStatus.NO_CONTENT + + @pytest.mark.parametrize("user_fixture,exp_mine,exp_other", (("tom", 1, 0), ("lucy", 0, 1))) + async def test_get_flow_submissions_review_count( + self, + client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + lucy_manager_in_applet_with_reviewable_flow, + request: FixtureRequest, + user_fixture, + exp_mine, + exp_other, + ): + user: User = request.getfixturevalue(user_fixture) + client.login(user) + url = self.flow_submissions_url.format( + applet_id=applet_with_reviewable_flow.id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + ) + response = await client.get(url, dict(respondentId=str(tom.id))) + assert response.status_code == 200 + data = response.json() + assert data["result"]["submissions"][0]["reviewCount"]["mine"] == exp_mine + assert data["result"]["submissions"][0]["reviewCount"]["other"] == exp_other + + async def test_add_submission_note( + self, + client: TestClient, + tom: User, + note_create_data: AnswerNote, + applet_with_reviewable_flow: AppletFull, + answers_reviewable_submission: list[AnswerSchema], + ): + client.login(tom) + last_flow_answer: AnswerSchema = next(filter(lambda a: a.is_flow_completed, answers_reviewable_submission)) + + response = await client.post( + self.submission_notes_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=last_flow_answer.submit_id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + ), + data=note_create_data, + ) + + assert response.status_code == http.HTTPStatus.CREATED, response.json() + + response = await client.get( + self.submission_notes_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=last_flow_answer.submit_id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + ) + ) + + assert response.status_code == http.HTTPStatus.OK, response.json() + assert response.json()["count"] == 1 + note = response.json()["result"][0] + assert note["note"] == note_create_data.note + assert note["user"]["firstName"] == tom.first_name + assert note["user"]["lastName"] == tom.last_name + assert note["id"] + assert note["createdAt"] + + async def test_edit_submission_note( + self, + client: TestClient, + tom: User, + submission_note: AnswerNoteSchema, + applet_with_reviewable_flow: AppletFull, + answers_reviewable_submission: list[AnswerSchema], + ): + client.login(tom) + last_flow_answer: AnswerSchema = next(filter(lambda a: a.is_flow_completed, answers_reviewable_submission)) + note_new = submission_note.note + "new" + response = await client.put( + self.submission_note_detail_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=last_flow_answer.submit_id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + note_id=submission_note.id, + ), + dict(note=note_new), + ) + assert response.status_code == http.HTTPStatus.OK + + response = await client.get( + self.submission_notes_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=last_flow_answer.submit_id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + ) + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert response.json()["count"] == 1 + assert response.json()["result"][0]["note"] == note_new + + async def test_delete_submission_note( + self, + client: TestClient, + tom: User, + submission_note: AnswerNoteSchema, + applet_with_reviewable_flow: AppletFull, + submission_answer: AnswerSchema, + ): + client.login(tom) + + response = await client.delete( + self.submission_note_detail_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=submission_answer.submit_id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + note_id=submission_note.id, + ) + ) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + + response = await client.get( + self.submission_notes_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=submission_answer.submit_id, + flow_id=applet_with_reviewable_flow.activity_flows[0].id, + ) + ) + assert response.status_code == http.HTTPStatus.OK + assert response.json()["count"] == 0 + + async def test_submission_get_export_data( + self, + client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + ): + client.login(tom) + response = await client.get( + self.applet_answers_export_url.format(applet_id=str(applet_with_reviewable_flow.id)), + ) + assert response.status_code == http.HTTPStatus.OK + data = response.json() + assert data["result"]["answers"] + assert next(filter(lambda answer: answer["reviewedFlowSubmitId"], data["result"]["answers"])) + + async def test_submission_get_reviews( + self, + client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + ): + client.login(tom) + result = await client.get( + self.submission_reviews_url.format( + applet_id=applet_with_reviewable_flow.id, + submission_id=assessment_submission_create.reviewed_flow_submit_id, + ) + ) + assert result.status_code == 200 + payload = result.json() + assert payload + assert payload["count"] == 1 + + @pytest.mark.usefixtures("mock_report_server_response", "answer") + async def test_get_latest_flow_summary( + self, + client: TestClient, + tom: User, + applet_with_flow: AppletFull, + tom_answer_activity_flow_multiple: AnswerSchema, + tom_applet_with_flow_subject: Subject, + ): + client.login(tom) + flow_id = applet_with_flow.activity_flows[0].id + applet_id = applet_with_flow.id + response = await client.post( + self.latest_flow_report_url.format( + applet_id=str(applet_id), + flow_id=str(flow_id), + subject_id=str(tom_applet_with_flow_subject.id), + ), + ) + assert response.status_code == http.HTTPStatus.OK + assert response.content == b"pdf body" + + @pytest.mark.parametrize( + "query,exp_count", + ( + ({"emptyIdentifiers": True, "identifiers": ""}, 3), + ({"emptyIdentifiers": False, "identifiers": ""}, 2), + ({"emptyIdentifiers": False, "identifiers": "Ident1,Ident2"}, 2), + ({"emptyIdentifiers": False, "identifiers": "Ident1"}, 1), + ), + ) + async def test_applet_activity_answers_empty_identifiers_filter( + self, + client: TestClient, + tom: User, + applet: AppletFull, + answer_ident_series: list[AnswerSchema], + query, + exp_count, + ): + client.login(tom) + activity_id = answer_ident_series[0].activity_history_id.split("_")[0] + response = await client.get( + self.activity_answers_url.format( + applet_id=str(applet.id), + activity_id=activity_id, + ), + query=query, + ) + assert response.status_code == http.HTTPStatus.OK + res = response.json() + assert res["count"] == exp_count + + async def test_summary_get_identifiers_deleted_from_flow( + self, client, session, tom: User, applet__with_deleted_and_order: tuple[AppletFull, list[uuid.UUID]] + ): + client.login(tom) + applet_id = applet__with_deleted_and_order[0].id + url = self.summary_activity_flows_url.format(applet_id=str(applet_id)) + response = await client.get(url) + assert response.status_code == 200 + payload = response.json() + assert payload + assert len(payload["result"]) == 2 + flow_ids = [uuid.UUID(flow["id"]) for flow in payload["result"]] + deleted_flow_id = flow_ids[-1:][0] + tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet_id) + assert tom_subject + identifier_url = self.flow_identifiers_url.format(applet_id=(applet_id), flow_id=str(deleted_flow_id)) + response = await client.get(identifier_url, dict(targetSubjectId=tom_subject.id)) + assert response.json() + assert response.status_code == http.HTTPStatus.OK + + async def test_summary_get_versions_deleted_from_flow( + self, client, session, tom: User, applet__with_deleted_and_order: tuple[AppletFull, list[uuid.UUID]] + ): + client.login(tom) + applet_id = applet__with_deleted_and_order[0].id + url = self.summary_activity_flows_url.format(applet_id=str(applet_id)) + response = await client.get(url) + assert response.status_code == 200 + payload = response.json() + assert payload + assert len(payload["result"]) == 2 + flow_ids = [uuid.UUID(flow["id"]) for flow in payload["result"]] + deleted_flow_id = flow_ids[-1:][0] + tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet_id) + assert tom_subject + response = await client.get( + self.flow_versions_url.format( + applet_id=applet_id, + flow_id=deleted_flow_id, + ) + ) + assert response.json() + assert response.status_code == http.HTTPStatus.OK + + async def test_summary_submissions_fow_deleted_flow_with_answers( + self, client, session, tom: User, applet__with_deleted_and_order: tuple[AppletFull, list[uuid.UUID]] + ): + client.login(tom) + applet_id = applet__with_deleted_and_order[0].id + url = self.summary_activity_flows_url.format(applet_id=str(applet_id)) + response = await client.get(url) + assert response.status_code == 200 + payload = response.json() + assert payload + assert len(payload["result"]) == 2 + flow_ids = [uuid.UUID(flow["id"]) for flow in payload["result"]] + deleted_flow_id = flow_ids[-1:][0] + tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(tom.id, applet_id) + assert tom_subject + response = await client.get(self.flow_submissions_url.format(applet_id=str(applet_id), flow_id=deleted_flow_id)) + assert response.json() + assert response.status_code == http.HTTPStatus.OK diff --git a/src/apps/answers/tests/test_answers_arbitrary.py b/src/apps/answers/tests/test_answers_arbitrary.py index 15488e32c2a..4aaa08f633d 100644 --- a/src/apps/answers/tests/test_answers_arbitrary.py +++ b/src/apps/answers/tests/test_answers_arbitrary.py @@ -11,7 +11,8 @@ from sqlalchemy.orm import Query from apps.answers.crud import AnswerItemsCRUD -from apps.answers.db.schemas import AnswerNoteSchema, AnswerSchema +from apps.answers.crud.answers import AnswersCRUD +from apps.answers.db.schemas import AnswerItemSchema, AnswerNoteSchema, AnswerSchema from apps.answers.domain import AnswerNote, AppletAnswerCreate, AssessmentAnswerCreate from apps.answers.service import AnswerService from apps.applets.domain.applet_full import AppletFull @@ -22,7 +23,9 @@ from apps.subjects.domain import Subject from apps.subjects.services import SubjectsService from apps.users.domain import User -from apps.workspaces.db.schemas import UserWorkspaceSchema +from apps.workspaces.crud.user_applet_access import UserAppletAccessCRUD +from apps.workspaces.db.schemas import UserAppletAccessSchema, UserWorkspaceSchema +from apps.workspaces.domain.constants import Role from infrastructure.utility import RedisCacheTest @@ -62,6 +65,42 @@ async def assert_answer_not_exist_on_arbitrary(submit_id: str, session: AsyncSes assert not answer +@pytest.fixture +async def lucy_manager_in_applet_with_reviewable_flow(session, tom, lucy, applet_with_reviewable_flow) -> User: + await UserAppletAccessCRUD(session).save( + UserAppletAccessSchema( + user_id=lucy.id, + applet_id=applet_with_reviewable_flow.id, + role=Role.MANAGER, + owner_id=tom.id, + invitor_id=tom.id, + meta=dict(), + nickname=str(uuid.uuid4()), + ) + ) + return lucy + + +@pytest.fixture +async def submission_assessment_answer( + tom: User, + session: AsyncSession, + arbitrary_session: AsyncSession, + assessment_submission_create: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, +) -> AnswerItemSchema | None: + service = AnswerService(session, tom.id, arbitrary_session) + assert assessment_submission_create.reviewed_flow_submit_id + answer = await service.get_submission_last_answer(assessment_submission_create.reviewed_flow_submit_id) + assert answer + submission_id = assessment_submission_create.reviewed_flow_submit_id + assert submission_id + await service.create_assessment_answer( + applet_with_reviewable_flow.id, answer.id, assessment_submission_create, submission_id + ) + return await AnswerItemsCRUD(arbitrary_session).get_assessment(answer.id, tom.id) + + @pytest.mark.usefixtures("mock_kiq_report") class TestAnswerActivityItems(BaseTest): fixtures = ["answers/fixtures/arbitrary_server_answers.json"] @@ -93,6 +132,12 @@ class TestAnswerActivityItems(BaseTest): check_existence_url = "/answers/check-existence" assessment_delete_url = "/answers/applet/{applet_id}/answers/{answer_id}/assessment/{assessment_id}" + assessment_submissions_url = "/answers/applet/{applet_id}/submissions/{submission_id}/assessments" + assessment_submissions_retrieve_url = "/answers/applet/{applet_id}/submissions/{submission_id}/assessments" + assessment_submission_delete_url = ( + "/answers/applet/{applet_id}/submissions/{submission_id}/assessments/{assessment_id}" + ) + async def test_answer_activity_items_create_for_respondent( self, mock_kiq_report: AsyncMock, @@ -576,7 +621,7 @@ async def test_answers_export( "version", "submitId", "scheduledDatetime", "startDatetime", "endDatetime", "legacyProfileId", "migratedDate", "relation", "sourceSubjectId", "targetSubjectId", "client", - "tzOffset", "scheduledEventId", + "tzOffset", "scheduledEventId", "reviewedFlowSubmitId" } assert set(assessment.keys()) == expected_keys @@ -860,3 +905,74 @@ async def test_own_review_delete( assert response.status_code == 204 assessment = await AnswerItemsCRUD(arbitrary_session).get_assessment(answer.id, tom.id) assert not assessment + + async def test_applet_assessment_create_for_submission( + self, + arbitrary_client: TestClient, + tom: User, + arbitrary_session: AsyncSession, + assessment_for_submission_arbitrary: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + ): + assert assessment_submission_create.reviewed_flow_submit_id + arbitrary_client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + flow_id = str(applet_with_reviewable_flow.activity_flows[0].id) + submission_id = assessment_submission_create.reviewed_flow_submit_id + response = await arbitrary_client.post( + self.assessment_submissions_url.format( + applet_id=applet_id, flow_id=flow_id, submission_id=str(submission_id) + ), + data=assessment_submission_create, + ) + assert response.status_code == http.HTTPStatus.CREATED + answer = await AnswersCRUD(arbitrary_session).get_last_answer_in_flow(submission_id) + assert answer + assessment = await AnswerItemsCRUD(arbitrary_session).get_assessment(answer.id, tom.id, submission_id) + assert assessment + assert assessment_submission_create.reviewed_flow_submit_id == assessment.reviewed_flow_submit_id + + async def test_applet_assessment_retrieve_for_submission( + self, + arbitrary_client: TestClient, + tom: User, + arbitrary_session: AsyncSession, + assessment_for_submission_arbitrary: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + ): + assert assessment_submission_create.reviewed_flow_submit_id + arbitrary_client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + submission_id = str(assessment_submission_create.reviewed_flow_submit_id) + response = await arbitrary_client.get( + self.assessment_submissions_retrieve_url.format( + applet_id=applet_id, + submission_id=submission_id, + ) + ) + assert response.status_code == http.HTTPStatus.OK + assert response.json() + + async def test_applet_assessment_delete_for_submission( + self, + arbitrary_client: TestClient, + tom: User, + session: AsyncSession, + assessment_for_submission_arbitrary: AssessmentAnswerCreate, + applet_with_reviewable_flow: AppletFull, + assessment_submission_create: AssessmentAnswerCreate, + submission_assessment_answer: AnswerItemSchema, + ): + assert assessment_submission_create.reviewed_flow_submit_id + arbitrary_client.login(tom) + applet_id = str(applet_with_reviewable_flow.id) + submission_id = str(assessment_submission_create.reviewed_flow_submit_id) + response = await arbitrary_client.delete( + self.assessment_submission_delete_url.format( + applet_id=applet_id, submission_id=submission_id, assessment_id=submission_assessment_answer.id + ) + ) + assert response.status_code == http.HTTPStatus.NO_CONTENT diff --git a/src/apps/applets/api/applets.py b/src/apps/applets/api/applets.py index acb45de603b..0904f830204 100644 --- a/src/apps/applets/api/applets.py +++ b/src/apps/applets/api/applets.py @@ -16,6 +16,7 @@ from apps.applets.domain.applet import ( AppletActivitiesBaseInfo, AppletDataRetention, + AppletMeta, AppletRetrieveResponse, AppletSingleLanguageDetailForPublic, AppletSingleLanguageDetailPublic, @@ -96,10 +97,12 @@ async def applet_retrieve( await CheckAccessService(session, user.id).check_applet_detail_access(applet_id) applet_future = service.get_single_language_by_id(applet_id, language) subject_future = SubjectsService(session, user.id).get_by_user_and_applet(user.id, applet_id) - applet, subject = await asyncio.gather(applet_future, subject_future) + has_assessment_future = AppletService(session, user.id).has_assessment(applet_id) + applet, subject, has_assessment = await asyncio.gather(applet_future, subject_future, has_assessment_future) return AppletRetrieveResponse( result=AppletSingleLanguageDetailPublic.from_orm(applet), respondent_meta={"nickname": subject.nickname if subject else None}, + applet_meta=AppletMeta(has_assessment=has_assessment), ) @@ -286,13 +289,9 @@ async def applet_flow_versions_data_retrieve( session=Depends(get_session), ) -> ResponseMulti[VersionPublic]: await AppletService(session, user.id).exist_by_id(applet_id) - service = FlowService(session=session) - flow = await service.get_by_id(flow_id) - if not flow or flow.applet_id != applet_id: - raise NotFoundError("Flow not found") await CheckAccessService(session, user.id).check_applet_detail_access(applet_id) - - versions = await service.get_versions(flow.id) + service = FlowService(session=session) + versions = await service.get_versions(applet_id, flow_id) return ResponseMulti( result=versions, count=len(versions), diff --git a/src/apps/applets/crud/applets.py b/src/apps/applets/crud/applets.py index 6b497b3d438..05a380b0063 100644 --- a/src/apps/applets/crud/applets.py +++ b/src/apps/applets/crud/applets.py @@ -780,3 +780,11 @@ async def get_workspace_applets_flat_list_count(self, owner_id: uuid.UUID, user_ query = query.subquery() db_result = await self._execute(select(func.count(query.c.id))) return db_result.scalars().first() or 0 + + async def has_assessment(self, applet_id: uuid.UUID) -> bool: + query: Query = select(ActivitySchema.id) + query = query.where(ActivitySchema.applet_id == applet_id) + query = query.where(ActivitySchema.is_reviewable.is_(True)) + query = query.exists() + result = await self._execute(select(query)) + return bool(result.scalars().first()) diff --git a/src/apps/applets/domain/applet.py b/src/apps/applets/domain/applet.py index e42334ca568..13a8ca6a9f3 100644 --- a/src/apps/applets/domain/applet.py +++ b/src/apps/applets/domain/applet.py @@ -128,9 +128,14 @@ def validate_period(cls, values): return values +class AppletMeta(PublicModel): + has_assessment: bool = False + + class AppletRetrieveResponse(PublicModel, GenericModel, Generic[_BaseModel]): result: _BaseModel respondent_meta: dict | None = None + applet_meta: AppletMeta | None = None class AppletActivitiesDetailsPublic(PublicModel): diff --git a/src/apps/applets/service/applet.py b/src/apps/applets/service/applet.py index 0056ccee6fc..e8341e8ac96 100644 --- a/src/apps/applets/service/applet.py +++ b/src/apps/applets/service/applet.py @@ -748,6 +748,9 @@ async def _get_info_by_id(self, schema: AppletSchema, language: str) -> AppletAc applet.activity_flows = futures[1] return applet + async def has_assessment(self, applet_id: uuid.UUID) -> bool: + return await AppletsCRUD(self.session).has_assessment(applet_id) + class PublicAppletService: def __init__(self, session): diff --git a/src/apps/applets/tests/conftest.py b/src/apps/applets/tests/conftest.py index d6eb4dcb06e..4468df71ddd 100644 --- a/src/apps/applets/tests/conftest.py +++ b/src/apps/applets/tests/conftest.py @@ -92,3 +92,19 @@ def applet_one_with_flow_update_data(applet_one_with_flow: AppletFull) -> Applet dct = applet_one_with_flow.dict() dct["activity_flows"][0]["items"][0]["activity_key"] = applet_one_with_flow.activities[0].key return AppletUpdate(**dct) + + +@pytest.fixture +async def applet_with_reviewable_activity( + session: AsyncSession, applet_minimal_data: AppletCreate, tom: User +) -> AppletFull: + data = applet_minimal_data.copy(deep=True) + data.display_name = "applet with reviewable activity" + reviewable_activity = data.activities[0].copy(deep=True) + reviewable_activity.name = data.activities[0].name + " review" + reviewable_activity.is_reviewable = True + data.activities.append(reviewable_activity) + applet_create = AppletCreate(**data.dict()) + srv = AppletService(session, tom.id) + applet = await srv.create(applet_create) + return applet diff --git a/src/apps/applets/tests/test_applet.py b/src/apps/applets/tests/test_applet.py index e9f2b9d33f0..9ee1395fdab 100644 --- a/src/apps/applets/tests/test_applet.py +++ b/src/apps/applets/tests/test_applet.py @@ -932,3 +932,11 @@ async def test_get_applet_changes__one_applet_version(self, client: TestClient, resp = await client.get(self.history_changes_url.format(pk=applet_one.id, version=applet_one.version)) assert resp.status_code == http.HTTPStatus.OK assert resp.json()["result"]["displayName"] == f"New applet {applet_one.display_name} added" + + async def test_applet_retrieve_meta( + self, client: TestClient, tom: User, applet_with_reviewable_activity: AppletFull + ): + client.login(tom) + response = await client.get(self.applet_detail_url.format(pk=applet_with_reviewable_activity.id)) + assert response.status_code == http.HTTPStatus.OK + assert response.json()["appletMeta"]["hasAssessment"] diff --git a/src/infrastructure/database/migrations/versions/2024_06_21_11_24-flow_assessments.py b/src/infrastructure/database/migrations/versions/2024_06_21_11_24-flow_assessments.py new file mode 100644 index 00000000000..4e0d8a7358f --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2024_06_21_11_24-flow_assessments.py @@ -0,0 +1,68 @@ +"""Flow assessments + +Revision ID: 297c9d675e2f +Revises: 62843fdc3466 +Create Date: 2024-05-15 11:24:20.074328 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "297c9d675e2f" +down_revision = "c587d336f28e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "answers_items", + sa.Column( + "reviewed_flow_submit_id", postgresql.UUID(as_uuid=True), nullable=True + ), + ) + op.add_column( + "answer_notes", + sa.Column( + "flow_submit_id", postgresql.UUID(as_uuid=True), nullable=True + ), + ) + op.add_column( + "answer_notes", + sa.Column( + "activity_flow_id", postgresql.UUID(as_uuid=True), nullable=True + ), + ) + op.create_index( + op.f("ix_answer_notes_flow_submit_id"), + "answer_notes", + ["flow_submit_id"], + unique=False, + ) + + op.create_index( + op.f("ix_answers_items_reviewed_flow_submit_id"), + "answers_items", + ["reviewed_flow_submit_id"], + unique=False, + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_answers_items_reviewed_flow_submit_id"), + table_name="answers_items", + ) + op.drop_column("answers_items", "reviewed_flow_submit_id") + op.drop_index( + op.f("ix_answer_notes_flow_submit_id"), table_name="answer_notes" + ) + op.drop_column("answer_notes", "flow_submit_id") + op.drop_column("answer_notes", "activity_flow_id") + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations_arbitrary/versions/2024_05_15_11_24-flow_assessments.py b/src/infrastructure/database/migrations_arbitrary/versions/2024_05_15_11_24-flow_assessments.py new file mode 100644 index 00000000000..60c59ecdcaa --- /dev/null +++ b/src/infrastructure/database/migrations_arbitrary/versions/2024_05_15_11_24-flow_assessments.py @@ -0,0 +1,43 @@ +"""Flow assessments + +Revision ID: 297c9d675e2f +Revises: 62843fdc3466 +Create Date: 2024-05-15 11:24:20.074328 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "297c9d675e2f" +down_revision = "267dd5b56abf" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "answers_items", + sa.Column( + "reviewed_flow_submit_id", postgresql.UUID(as_uuid=True), nullable=True + ), + ) + op.create_index( + op.f("ix_answers_items_reviewed_flow_submit_id"), + "answers_items", + ["reviewed_flow_submit_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_answers_items_reviewed_flow_submit_id"), + table_name="answers_items", + ) + op.drop_column("answers_items", "reviewed_flow_submit_id") + # ### end Alembic commands ###