From 05f4335bc101150553bb302193152feaed4f6a9f Mon Sep 17 00:00:00 2001 From: Paul Abumov Date: Tue, 8 Oct 2024 21:45:25 -0400 Subject: [PATCH] Add qualifications management functionality to TaskReview app --- .../how_to_use/review_app/server_api.md | 98 ++++++- mephisto/abstractions/database.py | 52 +++- .../abstractions/databases/local_database.py | 42 ++- ..._20241002_add_qualification_description.py | 15 + .../databases/migrations/__init__.py | 2 + mephisto/data_model/qualification.py | 24 +- mephisto/review_app/client/package-lock.json | 12 + mephisto/review_app/client/package.json | 1 + mephisto/review_app/client/src/App/App.tsx | 5 + .../EditGrantedQualificationModal.css | 65 +++++ .../EditGrantedQualificationModal.tsx | 158 +++++++++++ .../src/components/Preloader/Preloader.css | 13 + .../src/components/Preloader/Preloader.tsx | 24 ++ .../client/src/components/Tabs/Tabs.css | 70 +++++ .../client/src/components/Tabs/Tabs.tsx | 101 +++++++ .../review_app/client/src/consts/review.ts | 5 + mephisto/review_app/client/src/helpers.ts | 9 + .../DeleteQualificationModal.css | 39 +++ .../DeleteQualificationModal.tsx | 72 +++++ .../EditQualificationModal.css | 44 +++ .../EditQualificationModal.tsx | 152 +++++++++++ .../GrantedQualificationsTable.css | 112 ++++++++ .../GrantedQualificationsTable.tsx | 104 +++++++ .../QualificationPage/QualificationPage.css | 42 +++ .../QualificationPage/QualificationPage.tsx | 257 ++++++++++++++++++ .../pages/TaskPage/ModalForm/ModalForm.tsx | 212 +++++++++------ .../TaskPage/ReviewModal/ReviewModal.css | 16 +- .../TaskPage/ReviewModal/ReviewModal.tsx | 6 +- .../client/src/pages/TaskPage/TaskPage.css | 8 - .../client/src/pages/TaskPage/TaskPage.tsx | 22 +- .../client/src/pages/TaskPage/modalData.tsx | 6 +- .../src/pages/TaskStatsPage/TaskStatsPage.css | 8 - .../src/pages/TaskStatsPage/TaskStatsPage.tsx | 19 +- .../TaskTimelinePage/TaskTimelinePage.css | 8 - .../TaskTimelinePage/TaskTimelinePage.tsx | 17 +- .../src/pages/TaskUnitsPage/TaskUnitsPage.css | 8 - .../src/pages/TaskUnitsPage/TaskUnitsPage.tsx | 20 +- .../TaskWorkerOpinionsPage.css | 8 - .../TaskWorkerOpinionsPage.tsx | 17 +- .../CreateQualificationModal.css | 44 +++ .../CreateQualificationModal.tsx | 143 ++++++++++ .../QualificationsTab/QualificationsTab.css | 44 +++ .../QualificationsTab/QualificationsTab.tsx | 213 +++++++++++++++ .../QualificationsTable.css | 113 ++++++++ .../QualificationsTable.tsx | 117 ++++++++ .../client/src/pages/TasksPage/TasksPage.css | 116 +------- .../client/src/pages/TasksPage/TasksPage.tsx | 235 ++-------------- .../src/pages/TasksPage/TasksTab/TasksTab.css | 13 + .../src/pages/TasksPage/TasksTab/TasksTab.tsx | 50 ++++ .../pages/TasksPage/TasksTable/TasksTable.css | 114 ++++++++ .../pages/TasksPage/TasksTable/TasksTable.tsx | 207 ++++++++++++++ .../client/src/pages/UnitPage/UnitPage.css | 8 - .../client/src/pages/UnitPage/UnitPage.tsx | 28 +- .../UnitReviewsCollapsable.css | 61 +++++ .../UnitReviewsCollapsable.tsx | 96 +++++++ .../client/src/requests/qualifications.ts | 179 +++++++++++- .../client/src/types/qualifications.d.ts | 31 ++- .../client/src/types/reviewModal.d.ts | 3 +- .../review_app/client/src/types/tabs.d.ts | 15 + .../review_app/client/src/types/units.d.ts | 12 + mephisto/review_app/client/src/urls.ts | 4 + .../review_app/server/api/views/__init__.py | 3 + .../api/views/granted_qualifications_view.py | 228 ++++++++++++++++ .../api/views/qualification_details_view.py | 26 ++ .../server/api/views/qualification_view.py | 70 +++++ .../server/api/views/qualifications_view.py | 13 +- .../server/api/views/qualify_worker_view.py | 60 +++- .../review_app/server/api/views/task_view.py | 2 +- .../server/api/views/units_details_view.py | 64 +++++ mephisto/review_app/server/urls.py | 12 + .../server/api/test_grant_workers_view.py | 89 ------ .../api/test_granted_qualifications_view.py | 69 +++++ .../api/test_qualification_details_view.py | 72 +++++ .../server/api/test_qualification_view.py | 123 +++++++++ .../server/api/test_qualifications_view.py | 7 + .../server/api/test_qualify_worker_view.py | 256 +++++++++++++++++ .../server/api/test_revoke_workers_view.py | 89 ------ .../server/api/test_units_details_view.py | 3 + 78 files changed, 4153 insertions(+), 772 deletions(-) create mode 100644 mephisto/abstractions/databases/migrations/_002_20241002_add_qualification_description.py create mode 100644 mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.css create mode 100644 mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.tsx create mode 100644 mephisto/review_app/client/src/components/Preloader/Preloader.css create mode 100644 mephisto/review_app/client/src/components/Preloader/Preloader.tsx create mode 100644 mephisto/review_app/client/src/components/Tabs/Tabs.css create mode 100644 mephisto/review_app/client/src/components/Tabs/Tabs.tsx create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.css create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.tsx create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.css create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.tsx create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.css create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.tsx create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.css create mode 100644 mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.tsx create mode 100644 mephisto/review_app/client/src/pages/TasksPage/CreateQualificationModal/CreateQualificationModal.css create mode 100644 mephisto/review_app/client/src/pages/TasksPage/CreateQualificationModal/CreateQualificationModal.tsx create mode 100644 mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.css create mode 100644 mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.tsx create mode 100644 mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.css create mode 100644 mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.tsx create mode 100644 mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.css create mode 100644 mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.tsx create mode 100644 mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.css create mode 100644 mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.tsx create mode 100644 mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.css create mode 100644 mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.tsx create mode 100644 mephisto/review_app/client/src/types/tabs.d.ts create mode 100644 mephisto/review_app/server/api/views/granted_qualifications_view.py create mode 100644 mephisto/review_app/server/api/views/qualification_details_view.py create mode 100644 mephisto/review_app/server/api/views/qualification_view.py delete mode 100644 test/review_app/server/api/test_grant_workers_view.py create mode 100644 test/review_app/server/api/test_granted_qualifications_view.py create mode 100644 test/review_app/server/api/test_qualification_details_view.py create mode 100644 test/review_app/server/api/test_qualification_view.py create mode 100644 test/review_app/server/api/test_qualify_worker_view.py delete mode 100644 test/review_app/server/api/test_revoke_workers_view.py diff --git a/docs/web/docs/guides/how_to_use/review_app/server_api.md b/docs/web/docs/guides/how_to_use/review_app/server_api.md index 1114d702a..1f4205415 100644 --- a/docs/web/docs/guides/how_to_use/review_app/server_api.md +++ b/docs/web/docs/guides/how_to_use/review_app/server_api.md @@ -201,6 +201,8 @@ Get all available qualifications (to select "approve" and "reject" qualification { "qualifications": [ { + "creation_date": , + "description": , "id": , "name": , }, @@ -223,6 +225,52 @@ Create a new qualification --- +### `GET /api/qualifications/{id}` + +Get metadata for a qualificaition + +```json +{ + "creation_date": , + "description": , + "id": , + "name": , +} +``` + +--- + +### `PATCH /api/qualifications/{id}` + +Update a qualification + +```json +{ + "name": , + "description": , +} +``` + +--- + +### `GET /api/qualifications/{id}/details` + +Get additional data about a qualification + +```json +{ + "granted_qualifications_count": , +} +``` + +--- + +### `DELETE /api/qualifications/{id}` + +Delete a qualificaition + +--- + ### `GET /api/qualifications/{id}/workers?{task_id=}` Get list of all bearers of a qualification. @@ -256,6 +304,18 @@ Grant qualification to a worker --- +### `PATCH /api/qualifications/{id}/workers/{id}/grant` + +Update value of existing granted qualification + +```json +{ + "value": , +} +``` + +--- + ### `POST /api/qualifications/{id}/workers/{id}/revoke` Revoke qualification from a worker @@ -268,6 +328,42 @@ Revoke qualification from a worker --- +### `PATCH /api/qualifications/{id}/workers/{id}/revoke` + +Revoke qualification from a worker (see the difference from `POST` in the code) + +--- + +### `GET /api/granted-qualifications` + +Get list of all granted queslifications + +```json +{ + "granted_qualifications": [ + { + "granted_at": , + "qualification_id": , + "qualification_name": , + "units": [ + { + "task_id": , + "task_name": , + "unit_id": , + }, + ... // more units + ], + "value_current": , + "worker_id": , + "worker_name": , + }, + ... // more granted qualifications + ], +} +``` + +--- + ### `GET /api/units?{task_id=}{unit_ids=}` Get workers' results (filtered by task_id and/or unit_ids, etc) - without full details of input/output. At least one filtering parameter must be specified @@ -312,7 +408,7 @@ Get full input for specified workers results (`units_ids` parameter is mandatory "has_task_source_review": , "id": , "inputs": , // instructions for worker - "metadata": , // any metadata (e.g. Worker Opinion) + "metadata": , // any metadata (e.g. Worker Opinion, Unit Reviews, etc) "outputs": , // response from worker "prepared_inputs": , // prepared instructions from worker "unit_data_folder": }, // path to data dir in file system diff --git a/mephisto/abstractions/database.py b/mephisto/abstractions/database.py index 2fa77f477..da92e924d 100644 --- a/mephisto/abstractions/database.py +++ b/mephisto/abstractions/database.py @@ -78,6 +78,7 @@ GET_QUALIFICATION_LATENCY = DATABASE_LATENCY.labels(method="get_qualification") FIND_QUALIFICATIONS_LATENCY = DATABASE_LATENCY.labels(method="find_qualifications") DELETE_QUALIFICATION_LATENCY = DATABASE_LATENCY.labels(method="delete_qualification") +UPDATE_QUALIFICATION_LATENCY = DATABASE_LATENCY.labels(method="update_qualification") GRANT_QUALIFICATION_LATENCY = DATABASE_LATENCY.labels(method="grant_qualification") FIND_GRANT_QUALIFICATION_LATENCY = DATABASE_LATENCY.labels(method="find_granted_qualification") CHECK_GRANTED_QUALIFICATIONS_LATENCY = DATABASE_LATENCY.labels( @@ -600,7 +601,8 @@ def update_unit( self, unit_id: str, agent_id: Optional[str] = None, status: Optional[str] = None ) -> None: """ - Update the given task with the given parameters if possible, raise appropriate exception otherwise. + Update the given unit with the given parameters if possible, + raise appropriate exception otherwise. """ return self._update_unit(unit_id=unit_id, status=status) @@ -901,17 +903,21 @@ def find_onboarding_agents( ) @abstractmethod - def _make_qualification(self, qualification_name: str) -> str: + def _make_qualification( + self, qualification_name: str, description: Optional[str] = None + ) -> str: """make_qualification implementation""" raise NotImplementedError() @MAKE_QUALIFICATION_LATENCY.time() - def make_qualification(self, qualification_name: str) -> str: + def make_qualification(self, qualification_name: str, description: Optional[str] = None) -> str: """ Make a new qualification, throws an error if a qualification by the given name already exists. Return the id for the qualification. """ - return self._make_qualification(qualification_name=qualification_name) + return self._make_qualification( + qualification_name=qualification_name, description=description + ) @abstractmethod def _find_qualifications(self, qualification_name: Optional[str] = None) -> List[Qualification]: @@ -942,9 +948,7 @@ def get_qualification(self, qualification_id: str) -> Mapping[str, Any]: @abstractmethod def _delete_qualification(self, qualification_name: str) -> None: - """ - Remove this qualification from all workers that have it, then delete the qualification - """ + """delete_qualification implementation""" raise NotImplementedError() @DELETE_QUALIFICATION_LATENCY.time() @@ -958,16 +962,46 @@ def delete_qualification(self, qualification_name: str) -> None: provider = ProviderClass(self) provider.cleanup_qualification(qualification_name) + @abstractmethod + def _update_qualification( + self, + qualification_id: str, + name: str, + description: Optional[str] = None, + ) -> None: + """update_qualification implementation""" + raise NotImplementedError() + + @UPDATE_QUALIFICATION_LATENCY.time() + def update_qualification( + self, + qualification_id: str, + name: str, + description: Optional[str] = None, + ) -> None: + """ + Update the given qualification with the given parameters if possible, + raise appropriate exception otherwise. + """ + return self._update_qualification( + qualification_id=qualification_id, + name=name, + description=description, + ) + @FIND_GRANT_QUALIFICATION_LATENCY.time() def find_granted_qualifications( self, worker_id: Optional[str] = None, + qualification_id: Optional[str] = None, ) -> List[GrantedQualification]: """ Find granted qualifications. - If `worker_id` is not supplied, returns all granted qualifications. + If nothing supplied, returns all granted qualifications. """ - return self._check_granted_qualifications(worker_id=worker_id) + return self._check_granted_qualifications( + worker_id=worker_id, qualification_id=qualification_id + ) @abstractmethod def _grant_qualification(self, qualification_id: str, worker_id: str, value: int = 1) -> None: diff --git a/mephisto/abstractions/databases/local_database.py b/mephisto/abstractions/databases/local_database.py index bfca8f822..454b5409f 100644 --- a/mephisto/abstractions/databases/local_database.py +++ b/mephisto/abstractions/databases/local_database.py @@ -784,7 +784,7 @@ def _update_unit( self, unit_id: str, agent_id: Optional[str] = None, status: Optional[str] = None ) -> None: """ - Update the given task with the given parameters if possible, + Update the given unit with the given parameters if possible, raise appropriate exception otherwise. """ if status not in AssignmentState.valid_unit(): @@ -1117,7 +1117,9 @@ def _find_agents( rows = c.fetchall() return [Agent(self, str(r["agent_id"]), row=r, _used_new_call=True) for r in rows] - def _make_qualification(self, qualification_name: str) -> str: + def _make_qualification( + self, qualification_name: str, description: Optional[str] = None + ) -> str: """ Make a new qualification, throws an error if a qualification by the given name already exists. Return the id for the qualification. @@ -1128,8 +1130,8 @@ def _make_qualification(self, qualification_name: str) -> str: c = conn.cursor() try: c.execute( - "INSERT INTO qualifications(qualification_name) VALUES (?);", - (qualification_name,), + "INSERT INTO qualifications(qualification_name, description) VALUES (?, ?);", + (qualification_name, description), ) qualification_id = str(c.lastrowid) return qualification_id @@ -1195,6 +1197,38 @@ def _delete_qualification(self, qualification_name: str) -> None: (qualification_name,), ) + def _update_qualification( + self, + qualification_id: str, + name: str, + description: Optional[str] = None, + ) -> None: + """ + Update the given qualification with the given parameters if possible, + raise appropriate exception otherwise. + """ + with self.table_access_condition, self.get_connection() as conn: + c = conn.cursor() + try: + c.execute( + """ + UPDATE qualifications + SET qualification_name = ?2, description = ?3 + WHERE qualification_id = ?1; + """, + [ + nonesafe_int(qualification_id), + name, + description, + ], + ) + except sqlite3.IntegrityError as e: + if is_key_failure(e): + raise EntryDoesNotExistException( + f"Given qualification_id {qualification_id} not found in the database" + ) + raise MephistoDBException(e) + def _grant_qualification(self, qualification_id: str, worker_id: str, value: int = 1) -> None: """ Grant a worker the given qualification. Update the qualification value if it diff --git a/mephisto/abstractions/databases/migrations/_002_20241002_add_qualification_description.py b/mephisto/abstractions/databases/migrations/_002_20241002_add_qualification_description.py new file mode 100644 index 000000000..7e41a62fd --- /dev/null +++ b/mephisto/abstractions/databases/migrations/_002_20241002_add_qualification_description.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +""" +List of changes: +1. Add `description` field into `qualifications` table +""" + + +ADD_QUALIFICATION_DESCRIPTION = """ + ALTER TABLE qualifications ADD COLUMN description CHAR(500); +""" diff --git a/mephisto/abstractions/databases/migrations/__init__.py b/mephisto/abstractions/databases/migrations/__init__.py index 5f1d516d6..c80be9cce 100644 --- a/mephisto/abstractions/databases/migrations/__init__.py +++ b/mephisto/abstractions/databases/migrations/__init__.py @@ -5,8 +5,10 @@ # LICENSE file in the root directory of this source tree. from ._001_20240325_data_porter_feature import * +from ._002_20241002_add_qualification_description import * migrations = { "20240418_data_porter_feature": MODIFICATIONS_FOR_DATA_PORTER, + "20241002_add_qualification_description": ADD_QUALIFICATION_DESCRIPTION, } diff --git a/mephisto/data_model/qualification.py b/mephisto/data_model/qualification.py index d05eff0af..8cb617c60 100644 --- a/mephisto/data_model/qualification.py +++ b/mephisto/data_model/qualification.py @@ -4,18 +4,18 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -from mephisto.data_model._db_backed_meta import ( - MephistoDBBackedMeta, - MephistoDataModelComponentMixin, -) - -from typing import Optional, Mapping, TYPE_CHECKING, Any +from typing import Any +from typing import Mapping +from typing import Optional +from typing import TYPE_CHECKING +from mephisto.data_model._db_backed_meta import MephistoDataModelComponentMixin +from mephisto.data_model._db_backed_meta import MephistoDBBackedMeta +from mephisto.utils.logger_core import get_logger if TYPE_CHECKING: from mephisto.abstractions.database import MephistoDB -from mephisto.utils.logger_core import get_logger logger = get_logger(name=__name__) @@ -72,11 +72,16 @@ def __init__( "now deprecated in favor of calling Qualification.get(db, id). " ) self.db: "MephistoDB" = db + if row is None: row = db.get_qualification(db_id) + assert row is not None, f"Given db_id {db_id} did not exist in given db" + self.db_id: str = row["qualification_id"] self.qualification_name: str = row["qualification_name"] + self.description: str = row["description"] + self.creation_date: str = row["creation_date"] class GrantedQualification: @@ -90,9 +95,14 @@ def __init__( row: Optional[Mapping[str, Any]] = None, ): self.db: "MephistoDB" = db + if row is None: row = db.get_granted_qualification(qualification_id, worker_id) + assert row is not None, f"Granted qualification did not exist in given db" + self.worker_id: str = row["worker_id"] self.qualification_id: str = row["qualification_id"] self.value: str = row["value"] + self.creation_date: str = row["creation_date"] + self.update_date: str = row["update_date"] diff --git a/mephisto/review_app/client/package-lock.json b/mephisto/review_app/client/package-lock.json index 9cf63450c..63b78eced 100644 --- a/mephisto/review_app/client/package-lock.json +++ b/mephisto/review_app/client/package-lock.json @@ -12,6 +12,7 @@ "@types/react-dom": "^18.2.7", "bootstrap": "^5.3.1", "d3": "^7.9.0", + "jquery": "^3.6.0", "lodash": "^4.17.21", "mephisto-task": "^2.0.4", "moment": "^2.29.4", @@ -11764,6 +11765,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -26453,6 +26460,11 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==" }, + "jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/mephisto/review_app/client/package.json b/mephisto/review_app/client/package.json index cf0c54d7d..d95ad0cbb 100644 --- a/mephisto/review_app/client/package.json +++ b/mephisto/review_app/client/package.json @@ -7,6 +7,7 @@ "@types/react-dom": "^18.2.7", "bootstrap": "^5.3.1", "d3": "^7.9.0", + "jquery": "^3.6.0", "lodash": "^4.17.21", "mephisto-task": "^2.0.4", "moment": "^2.29.4", diff --git a/mephisto/review_app/client/src/App/App.tsx b/mephisto/review_app/client/src/App/App.tsx index de5bc225b..bd6c5ea41 100644 --- a/mephisto/review_app/client/src/App/App.tsx +++ b/mephisto/review_app/client/src/App/App.tsx @@ -8,6 +8,7 @@ import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/js/bootstrap.bundle.min"; import Errors from "components/Errors/Errors"; import HomePage from "pages/HomePage/HomePage"; +import QualificationPage from "pages/QualificationPage/QualificationPage"; import TaskPage from "pages/TaskPage/TaskPage"; import TasksPage from "pages/TasksPage/TasksPage"; import TaskStatsPage from "pages/TaskStatsPage/TaskStatsPage"; @@ -58,6 +59,10 @@ function App() { path={urls.client.tasks} element={} /> + } + /> diff --git a/mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.css b/mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.css new file mode 100644 index 000000000..800ca844f --- /dev/null +++ b/mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.css @@ -0,0 +1,65 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.edit-granted-qualification-modal { +} + +.edit-granted-qualification-modal .modal-dialog .modal-header { + background-color: #ecdadf; + display: flex; + justify-content: center; + padding: 5px; + border-radius: 0; +} + +.edit-granted-qualification-modal .modal-dialog .modal-header .modal-title { + font-size: 26px; +} + +.edit-granted-qualification-modal .modal-dialog .modal-header .btn-close { + position: absolute; + right: 14px; +} + +.edit-granted-qualification-modal .modal-dialog .modal-content { + border-radius: initial; +} + +.edit-granted-qualification-modal .edit-granted-qualification-form { +} + +.edit-granted-qualification-modal .edit-granted-qualification-form > * input, +.edit-granted-qualification-modal + .edit-granted-qualification-form + > * + textarea { + border: 1px solid black; +} + +.edit-granted-qualification-modal .edit-granted-qualification-form .or { + display: flex; + flex-direction: row; + justify-content: center; +} + +.edit-granted-qualification-modal + .edit-granted-qualification-form + .revoke-buttons { + display: flex; + flex-direction: row; + justify-content: center; + gap: 10px; +} + +.edit-granted-qualification-modal + .modal-dialog + .modal-content + .modal-footer + .edit-granted-qualification-buttons { + width: 100%; + display: flex; + justify-content: space-between; +} diff --git a/mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.tsx b/mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.tsx new file mode 100644 index 000000000..905e1dea9 --- /dev/null +++ b/mephisto/review_app/client/src/components/EditGrantedQualificationModal/EditGrantedQualificationModal.tsx @@ -0,0 +1,158 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { EDIT_GRANTED_QUALIFICATION_VALUE_LENGTH } from "consts/review"; +import cloneDeep from "lodash/cloneDeep"; +import * as React from "react"; +import { useEffect } from "react"; +import { Button, Col, Form, Modal, Row } from "react-bootstrap"; +import "./EditGrantedQualificationModal.css"; + +export type EditGrantedQualificationFormType = { + value: number; +}; + +type EditGrantedQualificationModalPropsType = { + grantedQualification: FullGrantedQualificationType; + onRevoke: Function; + onSubmit: Function; + setErrors: Function; + setShow: React.Dispatch>; + show: boolean; +}; + +function EditGrantedQualificationModal( + props: EditGrantedQualificationModalPropsType +) { + const defaultFormValue = { + value: props.grantedQualification?.value_current || 0, + }; + + const [form, setForm] = React.useState( + cloneDeep(defaultFormValue) + ); + const [formIsValid, setFormIsValid] = React.useState(false); + + // Methods + + function onModalClose() { + props.setShow(!props.show); + } + + function updateForm(fieldName: string, value: string) { + const re = /^[0-9\b]+$/; + // if value is not blank, then test the regex + if (value === "" || re.test(value)) { + setForm({ ...form, [fieldName]: value }); + } + } + + // Effects + + useEffect(() => { + if (String(form.value) !== "") { + setFormIsValid(true); + } else { + setFormIsValid(false); + } + }, [form]); + + useEffect(() => { + if (props.show) { + setForm(cloneDeep(defaultFormValue)); + } + }, [props.show]); + + return ( + props.show && ( + + + Edit Worker Qualification + + + +
{ + e.preventDefault(); + }} + > + + + Change qualification value + + + + updateForm("value", e.target.value)} + /> + + + +
or
+ +
+ +
+
+
+ + +
+ + + +
+
+
+ ) + ); +} + +export default EditGrantedQualificationModal; diff --git a/mephisto/review_app/client/src/components/Preloader/Preloader.css b/mephisto/review_app/client/src/components/Preloader/Preloader.css new file mode 100644 index 000000000..bbce0b887 --- /dev/null +++ b/mephisto/review_app/client/src/components/Preloader/Preloader.css @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.loading { + width: 100%; + height: 100px; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/mephisto/review_app/client/src/components/Preloader/Preloader.tsx b/mephisto/review_app/client/src/components/Preloader/Preloader.tsx new file mode 100644 index 000000000..22122c3b3 --- /dev/null +++ b/mephisto/review_app/client/src/components/Preloader/Preloader.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { Spinner } from "react-bootstrap"; +import "./Preloader.css"; + +type PreloaderPropsType = { + className?: string; + loading: boolean; +}; + +function Preloader(props: PreloaderPropsType) { + if (!props.loading) { + return null; + } + + return ( +
+ + Loading... + +
+ ); +} + +export default Preloader; diff --git a/mephisto/review_app/client/src/components/Tabs/Tabs.css b/mephisto/review_app/client/src/components/Tabs/Tabs.css new file mode 100644 index 000000000..957c62b61 --- /dev/null +++ b/mephisto/review_app/client/src/components/Tabs/Tabs.css @@ -0,0 +1,70 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.tabs { + margin-top: 20px; +} + +.tabs .tabs-nav { + --tab-nav-height: 58px; + --tab-nav-item-height: 41px; + + display: flex; + flex-direction: row; + gap: 4px; + padding-left: 20px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.tabs-empty { + height: 0; +} + +.tabs .tabs-nav .tabs-item { +} + +.tabs .tabs-nav .tabs-item.disabled { + background: none; + cursor: default; +} + +.tabs .tabs-nav .tabs-item:only-child { + display: none; +} + +.tabs .tabs-nav .tabs-item .tabs-item-link { +} + +.tabs .tabs-nav .tabs-item .tabs-item-link[aria-selected="true"] { + cursor: default; +} + +.tabs .tabs-nav .tabs-item .tabs-item-link[aria-selected="false"] { + color: #000000; + background-color: var(--bs-nav-tabs-border-color); +} + +.tabs .tabs-nav .tabs-item .tabs-item-link[aria-selected="false"]:hover { + background-color: #eeeeee; +} + +.tabs .tabs-content { + overflow-x: hidden; + overflow-y: auto; +} + +.tabs .tabs-content .tabs-content-pane { + margin-top: 10px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.no-margins { + margin: 0; +} diff --git a/mephisto/review_app/client/src/components/Tabs/Tabs.tsx b/mephisto/review_app/client/src/components/Tabs/Tabs.tsx new file mode 100644 index 000000000..eeff2562e --- /dev/null +++ b/mephisto/review_app/client/src/components/Tabs/Tabs.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Nav, Tab } from "react-bootstrap"; +import "./Tabs.css"; + +type TabsPropsType = { + activeTabName?: string; + navClassName?: string; + onPick?: (tabName: string) => void; + tabs: TabType[]; +}; + +let userKey = null; + +function Tabs(props: TabsPropsType) { + const { tabs, activeTabName } = props; + + const firstActiveTab = tabs.find((tab) => !tab.disabled); + + const [activeKey, setActiveKey] = useState( + activeTabName || firstActiveTab?.name + ); + + const tabContent = useRef(null); + + const onPick = (key: string) => { + userKey = key; + setActiveKey(key); + props.onPick && props.onPick(key); + }; + + useEffect(() => { + let tab = tabs.find((tab) => tab.name === userKey && !tab.disabled); + if (!tab) { + tab = tabs.find((tab) => !tab.disabled); + } + setActiveKey(activeTabName || tab.name); + }, [tabs]); + + useEffect(() => { + if (tabContent.current) { + tabContent.current.scrollTop = 0; + } + }); + + return ( +
+ {/* Tabs panel */} + onPick(k)}> + + + {/* Selected tab content */} + + {props.tabs.map((tab: TabType, i: number) => { + return ( + + {tab.children} + + ); + })} + + +
+ ); +} + +export default Tabs; diff --git a/mephisto/review_app/client/src/consts/review.ts b/mephisto/review_app/client/src/consts/review.ts index c2b1f030e..69ffea5a9 100644 --- a/mephisto/review_app/client/src/consts/review.ts +++ b/mephisto/review_app/client/src/consts/review.ts @@ -53,3 +53,8 @@ export const VIDEO_TYPES_BY_EXT = { mpeg: "video/mpeg", webm: "video/webm", }; + +export const NEW_QUALIFICATION_NAME_LENGTH = 50; +export const NEW_QUALIFICATION_DESCRIPTION_LENGTH = 500; + +export const EDIT_GRANTED_QUALIFICATION_VALUE_LENGTH = 50; diff --git a/mephisto/review_app/client/src/helpers.ts b/mephisto/review_app/client/src/helpers.ts index f4751691e..200e16ab1 100644 --- a/mephisto/review_app/client/src/helpers.ts +++ b/mephisto/review_app/client/src/helpers.ts @@ -31,3 +31,12 @@ export function setPageTitle(title: string) { export function capitalizeString(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } + +export function setResponseErrors( + setErrorsFunc: Function, + errorResponse: ErrorResponseType | null +) { + if (errorResponse) { + setErrorsFunc((oldErrors) => [...oldErrors, ...[errorResponse.error]]); + } +} diff --git a/mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.css b/mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.css new file mode 100644 index 000000000..af9674cf2 --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.css @@ -0,0 +1,39 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.delete-qualification-modal { +} + +.delete-qualification-modal .modal-dialog .modal-header { + background-color: #ecdadf; + display: flex; + justify-content: center; + padding: 5px; + border-radius: 0; +} + +.delete-qualification-modal .modal-dialog .modal-header .modal-title { + font-size: 26px; +} + +.delete-qualification-modal .modal-dialog .modal-header .btn-close { + position: absolute; + right: 14px; +} + +.delete-qualification-modal .modal-dialog .modal-content { + border-radius: initial; +} + +.delete-qualification-modal + .modal-dialog + .modal-content + .modal-footer + .delete-qualification-buttons { + width: 100%; + display: flex; + justify-content: space-between; +} diff --git a/mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.tsx b/mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.tsx new file mode 100644 index 000000000..e1a6b3387 --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/DeleteQualificationModal/DeleteQualificationModal.tsx @@ -0,0 +1,72 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from "react"; +import { Button, Modal } from "react-bootstrap"; +import "./DeleteQualificationModal.css"; + +type DeleteQualificationModalPropsType = { + grantedQualificationsAmount: number; + onSubmit: Function; + setErrors: Function; + setShow: React.Dispatch>; + show: boolean; +}; + +function DeleteQualificationModal(props: DeleteQualificationModalPropsType) { + // Methods + + function onModalClose() { + props.setShow(!props.show); + } + + return ( + props.show && ( + + + Delete qualification + + + + {props.grantedQualificationsAmount === 0 ? ( + <>Are you sure you want to delete it? + ) : ( + <> + This qualification was granted {props.grantedQualificationsAmount}{" "} + times - are you sure you want to delete it? + + )} + + + +
+ + + +
+
+
+ ) + ); +} + +export default DeleteQualificationModal; diff --git a/mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.css b/mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.css new file mode 100644 index 000000000..45890b0ac --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.css @@ -0,0 +1,44 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.edit-qualification-modal { +} + +.edit-qualification-modal .modal-dialog .modal-header { + background-color: #ecdadf; + display: flex; + justify-content: center; + padding: 5px; + border-radius: 0; +} + +.edit-qualification-modal .modal-dialog .modal-header .modal-title { + font-size: 26px; +} + +.edit-qualification-modal .modal-dialog .modal-header .btn-close { + position: absolute; + right: 14px; +} + +.edit-qualification-modal .modal-dialog .modal-content { + border-radius: initial; +} + +.edit-qualification-modal .edit-qualification-form > * input, +.edit-qualification-modal .edit-qualification-form > * textarea { + border: 1px solid black; +} + +.edit-qualification-modal + .modal-dialog + .modal-content + .modal-footer + .edit-qualification-buttons { + width: 100%; + display: flex; + justify-content: space-between; +} diff --git a/mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.tsx b/mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.tsx new file mode 100644 index 000000000..b9ecc508a --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/EditQualificationModal/EditQualificationModal.tsx @@ -0,0 +1,152 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + NEW_QUALIFICATION_DESCRIPTION_LENGTH, + NEW_QUALIFICATION_NAME_LENGTH, +} from "consts/review"; +import cloneDeep from "lodash/cloneDeep"; +import * as React from "react"; +import { useEffect } from "react"; +import { Button, Col, Form, Modal, Row } from "react-bootstrap"; +import "./EditQualificationModal.css"; + +export type EditQualificationFormType = { + description: string; + name: string; +}; + +const DEFAULT_FORM_STATE: EditQualificationFormType = { + description: "", + name: "", +}; + +type EditQualificationModalPropsType = { + onSubmit: Function; + qualification: QualificationType; + setErrors: Function; + setShow: React.Dispatch>; + show: boolean; +}; + +function EditQualificationModal(props: EditQualificationModalPropsType) { + const [form, setForm] = React.useState( + cloneDeep(DEFAULT_FORM_STATE) + ); + const [formIsValid, setFormIsValid] = React.useState(false); + + // Methods + + function onModalClose() { + props.setShow(!props.show); + } + + function updateForm(fieldName: string, value: string) { + setForm({ ...form, [fieldName]: value }); + } + + // Effects + + useEffect(() => { + if (form.name !== "") { + setFormIsValid(true); + } else { + setFormIsValid(false); + } + }, [form]); + + useEffect(() => { + if (props.qualification) { + setForm({ + name: props.qualification.name, + description: props.qualification.description, + }); + } + }, [props.qualification]); + + return ( + props.show && ( + + + Edit qualification + + + +
{ + e.preventDefault(); + }} + > + + + Name + + + + updateForm("name", e.target.value)} + /> + + + + + + Description + + + + updateForm("description", e.target.value)} + /> + + +
+
+ + +
+ + + +
+
+
+ ) + ); +} + +export default EditQualificationModal; diff --git a/mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.css b/mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.css new file mode 100644 index 000000000..858e0e3d2 --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.css @@ -0,0 +1,112 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.granted-qualification-table { + width: 100%; +} + +.granted-qualification-table .titles-row th { + background-color: #ecdadf; +} + +.granted-qualification-table .titles-row .qualification { + width: 350px; + max-width: 350px; +} + +.granted-qualification-table .titles-row .value-granted { + width: 130px; + text-align: center; + white-space: nowrap; +} + +.granted-qualification-table .titles-row .date-granted { + width: 130px; + text-align: center; +} + +.granted-qualification-table .titles-row .task { + width: 130px; + text-align: center; +} + +.granted-qualification-table .titles-row .worker { + width: 80px; + text-align: center; +} + +.granted-qualification-table .titles-row .unit { + width: 80px; + text-align: center; +} + +.granted-qualification-table .titles-row .actions { + width: 80px; + text-align: center; +} + +.granted-qualification-table .value-row:not(.no-hover) { + cursor: pointer; +} + +.granted-qualification-table .value-row:not(.no-hover):hover td { + background-color: rgba(236, 218, 223, 0.3); +} + +.granted-qualification-table .value-row .task { + width: 350px; + max-width: 350px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.granted-qualification-table .value-row .qualification.text-primary { + cursor: pointer; +} +.granted-qualification-table .value-row .qualification.text-primary:hover { + text-decoration: underline; +} + +.granted-qualification-table .value-row .value-granted, +.granted-qualification-table .value-row .date-granted, +.granted-qualification-table .value-row .task, +.granted-qualification-table .value-row .worker { + text-align: center; +} + +.granted-qualification-table .value-row .units { + width: 350px; + max-width: 350px; + text-align: left; +} + +.granted-qualification-table .value-row .units .unit { + display: inline-flex; + gap: 4px; +} + +.granted-qualification-table .value-row .units .unit.text-primary { + cursor: pointer; + text-decoration: initial; +} + +.granted-qualification-table .value-row .units .unit.text-primary:hover { + text-decoration: underline; +} + +.granted-qualification-table .value-row .units .unit .task-name { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.granted-qualification-table .value-row .units .unit .unit-id { + display: inline-block; +} diff --git a/mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.tsx b/mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.tsx new file mode 100644 index 000000000..0675065e0 --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/GrantedQualificationsTable/GrantedQualificationsTable.tsx @@ -0,0 +1,104 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as moment from "moment/moment"; +import * as React from "react"; +import { Button, Table } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import urls from "urls"; +import "./GrantedQualificationsTable.css"; + +type GrantedQualificationTablePropsType = { + grantedQualifications: FullGrantedQualificationType[]; + setEditModalGrantedQualification: Function; + setEditModalShow: Function; + setErrors: Function; +}; + +function GrantedQualificationsTable(props: GrantedQualificationTablePropsType) { + return ( + + + + + + + + + + + + + + {props.grantedQualifications && + props.grantedQualifications.map( + (gq: FullGrantedQualificationType, index: number) => { + const granted_at = moment(gq.granted_at).format("MMM D, YYYY"); + + return ( + + + + + + + + + ); + } + )} + +
+ Worker + + Current value + + Updated + + Units +
{gq.worker_name}{gq.value_current}{granted_at} + {gq.units.map((unit: FGQUnit, index: number) => { + return ( + <> + + + {unit.task_name} + + ({unit.unit_id.substring(0, 4)}*) + +
+ + ); + })} +
+ +
+ ); +} + +export default GrantedQualificationsTable; diff --git a/mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.css b/mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.css new file mode 100644 index 000000000..bae2fae3c --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.css @@ -0,0 +1,42 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.qualification { +} + +.qualification .header { + margin-bottom: 40px; + padding: 10px 20px; + background-color: #ecdadf; + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; +} + +.qualification .header .qualification-info { + display: flex; + flex-direction: column; + gap: 20px; +} + +.qualification .header .qualification-info .qualification-name { + font-size: 25px; +} + +.qualification .header .qualification-info .qualification-description { + max-width: 800px; +} + +.qualification .header .qualification-info .qualification-date { +} + +.qualification .header .header-buttons { + display: flex; + flex-direction: row; + gap: 10px; + align-items: flex-start; +} diff --git a/mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.tsx b/mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.tsx new file mode 100644 index 000000000..904c13e41 --- /dev/null +++ b/mephisto/review_app/client/src/pages/QualificationPage/QualificationPage.tsx @@ -0,0 +1,257 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import EditGrantedQualificationModal from "components/EditGrantedQualificationModal/EditGrantedQualificationModal"; +import Preloader from "components/Preloader/Preloader"; +import TasksHeader from "components/TasksHeader/TasksHeader"; +import { setResponseErrors } from "helpers"; +import * as moment from "moment"; +import * as React from "react"; +import { useEffect } from "react"; +import { Button } from "react-bootstrap"; +import { useParams } from "react-router-dom"; +import { + deleteQualification, + getGrantedQualifications, + getQualification, + getQualificationDetails, + patchQualification, + patchQualificationGrantWorker, + patchQualificationRevokeWorker, +} from "requests/qualifications"; +import urls from "urls"; +import DeleteQualificationModal from "./DeleteQualificationModal/DeleteQualificationModal"; +import EditQualificationModal from "./EditQualificationModal/EditQualificationModal"; +import GrantedQualificationsTable from "./GrantedQualificationsTable/GrantedQualificationsTable"; +import "./QualificationPage.css"; + +type ParamsType = { + id: string; +}; + +type QualificationPagePropsType = { + setErrors: Function; +}; + +function QualificationPage(props: QualificationPagePropsType) { + const params = useParams(); + + const [qualification, setQualification] = React.useState( + null + ); + const [grantedQualifications, setGrantedQualifications] = React.useState< + FullGrantedQualificationType[] + >(null); + const [loading, setLoading] = React.useState(false); + const [ + editQualificationModalShow, + setEditQualificationModalShow, + ] = React.useState(false); + const [ + deleteQualificationModalShow, + setDeleteQualificationModalShow, + ] = React.useState(false); + const [ + grantedQualificationsAmount, + setGrantedQualificationsAmount, + ] = React.useState(0); + const [ + editGrantedQualificationModalShow, + setEditGrantedQualificationModalShow, + ] = React.useState(false); + const [ + editModalGrantedQualification, + setEditModalGrantedQualification, + ] = React.useState(null); + + const onError = (response: ErrorResponseType) => + setResponseErrors(props.setErrors, response); + + // Methods + + function requestQualification() { + getQualification(params.id, setQualification, setLoading, onError); + } + + function requestGrantedQualifications() { + getGrantedQualifications(setGrantedQualifications, setLoading, onError, { + qualification_id: qualification?.id, + }); + } + + function onClickDeleteButton() { + function onSuccess(amount: number) { + setGrantedQualificationsAmount(amount); + setDeleteQualificationModalShow(true); + } + + getQualificationDetails( + qualification.id, + (data: QualificationDetailsType) => + onSuccess(data.granted_qualifications_count), + () => null, + onError + ); + } + + function onEditQualificationModalSubmit(data: CreateQualificationFormType) { + function onSuccess() { + requestQualification(); + setEditQualificationModalShow(false); + } + + patchQualification(qualification.id, onSuccess, setLoading, onError, data); + } + + function onDeleteModalSubmit() { + function onSuccess() { + setDeleteQualificationModalShow(false); + // Redirect to Tasks page + window.location.replace(urls.client.tasks); + } + + deleteQualification(qualification.id, onSuccess, setLoading, onError); + } + + function onEditGrantedQualificationModalSubmit( + qualificationId: string, + workerId: string, + value: number + ) { + function onSuccess() { + requestGrantedQualifications(); + setEditGrantedQualificationModalShow(false); + } + + patchQualificationGrantWorker( + qualificationId, + workerId, + onSuccess, + setLoading, + onError, + { + value: value, + } + ); + } + + function onEditGrantedQualificationModalRevoke( + qualificationId: string, + workerId: string + ) { + function onSuccess() { + requestGrantedQualifications(); + setEditGrantedQualificationModalShow(false); + } + + patchQualificationRevokeWorker( + qualificationId, + workerId, + onSuccess, + setLoading, + onError, + null + ); + } + + // Effects + useEffect(() => { + if (qualification === null) { + requestQualification(); + } + }, []); + + useEffect(() => { + if (qualification === null) { + return; + } + + if (grantedQualifications === null) { + requestGrantedQualifications(); + } + + document.title = `Mephisto - Task Review - Qualification "${qualification.name}"`; + }, [qualification]); + + return ( +
+ + + {!loading && qualification && ( +
+
+
+ Qualification "{qualification.name}" +
+ +
+ {qualification.description} +
+ +
+ Date created:{" "} + {moment(qualification.creation_date).format("MMM D, YYYY")} +
+
+ +
+ + + +
+
+ )} + + + + + + + + + + +
+ ); +} + +export default QualificationPage; diff --git a/mephisto/review_app/client/src/pages/TaskPage/ModalForm/ModalForm.tsx b/mephisto/review_app/client/src/pages/TaskPage/ModalForm/ModalForm.tsx index 9262ac731..ddcc162ea 100644 --- a/mephisto/review_app/client/src/pages/TaskPage/ModalForm/ModalForm.tsx +++ b/mephisto/review_app/client/src/pages/TaskPage/ModalForm/ModalForm.tsx @@ -4,13 +4,18 @@ * LICENSE file in the root directory of this source tree. */ -import { ReviewType } from "consts/review"; +import { + NEW_QUALIFICATION_DESCRIPTION_LENGTH, + NEW_QUALIFICATION_NAME_LENGTH, + ReviewType, +} from "consts/review"; +import { setResponseErrors } from "helpers"; import * as React from "react"; import { useEffect } from "react"; import { Button, Col, Form, Row } from "react-bootstrap"; import { getQualifications, postQualification } from "requests/qualifications"; -import "./ModalForm.css"; import { getWorkerGrantedQualifications } from "requests/workers"; +import "./ModalForm.css"; const BONUS_FOR_WORKER_ENABLED = true; const FEEDBACK_FOR_WORKER_ENABLED = true; @@ -19,14 +24,14 @@ const QUALIFICATION_VALUE_MAX = 10; const range = (start, end) => Array.from(Array(end + 1).keys()).slice(start); -type ModalFormProps = { +type ModalFormPropsType = { data: ModalDataType; setData: React.Dispatch>; setErrors: Function; workerId: string | null; }; -function ModalForm(props: ModalFormProps) { +function ModalForm(props: ModalFormPropsType) { const [ workerGrantedQualifications, setWorkerGrantedQualifications, @@ -36,25 +41,34 @@ function ModalForm(props: ModalFormProps) { >(null); const [loading, setLoading] = React.useState(false); const [_, setCreateQualificationLoading] = React.useState(false); + const [ + newQualificationFormIsValid, + setNewQualificationFormIsValid, + ] = React.useState(false); - const onChangeAssign = (value: boolean) => { + const onError = (response: ErrorResponseType) => + setResponseErrors(props.setErrors, response); + + // Methods + function onChangeAssign(value: boolean) { let prevFormData: FormType = Object(props.data.form); prevFormData.checkboxAssignQualification = value; props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeUnassign = (value: boolean) => { + function onChangeUnassign(value: boolean) { let prevFormData: FormType = Object(props.data.form); prevFormData.checkboxUnassignQualification = value; props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeAssignQualification = (value: string) => { + function onChangeAssignQualification(value: string) { let prevFormData: FormType = Object(props.data.form); if (value === "+") { prevFormData.showNewQualification = true; - prevFormData.newQualificationValue = ""; + prevFormData.newQualificationName = ""; + prevFormData.newQualificationDescription = ""; } else { prevFormData.qualification = value; @@ -70,31 +84,31 @@ function ModalForm(props: ModalFormProps) { } props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeAssignQualificationValue = (value: string) => { + function onChangeAssignQualificationValue(value: string) { let prevFormData: FormType = Object(props.data.form); prevFormData.qualificationValue = Number(value); props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeUnassignQualification = (id: string) => { + function onChangeUnassignQualification(id: string) { onChangeAssignQualification(id); - }; + } - const onChangeGiveBonus = (value: boolean) => { + function onChangeGiveBonus(value: boolean) { let prevFormData: FormType = Object(props.data.form); prevFormData.checkboxGiveBonus = value; props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeBonus = (value: string) => { + function onChangeBonus(value: string) { let prevFormData: FormType = Object(props.data.form); prevFormData.bonus = Number(value); props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeBanWorker = (value: boolean) => { + function onChangeBanWorker(value: boolean) { let prevFormData: FormType = Object(props.data.form); prevFormData.checkboxBanWorker = value; @@ -111,92 +125,93 @@ function ModalForm(props: ModalFormProps) { } props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeWriteReviewNote = (value: boolean) => { + function onChangeWriteReviewNote(value: boolean) { let prevFormData: FormType = Object(props.data.form); prevFormData.checkboxReviewNote = value; props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeReviewNote = (value: string) => { + function onChangeReviewNote(value: string) { let prevFormData: FormType = Object(props.data.form); prevFormData.reviewNote = value; props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeWriteReviewNoteSend = (value: boolean) => { + function onChangeWriteReviewNoteSend(value: boolean) { let prevFormData: FormType = Object(props.data.form); prevFormData.checkboxReviewNoteSend = value; props.setData({ ...props.data, form: prevFormData }); - }; + } - const onChangeNewQualificationValue = (value: string) => { + function onChangeNewQualificationValue(fieldName: string, value: string) { let prevFormData: FormType = Object(props.data.form); - prevFormData.newQualificationValue = value; + prevFormData[fieldName] = value; props.setData({ ...props.data, form: prevFormData }); - }; - - const onClickAddNewQualification = (value: string) => { - createNewQualification(value); - }; + } - const onError = (errorResponse: ErrorResponseType | null) => { - if (errorResponse) { - props.setErrors((oldErrors) => [...oldErrors, ...[errorResponse.error]]); - } - }; + function onClickAddNewQualification() { + createNewQualification( + props.data.form.newQualificationName, + props.data.form.newQualificationDescription + ); + } - const onCreateNewQualificationSuccess = () => { + function onCreateNewQualificationSuccess() { // Clear input let prevFormData: FormType = Object(props.data.form); - prevFormData.newQualificationValue = ""; + prevFormData.newQualificationName = ""; + prevFormData.newQualificationDescription = ""; prevFormData.showNewQualification = false; props.setData({ ...props.data, form: prevFormData }); // Update select with Qualifications requestQualifications(); - }; + } - const onGetWorkerGrantedQualificationsSuccess = ( + function onGetWorkerGrantedQualificationsSuccess( grantedQualifications: GrantedQualificationType[] - ) => { + ) { const _workerGrantedQualifications = {}; grantedQualifications.forEach((gq: GrantedQualificationType) => { _workerGrantedQualifications[gq.qualification_id] = gq; }); setWorkerGrantedQualifications(_workerGrantedQualifications); - }; + } - const requestQualifications = () => { + function requestQualifications() { let params; if (props.data.type === ReviewType.REJECT) { params = { worker_id: props.workerId }; } getQualifications(setQualifications, setLoading, onError, params); - }; + } - const requestWorkerGrantedQualifications = () => { + function requestWorkerGrantedQualifications() { getWorkerGrantedQualifications( props.workerId, onGetWorkerGrantedQualificationsSuccess, setLoading, onError ); - }; + } - const createNewQualification = (name: string) => { + function createNewQualification(name: string, description: string) { postQualification( onCreateNewQualificationSuccess, setCreateQualificationLoading, onError, - { name: name } + { + name: name, + description: description, + } ); - }; + } - // Effiects + // Effects useEffect(() => { requestWorkerGrantedQualifications(); @@ -205,6 +220,14 @@ function ModalForm(props: ModalFormProps) { } }, []); + useEffect(() => { + if (props.data.form.newQualificationName) { + setNewQualificationFormIsValid(true); + } else { + setNewQualificationFormIsValid(false); + } + }, [props.data.form.newQualificationName]); + if (loading) { return; } @@ -241,27 +264,32 @@ function ModalForm(props: ModalFormProps) { onChangeAssignQualification(e.target.value) } > - - - {qualifications && - qualifications.map((q: QualificationType) => { - const prevGrantedQualification = - workerGrantedQualifications[q.id]; - const prevGrantedQualificationValue = - prevGrantedQualification?.value; - - let nameSuffix = ""; - if (prevGrantedQualificationValue !== undefined) { - nameSuffix = ` (granted value: ${prevGrantedQualificationValue})`; - } - const qualificationName = `${q.name}${nameSuffix}`; - - return ( - - ); - })} + + + + + + + {qualifications && + qualifications.map((q: QualificationType) => { + const prevGrantedQualification = + workerGrantedQualifications[q.id]; + const prevGrantedQualificationValue = + prevGrantedQualification?.value; + + let nameSuffix = ""; + if (prevGrantedQualificationValue !== undefined) { + nameSuffix = ` (granted value: ${prevGrantedQualificationValue})`; + } + const qualificationName = `${q.name}${nameSuffix}`; + + return ( + + ); + })} + @@ -286,12 +314,33 @@ function ModalForm(props: ModalFormProps) { - onChangeNewQualificationValue(e.target.value) + onChangeNewQualificationValue( + "newQualificationName", + e.target.value + ) + } + /> + + + onChangeNewQualificationValue( + "newQualificationDescription", + e.target.value + ) } /> @@ -300,13 +349,16 @@ function ModalForm(props: ModalFormProps) { className={"new-qualification-name-button"} variant={"secondary"} size={"sm"} + title={ + newQualificationFormIsValid ? "" : "Name is required" + } + disabled={!newQualificationFormIsValid} onClick={() => - onClickAddNewQualification( - props.data.form.newQualificationValue - ) + newQualificationFormIsValid && + onClickAddNewQualification() } > - Add + Create diff --git a/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.css b/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.css index b54a3dd40..6523a50e3 100644 --- a/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.css +++ b/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.css @@ -16,6 +16,11 @@ font-size: 26px; } +.review-modal .modal-dialog .modal-header .btn-close { + position: absolute; + right: 14px; +} + /* Body */ .review-modal .modal-dialog .modal-content { border-radius: initial; @@ -28,17 +33,6 @@ justify-content: space-between; } -.review-modal - .modal-dialog - .modal-content - .modal-footer - .review-buttons - .btn-cancel-button { - text-decoration: none; - color: grey; - border: none; -} - .review-modal .modal-dialog .modal-content diff --git a/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.tsx b/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.tsx index 95545345b..89a6f3aec 100644 --- a/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.tsx +++ b/mephisto/review_app/client/src/pages/TaskPage/ReviewModal/ReviewModal.tsx @@ -38,7 +38,7 @@ function ReviewModal(props: ReviewModalProps) { return ( props.show && ( - + {props.data.title} @@ -54,11 +54,11 @@ function ReviewModal(props: ReviewModalProps) {
+ + +
+
+
+ ) + ); +} + +export default CreateQualificationModal; diff --git a/mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.css b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.css new file mode 100644 index 000000000..cb7044805 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.css @@ -0,0 +1,44 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.qualifications-tab { + width: 100%; +} + +.qualifications-tab .qualification-actions { + height: 60px; + padding-left: 10px; + padding-right: 10px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.qualifications-tab .qualification-actions .filter-qualifications { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; +} + +.qualifications-tab + .qualification-actions + .filter-qualifications + .select-qualifications-label { + white-space: nowrap; +} +.qualifications-tab + .qualification-actions + .filter-qualifications + .select-qualifications { + min-width: 300px; + max-width: 300px; +} + +.qualifications-tab .empty-message { + margin: 10px; +} diff --git a/mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.tsx b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.tsx new file mode 100644 index 000000000..54601d702 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTab/QualificationsTab.tsx @@ -0,0 +1,213 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import EditGrantedQualificationModal from "components/EditGrantedQualificationModal/EditGrantedQualificationModal"; +import Preloader from "components/Preloader/Preloader"; +import { setResponseErrors } from "helpers"; +import * as React from "react"; +import { useEffect } from "react"; +import { Button } from "react-bootstrap"; +import { + getGrantedQualifications, + getQualifications, + patchQualificationGrantWorker, + patchQualificationRevokeWorker, + postQualification, +} from "requests/qualifications"; +import CreateQualificationModal from "../CreateQualificationModal/CreateQualificationModal"; +import QualificationsTable from "../QualificationsTable/QualificationsTable"; +import "./QualificationsTab.css"; + +interface QualificationsTabPropsType { + setErrors: Function; +} + +function QualificationsTab(props: QualificationsTabPropsType) { + const [grantedQualifications, setGrantedQualifications] = React.useState< + FullGrantedQualificationType[] + >(null); + const [selectedQualification, setSelectedQualification] = React.useState< + string + >(null); + const [qualifications, setQualifications] = React.useState< + QualificationType[] + >([]); + const [loading, setLoading] = React.useState(false); + const [createModalShow, setCreateModalShow] = React.useState(false); + const [editModalShow, setEditModalShow] = React.useState(false); + const [ + editModalGrantedQualification, + setEditModalGrantedQualification, + ] = React.useState(null); + + const hasGrantedQualifications = + grantedQualifications && grantedQualifications.length !== 0; + + // Methods + + const onError = (response: ErrorResponseType) => + setResponseErrors(props.setErrors, response); + + function requestQualifications() { + getQualifications(setQualifications, setLoading, onError); + } + + function requestGrantedQualifications( + getParams: { [key: string]: string | number } = null + ) { + getGrantedQualifications( + setGrantedQualifications, + setLoading, + onError, + getParams + ); + } + + function onCreateModalSubmit(data: CreateQualificationFormType) { + function onSuccess() { + requestQualifications(); + setCreateModalShow(false); + } + + postQualification(onSuccess, () => null, onError, data); + } + + function onEditModalSubmit( + qualificationId: string, + workerId: string, + value: number + ) { + function onSuccess() { + requestGrantedQualifications(); + setEditModalShow(false); + } + + patchQualificationGrantWorker( + qualificationId, + workerId, + onSuccess, + setLoading, + onError, + { + value: value, + } + ); + } + + function onEditModalRevoke(qualificationId: string, workerId: string) { + function onSuccess() { + requestGrantedQualifications(); + setEditModalShow(false); + } + + patchQualificationRevokeWorker( + qualificationId, + workerId, + onSuccess, + setLoading, + onError, + null + ); + } + + // Effects + + useEffect(() => { + document.title = "Mephisto - Task Review - All Qualifications"; + + if (qualifications.length === 0) { + requestQualifications(); + } + + if (grantedQualifications === null) { + requestGrantedQualifications(); + } + }, []); + + useEffect(() => { + const getParams = {}; + if (![null, ""].includes(selectedQualification)) { + getParams["qualification_id"] = selectedQualification; + } + + requestGrantedQualifications(getParams); + }, [selectedQualification]); + + return ( +
+
+
+ + + +
+ +
+ +
+
+ + {hasGrantedQualifications ? ( + + ) : ( +
+ This qualification has not been granted to any worker yet. +
+ )} + + + + + + +
+ ); +} + +export default QualificationsTab; diff --git a/mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.css b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.css new file mode 100644 index 000000000..1cc8c7025 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.css @@ -0,0 +1,113 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.qualifications-table { + width: 100%; +} + +.qualifications-table .titles-row th { + background-color: #ecdadf; +} + +.qualifications-table .titles-row .qualification { + width: 350px; + max-width: 350px; +} + +.qualifications-table .titles-row .value-granted { + width: 130px; + text-align: center; + white-space: nowrap; +} + +.qualifications-table .titles-row .date-granted { + width: 130px; + text-align: center; +} + +.qualifications-table .titles-row .task { + width: 130px; + text-align: center; +} + +.qualifications-table .titles-row .worker { + width: 80px; + text-align: center; +} + +.qualifications-table .titles-row .unit { + width: 80px; + text-align: center; +} + +.qualifications-table .titles-row .actions { + width: 80px; + text-align: center; +} + +.qualifications-table .value-row:not(.no-hover) { + cursor: pointer; +} + +.qualifications-table .value-row:not(.no-hover):hover td { + background-color: rgba(236, 218, 223, 0.3); +} + +.qualifications-table .value-row .qualification { + width: 350px; + max-width: 350px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.qualifications-table .value-row .qualification .qualification-link { + text-decoration: initial; + cursor: pointer; +} +.qualifications-table .value-row .qualification .qualification-link:hover { + text-decoration: underline; +} + +.qualifications-table .value-row .value-granted, +.qualifications-table .value-row .date-granted, +.qualifications-table .value-row .task, +.qualifications-table .value-row .worker { + text-align: center; +} + +.qualifications-table .value-row .units { + width: 280px; + max-width: 280px; + text-align: left; +} + +.qualifications-table .value-row .units .unit { + display: inline-flex; + gap: 4px; +} + +.qualifications-table .value-row .units .unit.text-primary { + cursor: pointer; + text-decoration: initial; +} + +.qualifications-table .value-row .units .unit.text-primary:hover { + text-decoration: underline; +} + +.qualifications-table .value-row .units .unit .task-name { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.qualifications-table .value-row .units .unit .unit-id { + display: inline-block; +} diff --git a/mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.tsx b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.tsx new file mode 100644 index 000000000..ba68db59c --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/QualificationsTable/QualificationsTable.tsx @@ -0,0 +1,117 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as moment from "moment/moment"; +import * as React from "react"; +import { Button, Table } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import urls from "urls"; +import "./QualificationsTable.css"; + +type QualificationsTablePropsType = { + grantedQualifications: FullGrantedQualificationType[]; + setEditModalGrantedQualification: Function; + setEditModalShow: Function; + setErrors: Function; +}; + +function QualificationsTable(props: QualificationsTablePropsType) { + return ( + + + + + + + + + + + + + + + {props.grantedQualifications && + props.grantedQualifications.map( + (gq: FullGrantedQualificationType, index: number) => { + const granted_at = moment(gq.granted_at).format("MMM D, YYYY"); + + return ( + + + + + + + + + + ); + } + )} + +
+ Qualification + + Worker + + Current value + + Updated + + Units +
+ + {gq.qualification_name} + + {gq.worker_name}{gq.value_current}{granted_at} + {gq.units.map((unit: FGQUnit, index: number) => { + return ( + <> + + + {unit.task_name} + + ({unit.unit_id.substring(0, 4)}*) + +
+ + ); + })} +
+ +
+ ); +} + +export default QualificationsTable; diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksPage.css b/mephisto/review_app/client/src/pages/TasksPage/TasksPage.css index e40a4676c..562954f4f 100644 --- a/mephisto/review_app/client/src/pages/TasksPage/TasksPage.css +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksPage.css @@ -4,119 +4,5 @@ * LICENSE file in the root directory of this source tree. */ -.tasks .tasks-table { - width: 100%; -} - -.tasks .tasks-table .titles-row th { - background-color: #ecdadf; -} - -.tasks .tasks-table .titles-row .task { - width: 350px; - max-width: 350px; -} - -.tasks .tasks-table .titles-row .reviewed { - width: 80px; - text-align: center; -} - -.tasks .tasks-table .titles-row .units { - width: 80px; - text-align: center; -} - -.tasks .tasks-table .titles-row .date { - width: 130px; - text-align: center; -} - -.tasks .tasks-table .titles-row .stats { - width: 80px; - text-align: center; -} - -.tasks .tasks-table .titles-row .timeline { - width: 80px; - text-align: center; -} - -.tasks .tasks-table .titles-row .worker-opinions { - width: 80px; - text-align: center; -} - -.tasks .tasks-table .titles-row .display-units { - width: 115px; - text-align: center; - white-space: nowrap; -} - -.tasks .tasks-table .task-row:not(.no-hover) { - cursor: pointer; -} - -.tasks .tasks-table .task-row:not(.no-hover):hover td { - background-color: rgba(236, 218, 223, 0.3); -} - -.tasks .tasks-table .task-row .task { - width: 350px; - max-width: 350px; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tasks .tasks-table .task-row .task.text-primary { - cursor: pointer; -} -.tasks .tasks-table .task-row .task.text-primary:hover { - text-decoration: underline; -} - -.tasks .tasks-table .task-row .export-loading { - padding-left: 20px; -} - -.tasks .tasks-table .task-row .download-button { - cursor: pointer; -} -.tasks .tasks-table .task-row .download-button:hover { - text-decoration: underline; -} - -.tasks .tasks-table .task-row .stats a, -.tasks .tasks-table .task-row .timeline a, -.tasks .tasks-table .task-row .display-units a, -.tasks .tasks-table .task-row .worker-opinions a { - cursor: pointer; - text-decoration: unset; -} - -.tasks .tasks-table .task-row .stats a:hover, -.tasks .tasks-table .task-row .timeline a:hover, -.tasks .tasks-table .task-row .display-units a:hover, -.tasks .tasks-table .task-row .worker-opinions a:hover { - text-decoration: underline; -} - -.tasks .tasks-table .task-row .reviewed, -.tasks .tasks-table .task-row .units, -.tasks .tasks-table .task-row .stats, -.tasks .tasks-table .task-row .timeline, -.tasks .tasks-table .task-row .worker-opinions, -.tasks .tasks-table .task-row .display-units, -.tasks .tasks-table .task-row .date { - text-align: center; -} - -.tasks .loading { - width: 100%; - height: 100px; - display: flex; - align-items: center; - justify-content: center; +.tasks { } diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx b/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx index 1388c98c4..4306f5984 100644 --- a/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx @@ -4,233 +4,38 @@ * LICENSE file in the root directory of this source tree. */ +import Tabs from "components/Tabs/Tabs"; import TasksHeader from "components/TasksHeader/TasksHeader"; -import * as moment from "moment/moment"; import * as React from "react"; -import { useEffect } from "react"; -import { Spinner, Table } from "react-bootstrap"; -import { Link } from "react-router-dom"; -import { exportTaskResults, getTasks } from "requests/tasks"; -import urls from "urls"; +import QualificationsTab from "./QualificationsTab/QualificationsTab"; import "./TasksPage.css"; +import TasksTab from "./TasksTab/TasksTab"; -const STORAGE_TASK_ID_KEY: string = "selectedTaskID"; -const ENABLE_INCOMPLETE_TASK_RESULTS_EXPORT = true; - -interface TasksPagePropsType { +type TasksPagePropsType = { setErrors: Function; -} +}; function TasksPage(props: TasksPagePropsType) { - const { localStorage } = window; - - const [tasks, setTasks] = React.useState>(null); - const [loading, setLoading] = React.useState(false); - const [taskIdExportResults, setTaskIdExportResults] = React.useState(null); - const [loadingExportResults, setLoadingExportResults] = React.useState(false); - - function onTaskRowClick(id: string) { - localStorage.setItem(STORAGE_TASK_ID_KEY, String(id)); - - // Create a pseudo new link and click it to open a task in new tab (not window) - const pseudoLink = document.createElement("a"); - pseudoLink.setAttribute("href", urls.client.task(id)); - pseudoLink.setAttribute("target", "_blank"); - pseudoLink.click(); - } - - function onError(errorResponse: ErrorResponseType | null) { - if (errorResponse) { - props.setErrors((oldErrors) => [...oldErrors, ...[errorResponse.error]]); - } - } - - function requestTaskResults( - e: React.MouseEvent, - taskId: string, - nUnits: number - ) { - e.stopPropagation(); - - setTaskIdExportResults(taskId); - - function onSuccessExportResults(data) { - setTaskIdExportResults(null); - - if (data.file_created) { - // Create pseudo link and click it - const linkId = "result-json"; - const link = document.createElement("a"); - link.setAttribute("style", "display: none;"); - link.id = linkId; - link.href = urls.server.taskExportResultsJson(taskId, nUnits); - link.target = "_blank"; - link.click(); - link.remove(); - } - } - - exportTaskResults( - taskId, - onSuccessExportResults, - setLoadingExportResults, - onError - ); - } - - useEffect(() => { - document.title = "Mephisto - Task Review - All Tasks"; - - if (tasks === null) { - getTasks(setTasks, setLoading, onError, null); - } - }, []); + const tabs: TabType[] = [ + { + name: "tasks", + title: "Tasks", + children: , + noMargins: true, + }, + { + name: "worker_qualifications", + title: "Worker Qualifications", + children: , + noMargins: true, + }, + ]; return (
- {/* Header */} - {/* Tasks table */} - - - - - - - - - - - - - - - - - {tasks && - tasks.map((task: TaskType, index: number) => { - const date = moment(task.created_at).format("MMM D, YYYY"); - const nonClickable = - task.is_reviewed || task.unit_all_count === 0; - const allowTaskResultsDownload = - ENABLE_INCOMPLETE_TASK_RESULTS_EXPORT || task.is_reviewed; - - return ( - !nonClickable && onTaskRowClick(task.id)} - > - - - - - - - - - - - - ); - })} - -
- Task - - Reviewed? - - # Units - - Date - - Stats - - Timeline - - Opinions - - View Units - - Export results -
- {task.name} - - {task.is_reviewed ? : ""} - - {task.unit_finished_count}/{task.unit_all_count} - {date} - {task.has_stats && ( - - Show - - )} - - - Show - - - - Show - - - - Show - - - {allowTaskResultsDownload && - !( - loadingExportResults && taskIdExportResults === task.id - ) && ( - ) => - requestTaskResults( - e, - task.id, - task.unit_completed_count - ) - } - > - Download - - )} - - {taskIdExportResults === task.id && loadingExportResults && ( -
- - Loading... - -
- )} -
- - {/* Preloader when we request tasks */} - {loading && ( -
- - Loading... - -
- )} +
); } diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.css b/mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.css new file mode 100644 index 000000000..32d87d231 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.css @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.tasks-tab { + width: 100%; +} + +.tasks-tab .empty-message { + margin: 10px; +} diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.tsx b/mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.tsx new file mode 100644 index 000000000..d7929a1e6 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksTab/TasksTab.tsx @@ -0,0 +1,50 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import Preloader from "components/Preloader/Preloader"; +import { setResponseErrors } from "helpers"; +import * as React from "react"; +import { useEffect } from "react"; +import { getTasks } from "requests/tasks"; +import TasksTable from "../TasksTable/TasksTable"; +import "./TasksTab.css"; + +interface TasksTabPropsType { + setErrors: Function; +} + +function TasksTab(props: TasksTabPropsType) { + const [tasks, setTasks] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + const hasTasks = tasks && tasks.length !== 0; + + const onError = (response: ErrorResponseType) => + setResponseErrors(props.setErrors, response); + + // Effects + useEffect(() => { + document.title = "Mephisto - Task Review - All Tasks"; + + if (tasks === null) { + getTasks(setTasks, setLoading, onError, null); + } + }, []); + + return ( +
+ {hasTasks ? ( + + ) : ( +
No available tasks yet.
+ )} + + +
+ ); +} + +export default TasksTab; diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.css b/mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.css new file mode 100644 index 000000000..470b4b4f6 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.css @@ -0,0 +1,114 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.tasks-table { + width: 100%; +} + +.tasks-table .titles-row th { + background-color: #ecdadf; +} + +.tasks-table .titles-row .task { + width: 350px; + max-width: 350px; +} + +.tasks-table .titles-row .reviewed { + width: 80px; + text-align: center; +} + +.tasks-table .titles-row .units { + width: 80px; + text-align: center; +} + +.tasks-table .titles-row .date { + width: 130px; + text-align: center; +} + +.tasks-table .titles-row .stats { + width: 80px; + text-align: center; +} + +.tasks-table .titles-row .timeline { + width: 80px; + text-align: center; +} + +.tasks-table .titles-row .worker-opinions { + width: 80px; + text-align: center; +} + +.tasks-table .titles-row .display-units { + width: 115px; + text-align: center; + white-space: nowrap; +} + +.tasks-table .value-row:not(.no-hover) { + cursor: pointer; +} + +.tasks-table .value-row:not(.no-hover):hover td { + background-color: rgba(236, 218, 223, 0.3); +} + +.tasks-table .value-row .task { + width: 350px; + max-width: 350px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tasks-table .value-row .task.text-primary { + cursor: pointer; +} +.tasks-table .value-row .task.text-primary:hover { + text-decoration: underline; +} + +.tasks-table .value-row .export-loading { + padding-left: 20px; +} + +.tasks-table .value-row .download-button { + cursor: pointer; +} +.tasks-table .value-row .download-button:hover { + text-decoration: underline; +} + +.tasks-table .value-row .stats a, +.tasks-table .value-row .timeline a, +.tasks-table .value-row .display-units a, +.tasks-table .value-row .worker-opinions a { + cursor: pointer; + text-decoration: unset; +} + +.tasks-table .value-row .stats a:hover, +.tasks-table .value-row .timeline a:hover, +.tasks-table .value-row .display-units a:hover, +.tasks-table .value-row .worker-opinions a:hover { + text-decoration: underline; +} + +.tasks-table .value-row .reviewed, +.tasks-table .value-row .units, +.tasks-table .value-row .stats, +.tasks-table .value-row .timeline, +.tasks-table .value-row .worker-opinions, +.tasks-table .value-row .display-units, +.tasks-table .value-row .date { + text-align: center; +} diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.tsx b/mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.tsx new file mode 100644 index 000000000..72ba5e420 --- /dev/null +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksTable/TasksTable.tsx @@ -0,0 +1,207 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { setResponseErrors } from "helpers"; +import * as moment from "moment/moment"; +import * as React from "react"; +import { Spinner, Table } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { exportTaskResults } from "requests/tasks"; +import urls from "urls"; +import "./TasksTable.css"; + +const STORAGE_TASK_ID_KEY: string = "selectedTaskID"; +const ENABLE_INCOMPLETE_TASK_RESULTS_EXPORT = true; + +interface TasksTablePropsType { + setErrors: Function; + tasks: TaskType[]; +} + +function TasksTable(props: TasksTablePropsType) { + const { localStorage } = window; + + const [taskIdExportResults, setTaskIdExportResults] = React.useState(null); + const [loadingExportResults, setLoadingExportResults] = React.useState(false); + + const onError = (response: ErrorResponseType) => + setResponseErrors(props.setErrors, response); + + function onTaskRowClick(id: string) { + localStorage.setItem(STORAGE_TASK_ID_KEY, String(id)); + + // Create a pseudo new link and click it to open a task in new tab (not window) + const pseudoLink = document.createElement("a"); + pseudoLink.setAttribute("href", urls.client.task(id)); + pseudoLink.setAttribute("target", "_blank"); + pseudoLink.click(); + } + + function requestTaskResults( + e: React.MouseEvent, + taskId: string, + nUnits: number + ) { + e.stopPropagation(); + + setTaskIdExportResults(taskId); + + function onSuccessExportResults(data) { + setTaskIdExportResults(null); + + if (data.file_created) { + // Create pseudo link and click it + const linkId = "result-json"; + const link = document.createElement("a"); + link.setAttribute("style", "display: none;"); + link.id = linkId; + link.href = urls.server.taskExportResultsJson(taskId, nUnits); + link.target = "_blank"; + link.click(); + link.remove(); + } + } + + exportTaskResults( + taskId, + onSuccessExportResults, + setLoadingExportResults, + onError + ); + } + + return ( + + + + + + + + + + + + + + + + + + {props.tasks && + props.tasks.map((task: TaskType, index: number) => { + const date = moment(task.created_at).format("MMM D, YYYY"); + const nonClickable = task.is_reviewed || task.unit_all_count === 0; + const allowTaskResultsDownload = + ENABLE_INCOMPLETE_TASK_RESULTS_EXPORT || task.is_reviewed; + + return ( + !nonClickable && onTaskRowClick(task.id)} + > + + + + + + + + + + + + ); + })} + +
+ Task + + Reviewed? + + # Units + + Date + + Stats + + Timeline + + Opinions + + View Units + + Export results +
+ {task.name} + + {task.is_reviewed ? : ""} + + {task.unit_finished_count}/{task.unit_all_count} + {date} + {task.has_stats && ( + + Show + + )} + + + Show + + + + Show + + + + Show + + + {allowTaskResultsDownload && + !( + loadingExportResults && taskIdExportResults === task.id + ) && ( + ) => + requestTaskResults( + e, + task.id, + task.unit_completed_count + ) + } + > + Download + + )} + + {taskIdExportResults === task.id && loadingExportResults && ( +
+ + Loading... + +
+ )} +
+ ); +} + +export default TasksTable; diff --git a/mephisto/review_app/client/src/pages/UnitPage/UnitPage.css b/mephisto/review_app/client/src/pages/UnitPage/UnitPage.css index 9e878ca89..0f9a96f01 100644 --- a/mephisto/review_app/client/src/pages/UnitPage/UnitPage.css +++ b/mephisto/review_app/client/src/pages/UnitPage/UnitPage.css @@ -37,14 +37,6 @@ cursor: default; } -.unit .loading { - width: 100%; - height: 100px; - display: flex; - align-items: center; - justify-content: center; -} - .unit-preview-iframe { width: 100%; } diff --git a/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx b/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx index aa29b8e16..c3f5f72c6 100644 --- a/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx +++ b/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx @@ -6,6 +6,7 @@ import InitialParametersCollapsable from "components/InitialParametersCollapsable/InitialParametersCollapsable"; import { InReviewFileModal } from "components/InReviewFileModal/InReviewFileModal"; +import Preloader from "components/Preloader/Preloader"; import ResultsCollapsable from "components/ResultsCollapsable/ResultsCollapsable"; import TasksHeader from "components/TasksHeader/TasksHeader"; import VideoAnnotatorWebVTTCollapsable from "components/VideoAnnotatorWebVTTCollapsable/VideoAnnotatorWebVTTCollapsable"; @@ -14,15 +15,15 @@ import { MESSAGES_IFRAME_DATA_KEY, MESSAGES_IN_REVIEW_FILE_DATA_KEY, } from "consts/review"; -import { setPageTitle } from "helpers"; +import { setPageTitle, setResponseErrors } from "helpers"; import * as React from "react"; import { useEffect } from "react"; -import { Spinner } from "react-bootstrap"; import { useParams } from "react-router-dom"; import { getTask } from "requests/tasks"; import { getUnits, getUnitsDetails } from "requests/units"; import urls from "urls"; import "./UnitPage.css"; +import UnitReviewsCollapsable from "./UnitReviewsCollapsable/UnitReviewsCollapsable"; type ParamsType = { taskId: string; @@ -118,11 +119,8 @@ function UnitPage(props: UnitPagePropsType) { setInReviewFileModalShow(true); } - function onError(errorResponse: ErrorResponseType | null) { - if (errorResponse) { - props.setErrors((oldErrors) => [...oldErrors, ...[errorResponse.error]]); - } - } + const onError = (response: ErrorResponseType) => + setResponseErrors(props.setErrors, response); // [RECEIVING WIDGET DATA] // --- @@ -222,13 +220,7 @@ function UnitPage(props: UnitPagePropsType) {
{/* Preloader when we request unit */} - {loading && ( -
- - Loading... - -
- )} + {/* Initial Unit parameters */} {unitDetails?.inputs && ( @@ -255,6 +247,14 @@ function UnitPage(props: UnitPagePropsType) { /> )} + {/* Review history of Unit */} + {unitDetails?.metadata?.unit_reviews && ( + + )} + {unitDetails?.outputs && ( <> {/* Results */} diff --git a/mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.css b/mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.css new file mode 100644 index 000000000..012398f75 --- /dev/null +++ b/mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.css @@ -0,0 +1,61 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.unit-reviews { +} + +.unit-reviews .unit-reviews-table .titles-row th { + background-color: #ecdadf; +} + +.unit-reviews .unit-reviews-table .titles-row .value, +.unit-reviews .unit-reviews-table .titles-row .status, +.unit-reviews .unit-reviews-table .titles-row .blocked, +.unit-reviews .unit-reviews-table .titles-row .bonus { + width: 80px; + text-align: center; + white-space: nowrap; +} + +.unit-reviews .unit-reviews-table .titles-row .qualification { + width: 350px; + max-width: 350px; +} + +.unit-reviews .unit-reviews-table .titles-row .date { + width: 130px; + text-align: center; + white-space: nowrap; +} + +.unit-reviews .unit-reviews-table .titles-row .note { + width: 350px; + max-width: 350px; + text-align: center; + white-space: nowrap; +} + +.unit-reviews .unit-reviews-table .value-row .value, +.unit-reviews .unit-reviews-table .value-row .date, +.unit-reviews .unit-reviews-table .value-row .status, +.unit-reviews .unit-reviews-table .value-row .blocked, +.unit-reviews .unit-reviews-table .value-row .bonus { + text-align: center; +} + +.unit-reviews .unit-reviews-table .value-row .qualification { + width: 350px; + max-width: 350px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.unit-reviews .unit-reviews-table .value-row .note { + width: 350px; + max-width: 350px; + text-align: left; +} diff --git a/mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.tsx b/mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.tsx new file mode 100644 index 000000000..bdbe2513d --- /dev/null +++ b/mephisto/review_app/client/src/pages/UnitPage/UnitReviewsCollapsable/UnitReviewsCollapsable.tsx @@ -0,0 +1,96 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import CollapsableBlock from "components/CollapsableBlock/CollapsableBlock"; +import { capitalizeString } from "helpers"; +import * as moment from "moment"; +import * as React from "react"; +import { Table } from "react-bootstrap"; +import "./UnitReviewsCollapsable.css"; + +type UnitReviewsCollapsablePropsType = { + className?: string; + unitReviews: UnitReviewType[]; + open?: boolean; + title?: string | React.ReactElement; +}; + +function UnitReviewsCollapsable(props: UnitReviewsCollapsablePropsType) { + const { className, open, title, unitReviews } = props; + + const _title = title || "Review history"; + + return ( + + + + + + + + + + + + + + + + {(unitReviews || [].length) && + unitReviews.map((unitReview: UnitReviewType, index: number) => { + const date = moment(unitReview.creation_date).format( + "MMM D, YYYY" + ); + + return ( + + + + + + + + + + + ); + })} + +
+ Qualification + + Value + + Date + + Status + + Worker blocked + + Bonus + + Note +
+ {unitReview.qualification_name} + {unitReview.value}{date} + {capitalizeString(unitReview.status.replace("_", "-"))} + + {unitReview.blocked_worker ? : ""} + {unitReview.bonus}{unitReview.review_note}
+
+ ); +} + +export default UnitReviewsCollapsable; diff --git a/mephisto/review_app/client/src/requests/qualifications.ts b/mephisto/review_app/client/src/requests/qualifications.ts index 70e5a9b05..ed3affaf5 100644 --- a/mephisto/review_app/client/src/requests/qualifications.ts +++ b/mephisto/review_app/client/src/requests/qualifications.ts @@ -24,24 +24,62 @@ export function getQualifications( (data) => setDataAction(data.qualifications), setLoadingAction, setErrorsAction, - "getTasks error:", + "getQualifications error:", abortController ); } -export function getQualificationWorkers( +export function getQualification( id: string, setDataAction: SetRequestDataActionType, setLoadingAction: SetRequestLoadingActionType, setErrorsAction: SetRequestErrorsActionType, - getParams: { [key: string]: string | number } = null, abortController?: AbortController ) { - const url = generateURL( - urls.server.qualificationWorkers(id), + const url = generateURL(urls.server.qualification, [id], null); + + makeRequest( + "GET", + url, + null, + (data) => setDataAction(data), + setLoadingAction, + setErrorsAction, + "getQualification error:", + abortController + ); +} + +export function getQualificationDetails( + id: string, + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + abortController?: AbortController +) { + const url = generateURL(urls.server.qualificationDetails, [id], null); + + makeRequest( + "GET", + url, null, - getParams + (data) => setDataAction(data), + setLoadingAction, + setErrorsAction, + "getQualificationDetails error:", + abortController ); +} + +export function getQualificationWorkers( + id: string, + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + getParams: { [key: string]: string | number } = null, + abortController?: AbortController +) { + const url = generateURL(urls.server.qualificationWorkers, [id], getParams); makeRequest( "GET", @@ -76,8 +114,51 @@ export function postQualification( ); } -export function postQualificationGrantWorker( +export function patchQualification( + id: string, + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + data: { [key: string]: string | number }, + abortController?: AbortController +) { + const url = generateURL(urls.server.qualification, [id], null); + + makeRequest( + "PATCH", + url, + JSON.stringify(data), + (data) => setDataAction(data), + setLoadingAction, + setErrorsAction, + "patchQualification error:", + abortController + ); +} + +export function deleteQualification( id: string, + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + abortController?: AbortController +) { + const url = generateURL(urls.server.qualification, [id], null); + + makeRequest( + "DELETE", + url, + null, + (data) => setDataAction(data), + setLoadingAction, + setErrorsAction, + "deleteQualification error:", + abortController + ); +} + +export function postQualificationGrantWorker( + qualificationId: string, workerId: string, setDataAction: SetRequestDataActionType, setLoadingAction: SetRequestLoadingActionType, @@ -86,8 +167,8 @@ export function postQualificationGrantWorker( abortController?: AbortController ) { const url = generateURL( - urls.server.qualificationGrantWorker(id, workerId), - null, + urls.server.qualificationGrantWorker, + [qualificationId, workerId], null ); @@ -104,7 +185,7 @@ export function postQualificationGrantWorker( } export function postQualificationRevokeWorker( - id: string, + qualificationId: string, workerId: string, setDataAction: SetRequestDataActionType, setLoadingAction: SetRequestLoadingActionType, @@ -113,8 +194,8 @@ export function postQualificationRevokeWorker( abortController?: AbortController ) { const url = generateURL( - urls.server.qualificationRevokeWorker(id, workerId), - null, + urls.server.qualificationRevokeWorker, + [qualificationId, workerId], null ); @@ -129,3 +210,77 @@ export function postQualificationRevokeWorker( abortController ); } + +export function patchQualificationGrantWorker( + quailificationId: string, + workerId: string, + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + data: { [key: string]: string[] | number[] | number | string }, + abortController?: AbortController +) { + const url = generateURL( + urls.server.qualificationGrantWorker, + [quailificationId, workerId], + null + ); + + makeRequest( + "PATCH", + url, + JSON.stringify(data), + (data) => setDataAction(data), + setLoadingAction, + setErrorsAction, + "patchQualificationGrantWorker error:", + abortController + ); +} + +export function patchQualificationRevokeWorker( + quailificationId: string, + workerId: string, + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + abortController?: AbortController +) { + const url = generateURL( + urls.server.qualificationRevokeWorker, + [quailificationId, workerId], + null + ); + + makeRequest( + "PATCH", + url, + "{}", + (data) => setDataAction(data), + setLoadingAction, + setErrorsAction, + "patchQualificationRevokeWorker error:", + abortController + ); +} + +export function getGrantedQualifications( + setDataAction: SetRequestDataActionType, + setLoadingAction: SetRequestLoadingActionType, + setErrorsAction: SetRequestErrorsActionType, + getParams: { [key: string]: string | number } = null, + abortController?: AbortController +) { + const url = generateURL(urls.server.grantedQualifications, null, getParams); + + makeRequest( + "GET", + url, + null, + (data) => setDataAction(data.granted_qualifications), + setLoadingAction, + setErrorsAction, + "getGrantedQualifications error:", + abortController + ); +} diff --git a/mephisto/review_app/client/src/types/qualifications.d.ts b/mephisto/review_app/client/src/types/qualifications.d.ts index 1d04771c2..be1b1a8af 100644 --- a/mephisto/review_app/client/src/types/qualifications.d.ts +++ b/mephisto/review_app/client/src/types/qualifications.d.ts @@ -5,17 +5,44 @@ */ declare type QualificationType = { + creation_date: string; + description: string; id: string; name: string; }; +declare type QualificationDetailsType = { + granted_qualifications_count: number; +}; + declare type GrantedQualificationType = { - worker_id: string; + granted_at: string; qualification_id: number; value: number; - granted_at: string; + worker_id: string; }; declare type WorkerGrantedQualificationsType = { [key: string]: GrantedQualificationType; }; + +declare type FGQUnit = { + task_id: string; + task_name: string; + unit_id: string; +}; + +declare type FullGrantedQualificationType = { + granted_at: string; + qualification_id: string; + qualification_name: string; + units: FGQUnit[]; + value_current: number; + worker_id: string; + worker_name: string; +}; + +declare type CreateQualificationFormType = { + description: string; + name: string; +}; diff --git a/mephisto/review_app/client/src/types/reviewModal.d.ts b/mephisto/review_app/client/src/types/reviewModal.d.ts index c88b4e859..b32b14c15 100644 --- a/mephisto/review_app/client/src/types/reviewModal.d.ts +++ b/mephisto/review_app/client/src/types/reviewModal.d.ts @@ -12,7 +12,8 @@ type FormType = { checkboxReviewNote: boolean; checkboxReviewNoteSend?: boolean; checkboxUnassignQualification?: boolean; - newQualificationValue?: string; + newQualificationName?: string; + newQualificationDescription?: string; qualification: string | null; qualificationValue: number; reviewNote: string; diff --git a/mephisto/review_app/client/src/types/tabs.d.ts b/mephisto/review_app/client/src/types/tabs.d.ts new file mode 100644 index 000000000..758524d08 --- /dev/null +++ b/mephisto/review_app/client/src/types/tabs.d.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +declare type TabType = { + name: string; + title: string; + hoverText?: string; + hoverTextDisabled?: string; + children?: React.ReactNode | string; + noMargins?: boolean; + disabled?: boolean; +}; diff --git a/mephisto/review_app/client/src/types/units.d.ts b/mephisto/review_app/client/src/types/units.d.ts index f1aa1b0b7..25f7a3f97 100644 --- a/mephisto/review_app/client/src/types/units.d.ts +++ b/mephisto/review_app/client/src/types/units.d.ts @@ -55,6 +55,7 @@ declare type WorkerOpinionType = { declare type UnitDetailsMetadataType = { worker_opinion?: WorkerOpinionType; webvtt?: string; + unit_reviews: UnitReviewType[]; }; declare type UnitDetailsType = { @@ -66,3 +67,14 @@ declare type UnitDetailsType = { prepared_inputs: object; unit_data_folder: string; }; + +declare type UnitReviewType = { + blocked_worker: number; + bonus: number; + creation_date: string; + qualification_id: string; + qualification_name: string; + review_note: string; + status: string; + value: number; +}; diff --git a/mephisto/review_app/client/src/urls.ts b/mephisto/review_app/client/src/urls.ts index 579e86022..95d296ec7 100644 --- a/mephisto/review_app/client/src/urls.ts +++ b/mephisto/review_app/client/src/urls.ts @@ -9,6 +9,7 @@ const API_URL = process.env.REACT_APP__API_URL || ""; const urls = { client: { home: "/", + qualification: (id) => `/qualifications/${id}`, task: (id) => `/tasks/${id}`, taskStats: (id) => `/tasks/${id}/stats`, taskTimeline: (id) => `/tasks/${id}/timeline`, @@ -18,6 +19,9 @@ const urls = { tasks: "/tasks", }, server: { + grantedQualifications: API_URL + "/api/granted-qualifications", + qualification: (id) => API_URL + `/api/qualifications/${id}`, + qualificationDetails: (id) => API_URL + `/api/qualifications/${id}/details`, qualifications: API_URL + "/api/qualifications", qualificationWorkers: (id) => API_URL + `/api/qualifications/${id}/workers`, qualificationGrantWorker: (id, workerId) => diff --git a/mephisto/review_app/server/api/views/__init__.py b/mephisto/review_app/server/api/views/__init__.py index 758122a88..3068c60bf 100644 --- a/mephisto/review_app/server/api/views/__init__.py +++ b/mephisto/review_app/server/api/views/__init__.py @@ -4,7 +4,10 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +from .granted_qualifications_view import GrantedQualificationsView from .home_view import HomeView +from .qualification_details_view import QualificationDetailsView +from .qualification_view import QualificationView from .qualification_workers_view import QualificationWorkersView from .qualifications_view import QualificationsView from .qualify_worker_view import QualifyWorkerView diff --git a/mephisto/review_app/server/api/views/granted_qualifications_view.py b/mephisto/review_app/server/api/views/granted_qualifications_view.py new file mode 100644 index 000000000..3326dc67a --- /dev/null +++ b/mephisto/review_app/server/api/views/granted_qualifications_view.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from typing import List +from typing import Optional + +from flask import current_app as app +from flask import request +from flask.views import MethodView + +from mephisto.abstractions.databases.local_database import LocalMephistoDB +from mephisto.abstractions.databases.local_database import nonesafe_int +from mephisto.abstractions.databases.local_database import StringIDRow +from mephisto.data_model.constants.assignment_state import AssignmentState + +LIMIT_UNITS_FOR_QUALIFICATION = 3 +STATUSES_UNITS_FOR_QUALIFICATION = AssignmentState.completed() + + +def _find_granted_qualifications( + db: LocalMephistoDB, + qualification_id: Optional[str] = None, +) -> List[StringIDRow]: + """Return the granted qualifications in the database""" + + with db.table_access_condition: + conn = db.get_connection() + c = conn.cursor() + + params = [] + + # Exclude granted qualifications for blocked workers + blocked_worker_query = "blocked_worker IS NULL" + + qualification_query = "gq.qualification_id = ?1" if qualification_id else "" + if qualification_id is not None: + params.append(nonesafe_int(qualification_id)) + + joined_queries = " AND ".join( + list( + filter( + bool, + [ + blocked_worker_query, + qualification_query, + ], + ) + ) + ) + + where_query = f"WHERE {joined_queries}" if joined_queries else "" + + c.execute( + f""" + SELECT + gq.qualification_id AS qualification_id, + q.qualification_name AS qualification_name, + gq.worker_id AS worker_id, + w.worker_name AS worker_name, + gq.value AS current_value, + gq.update_date AS granted_at, + ur.blocked_worker AS blocked_worker + FROM granted_qualifications AS gq + LEFT JOIN ( + SELECT + worker_id, + worker_name, + creation_date + FROM workers + ) AS w ON w.worker_id = gq.worker_id + LEFT JOIN ( + SELECT + qualification_id, + qualification_name, + creation_date + FROM qualifications + ) AS q ON q.qualification_id = gq.qualification_id + LEFT JOIN ( + SELECT + id, + blocked_worker, + updated_qualification_id, + revoked_qualification_id, + worker_id, + creation_date + FROM unit_review + WHERE blocked_worker = 1 + ) AS ur ON ( + ur.worker_id = gq.worker_id AND ( + ( + ur.updated_qualification_id = gq.qualification_id AND + ur.revoked_qualification_id IS NULL + ) + OR + ( + ur.revoked_qualification_id = gq.qualification_id AND + ur.updated_qualification_id IS NULL + ) + ) + ) + {where_query}; + """, + params, + ) + rows = c.fetchall() + return rows + + +def _find_units( + db: LocalMephistoDB, + worker_id: str, + qualification_id: str, + statuses: Optional[List[str]] = None, + units_limit: Optional[int] = None, +) -> List[StringIDRow]: + """Return the units for granted qualification""" + + with db.table_access_condition: + conn = db.get_connection() + c = conn.cursor() + + params = [ + nonesafe_int(worker_id), + nonesafe_int(qualification_id), + ] + + worker_query = "ur.worker_id = ?1" + + qualification_query = "ur.updated_qualification_id = ?2" + + units_statuses_string = ",".join([f"'{s}'" for s in statuses]) + status_query = f"status IN ({units_statuses_string})" if statuses else "" + + joined_queries = " AND ".join( + list( + filter( + bool, + [ + worker_query, + qualification_query, + ], + ) + ) + ) + + where_query = f"WHERE {joined_queries}" if joined_queries else "" + + units_limit_query = "LIMIT ?3" if units_limit else "" + if units_limit: + params.append(nonesafe_int(units_limit)) + + c.execute( + f""" + SELECT + ur.task_id as task_id, + t.task_name as task_name, + ur.unit_id as unit_id + FROM unit_review AS ur + LEFT JOIN ( + SELECT + task_id, + task_name + FROM tasks + ) AS t ON t.task_id = ur.task_id + LEFT JOIN ( + SELECT + unit_id, + status + FROM units + WHERE {status_query} + ORDER BY creation_date DESC {units_limit_query} + ) AS u ON u.unit_id = ur.unit_id + {where_query} + ORDER BY creation_date DESC; + """, + params, + ) + rows = c.fetchall() + return rows + + +class GrantedQualificationsView(MethodView): + def get(self) -> dict: + """Get list of all granted queslifications.""" + + qualification_id = request.args.get("qualification_id") + + db_granted_qualifications = _find_granted_qualifications( + db=app.db, + qualification_id=qualification_id, + ) + + app.logger.debug(f"Found granted qualifications in DB: {list(db_granted_qualifications)}") + + granted_qualifications = [] + for gq in db_granted_qualifications: + units = [ + { + "task_id": u["task_id"], + "task_name": u["task_name"], + "unit_id": u["unit_id"], + } + for u in _find_units( + db=app.db, + worker_id=gq["worker_id"], + qualification_id=gq["qualification_id"], + statuses=STATUSES_UNITS_FOR_QUALIFICATION, + units_limit=LIMIT_UNITS_FOR_QUALIFICATION, + ) + ] + granted_qualifications.append( + { + "granted_at": gq["granted_at"], + "qualification_id": gq["qualification_id"], + "qualification_name": gq["qualification_name"], + "units": units, + "value_current": gq["current_value"], + "worker_id": gq["worker_id"], + "worker_name": gq["worker_name"], + }, + ) + + return { + "granted_qualifications": granted_qualifications, + } diff --git a/mephisto/review_app/server/api/views/qualification_details_view.py b/mephisto/review_app/server/api/views/qualification_details_view.py new file mode 100644 index 000000000..929c155d1 --- /dev/null +++ b/mephisto/review_app/server/api/views/qualification_details_view.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from flask import current_app as app +from flask.views import MethodView + +from mephisto.abstractions.databases.local_database import StringIDRow + + +class QualificationDetailsView(MethodView): + def get(self, qualification_id: str = None) -> dict: + """Get qualification details""" + + db_qualification: StringIDRow = app.db.get_qualification(qualification_id) + app.logger.debug(f"Found Qualification in DB: {db_qualification}") + + db_granted_qualifications: StringIDRow = app.db.find_granted_qualifications( + qualification_id=qualification_id + ) + + return { + "granted_qualifications_count": len(db_granted_qualifications), + } diff --git a/mephisto/review_app/server/api/views/qualification_view.py b/mephisto/review_app/server/api/views/qualification_view.py new file mode 100644 index 000000000..2383fbe72 --- /dev/null +++ b/mephisto/review_app/server/api/views/qualification_view.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Tuple + +from flask import current_app as app +from flask import request +from flask.views import MethodView +from werkzeug.exceptions import BadRequest + +from mephisto.abstractions.databases.local_database import StringIDRow + + +class QualificationView(MethodView): + def get(self, qualification_id: str = None) -> dict: + """Get qualification""" + + db_qualification: StringIDRow = app.db.get_qualification(qualification_id) + app.logger.debug(f"Found Qualification in DB: {db_qualification}") + + return { + "creation_date": db_qualification["creation_date"], + "description": db_qualification["description"], + "id": db_qualification["qualification_id"], + "name": db_qualification["qualification_name"], + } + + def patch(self, qualification_id: str = None) -> dict: + """Update qualification""" + + db_qualification: StringIDRow = app.db.get_qualification(qualification_id) + app.logger.debug(f"Found Qualification in DB: {db_qualification}") + + data: dict = request.json + name: str = data and data.get("name") + description: str = data and data.get("description") + + if not name: + raise BadRequest('Field "name" is required.') + + name = name.strip() + description = description.strip() if description else None + + app.db.update_qualification( + qualification_id=qualification_id, + name=name, + description=description, + ) + + updated_qualification: StringIDRow = app.db.get_qualification(qualification_id) + + return { + "creation_date": updated_qualification["creation_date"], + "description": updated_qualification["description"], + "id": updated_qualification["qualification_id"], + "name": updated_qualification["qualification_name"], + } + + def delete(self, qualification_id: str = None) -> Tuple[dict, int]: + """Delete qualification""" + + db_qualification: StringIDRow = app.db.get_qualification(qualification_id) + app.logger.debug(f"Found Qualification in DB: {db_qualification}") + + app.db.delete_qualification(qualification_name=db_qualification["qualification_name"]) + + return {} diff --git a/mephisto/review_app/server/api/views/qualifications_view.py b/mephisto/review_app/server/api/views/qualifications_view.py index 601559fb7..a81b4f839 100644 --- a/mephisto/review_app/server/api/views/qualifications_view.py +++ b/mephisto/review_app/server/api/views/qualifications_view.py @@ -71,6 +71,8 @@ def get(self) -> dict: qualifications = [ { + "creation_date": q.creation_date, + "description": q.description, "id": q.db_id, "name": q.qualification_name, } @@ -88,6 +90,10 @@ def post(self) -> dict: data: dict = request.json qualification_name = data and data.get("name") + qualification_description = data and data.get("description") + + if qualification_description: + qualification_description = qualification_description[:500] if not qualification_name: raise BadRequest('Field "name" is required.') @@ -97,10 +103,15 @@ def post(self) -> dict: if db_qualifications: raise BadRequest(f'Qualification with name "{qualification_name}" already exists.') - db_qualification_id: str = app.db.make_qualification(qualification_name) + db_qualification_id: str = app.db.make_qualification( + qualification_name, + qualification_description, + ) db_qualification: StringIDRow = app.db.get_qualification(db_qualification_id) return { + "creation_date": db_qualification["creation_date"], + "description": db_qualification["description"], "id": db_qualification["qualification_id"], "name": db_qualification["qualification_name"], } diff --git a/mephisto/review_app/server/api/views/qualify_worker_view.py b/mephisto/review_app/server/api/views/qualify_worker_view.py index 0656d7faf..bc34ac331 100644 --- a/mephisto/review_app/server/api/views/qualify_worker_view.py +++ b/mephisto/review_app/server/api/views/qualify_worker_view.py @@ -4,6 +4,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +from typing import List from typing import Optional from flask import current_app as app @@ -11,6 +12,8 @@ from flask.views import MethodView from werkzeug.exceptions import BadRequest +from mephisto.abstractions.databases.local_database import LocalMephistoDB +from mephisto.abstractions.databases.local_database import nonesafe_int from mephisto.abstractions.databases.local_database import StringIDRow from mephisto.data_model.unit import Unit from mephisto.data_model.worker import Worker @@ -36,6 +39,39 @@ def _write_revoke_unit_review( db.update_unit_review(unit_id, qualification_id, worker_id, value, revoke=True) +def _find_units_ids( + db: LocalMephistoDB, + worker_id: int, + qualification_id: int, +) -> List[str]: + """Return the units for granted qualification""" + + with db.table_access_condition: + conn = db.get_connection() + c = conn.cursor() + + params = [ + nonesafe_int(qualification_id), + nonesafe_int(worker_id), + ] + + c.execute( + f""" + SELECT + ur.unit_id as unit_id + FROM unit_review AS ur + WHERE ( + ur.worker_id = ?2 AND + (ur.updated_qualification_id = ?1 OR ur.revoked_qualification_id = ?1) + ); + """, + params, + ) + rows = c.fetchall() + unit_ids = list(set([u["unit_id"] for u in rows])) + return unit_ids + + class QualifyWorkerView(MethodView): @staticmethod def _grant_worker_qualification( @@ -69,8 +105,8 @@ def post(self, qualification_id: int, worker_id: int, action: str) -> dict: """Grant/Revoke qualification to a worker""" data: dict = request.json - unit_ids: Optional[str] = data and data.get("unit_ids") - value = data and data.get("value") + unit_ids: Optional[List[str]] = data and data.get("unit_ids") + value: Optional[int] = data and data.get("value") if not unit_ids: raise BadRequest('Field "unit_ids" is required.') @@ -95,3 +131,23 @@ def post(self, qualification_id: int, worker_id: int, action: str) -> dict: raise BadRequest(f"Could not {action} qualification. Reason: {e}") return {} + + def patch(self, qualification_id: int, worker_id: int, action: str) -> dict: + """Update value of existing granted qualification or revoke qualification from a worker""" + + # TODO: Note that it will not affect `unit_review` table + # as we have required field `unit_id`, + # but in this case we update granted qualification directly + + data: dict = request.json + value: Optional[int] = data and data.get("value") + + if action == "grant": + if not value: + raise BadRequest('Field "value" is required.') + + app.db.grant_qualification(qualification_id, worker_id, value) + elif action == "revoke": + app.db.revoke_qualification(qualification_id, worker_id) + + return {} diff --git a/mephisto/review_app/server/api/views/task_view.py b/mephisto/review_app/server/api/views/task_view.py index 8fec0cfcf..65e797367 100644 --- a/mephisto/review_app/server/api/views/task_view.py +++ b/mephisto/review_app/server/api/views/task_view.py @@ -12,7 +12,7 @@ class TaskView(MethodView): def get(self, task_id: str = None) -> dict: - """Get all available tasks (to select one for review)""" + """Get task""" db_task: StringIDRow = app.db.get_task(task_id) app.logger.debug(f"Found Task in DB: {db_task}") diff --git a/mephisto/review_app/server/api/views/units_details_view.py b/mephisto/review_app/server/api/views/units_details_view.py index 58c7a3a2c..92a2f2786 100644 --- a/mephisto/review_app/server/api/views/units_details_view.py +++ b/mephisto/review_app/server/api/views/units_details_view.py @@ -11,6 +11,7 @@ from flask.views import MethodView from werkzeug.exceptions import BadRequest +from mephisto.abstractions.databases.local_database import LocalMephistoDB from mephisto.client.cli_form_composer_commands import set_form_composer_env_vars from mephisto.data_model.task_run import TaskRun from mephisto.data_model.unit import Unit @@ -26,6 +27,67 @@ from mephisto.review_app.server.utils.video_annotator import convert_annotation_tracks_to_webvtt +def _find_unit_reviews( + db: LocalMephistoDB, + unit_id: str, +) -> List[dict]: + """Return all unit reviews for unit""" + + with db.table_access_condition: + conn = db.get_connection() + c = conn.cursor() + c.execute( + f""" + SELECT + blocked_worker, + bonus, + creation_date, + qualification_name, + review_note, + revoked_qualification_id, + status, + updated_qualification_id, + updated_qualification_value + FROM unit_review AS ur + LEFT JOIN ( + SELECT + qualification_id, + qualification_name + FROM qualifications + ) AS q ON ( + ( + ur.updated_qualification_id = q.qualification_id AND + ur.revoked_qualification_id IS NULL + ) + OR + ( + ur.revoked_qualification_id = q.qualification_id AND + ur.updated_qualification_id IS NULL + ) + ) + WHERE unit_id = ?1 + ORDER BY creation_date DESC; + """, + [unit_id], + ) + rows = c.fetchall() + + unit_reviews = [ + { + "blocked_worker": u["blocked_worker"], + "bonus": u["bonus"], + "creation_date": u["creation_date"], + "qualification_id": u["updated_qualification_id"] or ["revoked_qualification_id"], + "qualification_name": u["qualification_name"], + "review_note": u["review_note"], + "status": u["status"], + "value": u["updated_qualification_value"], + } + for u in rows + ] + return unit_reviews + + class UnitsDetailsView(MethodView): def get(self) -> dict: """Get full input for specified workers results (`unit_ids` is mandatory)""" @@ -92,6 +154,8 @@ def get(self) -> dict: task_name = task_run.get_task().task_name metadata["webvtt"] = convert_annotation_tracks_to_webvtt(task_name, inputs, outputs) + metadata["unit_reviews"] = _find_unit_reviews(app.db, unit.db_id) + # Get Unit data path agent = unit.get_assigned_agent() unit_data_folder = agent.get_data_dir() if agent else None diff --git a/mephisto/review_app/server/urls.py b/mephisto/review_app/server/urls.py index 671ff40d2..4a6bfdefa 100644 --- a/mephisto/review_app/server/urls.py +++ b/mephisto/review_app/server/urls.py @@ -34,6 +34,18 @@ def init_urls(app: Flask): "/api/qualifications", view_func=api_views.QualificationsView.as_view("qualifications"), ) + app.add_url_rule( + "/api/qualifications/", + view_func=api_views.QualificationView.as_view("qualification"), + ) + app.add_url_rule( + "/api/qualifications//details", + view_func=api_views.QualificationDetailsView.as_view("qualification_details"), + ) + app.add_url_rule( + "/api/granted-qualifications", + view_func=api_views.GrantedQualificationsView.as_view("granted_qualifications"), + ) app.add_url_rule( "/api/tasks//worker-units-ids", view_func=api_views.TaskUnitIdsView.as_view("worker_units_ids"), diff --git a/test/review_app/server/api/test_grant_workers_view.py b/test/review_app/server/api/test_grant_workers_view.py deleted file mode 100644 index c906ea346..000000000 --- a/test/review_app/server/api/test_grant_workers_view.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (c) Meta Platforms and its affiliates. -# This source code is licensed under the MIT license found in the -# LICENSE file in the root directory of this source tree. - -import unittest - -from flask import url_for - -from mephisto.utils import http_status -from mephisto.data_model.constants.assignment_state import AssignmentState -from mephisto.data_model.unit import Unit -from mephisto.utils.testing import find_unit_reviews -from mephisto.utils.testing import get_test_qualification -from mephisto.utils.testing import get_test_task_run -from mephisto.utils.testing import get_test_worker -from mephisto.utils.testing import make_completed_unit -from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase - - -class TestGrantWorkersView(BaseTestApiViewCase): - def test_grant_success(self, *args, **kwargs): - # Task Run - get_test_task_run(self.db) - - # Worker - _, worker_id = get_test_worker(self.db) - - # Unit - unit_id = make_completed_unit(self.db) - unit: Unit = Unit.get(self.db, unit_id) - unit.set_db_status(AssignmentState.COMPLETED) - - # Qualification - qualification_id = get_test_qualification(self.db) - - # Unit Review - self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) - - with self.app_context: - url = url_for( - "qualification_worker_grant", - qualification_id=qualification_id, - worker_id=worker_id, - ) - response = self.client.post(url, json={"unit_ids": [unit_id], "value": 10}) - result = response.json - - unit_reviews = find_unit_reviews(self.db, qualification_id, worker_id, unit.task_id) - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - self.assertEqual(result, {}) - self.assertEqual(len(unit_reviews), 1) - self.assertEqual(unit_reviews[0]["updated_qualification_id"], qualification_id) - self.assertEqual(unit_reviews[0]["revoked_qualification_id"], None) - - def test_grant_no_unit_ids_error(self, *args, **kwargs): - # Task Run - get_test_task_run(self.db) - - # Worker - _, worker_id = get_test_worker(self.db) - - # Unit - unit_id = make_completed_unit(self.db) - unit: Unit = Unit.get(self.db, unit_id) - unit.set_db_status(AssignmentState.COMPLETED) - - # Qualification - qualification_id = get_test_qualification(self.db) - - # Unit Review - self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) - - with self.app_context: - url = url_for( - "qualification_worker_grant", - qualification_id=qualification_id, - worker_id=worker_id, - ) - response = self.client.post(url, json={}) - result = response.json - - self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) - self.assertEqual(result["error"], 'Field "unit_ids" is required.') - - -if __name__ == "__main__": - unittest.main() diff --git a/test/review_app/server/api/test_granted_qualifications_view.py b/test/review_app/server/api/test_granted_qualifications_view.py new file mode 100644 index 000000000..f8e2992be --- /dev/null +++ b/test/review_app/server/api/test_granted_qualifications_view.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +from flask import url_for + +from mephisto.data_model.constants.assignment_state import AssignmentState +from mephisto.data_model.unit import Unit +from mephisto.utils import http_status +from mephisto.utils.testing import get_test_qualification +from mephisto.utils.testing import get_test_task_run +from mephisto.utils.testing import get_test_worker +from mephisto.utils.testing import grant_test_qualification +from mephisto.utils.testing import make_completed_unit +from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase + + +class TestGrantedQualificationsView(BaseTestApiViewCase): + def test_granted_qualifications_list_one_qualification_success(self, *args, **kwargs): + granted_value = 999 + + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Unit + unit_id = make_completed_unit(self.db) + unit: Unit = Unit.get(self.db, unit_id) + unit.set_db_status(AssignmentState.COMPLETED) + + # Qualifications + qualification_id = get_test_qualification(self.db) + grant_test_qualification(self.db, qualification_id, worker_id, granted_value) + + # Unit Review + self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) + self.db.update_unit_review(unit_id, qualification_id, worker_id) + + with self.app_context: + url = url_for("granted_qualifications") + response = self.client.get(url) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(len(result["granted_qualifications"]), 1) + self.assertIn("granted_at", result["granted_qualifications"][0]) + self.assertIn("qualification_id", result["granted_qualifications"][0]) + self.assertIn("qualification_name", result["granted_qualifications"][0]) + self.assertIn("units", result["granted_qualifications"][0]) + self.assertIn("value_current", result["granted_qualifications"][0]) + self.assertIn("worker_id", result["granted_qualifications"][0]) + self.assertIn("worker_name", result["granted_qualifications"][0]) + self.assertEqual(result["granted_qualifications"][0]["qualification_id"], qualification_id) + self.assertEqual(result["granted_qualifications"][0]["value_current"], granted_value) + self.assertEqual(result["granted_qualifications"][0]["worker_id"], worker_id) + self.assertIn("task_id", result["granted_qualifications"][0]["units"][0]) + self.assertIn("task_name", result["granted_qualifications"][0]["units"][0]) + self.assertIn("unit_id", result["granted_qualifications"][0]["units"][0]) + self.assertEqual(result["granted_qualifications"][0]["units"][0]["unit_id"], unit_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/review_app/server/api/test_qualification_details_view.py b/test/review_app/server/api/test_qualification_details_view.py new file mode 100644 index 000000000..23b1bd3cf --- /dev/null +++ b/test/review_app/server/api/test_qualification_details_view.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +from flask import url_for + +from mephisto.utils import http_status +from mephisto.utils.testing import get_test_qualification +from mephisto.utils.testing import get_test_task_run +from mephisto.utils.testing import get_test_worker +from mephisto.utils.testing import grant_test_qualification +from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase + + +class TestQualificationDetailsView(BaseTestApiViewCase): + def test_qualification_details_success(self, *args, **kwargs): + granted_value = 999 + + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id_1 = get_test_worker(self.db, "first") + _, worker_id_2 = get_test_worker(self.db, "second") + + # Qualifications + qualification_id = get_test_qualification(self.db) + grant_test_qualification(self.db, qualification_id, worker_id_1, granted_value) + grant_test_qualification(self.db, qualification_id, worker_id_2, granted_value) + + with self.app_context: + url = url_for("qualification_details", qualification_id=qualification_id) + response = self.client.get(url) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result["granted_qualifications_count"], 2) + + def test_qualification_details_success_no_results(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Qualifications + qualification_id = get_test_qualification(self.db) + + with self.app_context: + url = url_for("qualification_details", qualification_id=qualification_id) + response = self.client.get(url) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result["granted_qualifications_count"], 0) + + def test_qualification_details_no_qualification_error(self, *args, **kwargs): + incorrect_qualification_id = 8888 + + # Task Run + get_test_task_run(self.db) + + with self.app_context: + url = url_for("qualification_details", qualification_id=incorrect_qualification_id) + response = self.client.get(url) + + self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/review_app/server/api/test_qualification_view.py b/test/review_app/server/api/test_qualification_view.py new file mode 100644 index 000000000..f10068385 --- /dev/null +++ b/test/review_app/server/api/test_qualification_view.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +from flask import url_for + +from mephisto.utils import http_status +from mephisto.utils.db import EntryDoesNotExistException +from mephisto.utils.testing import get_test_qualification +from mephisto.utils.testing import get_test_task_run +from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase + + +class TestQualificationDetailsView(BaseTestApiViewCase): + def test_get_qualification_success(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Qualifications + qualification_id = get_test_qualification(self.db) + + with self.app_context: + url = url_for("qualification", qualification_id=qualification_id) + response = self.client.get(url) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result["id"], str(qualification_id)) + self.assertIn("creation_date", result) + self.assertIn("description", result) + self.assertIn("id", result) + self.assertIn("name", result) + + def test_get_qualification_no_qualification_error(self, *args, **kwargs): + incorrect_qualification_id = 8888 + + # Task Run + get_test_task_run(self.db) + + # Qualifications + get_test_qualification(self.db) + + with self.app_context: + url = url_for("qualification", qualification_id=incorrect_qualification_id) + response = self.client.get(url) + + self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) + + def test_patch_qualification_success(self, *args, **kwargs): + expected_name = "Test name" + expected_description = "Test description" + + # Task Run + get_test_task_run(self.db) + + # Qualifications + qualification_id = get_test_qualification(self.db) + + with self.app_context: + url = url_for("qualification", qualification_id=qualification_id) + response = self.client.patch( + url, + json={"name": expected_name, "description": expected_description}, + ) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertIn("creation_date", result) + self.assertIn("description", result) + self.assertIn("id", result) + self.assertIn("name", result) + self.assertEqual(result["id"], str(qualification_id)) + self.assertEqual(result["name"], expected_name) + self.assertEqual(result["description"], expected_description) + + def test_patch_qualification_no_field_name_error(self, *args, **kwargs): + incorrect_name = "" + expected_description = "Test description" + + # Task Run + get_test_task_run(self.db) + + # Qualifications + qualification_id = get_test_qualification(self.db) + + with self.app_context: + url = url_for("qualification", qualification_id=qualification_id) + response = self.client.patch( + url, + json={"name": incorrect_name, "description": expected_description}, + ) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertEqual(result, {"error": 'Field "name" is required.'}) + + def test_delete_qualification_success(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Qualifications + qualification_id = get_test_qualification(self.db) + + with self.app_context: + url = url_for("qualification", qualification_id=qualification_id) + response = self.client.delete(url) + + with self.assertRaises(EntryDoesNotExistException) as cm: + self.db.get_qualification(qualification_id) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual( + str(cm.exception), + f"Table qualifications has no qualification_id {qualification_id}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/review_app/server/api/test_qualifications_view.py b/test/review_app/server/api/test_qualifications_view.py index 8ad73a705..d8b718bc4 100644 --- a/test/review_app/server/api/test_qualifications_view.py +++ b/test/review_app/server/api/test_qualifications_view.py @@ -25,6 +25,10 @@ def test_qualification_list_one_qualification_success(self, *args, **kwargs): self.assertEqual(response.status_code, http_status.HTTP_200_OK) self.assertEqual(len(result["qualifications"]), 1) self.assertEqual(result["qualifications"][0]["id"], qualification_id) + self.assertTrue("creation_date" in result["qualifications"][0]) + self.assertTrue("description" in result["qualifications"][0]) + self.assertTrue("id" in result["qualifications"][0]) + self.assertTrue("name" in result["qualifications"][0]) def test_qualification_list_empty_success(self, *args, **kwargs): with self.app_context: @@ -45,7 +49,10 @@ def test_qualification_create_success(self, *args, **kwargs): self.assertEqual(response.status_code, http_status.HTTP_200_OK) self.assertEqual(result["name"], qualification_name) + self.assertTrue("creation_date" in result) + self.assertTrue("description" in result) self.assertTrue("id" in result) + self.assertTrue("name" in result) def test_qualification_create_no_passed_name_error(self, *args, **kwargs): with self.app_context: diff --git a/test/review_app/server/api/test_qualify_worker_view.py b/test/review_app/server/api/test_qualify_worker_view.py new file mode 100644 index 000000000..081903cd3 --- /dev/null +++ b/test/review_app/server/api/test_qualify_worker_view.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +from flask import url_for + +from mephisto.utils import http_status +from mephisto.data_model.constants.assignment_state import AssignmentState +from mephisto.data_model.unit import Unit +from mephisto.utils.db import EntryDoesNotExistException +from mephisto.utils.testing import find_unit_reviews +from mephisto.utils.testing import get_test_qualification +from mephisto.utils.testing import get_test_task_run +from mephisto.utils.testing import get_test_worker +from mephisto.utils.testing import grant_test_qualification +from mephisto.utils.testing import make_completed_unit +from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase + + +class TestQualifyWorkerView(BaseTestApiViewCase): + def test_grant_success(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Unit + unit_id = make_completed_unit(self.db) + unit: Unit = Unit.get(self.db, unit_id) + unit.set_db_status(AssignmentState.COMPLETED) + + # Qualification + qualification_id = get_test_qualification(self.db) + + # Unit Review + self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) + + with self.app_context: + url = url_for( + "qualification_worker_grant", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.post(url, json={"unit_ids": [unit_id], "value": 10}) + result = response.json + + unit_reviews = find_unit_reviews(self.db, qualification_id, worker_id, unit.task_id) + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result, {}) + self.assertEqual(len(unit_reviews), 1) + self.assertEqual(unit_reviews[0]["updated_qualification_id"], qualification_id) + self.assertEqual(unit_reviews[0]["revoked_qualification_id"], None) + + def test_grant_no_unit_ids_error(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Unit + unit_id = make_completed_unit(self.db) + unit: Unit = Unit.get(self.db, unit_id) + unit.set_db_status(AssignmentState.COMPLETED) + + # Qualification + qualification_id = get_test_qualification(self.db) + + # Unit Review + self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) + + with self.app_context: + url = url_for( + "qualification_worker_grant", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.post(url, json={}) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertEqual(result["error"], 'Field "unit_ids" is required.') + + def test_revoke_success(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Unit + unit_id = make_completed_unit(self.db) + unit: Unit = Unit.get(self.db, unit_id) + unit.set_db_status(AssignmentState.COMPLETED) + + # Qualification + qualification_id = get_test_qualification(self.db) + + # Unit Review + self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) + + with self.app_context: + url = url_for( + "qualification_worker_revoke", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.post(url, json={"unit_ids": [unit_id], "value": 10}) + result = response.json + + unit_reviews = find_unit_reviews(self.db, qualification_id, worker_id, unit.task_id) + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result, {}) + self.assertEqual(len(unit_reviews), 1) + self.assertEqual(unit_reviews[0]["revoked_qualification_id"], qualification_id) + self.assertEqual(unit_reviews[0]["updated_qualification_id"], None) + + def test_revoke_no_unit_ids_error(self, *args, **kwargs): + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Unit + unit_id = make_completed_unit(self.db) + unit: Unit = Unit.get(self.db, unit_id) + unit.set_db_status(AssignmentState.COMPLETED) + + # Qualification + qualification_id = get_test_qualification(self.db) + + # Unit Review + self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) + + with self.app_context: + url = url_for( + "qualification_worker_revoke", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.post(url, json={}) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertEqual(result["error"], 'Field "unit_ids" is required.') + + def test_update_granted_qualification_success(self, *args, **kwargs): + granted_value = 1 + expected_value = 2 + + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Qualification + qualification_id = get_test_qualification(self.db) + + grant_test_qualification(self.db, qualification_id, worker_id, granted_value) + + with self.app_context: + url = url_for( + "qualification_worker_grant", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.patch(url, json={"value": expected_value}) + result = response.json + + updated_granted_qualification = self.db.get_granted_qualification( + worker_id=worker_id, + qualification_id=qualification_id, + ) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result, {}) + self.assertEqual(updated_granted_qualification["value"], expected_value) + + def test_update_granted_qualification_no_value_error(self, *args, **kwargs): + granted_value = 1 + + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Qualification + qualification_id = get_test_qualification(self.db) + + grant_test_qualification(self.db, qualification_id, worker_id, granted_value) + + with self.app_context: + url = url_for( + "qualification_worker_grant", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.patch(url, json={}) + result = response.json + + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertEqual(result, {"error": 'Field "value" is required.'}) + + def test_revoke_granted_qualification_success(self, *args, **kwargs): + granted_value = 1 + + # Task Run + get_test_task_run(self.db) + + # Worker + _, worker_id = get_test_worker(self.db) + + # Qualification + qualification_id = get_test_qualification(self.db) + + grant_test_qualification(self.db, qualification_id, worker_id, granted_value) + + before_granted_qualification = self.db.get_granted_qualification( + worker_id=worker_id, + qualification_id=qualification_id, + ) + + with self.app_context: + url = url_for( + "qualification_worker_revoke", + qualification_id=qualification_id, + worker_id=worker_id, + ) + response = self.client.patch(url, json={}) + result = response.json + + with self.assertRaises(EntryDoesNotExistException) as cm: + self.db.get_granted_qualification( + worker_id=worker_id, + qualification_id=qualification_id, + ) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertEqual(result, {}) + self.assertIsNotNone(before_granted_qualification) + self.assertEqual( + str(cm.exception), + f"No such granted qualification {qualification_id}, {worker_id}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/review_app/server/api/test_revoke_workers_view.py b/test/review_app/server/api/test_revoke_workers_view.py deleted file mode 100644 index e4cb42183..000000000 --- a/test/review_app/server/api/test_revoke_workers_view.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (c) Meta Platforms and its affiliates. -# This source code is licensed under the MIT license found in the -# LICENSE file in the root directory of this source tree. - -import unittest - -from flask import url_for - -from mephisto.utils import http_status -from mephisto.data_model.constants.assignment_state import AssignmentState -from mephisto.data_model.unit import Unit -from mephisto.utils.testing import find_unit_reviews -from mephisto.utils.testing import get_test_qualification -from mephisto.utils.testing import get_test_task_run -from mephisto.utils.testing import get_test_worker -from mephisto.utils.testing import make_completed_unit -from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase - - -class TestRevokeWorkersView(BaseTestApiViewCase): - def test_grant_success(self, *args, **kwargs): - # Task Run - get_test_task_run(self.db) - - # Worker - _, worker_id = get_test_worker(self.db) - - # Unit - unit_id = make_completed_unit(self.db) - unit: Unit = Unit.get(self.db, unit_id) - unit.set_db_status(AssignmentState.COMPLETED) - - # Qualification - qualification_id = get_test_qualification(self.db) - - # Unit Review - self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) - - with self.app_context: - url = url_for( - "qualification_worker_revoke", - qualification_id=qualification_id, - worker_id=worker_id, - ) - response = self.client.post(url, json={"unit_ids": [unit_id], "value": 10}) - result = response.json - - unit_reviews = find_unit_reviews(self.db, qualification_id, worker_id, unit.task_id) - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - self.assertEqual(result, {}) - self.assertEqual(len(unit_reviews), 1) - self.assertEqual(unit_reviews[0]["revoked_qualification_id"], qualification_id) - self.assertEqual(unit_reviews[0]["updated_qualification_id"], None) - - def test_grant_no_unit_ids_error(self, *args, **kwargs): - # Task Run - get_test_task_run(self.db) - - # Worker - _, worker_id = get_test_worker(self.db) - - # Unit - unit_id = make_completed_unit(self.db) - unit: Unit = Unit.get(self.db, unit_id) - unit.set_db_status(AssignmentState.COMPLETED) - - # Qualification - qualification_id = get_test_qualification(self.db) - - # Unit Review - self.db.new_unit_review(unit_id, unit.task_id, worker_id, unit.db_status) - - with self.app_context: - url = url_for( - "qualification_worker_revoke", - qualification_id=qualification_id, - worker_id=worker_id, - ) - response = self.client.post(url, json={}) - result = response.json - - self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) - self.assertEqual(result["error"], 'Field "unit_ids" is required.') - - -if __name__ == "__main__": - unittest.main() diff --git a/test/review_app/server/api/test_units_details_view.py b/test/review_app/server/api/test_units_details_view.py index 29b5c6ad1..d61e9e9eb 100644 --- a/test/review_app/server/api/test_units_details_view.py +++ b/test/review_app/server/api/test_units_details_view.py @@ -61,10 +61,13 @@ def test_one_unit_success(self, *args, **kwargs): "outputs", "prepared_inputs", "unit_data_folder", + "metadata", ] for unit_field in unit_fields: self.assertTrue(unit_field in first_unit) + self.assertIn("unit_reviews", first_unit["metadata"]) + if __name__ == "__main__": unittest.main()