diff --git a/api/v1/_submissions/PKPBackendSubmissionsController.php b/api/v1/_submissions/PKPBackendSubmissionsController.php index d0c9c99ab24..65f90ffccc9 100644 --- a/api/v1/_submissions/PKPBackendSubmissionsController.php +++ b/api/v1/_submissions/PKPBackendSubmissionsController.php @@ -39,7 +39,6 @@ use PKP\submission\PKPSubmission; use PKP\userGroup\UserGroup; - abstract class PKPBackendSubmissionsController extends PKPBaseController { use AnonymizeData; @@ -204,6 +203,8 @@ public function getMany(Request $illuminateRequest): JsonResponse } } + $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES); + /** * FIXME: Clean up before release pkp/pkp-lib#7495. * In new submission lists this endpoint is dedicated to retrieve all submissions only by admins and managers @@ -211,7 +212,6 @@ public function getMany(Request $illuminateRequest): JsonResponse if (!Config::getVar('features', 'enable_new_submission_listing')) { // Anyone not a manager or site admin can only access their assigned submissions - $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES); $canAccessUnassignedSubmission = !empty(array_intersect([Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER], $userRoles)); Hook::run('API::_submissions::params', [$collector, $illuminateRequest]); if (!$canAccessUnassignedSubmission) { @@ -238,7 +238,7 @@ public function getMany(Request $illuminateRequest): JsonResponse return response()->json([ 'itemsMax' => $collector->getCount(), - 'items' => Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($submissions, $userGroups, $genres)->values(), + 'items' => Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($submissions, $userGroups, $genres, $userRoles)->values(), ], Response::HTTP_OK); } @@ -276,6 +276,7 @@ public function assigned(Request $illuminateRequest): JsonResponse $submissions, $userGroups, $genres, + $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES), $this->anonymizeReviews($submissions) )->values(), ], Response::HTTP_OK); @@ -343,7 +344,12 @@ public function reviews(Request $illuminateRequest): JsonResponse return response()->json([ 'itemsMax' => $collector->getCount(), - 'items' => Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($submissions, $userGroups, $genres)->values(), + 'items' => Repo::submission()->getSchemaMap()->mapManyToSubmissionsList( + $submissions, + $userGroups, + $genres, + $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES) + )->values(), ], Response::HTTP_OK); } diff --git a/classes/stageAssignment/StageAssignment.php b/classes/stageAssignment/StageAssignment.php index e1b96cefb8c..6fe995fdeb0 100644 --- a/classes/stageAssignment/StageAssignment.php +++ b/classes/stageAssignment/StageAssignment.php @@ -28,7 +28,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use PKP\user\User; use PKP\userGroup\relationships\UserGroupStage; use PKP\userGroup\UserGroup; diff --git a/classes/submission/maps/Schema.php b/classes/submission/maps/Schema.php index 4f17ab277a1..67ad7c7672b 100644 --- a/classes/submission/maps/Schema.php +++ b/classes/submission/maps/Schema.php @@ -1,4 +1,5 @@ The user groups for this context. */ public Enumerable $userGroups; + /** @var array user roles associated with the context, Role::ROLE_ID_ constants */ + public array $userRoles; + /** @var Genre[] The file genres in this context. */ public array $genres; @@ -71,6 +76,7 @@ protected function getSubmissionsListProps(): array $props = [ '_href', + 'canCurrentUserChangeMetadata', 'contextId', 'currentPublicationId', 'dateLastActivity', @@ -282,11 +288,13 @@ public function mapManyToSubmissionsList( Enumerable $collection, Enumerable $userGroups, array $genres, + array $userRoles, bool|Collection $anonymizeReviews = false ): Enumerable { $this->collection = $collection; $this->userGroups = $userGroups; $this->genres = $genres; + $this->userRoles = $userRoles; $submissionIds = $collection->keys()->toArray(); $this->reviewAssignments = Repo::reviewAssignment()->getCollector()->filterBySubmissionIds($submissionIds)->getMany()->remember(); @@ -377,12 +385,28 @@ protected function mapByProperties(array $props, Submission $submission, bool|Co $reviewRounds = $this->getReviewRoundsFromSubmission($submission); $currentReviewRound = $reviewRounds->sortKeys()->last(); /** @var ReviewRound|null $currentReviewRound */ + $stages = in_array('stages', $props) ? + $this->getPropertyStages($this->stageAssignments, $submission, $this->decisions ?? null, $currentReviewRound) : + []; foreach ($props as $prop) { switch ($prop) { case '_href': $output[$prop] = Repo::submission()->getUrlApi($this->context, $submission->getId()); break; + case 'availableEditorialDecisions': + $output[$prop] = collect(Application::get()->getApplicationStages())->mapWithKeys(function (int $stageId) use ($submission) { + $availableEditorialDecisions = $this->getAvailableEditorialDecisions($stageId, $submission); + return [$stageId => array_map(fn (DecisionType $decisionType) => ['id' => $decisionType->getDecision(), 'label' => $decisionType->getLabel()], $availableEditorialDecisions)]; + })->values()->all(); + break; + case 'canCurrentUserChangeMetadata': + // Identify if current user can change metadata. Consider roles in the active stage. + $output[$prop] = !empty(array_intersect( + PKPApplication::getWorkflowTypeRoles()[PKPApplication::WORKFLOW_TYPE_EDITORIAL], + $stages[$submission->getData('stageId')]['currentUserAssignedRoles'] + )); + break; case 'editorAssigned': $output[$prop] = $this->getPropertyStageAssignments($this->stageAssignments); break; @@ -417,7 +441,7 @@ protected function mapByProperties(array $props, Submission $submission, bool|Co $output[$prop] = __(Application::get()->getWorkflowStageName($submission->getData('stageId'))); break; case 'stages': - $output[$prop] = $this->getPropertyStages($this->stageAssignments, $submission, $this->decisions ?? null, $currentReviewRound); + $output[$prop] = array_values($stages); break; case 'statusLabel': $output[$prop] = __($submission->getStatusKey()); @@ -518,41 +542,32 @@ protected function getPropertyReviewRounds(Collection $reviewRounds): array * { * `id` int stage id * `label` string translated stage name - * `queries` array [{ - * `id` int query id - * `assocType` int - * `assocId` int - * `stageId` int - * `seq` int - * `closed` bool - * }] - * `statusId` int stage status. note: on review stage, this refers to the - * status of the latest round. - * `status` string translated stage status name - * `files` array { - * `count` int number of files attached to stage. note: this only counts - * revision files. - * } - * ] + * `isActiveStage` boolean whether the stage is active + * `editorAssigned` boolean whether the editor is assigned to the submission + * `isDecidingEditorAssigned` boolean whether apart from recommend only editor, there is at least one editor without recommend only flag assigned + * `isCurrentUserDecidingEditor` boolean whether the current user is assigned as an editor without recommend only flag (and there are recommend only editors assigned) + * `currentUserAssignedRoles` array the roles of the current user in the submission per stage, user may be unassigned but have global manager role + * `currentUserCanRecommendOnly` + * `currentUserRecommendation` object includes the recommendation decision of the current user + * { + * `decision` => recommendation decision, + * `label` => decision label + * }, + * `recommendations` array shows to the deciding editor all recommendations associated with the submission in the review stages + * [ + * decisionID => + * { + * `decision` => recommendation decision, + * `label` => decision label + * } + * ] + * } + * ] */ protected function getPropertyStages(Enumerable $stageAssignments, Submission $submission, ?Enumerable $decisions, ?ReviewRound $currentReviewRound): array { $request = Application::get()->getRequest(); $currentUser = $request->getUser(); - // Replace this part with eager loaded UserGroups - $userGroupsByStageAssignments = $stageAssignments->mapWithKeys( - fn (StageAssignment $stageAssignment) => - [$stageAssignment->id => $stageAssignment->userGroupId] - ); - - $userGroups = Repo::userGroup()->getCollector() - ->filterByUserGroupIds($userGroupsByStageAssignments->toArray()) - ->getMany(); - - $userGroupsByStageAssignments = $userGroupsByStageAssignments->mapWithKeys( - fn (int $userGroupId, int $assignmentId) => - [$assignmentId => $userGroups->get($userGroupId)] - ); // Create stages and fill with predefined data $stages = []; @@ -568,23 +583,23 @@ protected function getPropertyStages(Enumerable $stageAssignments, Submission $s 'editorAssigned' => false, 'isDecidingEditorAssigned' => false, 'isCurrentUserDecidingEditor' => false, + 'currentUserAssignedRoles' => [], ]; } - $stages['unstaged']['canCurrentUserChangeMetadata'] = false; - $recommendations = []; + $isAssignedInAnyRole = false; // Determine if the current user is assigned to the submission in any role // Determine stage assignment related data foreach ($stageAssignments as $stageAssignment) { - $userGroup = $userGroupsByStageAssignments->get($stageAssignment->id); /** @var UserGroup $userGroup */ + $userGroup = $stageAssignment->userGroup; /** @var UserGroup $userGroup */ - foreach ($stageAssignment->userGroupStages as $groupStage) { + foreach ($userGroup->userGroupStages as $groupStage) { /** @var UserGroupStage $groupStage */ // Identify the first user with the editor if ( !$stages[$groupStage->stageId]['editorAssigned'] && in_array( - $userGroup->getRoleId(), + $userGroup->roleId, [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR] ) ) { @@ -595,7 +610,8 @@ protected function getPropertyStages(Enumerable $stageAssignments, Submission $s if ( !$stages[$groupStage->stageId]['isDecidingEditorAssigned'] && isset($editorAssigned) && - !$stageAssignment->recommendOnly) { + !$stageAssignment->recommendOnly + ) { $isDecidingEditorAssigned = $stages[$groupStage->stageId]['isDecidingEditorAssigned'] = true; } @@ -624,25 +640,23 @@ protected function getPropertyStages(Enumerable $stageAssignments, Submission $s continue; } - $stages[$groupStage->stageId]['currentUserAssignedRoles'] = [ - $userGroup->getRoleId(), - ]; + // Identify current user roles associated with the assignment, include global roles and roles from other assignments + if ($roleId = $this->getAssignmentRoles($stageAssignment)) { + $stages[$groupStage->stageId]['currentUserAssignedRoles'][] = $roleId; - if (isset($isDecidingEditorAssigned)) { - $stages[$groupStage->stageId]['isCurrentUserDecidingEditor'] = true; + // Check that the user is assigned in any non-revoked role + if (!$isAssignedInAnyRole) { + $isAssignedInAnyRole = true; + } } - // Check if current user can edit publication - if ( - $stages[$groupStage->stageId]['isActiveStage'] && - in_array($userGroup->getRoleId(), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]) - ) { - + if (isset($isDecidingEditorAssigned)) { + $stages[$groupStage->stageId]['isCurrentUserDecidingEditor'] = true; } // Identify if the current user gave recommendation if ( - in_array($userGroup->getRoleId(), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]) && // this user is assigned as an editor + in_array($userGroup->roleId, [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]) && // this user is assigned as an editor !isset($isDecidingEditorAssigned) && // this user only can give recommendations, isn't a deciding editor in_array($groupStage->stageId, [WORKFLOW_STAGE_ID_EXTERNAL_REVIEW, WORKFLOW_STAGE_ID_INTERNAL_REVIEW]) && isset($decisions) && $decisions->isNotEmpty() // only for submissions list @@ -676,7 +690,7 @@ protected function getPropertyStages(Enumerable $stageAssignments, Submission $s // if the user is assigned several times in the editorial role, and // one of the assignments have recommendOnly option set, consider it here if ( - in_array($userGroup->getRoleId(), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]) && + in_array($userGroup->roleId, [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]) && $stageAssignment->recommendOnly ) { $stages[$groupStage->stageId]['currentUserCanRecommendOnly'] = true; @@ -684,6 +698,16 @@ protected function getPropertyStages(Enumerable $stageAssignments, Submission $s } } + // if the current user is not assigned in any non-revoked role but has a global role as a manager or admin, consider it in the submission + if (!$isAssignedInAnyRole) { + $globalRoles = array_intersect([Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN], $this->userRoles); + if (!empty($globalRoles)) { + foreach ($stageIds as $stageId) { + $stages[$stageId]['currentUserAssignedRoles'] = $globalRoles; + } + } + } + // Set recommendation if current user is a deciding editor foreach ($stages as $stageId => $stage) { if (empty($recommendations)) { @@ -706,75 +730,23 @@ protected function getPropertyStages(Enumerable $stageAssignments, Submission $s } } - // FIXME pkp/pkp-lib#7495 Backward compatibility only, remove before 3.5 release - if (!Config::getVar('features', 'enable_new_submission_listing')) { - $openPerStage = Repo::query()->countOpenPerStage($submission->getId(), [$request->getUser()->getId()]); - foreach ($stageIds as $stageId) { - $stages[$stageId]['openQueryCount'] = $openPerStage[$stageId]; - - // Stage-specific statuses - switch ($stageId) { - case WORKFLOW_STAGE_ID_SUBMISSION: - $assignedEditors = $stages[$stageId]['editorAssigned']; - if (!$assignedEditors) { - $stages[$stageId]['statusId'] = Repo::submission()::STAGE_STATUS_SUBMISSION_UNASSIGNED; - $stages[$stageId]['status'] = __('submissions.queuedUnassigned'); - } - - // Submission stage never has revisions - $stages[$stageId]['files'] = [ - 'count' => 0, - ]; - break; - - case WORKFLOW_STAGE_ID_INTERNAL_REVIEW: - case WORKFLOW_STAGE_ID_EXTERNAL_REVIEW: - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $stageId); - if ($reviewRound) { - $stages[$stageId]['statusId'] = $reviewRound->determineStatus(); - $stages[$stageId]['status'] = __($reviewRound->getStatusKey()); - - // Revision files in this round. - $stages[$stageId]['files'] = [ - 'count' => Repo::submissionFile()->getCollector() - ->filterBySubmissionIds([$submission->getId()]) - ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION]) - ->filterByReviewRoundIds([$reviewRound->getId()]) - ->getCount() - ]; - } else { - // workaround for pkp/pkp-lib#4231, pending formal data model - $stages[$stageId]['files'] = [ - 'count' => 0 - ]; - } - break; - - // Get revision files for editing and production stages. - // Review rounds are handled separately in the review stage below. - case WORKFLOW_STAGE_ID_EDITING: - case WORKFLOW_STAGE_ID_PRODUCTION: - $fileStages = [WORKFLOW_STAGE_ID_EDITING ? SubmissionFile::SUBMISSION_FILE_COPYEDIT : SubmissionFile::SUBMISSION_FILE_PROOF]; - // Revision files in this round. - $stages[$stageId]['files'] = [ - 'count' => Repo::submissionFile()->getCollector() - ->filterBySubmissionIds([$submission->getId()]) - ->filterByFileStages($fileStages) - ->getCount() - ]; - break; - } - } - - - $availableEditorialDecisions = $this->getAvailableEditorialDecisions($stageId, $submission); - $stage['availableEditorialDecisions'] = array_map(fn (DecisionType $decisionType) => ['id' => $decisionType->getDecision(), 'label' => $decisionType->getLabel()], $availableEditorialDecisions); + return $stages; + } - $stages[] = $stage; - } + /** + * @return array Roles associated with the + */ + protected function getAssignmentRoles(StageAssignment $stageAssignment): ?int + { + $userGroup = $stageAssignment->userGroup; + $userUserGroup = $userGroup->userUserGroups->first( + fn (UserUserGroup $userUserGroup) => + $userUserGroup->userId === $stageAssignment->userId && // Check if user is associated with stage assignment + (!$userUserGroup->dateEnd || $userUserGroup->dateEnd->gt(now())) && + (!$userUserGroup->dateStart || $userUserGroup->dateStart->lte(now())) + ); - return array_values($stages); + return $userUserGroup ? $userGroup->roleId : null; } /** @@ -809,7 +781,7 @@ protected function getUserGroup(int $userGroupId): ?UserGroup protected function getStageAssignmentsBySubmissions(Enumerable $submissions, array $roleIds = []): LazyCollection { $submissionIds = $submissions->map(fn (Submission $submission) => $submission->getId())->toArray(); - $stageAssignments = StageAssignment::with(['userGroupStages']) + $stageAssignments = StageAssignment::with(['userGroup.userUserGroups', 'userGroup.userGroupStages']) ->withSubmissionIds($submissionIds) ->withRoleIds(empty($roleIds) ? null : $roleIds) ->lazy(); diff --git a/classes/userGroup/relationships/UserUserGroup.php b/classes/userGroup/relationships/UserUserGroup.php index e54e2f7f11a..32c01d548c7 100644 --- a/classes/userGroup/relationships/UserUserGroup.php +++ b/classes/userGroup/relationships/UserUserGroup.php @@ -15,10 +15,10 @@ namespace PKP\userGroup\relationships; use APP\facades\Repo; +use Eloquence\Behaviours\HasCamelCasing; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Eloquence\Behaviours\HasCamelCasing; use PKP\core\Core; use PKP\userGroup\UserGroup; @@ -30,6 +30,10 @@ class UserUserGroup extends \Illuminate\Database\Eloquent\Model public $incrementing = false; protected $primaryKey = null; protected $fillable = ['userGroupId', 'userId', 'dateStart', 'dateEnd', 'masthead']; + protected $casts = [ + 'dateStart' => 'datetime', + 'dateEnd' => 'datetime', + ]; public function user(): Attribute { diff --git a/schemas/submission.json b/schemas/submission.json index 74b7757f646..de5c3e6ace1 100644 --- a/schemas/submission.json +++ b/schemas/submission.json @@ -11,6 +11,16 @@ "readOnly": true, "apiSummary": true }, + "availableEditorialDecisions": { + "type": "array", + "description": "The list of decision types per stage", + "readOnly": true + }, + "canCurrentUserChangeMetadata": { + "type": "boolean", + "description": "Whether the current user has access to the publication metadata", + "readOnly": true + }, "commentsForTheEditors": { "type": "string", "description": "Optional. Comments from the submitting author to the editors. This is only available for submissions that have not yet been submitted. Once submitted, the message is converted to a discussion and you can not read or write to this property.", @@ -284,11 +294,6 @@ } } }, - "stagesAccess": { - "type": "array", - "description": "Key data about the status, files and discussions of each stage.", - "readOnly": true - }, "status": { "type": "integer", "description": "Whether the submission is Published, Declined, Scheduled or Queued (still in the workflow). One of the `PKPSubmission::STATUS_*` constants. Default is `PKPSubmission::STATUS_QUEUED`.",