From 58ca2df83f308e8acf8cddfbae68c3cf6abdd316 Mon Sep 17 00:00:00 2001 From: Marcus Eibrink-Lunzenauer Date: Wed, 15 Jan 2025 09:08:37 +0000 Subject: Integration von Peer-Review in Courseware Closes #2484 Merge request studip/studip!3196 --- app/controllers/course/courseware.php | 12 +- db/migrations/6.0.41_add_peer_review_tables.php | 56 ++++ lib/classes/JsonApi/RouteMap.php | 17 + .../JsonApi/Routes/Courseware/Authority.php | 128 +++++++- .../Courseware/PeerReview/ProcessesCreate.php | 124 +++++++ .../Courseware/PeerReview/ProcessesDelete.php | 38 +++ .../Courseware/PeerReview/ProcessesIndex.php | 108 +++++++ .../Routes/Courseware/PeerReview/ProcessesShow.php | 47 +++ .../Courseware/PeerReview/ProcessesUpdate.php | 121 +++++++ .../Courseware/PeerReview/ReviewsByTaskIndex.php | 76 +++++ .../Routes/Courseware/PeerReview/ReviewsCreate.php | 180 +++++++++++ .../Routes/Courseware/PeerReview/ReviewsDelete.php | 38 +++ .../Routes/Courseware/PeerReview/ReviewsIndex.php | 77 +++++ .../PeerReview/ReviewsOfProcessesIndex.php | 74 +++++ .../Routes/Courseware/PeerReview/ReviewsShow.php | 50 +++ .../Routes/Courseware/PeerReview/ReviewsUpdate.php | 78 +++++ .../JsonApi/Routes/Courseware/TaskGroupsShow.php | 1 + .../JsonApi/Routes/Courseware/TasksIndex.php | 1 + .../JsonApi/Routes/Courseware/TasksShow.php | 4 + lib/classes/JsonApi/SchemaMap.php | 2 + .../JsonApi/Schemas/Courseware/PeerReview.php | 101 ++++++ .../Schemas/Courseware/PeerReviewProcess.php | 77 +++++ lib/classes/JsonApi/Schemas/Courseware/Task.php | 58 +++- .../JsonApi/Schemas/Courseware/TaskGroup.php | 31 +- lib/models/Courseware/PeerReview.php | 93 ++++++ lib/models/Courseware/PeerReviewProcess.php | 188 +++++++++++ lib/models/Courseware/StructuralElement.php | 4 +- lib/models/Courseware/Task.php | 75 ++++- lib/models/Courseware/TaskGroup.php | 31 ++ resources/assets/stylesheets/scss/wizard.scss | 2 +- resources/vue/components/ConsultationCreator.vue | 4 +- resources/vue/components/StudipActionMenu.vue | 2 + resources/vue/components/StudipArticle.vue | 62 ++++ resources/vue/components/StudipContentBox.vue | 46 +++ resources/vue/components/StudipUserAvatar.vue | 38 +++ .../blocks/CoursewareTableOfContentsBlock.vue | 24 +- .../CoursewareStructuralElement.vue | 136 +++++++- .../structural-element/CoursewareTreeItem.vue | 13 +- .../tasks/CoursewareDashboardStudents.vue | 37 ++- .../courseware/tasks/CoursewareDashboardTasks.vue | 355 +-------------------- .../tasks/CoursewareDashboardTasksList.vue | 351 ++++++++++++++++++++ .../tasks/CoursewareTasksDialogDistribute.vue | 10 +- .../courseware/tasks/PagesTaskGroupsShow.vue | 43 ++- .../components/courseware/tasks/RenewalDialog.vue | 5 +- .../vue/components/courseware/tasks/TaskGroup.vue | 19 +- .../tasks/TaskGroupPeerReviewProcesses.vue | 158 +++++++++ .../tasks/TaskGroupsModifyDeadlineDialog.vue | 5 +- .../tasks/peer-review/AssessmentDialog.vue | 115 +++++++ .../tasks/peer-review/AssessmentTypeEditor.vue | 65 ++++ .../peer-review/AssessmentTypeEditorDialog.vue | 84 +++++ .../courseware/tasks/peer-review/PairingEditor.vue | 200 ++++++++++++ .../tasks/peer-review/PairingEditorDialog.vue | 102 ++++++ .../tasks/peer-review/PeerReviewList.vue | 66 ++++ .../tasks/peer-review/PeerReviewListItem.vue | 134 ++++++++ .../tasks/peer-review/ProcessConfiguration.vue | 39 +++ .../tasks/peer-review/ProcessCreateDialog.vue | 131 ++++++++ .../tasks/peer-review/ProcessCreateForm.vue | 319 ++++++++++++++++++ .../courseware/tasks/peer-review/ProcessDetail.vue | 217 +++++++++++++ .../tasks/peer-review/ProcessDurationDialog.vue | 116 +++++++ .../tasks/peer-review/ProcessEditDialog.vue | 64 ++++ .../courseware/tasks/peer-review/ProcessStatus.vue | 47 +++ .../courseware/tasks/peer-review/ProcessesList.vue | 174 ++++++++++ .../courseware/tasks/peer-review/ResultDialog.vue | 71 +++++ .../assessment-types/editors/EditorForm.vue | 149 +++++++++ .../assessment-types/editors/EditorTable.vue | 138 ++++++++ .../assessment-types/forms/AssessmentTypeForm.vue | 70 ++++ .../forms/AssessmentTypeFreetext.vue | 64 ++++ .../assessment-types/forms/AssessmentTypeTable.vue | 114 +++++++ .../peer-review/assessment-types/results/Form.vue | 54 ++++ .../assessment-types/results/Freetext.vue | 42 +++ .../peer-review/assessment-types/results/Table.vue | 71 +++++ .../courseware/tasks/peer-review/definitions.ts | 57 ++++ .../tasks/peer-review/process-configuration.ts | 129 ++++++++ .../widgets/CoursewareTasksActionWidget.vue | 8 +- resources/vue/components/forms/LabelRequired.vue | 22 ++ .../store/courseware/courseware-tasks.module.js | 92 +++++- .../vue/store/courseware/courseware.module.js | 2 +- webpack.common.js | 3 +- 78 files changed, 5722 insertions(+), 437 deletions(-) create mode 100644 db/migrations/6.0.41_add_peer_review_tables.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php create mode 100644 lib/classes/JsonApi/Schemas/Courseware/PeerReview.php create mode 100644 lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php create mode 100644 lib/models/Courseware/PeerReview.php create mode 100644 lib/models/Courseware/PeerReviewProcess.php create mode 100644 resources/vue/components/StudipArticle.vue create mode 100644 resources/vue/components/StudipContentBox.vue create mode 100644 resources/vue/components/StudipUserAvatar.vue create mode 100644 resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue create mode 100644 resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue create mode 100644 resources/vue/components/courseware/tasks/peer-review/definitions.ts create mode 100644 resources/vue/components/courseware/tasks/peer-review/process-configuration.ts create mode 100644 resources/vue/components/forms/LabelRequired.vue diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 8ecff0a..ef7c38d 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -98,8 +98,16 @@ class Course_CoursewareController extends CoursewareController Context::getId(), $GLOBALS['user']->id ); - Navigation::activateItem('course/courseware/tasks'); - PageLayout::setTitle(_('Courseware: Aufgaben')); + switch ($route) { + case 'peer-review-processes': + Navigation::activateItem('course/courseware/peer-review'); + PageLayout::setTitle(_('Courseware: Peer-Review-Prozesse')); + break; + default: + Navigation::activateItem('course/courseware/tasks'); + PageLayout::setTitle(_('Courseware: Aufgaben')); + break; + } $this->setTasksSidebar(); } diff --git a/db/migrations/6.0.41_add_peer_review_tables.php b/db/migrations/6.0.41_add_peer_review_tables.php new file mode 100644 index 0000000..893d59a --- /dev/null +++ b/db/migrations/6.0.41_add_peer_review_tables.php @@ -0,0 +1,56 @@ +exec( + "CREATE TABLE `cw_peer_review_processes`( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `task_group_id` INT(11) NOT NULL, + `owner_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `configuration` MEDIUMTEXT NOT NULL, + `review_start` INT(11) UNSIGNED NOT NULL, + `review_end` INT(11) UNSIGNED NOT NULL, + `paired_at` INT(11) UNSIGNED NULL, + `mkdate` INT(11) UNSIGNED NOT NULL, + `chdate` INT(11) UNSIGNED NOT NULL, + PRIMARY KEY(`id`), + INDEX index_task_group_id(`task_group_id`), + INDEX index_owner_id(`owner_id`) + )" + ); + + $db->exec( + "CREATE TABLE `cw_peer_reviews`( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `process_id` INT(11) UNSIGNED NOT NULL, + `task_id` INT(11) UNSIGNED NOT NULL, + `submitter_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `reviewer_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `reviewer_type` ENUM('autor', 'group') COLLATE latin1_bin, + `assessment` TEXT, + `mkdate` INT(11) UNSIGNED NOT NULL, + `chdate` INT(11) UNSIGNED NOT NULL, + PRIMARY KEY(`id`), + INDEX index_process_id(`process_id`), + INDEX index_task_id(`task_id`), + INDEX index_submitter_id(`submitter_id`), + INDEX index_reviewer_id(`reviewer_id`) + )" + ); + } + + public function down() + { + $db = \DBManager::get(); + $db->exec('DROP TABLE IF EXISTS `cw_peer_reviews`'); + $db->exec('DROP TABLE IF EXISTS `cw_peer_review_processes`'); + } +} diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 3870d9a..6b2f429 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -594,6 +594,23 @@ class RouteMap $group->delete('/courseware-clipboards/{id}', Routes\Courseware\ClipboardsDelete::class); $group->post('/courseware-clipboards/{id}/insert', Routes\Courseware\ClipboardsInsert::class); + + $group->get('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesIndex::class); + $group->get('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesShow::class); + $group->get('/courseware-peer-review-processes/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsOfProcessesIndex::class); + + $group->patch('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesUpdate::class); + $group->delete('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesDelete::class); + + $group->post('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesCreate::class); + + $group->get('/courses/{id}/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsIndex::class); + $group->get('/courseware-tasks/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsByTaskIndex::class); + + $group->get('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsShow::class); + $group->post('/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsCreate::class); + $group->patch('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsUpdate::class); + $group->delete('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsDelete::class); } private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 7ed609f..87bda5e 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -8,6 +8,8 @@ use Courseware\BlockFeedback; use Courseware\Clipboard; use Courseware\Container; use Courseware\Instance; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; use Courseware\StructuralElement; use Courseware\StructuralElementComment; use Courseware\StructuralElementFeedback; @@ -324,12 +326,31 @@ class Authority public static function canShowTask(User $user, Task $resource): bool { - return self::canUpdateTask($user, $resource) || $resource->visible; + // TODO (mel): Das beißt sich hier ein wenig und muß mit Nico besprochen werden. Peer Review vs. visible + return ($resource->isPeerReviewed() && $resource->isPeerReviewedBy($user)) + || self::canUpdateTask($user, $resource) + || $resource->visible; + } + + public static function canShowTaskSolver(User $user, Task $resource): bool + { + if (self::canUpdateTask($user, $resource)) { + return true; + } + + if ($resource->userIsAPeerReviewer($user)) { + return array_reduce( + $resource->getPeerReviewProcessessWithReviewsBy($user), + fn($memo, $process) => $memo || !$process->isAnonymous(), + false + ); + } + + return false; } public static function canIndexTasks(User $user): bool { - // TODO: filtered index permissions are handled in the route return $GLOBALS['perm']->have_perm('root', $user->id); } @@ -584,4 +605,107 @@ class Authority return $resource->user_id === $user->id; } + public static function canIndexPeerReviewProcesses(User $user): bool + { + return (bool) $user; + } + + public static function canShowPeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return $GLOBALS['perm']->have_studip_perm('user', $process->task_group['seminar_id'], $user->getId()); + } + + public static function canCreatePeerReviewProcesses(User $user, TaskGroup $taskGroup): bool + { + return $GLOBALS['perm']->have_studip_perm('tutor', $taskGroup['seminar_id'], $user->getId()); + } + + public static function canUpdatePeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canDeletePeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canIndexPeerReviews(User $user) + { + return (bool) $user; + } + + public static function canShowPeerReview(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + return $review->isReviewer($user) || $review->isSubmitter($user); + } + + public static function canShowPeerReviewReviewer(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + if ($review->isReviewer($user)) { + return true; + } + + return $review->isSubmitter($user) && !$review->isAnonymous(); + } + + public static function canShowPeerReviewSubmitter(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + if ($review->isSubmitter($user)) { + return true; + } + + return $review->isReviewer($user) && !$review->isAnonymous(); + } + + public static function canShowPeerReviewAssessment(User $user, PeerReview $review): bool + { + if ($review->isReviewer($user)) { + return true; + } + + $isTutor = $GLOBALS['perm']->have_studip_perm( + 'tutor', + $review->process->task_group['seminar_id'], + $user->getId() + ); + + return ($isTutor || $review->isSubmitter($user)) + && $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER; + } + + public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool + { + return self::canShowPeerReviewProcess($user, $process); + } + + public static function canUpdatePeerReview(User $user, PeerReview $review): bool + { + return $review->process->getCurrentState() === PeerReviewProcess::STATE_ACTIVE && $review->isReviewer($user); + } + + public static function canCreatePeerReviews(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canDeletePeerReview(User $user, PeerReview $review): bool + { + return self::canCreatePeerReviews($user, $review->process); + } } diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php new file mode 100644 index 0000000..4f8099b --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php @@ -0,0 +1,124 @@ +validate($request); + $taskGroup = $this->getTaskGroupFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreatePeerReviewProcesses($user, $taskGroup)) { + throw new AuthorizationFailedException(); + } + + $process = $this->create($user, $json); + + return $this->getCreatedResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + if (!self::arrayHas($json, 'data.attributes.configuration')) { + return 'Missing `configuration` attribute.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-start')) { + return 'Missing `review-start` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.review-start'); + if (!self::isValidTimestamp($startDate)) { + return '`review-start` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-end')) { + return 'Missing `review-end` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.review-end'); + if (!self::isValidTimestamp($endDate)) { + return '`review-end` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.relationships.task-group')) { + return 'Missing `task-group` relationship.'; + } + if (!$this->getTaskGroupFromJson($json)) { + return 'Invalid `task-group` relationship.'; + } + } + + private function getTaskGroupFromJson(array $json): ?TaskGroup + { + if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id'); + + return TaskGroup::find($resourceId); + } + + private function create(\User $user, array $json): PeerReviewProcess + { + $taskGroup = $this->getTaskGroupFromJson($json); + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end')); + $configuration = self::arrayGet($json, 'data.attributes.configuration'); + + $process = PeerReviewProcess::create([ + 'task_group_id' => $taskGroup->getId(), + 'owner_id' => $user->getId(), + 'configuration' => $configuration, + 'review_start' => $startDate->getTimestamp(), + 'review_end' => $endDate->getTimestamp(), + ]); + + return $process; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php new file mode 100644 index 0000000..fc19e8b --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php @@ -0,0 +1,38 @@ +getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php new file mode 100644 index 0000000..d45bc23 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php @@ -0,0 +1,108 @@ +getUser($request); + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + $this->validateFilters($filtering); + $this->authorize($user, $filtering); + + $resources = empty($filtering) ? $this->findAllProcesses($user) : $this->filterProcesses($user, $filtering); + + return $this->getPaginatedContentResponse( + array_slice($resources, ...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws BadRequestException + */ + private function validateFilters(array $filtering): void + { + if (isset($filtering['cid']) && !Course::exists($filtering['cid'])) { + throw new BadRequestException('Could not find a course matching this `filter[cid]`.'); + } + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user, array $filtering): void + { + if (!Authority::canIndexPeerReviewProcesses($user)) { + throw new AuthorizationFailedException(); + } + + if (isset($filtering['cid'])) { + if ( + !CoursesAuthority::canShowCourse( + $user, + Course::find($filtering['cid']), + CoursesAuthority::SCOPE_EXTENDED + ) + ) { + throw new AuthorizationFailedException(); + } + } + } + + private function findAllProcesses(User $user): array + { + return PeerReviewProcess::findByUser($user); + } + + private function filterProcesses(User $user, array $filtering): array + { + if (isset($filtering['cid'])) { + /** @var ?\Course $course */ + $course = \Course::find($filtering['cid']); + + return array_filter(PeerReviewProcess::findByCourse($course), function ($process) use ($user) { + return Authority::canShowPeerReviewProcess($user, $process); + }); + } + + return []; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php new file mode 100644 index 0000000..7579fcd --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php @@ -0,0 +1,47 @@ +getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php new file mode 100644 index 0000000..d5b6fb5 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php @@ -0,0 +1,121 @@ +validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdatePeerReviewProcess($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $process = $this->update($user, $resource, $json); + + return $this->getContentResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.configuration')) { + return 'Missing `configuration` attribute.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-start')) { + return 'Missing `review-start` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.review-start'); + if (!self::isValidTimestamp($startDate)) { + return '`review-start` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-end')) { + return 'Missing `review-end` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.review-end'); + if (!self::isValidTimestamp($endDate)) { + return '`review-end` is not an ISO 8601 timestamp.'; + } + + if (self::arrayHas($json, 'data.relationships.task-group')) { + if (!$this->getTaskGroupFromJson($json)) { + return 'Invalid `task-group` relationship.'; + } + } + } + + private function getTaskGroupFromJson(array $json): ?TaskGroup + { + if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id'); + + return TaskGroup::find($resourceId); + } + + private function update(User $user, PeerReviewProcess $process, array $json): PeerReviewProcess + { + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end')); + $configuration = self::arrayGet($json, 'data.attributes.configuration'); + + $process->review_start = $startDate->getTimestamp(); + $process->review_end = $endDate->getTimestamp(); + $process->configuration = $configuration; + + $process->store(); + + return $process; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php new file mode 100644 index 0000000..d03deb3 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php @@ -0,0 +1,76 @@ +getUser($request); + $this->authorize($user); + + $resources = $this->findPeerReviews($task, $user); + + return $this->getPaginatedContentResponse( + $resources->limit(...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user): void + { + if (!Authority::canIndexPeerReviews($user)) { + throw new AuthorizationFailedException(); + } + } + + private function findPeerReviews(Task $task, User $user): \SimpleCollection + { + return $task->peer_reviews->filter(function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php new file mode 100644 index 0000000..414f2b4 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php @@ -0,0 +1,180 @@ +validate($request); + $process = $this->getProcessFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreatePeerReviews($user, $process)) { + throw new AuthorizationFailedException(); + } + + $resource = $this->create($json); + + return $this->getCreatedResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + // process + if (!self::arrayHas($json, 'data.relationships.process')) { + return 'Missing `process` relationship.'; + } + if (!$this->getProcessFromJson($json)) { + return 'Invalid `process` relationship.'; + } + + // submitter + if (!self::arrayHas($json, 'data.relationships.submitter')) { + return 'Missing `submitter` relationship.'; + } + if (!$this->getSubmitterFromJson($json)) { + return 'Invalid `submitter` relationship.'; + } + + // reviewer + if (!self::arrayHas($json, 'data.relationships.reviewer')) { + return 'Missing `reviewer` relationship.'; + } + if (!$this->getReviewerFromJson($json)) { + return 'Invalid `reviewer` relationship.'; + } + } + + private function create(array $json): PeerReview + { + $process = $this->getProcessFromJson($json); + $reviewer = $this->getReviewerFromJson($json); + $submitter = $this->getSubmitterFromJson($json); + + $task = $process['task_group']->findTaskBySolver($submitter); + $reviewerType = $this->getReviewerType($reviewer); + + $review = PeerReview::create([ + 'process_id' => $process->id, + 'task_id' => $task->id, + 'submitter_id' => $submitter->id, + 'reviewer_id' => $reviewer->id, + 'reviewer_type' => $reviewerType, + ]); + + return $review; + } + + /** + * @return User|Statusgruppen|null + */ + private function getActorFromJson(array $json, string $relation) + { + $relationship = 'data.relationships.' . $relation; + if ( + !( + $this->validateResourceObject($json, $relationship, UserSchema::TYPE) + || $this->validateResourceObject($json, $relationship, StatusGroupSchema::TYPE) + ) + ) { + return null; + } + $resourceId = self::arrayGet($json, $relationship . '.data.id'); + + switch (self::arrayGet($json, $relationship . '.data.type')) { + case UserSchema::TYPE: + return User::find($resourceId); + case StatusGroupSchema::TYPE: + return Statusgruppen::find($resourceId); + } + + throw new InvalidArgumentException(); + } + + private function getProcessFromJson(array $json): ?PeerReviewProcess + { + if (!$this->validateResourceObject($json, 'data.relationships.process', PeerReviewProcessSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.process.data.id'); + + return PeerReviewProcess::find($resourceId); + } + + /** + * @return User|Statusgruppen|null + */ + private function getReviewerFromJson(array $json) + { + return $this->getActorFromJson($json, 'reviewer'); + } + + private function getReviewerType($reviewer): string + { + if ($reviewer instanceof User) { + return 'autor'; + } + if ($reviewer instanceof Statusgruppen) { + return 'group'; + } + + throw new InvalidArgumentException(); + } + + /** + * @return User|Statusgruppen|null + */ + private function getSubmitterFromJson(array $json) + { + return $this->getActorFromJson($json, 'submitter'); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php new file mode 100644 index 0000000..bf0a6c6 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php @@ -0,0 +1,38 @@ +getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php new file mode 100644 index 0000000..92d77ce --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php @@ -0,0 +1,77 @@ +getUser($request); + $this->authorize($user); + + $resources = $this->findPeerReviews($course, $user); + + return $this->getPaginatedContentResponse( + array_slice($resources, ...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user): void + { + if (!Authority::canIndexPeerReviews($user)) { + throw new AuthorizationFailedException(); + } + } + + private function findPeerReviews(Course $course, User $user): array + { + return array_filter(PeerReview::findByCourse($course), function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php new file mode 100644 index 0000000..c67e1a5 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php @@ -0,0 +1,74 @@ +getUser($request); + $this->authorize($user, $process); + + $resources = $this->findReviews($user, $process); + + return $this->getPaginatedContentResponse( + $resources->limit(...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user, PeerReviewProcess $process): void + { + if (!Authority::canIndexReviewsOfProcesses($user, $process)) { + throw new AuthorizationFailedException(); + } + } + + private function findReviews(User $user, PeerReviewProcess $process): \SimpleCollection + { + return $process->peer_reviews->filter(function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php new file mode 100644 index 0000000..83a6cb0 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php @@ -0,0 +1,50 @@ +getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php new file mode 100644 index 0000000..65a2108 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php @@ -0,0 +1,78 @@ +validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdatePeerReview($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $review = $this->update($resource, $json); + + return $this->getContentResponse($review); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.assessment')) { + return 'Missing `assessment` attribute.'; + } + } + + private function update(PeerReview $review, array $json): PeerReview + { + $review->assessment = self::arrayGet($json, 'data.attributes.assessment'); + $review->store(); + + return $review; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php index c8ebb86..ff3fba4 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php @@ -18,6 +18,7 @@ class TaskGroupsShow extends JsonApiController protected $allowedIncludePaths = [ TaskGroupSchema::REL_COURSE, TaskGroupSchema::REL_LECTURER, + TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, TaskGroupSchema::REL_SOLVERS, TaskGroupSchema::REL_TARGET, TaskGroupSchema::REL_TASK_TEMPLATE, diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php index 26a021c..9952437 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php @@ -25,6 +25,7 @@ class TasksIndex extends JsonApiController TaskSchema::REL_STRUCTURAL_ELEMENT, TaskSchema::REL_TASK_GROUP, TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, ]; /** diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php index 619e7ea..419f950 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php @@ -5,6 +5,7 @@ namespace JsonApi\Routes\Courseware; use Courseware\Task; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; use JsonApi\Schemas\Courseware\Task as TaskSchema; use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; use JsonApi\JsonApiController; @@ -18,10 +19,13 @@ class TasksShow extends JsonApiController { protected $allowedIncludePaths = [ TaskSchema::REL_FEEDBACK, + TaskSchema::REL_PEER_REVIEWS, + TaskSchema::REL_PEER_REVIEWS . '.' . PeerReviewSchema::REL_PROCESS, TaskSchema::REL_SOLVER, TaskSchema::REL_STRUCTURAL_ELEMENT, TaskSchema::REL_TASK_GROUP, TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, ]; /** diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index 801bf29..b3c3179 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -81,6 +81,8 @@ class SchemaMap \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class, \Courseware\Container::class => Schemas\Courseware\Container::class, \Courseware\Instance::class => Schemas\Courseware\Instance::class, + \Courseware\PeerReview::class => Schemas\Courseware\PeerReview::class, + \Courseware\PeerReviewProcess::class => Schemas\Courseware\PeerReviewProcess::class, \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class, \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class, diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php new file mode 100644 index 0000000..2096d32 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php @@ -0,0 +1,101 @@ +id; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + $user = $this->currentUser; + $assessment = null; + if ($resource->assessment && Authority::canShowPeerReviewAssessment($user, $resource)) { + $assessment = $resource->assessment->getIterator(); + } + return [ + 'assessment' => $assessment, + 'is-reviewer' => $resource->isReviewer($user), + 'is-submitter' => $resource->isSubmitter($user), + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_PROCESS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->process), + ], + self::RELATIONSHIP_DATA => $resource->process, + ]; + + $user = $this->currentUser; + + if (Authority::canShowPeerReviewReviewer($user, $resource)) { + $reviewer = $resource->getReviewer(); + $relationships[self::REL_REVIEWER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($reviewer), + ], + self::RELATIONSHIP_DATA => $reviewer, + ]; + } else { + $relationships[self::REL_REVIEWER] = [ + self::RELATIONSHIP_DATA => null, + ]; + } + + if (Authority::canShowPeerReviewSubmitter($user, $resource)) { + $submitter = $resource->getSubmitter(); + $relationships[self::REL_SUBMITTER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($submitter), + ], + self::RELATIONSHIP_DATA => $submitter, + ]; + } else { + $relationships[self::REL_SUBMITTER] = [ + self::RELATIONSHIP_DATA => null, + ]; + } + + $relationships[self::REL_TASK] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task), + ], + self::RELATIONSHIP_DATA => $resource->task, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000..0eca67c --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php @@ -0,0 +1,77 @@ +id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'configuration' => $resource['configuration']->getIterator(), + 'review-start' => date('c', $resource['review_start']), + 'review-end' => date('c', $resource['review_end']), + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $course = $resource->getCourse(); + $relationships[self::REL_COURSE] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($course), + ], + self::RELATIONSHIP_DATA => $course, + ]; + + $relationships[self::REL_OWNER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->owner), + ], + self::RELATIONSHIP_DATA => $resource->owner, + ]; + + $relationships[self::REL_PEER_REVIEWS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS), + ], + ]; + + $relationships[self::REL_TASK_GROUP] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task_group), + ], + self::RELATIONSHIP_DATA => $resource->task_group, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php index c612333..1793e62 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Task.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php @@ -8,11 +8,15 @@ use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Link; +/** + * @SuppressWarnings(PHPMD.StaticAccess) + */ class Task extends SchemaProvider { const TYPE = 'courseware-tasks'; const REL_FEEDBACK = 'task-feedback'; + const REL_PEER_REVIEWS = 'peer-reviews'; const REL_SOLVER = 'solver'; const REL_STRUCTURAL_ELEMENT = 'structural-element'; const REL_TASK_GROUP = 'task-group'; @@ -30,6 +34,8 @@ class Task extends SchemaProvider */ public function getAttributes($resource, ContextInterface $context): iterable { + $user = $this->currentUser; + return [ 'progress' => (float) $resource->getTaskProgress(), 'submission-date' => date('c', $resource['submission_date']), @@ -37,6 +43,8 @@ class Task extends SchemaProvider 'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'], 'renewal-date' => date('c', $resource['renewal_date']), 'visible' => (bool) $resource['visible'], + 'can-peer-review' => $resource->userIsAPeerReviewer($user), + 'can-solve' => $resource->userIsASolver($user), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), ]; @@ -59,15 +67,28 @@ class Task extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; - $solver = $resource->getSolver(); - $relationships[self::REL_SOLVER] = $solver - ? [ - self::RELATIONSHIP_LINKS => [ - Link::RELATED => $this->createLinkToResource($solver), - ], - self::RELATIONSHIP_DATA => $solver, - ] - : [self::RELATIONSHIP_DATA => null]; + $relationships = $this->addPeerReviews( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_PEER_REVIEWS) + ); + + $user = $this->currentUser; + + if (CoursewareAuthority::canShowTaskSolver($user, $resource)) { + $relationships[self::REL_SOLVER] = $resource['solver_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->solver), + ], + self::RELATIONSHIP_DATA => $resource->solver, + ] + : [self::RELATIONSHIP_DATA => null]; + } else { + $relationships[self::REL_SOLVER] = [ + self::RELATIONSHIP_DATA => null, + ]; + } $relationships[self::REL_STRUCTURAL_ELEMENT] = $resource['structural_element_id'] ? [ @@ -87,4 +108,23 @@ class Task extends SchemaProvider return $relationships; } + + private function addPeerReviews(array $relationships, TaskModel $resource, bool $includeData): array + { + $relationships[self::REL_PEER_REVIEWS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS), + ], + ]; + + if ($includeData) { + $relationships[self::REL_PEER_REVIEWS][self::RELATIONSHIP_DATA] = $resource->isPeerReviewed() + ? $resource->peer_reviews->filter( + fn($review) => CoursewareAuthority::canShowPeerReview($this->currentUser, $review) + ) + : []; + } + + return $relationships; + } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php index c950671..97d7628 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php +++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php @@ -4,17 +4,22 @@ namespace JsonApi\Schemas\Courseware; use Courseware\StructuralElement; use Courseware\TaskGroup as TaskGroupModel; +use JsonApi\Routes\Courseware\Authority as CoursewareAuthority; use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Identifier; use Neomerx\JsonApi\Schema\Link; +/** + * @SuppressWarnings(PHPMD.StaticAccess) + */ class TaskGroup extends SchemaProvider { const TYPE = 'courseware-task-groups'; const REL_COURSE = 'course'; const REL_LECTURER = 'lecturer'; + const REL_PEER_REVIEW_PROCESSES = 'peer-review-processes'; const REL_SOLVERS = 'solvers'; const REL_TARGET = 'target'; const REL_TASK_TEMPLATE = 'task-template'; @@ -68,8 +73,14 @@ class TaskGroup extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; + $relationships = $this->addPeerReviewProcessesRelationship($relationships, $resource, $context); + + $user = $this->currentUser; $relationships[self::REL_SOLVERS] = [ - self::RELATIONSHIP_DATA => $resource->getSolvers(), + self::RELATIONSHIP_DATA => + $resource->tasks->filter( + fn($task) => CoursewareAuthority::canShowTaskSolver($user, $task) + )->map(fn ($task) => $task->solver), ]; $target = StructuralElement::build(['id' => $resource['target_id']]); @@ -104,4 +115,22 @@ class TaskGroup extends SchemaProvider return $relationships; } + + private function addPeerReviewProcessesRelationship( + iterable $relationships, + TaskGroupModel $resource, + ContextInterface $context + ): iterable { + $relationships[self::REL_PEER_REVIEW_PROCESSES] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEW_PROCESSES), + ], + ]; + + if ($this->shouldInclude($context, self::REL_PEER_REVIEW_PROCESSES)) { + $relationships[self::REL_PEER_REVIEW_PROCESSES][self::RELATIONSHIP_DATA] = $resource->peer_review_processes; + } + + return $relationships; + } } diff --git a/lib/models/Courseware/PeerReview.php b/lib/models/Courseware/PeerReview.php new file mode 100644 index 0000000..0a62527 --- /dev/null +++ b/lib/models/Courseware/PeerReview.php @@ -0,0 +1,93 @@ + PeerReviewProcess::class, + 'foreign_key' => 'process_id', + ]; + $config['belongs_to']['task'] = [ + 'class_name' => Task::class, + 'foreign_key' => 'task_id', + ]; + $config['belongs_to']['submitter'] = [ + 'class_name' => User::class, + 'foreign_key' => 'submitter_id', + ]; + $config['belongs_to']['reviewer'] = [ + 'class_name' => User::class, + 'foreign_key' => 'reviewer_id', + ]; + + parent::configure($config); + } + + public static function findByCourse(Course $course): iterable + { + $collections = []; + foreach (PeerReviewProcess::findByCourse($course) as $process) { + $collections[] = $process->getPeerReviews()->getArrayCopy(); + } + + return array_flatten($collections); + } + + public function getCourse(): Course + { + return $this->process->getCourse(); + } + + public function isAnonymous(): bool + { + return $this->process->isAnonymous(); + } + + public function isReviewer(User $user): bool + { + return match($this->reviewer_type) { + 'autor' => $this->reviewer_id === $user->id, + 'group' => \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId()), + }; + } + + public function getReviewer(): User|Statusgruppen + { + return match($this->reviewer_type) { + 'autor' => User::find($this->reviewer_id), + 'group' => Statusgruppen::find($this->reviewer_id), + }; + } + + public function isSubmitter(User $user): bool + { + return match (get_class($this->getSubmitter())) { + Statusgruppen::class => \Statusgruppen::isMemberOf($this->submitter_id, $user->id), + User::class => $this->submitter_id === $user->id + }; + } + + public function getSubmitter(): User|Statusgruppen + { + return User::find($this->submitter_id) + ?? Statusgruppen::find($this->submitter_id); + } +} diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000..ae92698 --- /dev/null +++ b/lib/models/Courseware/PeerReviewProcess.php @@ -0,0 +1,188 @@ + TaskGroup::class, + 'foreign_key' => 'task_group_id', + ]; + $config['belongs_to']['owner'] = [ + 'class_name' => User::class, + 'foreign_key' => 'owner_id', + ]; + + $config['additional_fields']['peer_reviews'] = [ + 'get' => 'getPeerReviews', + 'set' => false, + ]; + + $config['has_many']['_peer_reviews'] = [ + 'class_name' => PeerReview::class, + 'assoc_foreign_key' => 'process_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + parent::configure($config); + } + + public static function findByCourse(Course $course): iterable + { + return self::findBySQL('task_group_id IN (?) ORDER BY mkdate', [ + DBManager::get()->fetchFirst('SELECT id FROM `cw_task_groups` WHERE seminar_id = ?', [$course->getId()]), + ]); + } + + public static function findByUser(User $user): iterable + { + return self::findMany( + DBManager::get()->fetchFirst( + 'SELECT id FROM cw_peer_review_processes + WHERE task_group_id IN ( + SELECT id FROM cw_task_groups + WHERE cw_task_groups.seminar_id IN ( + SELECT seminar_id FROM seminar_user WHERE user_id = ?))', + [$user->getId()] + ) + ); + } + + public function getCourse(): Course + { + return $this->task_group->course; + } + + public function getPeerReviews(): SimpleORMapCollection + { + $this->checkAutomaticPairing(); + + return SimpleORMapCollection::createFromArray( + PeerReview::findBySql('process_id = ? ORDER BY mkdate', [$this->getId()]) + ); + } + + public function getDuration(): int + { + if (!isset($this->configuration['duration'])) { + return self::DEFAULT_DURATION; + } + + return (int) $this->configuration['duration']; + } + + public function isAnonymous(): bool + { + if (!isset($this->configuration['anonymous'])) { + return true; + } + + return (bool) $this->configuration['anonymous']; + } + + public function isAutomaticPairing(): bool + { + if (!isset($this->configuration['automaticPairing'])) { + return true; + } + + return (bool) $this->configuration['automaticPairing']; + } + + public function getCurrentState(int $date = null): string + { + if (is_null($date)) { + $date = time(); + } + + if ($this->review_end < $date) { + return self::STATE_AFTER; + } + + if ($date < $this->review_start) { + return self::STATE_BEFORE; + } + + return self::STATE_ACTIVE; + } + + public function checkAutomaticPairing(): void + { + if ($this->isAutomaticPairing() && !$this->paired_at) { + $now = time(); + if ($now > $this->review_start) { + $this->createAutomaticPairings(); + $this->content['paired_at'] = $now; + $this->content_db['paired_at'] = $now; + $stmt = \DBManager::get()->prepare( + 'UPDATE `' . $this->db_table() . '` SET `paired_at` = ? WHERE id = ?' + ); + $stmt->execute([$now, $this->getId()]); + } + } + } + + public function createAutomaticPairings(): iterable + { + $taskGroup = $this->task_group; + $submitters = $taskGroup->getSubmitters(); + + if (count($submitters) < 2) { + return []; + } + + shuffle($submitters); + $copy = $submitters; + $copy[] = array_shift($copy); + $pairings = array_map(null, $submitters, $copy); + + return array_map(function ($pairing) use ($taskGroup) { + [$submitter, $reviewer] = $pairing; + $task = $taskGroup->findTaskBySolver($submitter); + + return PeerReview::create([ + 'process_id' => $this->getId(), + 'task_id' => $task->getId(), + 'submitter_id' => $submitter->getId(), + 'reviewer_id' => $reviewer->getId(), + 'reviewer_type' => $reviewer instanceof User ? 'autor' : 'group', + ]); + }, $pairings); + } + + public function rescheduleTo(int $newStartDate): void + { + $newEndDate = $newStartDate + $this->getDuration() * (24 * 60 * 60); + $this->setData([ + 'review_start' => $newStartDate, + 'review_end' => $newEndDate, + ]); + $this->store(); + } +} diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index bf3644c..3f7c569 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -285,7 +285,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac if ($this->range_id === $user->id) { return true; } - + return $this->hasWriteContentApproval($user); case 'course': @@ -420,6 +420,8 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac } return $task->userIsASolver($user); + // TODO (mel): Das ist die ursprüngliche Variante, die aber jetzt kompliziert ist. Mit Nico sprechen! + // return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user); } if ($this->canEdit($user)) { diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php index 7842830..5f38ce9 100644 --- a/lib/models/Courseware/Task.php +++ b/lib/models/Courseware/Task.php @@ -2,6 +2,7 @@ namespace Courseware; +use Seminar_User; use User; /** @@ -79,6 +80,14 @@ class Task extends \SimpleORMap 'foreign_key' => 'feedback_id', ]; + $config['has_many']['peer_reviews'] = [ + 'class_name' => PeerReview::class, + 'assoc_foreign_key' => 'task_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + $config['additional_fields']['solver'] = [ 'get' => 'getSolver', ]; @@ -123,12 +132,11 @@ class Task extends \SimpleORMap return 1 === (int) $this->submitted; } - /** - * @param \User|\Seminar_User $user - */ - public function canUpdate($user): bool + public function canUpdate(User|Seminar_User $user): bool { - $perm = false; + // TODO (mel): Das ist hier eine Code-Verdopplung gegenüber: + // $this->userIsASolver($user) + // Mit Nico besprechen switch ($this->solver_type) { case 'autor': if ($this->solver_id === $user->id) { @@ -157,10 +165,7 @@ class Task extends \SimpleORMap return $this->getStructuralElement()->hasEditingPermission($user); } - /** - * @param \User|\Seminar_User $user - */ - public function userIsASolver($user): bool + public function userIsASolver(User|Seminar_User $user): bool { switch ($this->solver_type) { case 'autor': @@ -175,6 +180,11 @@ class Task extends \SimpleORMap return false; } + public function userIsAPeerReviewer(User|Seminar_User $user): bool + { + return $this->isPeerReviewed() && $this->isPeerReviewedBy($user); + } + /** * @return \User|\Statusgruppen|null the solver */ @@ -255,6 +265,53 @@ class Task extends \SimpleORMap $this->store(); } + public function isPeerReviewed(): bool + { + return PeerReview::countBySql('task_id = ?', [$this->id]) !== 0; + } + + public function isPeerReviewedBy(User|Seminar_User $user): bool + { + $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"'; + if (PeerReview::countBySql($sql, [$this->id, $user->id]) !== 0) { + return true; + } + + $sql = 'SELECT reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"'; + foreach (\DBManager::get()->fetchFirst($sql, [$this->id]) as $reviewerId) { + if (\Statusgruppen::isMemberOf($reviewerId, $user->id)) { + return true; + } + } + + return false; + } + + public function getPeerReviewProcessessWithReviewsBy(User|Seminar_User $user): array + { + return PeerReviewProcess::findBySql( + 'id IN (?)', + array_unique( + array_merge( + \DBManager::get()->fetchFirst( + 'SELECT DISTINCT process_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"', + [$this->id, $user->id] + ), + array_column( + array_filter( + \DBManager::get()->fetchAll( + 'SELECT process_id, reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"', + [$this->id] + ), + fn($row) => \Statusgruppen::isMemberOf($row['reviewer_id'], $user->id) + ), + 'process_id' + ) + ) + ) + ); + } + private function getStructuralElementProgress(StructuralElement $structural_element): float { $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]); diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php index 6902cb3..626e7cc 100644 --- a/lib/models/Courseware/TaskGroup.php +++ b/lib/models/Courseware/TaskGroup.php @@ -30,6 +30,7 @@ use User; * @property \Course $course belongs_to \Course * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement * @property \SimpleORMapCollection $tasks has_many Courseware\Task + * @property \SimpleORMapCollection $peer_review_processes has_many Courseware\PeerReviewProcess * * @SuppressWarnings(PHPMD.StaticAccess) */ @@ -62,6 +63,16 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject 'order_by' => 'ORDER BY mkdate', ]; + $config['has_many']['peer_review_processes'] = [ + 'class_name' => PeerReviewProcess::class, + 'assoc_foreign_key' => 'task_group_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + $config['registered_callbacks']['after_store'][] = 'cbAfterStore'; + parent::configure($config); } @@ -109,6 +120,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject ); } + public function hasPeerReviewProcesses(): bool + { + return PeerReviewProcess::countBySql('task_group_id = ?', [$this->getId()]) > 0; + } + /** * Returns the task of this TaskGroup given to $solver. * @@ -130,4 +146,19 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject return empty($row) ? null : Task::find($row['id']); } + public function cbAfterStore(): void + { + if ($this->isFieldDirty('end_date')) { + $this->reschedulePeerReviewProcesses(); + } + } + + private function reschedulePeerReviewProcesses(): void + { + if ($this->hasPeerReviewProcesses()) { + foreach ($this->peer_review_processes as $process) { + $process->rescheduleTo($this->end_date); + } + } + } } diff --git a/resources/assets/stylesheets/scss/wizard.scss b/resources/assets/stylesheets/scss/wizard.scss index 4a9fd5d..9663f98 100644 --- a/resources/assets/stylesheets/scss/wizard.scss +++ b/resources/assets/stylesheets/scss/wizard.scss @@ -7,6 +7,7 @@ width: 270px; min-height: 440px; margin-top: 38px; + flex-shrink: 0; img { margin: auto; @@ -277,4 +278,3 @@ form.default fieldset.radiobutton-set { } } } - diff --git a/resources/vue/components/ConsultationCreator.vue b/resources/vue/components/ConsultationCreator.vue index aa5621c..03482c8 100644 --- a/resources/vue/components/ConsultationCreator.vue +++ b/resources/vue/components/ConsultationCreator.vue @@ -473,9 +473,7 @@ export default { combineDateAndTime(date, time) { const [hour, minute] = time.split(':').map(item => parseInt(item, 10)); const result = new Date(date); - result.setHours(hour); - result.setMinutes(minute); - result.setSeconds(0); + result.setHours(hour, minute, 0, 0); return result; } }, diff --git a/resources/vue/components/StudipActionMenu.vue b/resources/vue/components/StudipActionMenu.vue index a301ac3..bb189f8 100644 --- a/resources/vue/components/StudipActionMenu.vue +++ b/resources/vue/components/StudipActionMenu.vue @@ -55,6 +55,7 @@