diff --git a/api/v1/_submissions/PKPBackendSubmissionsController.php b/api/v1/_submissions/PKPBackendSubmissionsController.php index 83621f7395c..bef8424cf44 100644 --- a/api/v1/_submissions/PKPBackendSubmissionsController.php +++ b/api/v1/_submissions/PKPBackendSubmissionsController.php @@ -242,6 +242,24 @@ public function assigned(Request $illuminateRequest): JsonResponse ], Response::HTTP_OK); } + /** + * Get submissions which need reviewer(s) to be assigned + */ + public function needsReviewers(SlimRequest $slimRequest, APIResponse $response, array $args) + { + $request = Application::get()->getRequest(); + $context = $request->getContext(); + if (!$context) { + return $response->withStatus(404)->withJsonError('api.404.resourceNotFound'); + } + + $collector = $this->getSubmissionCollector($slimRequest->getQueryParams()); + $submissions = $collector + ->filterByContextIds($context->getId()) + ->filterByStageIds([WORKFLOW_STAGE_ID_INTERNAL_REVIEW, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW]) + -> + } + /** * Delete a submission */ diff --git a/classes/components/forms/context/PKPReviewSetupForm.php b/classes/components/forms/context/PKPReviewSetupForm.php index 10b66359103..c98ba682a3d 100644 --- a/classes/components/forms/context/PKPReviewSetupForm.php +++ b/classes/components/forms/context/PKPReviewSetupForm.php @@ -83,6 +83,12 @@ public function __construct($action, $locales, $context) 'description' => __('manager.setup.reviewOptions.numWeeksPerReview'), 'value' => $context->getData('numWeeksPerReview'), 'size' => 'small', + ])) + ->addField(new FieldText('numReviewersPerSubmission', [ + 'label' => __('manager.setup.reviewOptions.numReviewersPerSubmission'), + 'description' => __('manager.setup.reviewOptions.numReviewersPerSubmission.description'), + 'value' => $context->getData('numReviewersPerSubmission'), + 'size' => 'small', ])); if (Config::getVar('general', 'scheduled_tasks')) { diff --git a/classes/submission/Collector.php b/classes/submission/Collector.php index 4b710ff1b18..1848e0421b6 100644 --- a/classes/submission/Collector.php +++ b/classes/submission/Collector.php @@ -75,6 +75,8 @@ abstract class Collector implements CollectorInterface public $assignedTo = null; public array|int|null $isReviewedBy = null; + public ?array $reviewersNumber = null; + public function __construct(DAO $dao) { $this->dao = $dao; @@ -196,6 +198,17 @@ public function filterByDaysInactive(?int $daysInactive): AppCollector return $this; } + /** + * Limit results to the number of the assigned reviewers. + * Review assignment is considered active after the request is sent by the reviewer + * and it isn't cancelled, declined or overdue + */ + public function filterByReviewersActive(?int $reviewersNumber) + { + $this->reviewersNumber = $reviewersNumber; + return $this; + } + /** * Limit results to submissions assigned to these users * @@ -564,13 +577,40 @@ public function getQueryBuilder(): Builder // Filter by is reviewed by if ($this->isReviewedBy !== null) { // TODO consider review round and other criteria; refactor query builder to use ->when - $q->when($this->isReviewedBy === self::UNASSIGNED, function (Builder $q) { - $q->leftJoin('review_assignments AS ra', 'ra.submission_id', '=', 's.submission_id') - ->whereIn('s.stage_id', [WORKFLOW_STAGE_ID_EXTERNAL_REVIEW, WORKFLOW_STAGE_ID_INTERNAL_REVIEW]) - ->whereNull('ra.submission_id'); - }); + $q->whereIn('s.stage_id', [WORKFLOW_STAGE_ID_EXTERNAL_REVIEW, WORKFLOW_STAGE_ID_INTERNAL_REVIEW]) + ->when( + $this->isReviewedBy === self::UNASSIGNED, + // Submission considered not under the review when ... + fn (Builder $q) => $q + // review assignments don't exist for the submission or exist but are declined or cancelled + ->whereNotIn('s.submission_id', fn (Builder $q) => $q + ->select('ra.submission_id') + ->from('review_assignments AS ra') + ->where('declined', 0) + ->where('canceled', 0) + ->distinct()), + fn (Builder $q) => $q + ->whereIn('s.submission_id', fn (Builder $q) => $q + ->select('ra.submission_id') + ->from('review_assignments AS ra') + ->whereIn('reviewer_id', (array) $this->isReviewedBy) + ->where('declined', 0) + ->where('canceled', 0) + ) + ); } + $q->when($this->reviewersNumber !== null, fn (Builder $q) => $q + ->whereIn('s.submission_id', fn(Builder $q) => $q + ->select('ra.submission_id', DB::raw('count(*) as number')) + ->groupBy('ra.submission_id') + ->from('review_assignments AS ra') + ->where('declined', 0) + ->where('cancel', 0) + ->havingRaw('number IN ?', $this->reviewersNumber) + ) + ); + // By any child pub object's DOI status // Filter by any child pub object's DOI status $q->when($this->doiStatuses !== null, fn (Builder $q) => $this->addDoiStatusFilterToQuery($q)); diff --git a/locale/en/manager.po b/locale/en/manager.po index c462ae781fd..3153ea71b7f 100644 --- a/locale/en/manager.po +++ b/locale/en/manager.po @@ -1244,6 +1244,12 @@ msgstr "" "Present a link to during upload" +msgid "manager.setup.reviewOptions.numReviewersPerSubmission" +msgstr "Recommended Reviewers Number" + +msgid "manager.setup.reviewOptions.numReviewersPerSubmission.description" +msgstr "Sufficient number of reviewers needed per submission to make recommendation before making a decision" + msgid "manager.setup.sponsors.note" msgstr "Sponsor Relationship and Policy Description" diff --git a/schemas/context.json b/schemas/context.json index 862aa2bafc9..3f2ef82e81c 100644 --- a/schemas/context.json +++ b/schemas/context.json @@ -551,6 +551,14 @@ "min:0" ] }, + "numReviewersPerSubmission": { + "type": "integer", + "default": 0, + "validation": [ + "nullable", + "min:0" + ] + }, "openAccessPolicy": { "type": "string", "multilingual": true, @@ -875,4 +883,4 @@ ] } } -} \ No newline at end of file +}