diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 8ecff0a9e3089a003ddf66881e2e6a910b2ad92a..ef7c38d205ae5b8ea3b13c7ab230a8e1cc516187 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.38_add_peer_review_tables.php b/db/migrations/6.0.38_add_peer_review_tables.php new file mode 100644 index 0000000000000000000000000000000000000000..893d59a60079eeda67594cc71f63fcb81d267a7c --- /dev/null +++ b/db/migrations/6.0.38_add_peer_review_tables.php @@ -0,0 +1,56 @@ +<?php +class AddPeerReviewTables extends Migration +{ + public function description() + { + return "Adds the Courseware peer review tables."; + } + + public function up() + { + $db = \DBManager::get(); + + $db->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 3870d9a2055b7477f44b754d3f636480b82194fd..6b2f429314c06609c8ab2c69cc71d0d69c574f7a 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 7ed609fc65d5a5580c014851f0aa3ee94ec930de..87bda5e8480e212bfe0e0fe7445b95c5d58e3cc4 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 0000000000000000000000000000000000000000..4f8099b707012fb54bed67fb0e81ac7fb0f3c037 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php @@ -0,0 +1,124 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesCreate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->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 0000000000000000000000000000000000000000..fc19e8b4bffbeca0e52c44b4ea190aeb4f2074b5 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php @@ -0,0 +1,38 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one PeerPreviewProcess. + */ +class ProcessesDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePeerReviewProcess($this->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 0000000000000000000000000000000000000000..d45bc23cf243d3fec64ec4b5aa7c076d11f5d075 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php @@ -0,0 +1,108 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all visible PeerReviewProcesses. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesIndex extends JsonApiController +{ + protected $allowedFilteringParameters = ['cid']; + + protected $allowedIncludePaths = [ + ProcessSchema::REL_COURSE, + ProcessSchema::REL_OWNER, + ProcessSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->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 0000000000000000000000000000000000000000..7579fcd15ac8c6cae21f3ad5a0a47016c4f364b9 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php @@ -0,0 +1,47 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + ProcessSchema::REL_COURSE, + ProcessSchema::REL_OWNER, + ProcessSchema::REL_TASK_GROUP, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\PeerReviewProcess $resource */ + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowPeerReviewProcess($this->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 0000000000000000000000000000000000000000..d5b6fb55135477fb34f65ce14248bcdf3368f9eb --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php @@ -0,0 +1,121 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Updates one PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\PeerReviewProcess $resource */ + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->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 0000000000000000000000000000000000000000..d03deb3f81ae5bded0fbbb5b192eeb31fc6387a6 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php @@ -0,0 +1,76 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\Task; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all PeerReviews of a course. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsByTaskIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $task = Task::find($args['id']); + if (!$task) { + throw new RecordNotFoundException(); + } + + $user = $this->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 0000000000000000000000000000000000000000..414f2b441e14f2a6916010d61368a9f3f4c94262 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php @@ -0,0 +1,180 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use InvalidArgumentException; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\StatusGroup as StatusGroupSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Statusgruppen; +use User; + +/** + * Create a PeerReview. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->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 0000000000000000000000000000000000000000..bf0a6c654d6246e1b02414bb131ddacaf81484a6 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php @@ -0,0 +1,38 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one PeerPreview. + */ +class ReviewsDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePeerReview($this->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 0000000000000000000000000000000000000000..92d77ced5473cc8c1035d64d7b1a3dfdbfeb9230 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReview; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all PeerReviews of a course. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $course = Course::find($args['id']); + if (!$course) { + throw new RecordNotFoundException(); + } + + $user = $this->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 0000000000000000000000000000000000000000..c67e1a5b10d6a1185305509bfdd8df6249bae333 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php @@ -0,0 +1,74 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all visible PeerReviewProcesses. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsOfProcessesIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerReviewProcess $process */ + $process = PeerReviewProcess::find($args['id']); + if (!$process) { + throw new RecordNotFoundException(); + } + + $user = $this->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 0000000000000000000000000000000000000000..83a6cb0f562264f01540058a511834f95c8718b3 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php @@ -0,0 +1,50 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one PeerReview. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowPeerReview($this->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 0000000000000000000000000000000000000000..65a210875083169f335f9d06490cba1712ea9649 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php @@ -0,0 +1,78 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Updates one PeerReview. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->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 c8ebb86e31bcd1c3480111ee3514159ad7efa8cb..ff3fba44ecbd55ccd87a22045a28982b1a15fded 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 26a021c9682052271e9995c07867d91fded6d555..995243741ffd9c5260b0b9b1f20d804dbd55f6a9 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 619e7eab9e7f565def448dc604217e2c508abebc..419f950950541458af9dc7e202845b9ee418e41c 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 801bf293831782ec2d2ed33c04e9daec26968bfd..b3c3179c8277159c9339cb24cad35c48f188015f 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 0000000000000000000000000000000000000000..2096d324af2e7219ab89be6f5ad7b24f7e1b5006 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php @@ -0,0 +1,101 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PeerReview extends SchemaProvider +{ + public const TYPE = 'courseware-peer-reviews'; + + public const REL_PROCESS = 'process'; + public const REL_REVIEWER = 'reviewer'; + public const REL_SUBMITTER = 'submitter'; + public const REL_TASK = 'task'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->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 0000000000000000000000000000000000000000..0eca67c523cca3d75f5d3f6d0844a5e529c77946 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PeerReviewProcess extends SchemaProvider +{ + const TYPE = 'courseware-peer-review-processes'; + + const REL_COURSE = 'course'; + const REL_OWNER = 'owner'; + const REL_PEER_REVIEWS = 'reviews'; + const REL_TASK_GROUP = 'task-group'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->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 c612333886cfd7c270eee6aec3b7c009cced1f8d..1793e62a06c1f970f658ba49d84435b5c794254e 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 c950671ea47bac8821dcf11bc523eb6c09f6d3a7..97d7628ef91ed905c66d85183cc511c648ff124c 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 0000000000000000000000000000000000000000..0a62527858a03bdb76c53ef284ea7f71822e498a --- /dev/null +++ b/lib/models/Courseware/PeerReview.php @@ -0,0 +1,93 @@ +<?php + +namespace Courseware; + +use Course; +use Statusgruppen; +use User; + +/** + * Courseware's peer review instances. + * + * @since Stud.IP 6.0 + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class PeerReview extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_peer_reviews'; + + $config['serialized_fields']['assessment'] = 'JSONArrayObject'; + + $config['belongs_to']['process'] = [ + 'class_name' => 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 0000000000000000000000000000000000000000..ae92698af55e4193dd455596624d4162a53c0556 --- /dev/null +++ b/lib/models/Courseware/PeerReviewProcess.php @@ -0,0 +1,188 @@ +<?php + +namespace Courseware; + +use Course; +use DBManager; +use SimpleORMapCollection; +use User; + +/** + * A PeerReviewProcess groups a set of PeerReviews. + * + * @SuppressWarnings(PHPMD.StaticAccess) + * + * @since Stud.IP 6.0 + */ +class PeerReviewProcess extends \SimpleORMap +{ + public const DEFAULT_DURATION = 7; + + public const STATE_BEFORE = 'before'; + public const STATE_ACTIVE = 'active'; + public const STATE_AFTER = 'after'; + + protected static function configure($config = []) + { + $config['db_table'] = 'cw_peer_review_processes'; + + $config['serialized_fields']['configuration'] = 'JSONArrayObject'; + + $config['belongs_to']['task_group'] = [ + 'class_name' => 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 bf3644cf4c475ead05ca86f58ee4701f5cf4c58a..3f7c56934e55d6fc39f87f60f596659a05f11447 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 7842830050d45156180786e19eba5b9653c2e28f..5f38ce9dfc153d59a32602e567a0961cfc98978b 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 6902cb36a678fb2f39db89663263f3412d913ef6..626e7ccecb9c13ee5b2908c87e397840e18f4279 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 4a9fd5d3ba71388ee5779df338e30c730154ef25..9663f98e63162e676af5cf56a32e56c2c6b34da7 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 aa5621cf9afd0505fbab4146e9baa4a882ab3dba..03482c8354daeec59cce4ce061f845dd84d851ab 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 a301ac3fa856fd5043fa6d62e0c5fab39dcb3281..bb189f8892ba320bdc22fa29140f4118cdec669c 100644 --- a/resources/vue/components/StudipActionMenu.vue +++ b/resources/vue/components/StudipActionMenu.vue @@ -55,6 +55,7 @@ <template v-for="item in navigationItems"> <label v-if="item.disabled" :key="item.id" aria-disabled="true" v-bind="item.attributes"> <studip-icon :shape="item.icon" + :alt="item.label" :title="item.label" role="inactive" class="action-menu-item-icon" @@ -63,6 +64,7 @@ <span v-else-if="item.type === 'separator'" :key="item.id" class="quiet">|</span> <a v-else :key="item.id" v-bind="item.attributes" v-on="linkEvents(item)"> <studip-icon :shape="item.icon" + :alt="item.label" :title="item.label" class="action-menu-item-icon" ></studip-icon> diff --git a/resources/vue/components/StudipArticle.vue b/resources/vue/components/StudipArticle.vue new file mode 100644 index 0000000000000000000000000000000000000000..68dffd0aad0e9d72686d307ec36d5d6654030b2e --- /dev/null +++ b/resources/vue/components/StudipArticle.vue @@ -0,0 +1,62 @@ +<template> + <article class="studip" :class="{ collapsable, collapsed }" v-bind="$attrs"> + <header> + <h1 @click="doToggle"> + <template v-if="collapsable"> + <StudipIcon class="studip-articles--icon" shape="arr_1right" v-if="collapsed" /> + <StudipIcon class="studip-articles--icon" shape="arr_1down" v-else /> + </template> + <slot name="title" v-bind="{ isOpen: collapsed }"></slot> + </h1> + <slot v-if="$slots.titleplus" name="titleplus"></slot> + </header> + <section v-if="!collapsed"> + <slot name="body"></slot> + </section> + <footer v-if="$slots.footer"> + <slot name="footer"></slot> + </footer> + </article> +</template> + +<script> +import StudipIcon from './StudipIcon.vue'; + +export default { + props: { + collapsable: { + type: Boolean, + default: false, + }, + closed: { + type: Boolean, + default: false, + }, + }, + components: { StudipIcon }, + data() { + return { collapsed: this.closed }; + }, + methods: { + doToggle() { + if (this.collapsable) { + this.collapsed = !this.collapsed; + } + }, + }, +}; +</script> +<style scoped> +article.studip.collapsable.collapsed { + padding-block-end: 0; +} +article.studip.collapsable.collapsed > header { + margin-block-end: 0; +} +article.studip.collapsable > header > h1 { + cursor: pointer; +} + +.studip-articles--icon { +} +</style> diff --git a/resources/vue/components/StudipContentBox.vue b/resources/vue/components/StudipContentBox.vue new file mode 100644 index 0000000000000000000000000000000000000000..ab5bf9584c20ff267e6b0cba472940af5a0b1933 --- /dev/null +++ b/resources/vue/components/StudipContentBox.vue @@ -0,0 +1,46 @@ +<template> + <section class="contentbox"> + <header v-if="title || $slots.header"> + <slot name="header"> + <h1> + <StudipIcon v-if="icon" :shape="icon" /> + {{ title }} + </h1> + </slot> + <slot name="header-nav"> + <nav v-if="items"> + <StudipActionMenu :items="items" /> + </nav> + </slot> + </header> + + <slot></slot> + + <footer> + <slot name="footer"></slot> + </footer> + </section> +</template> + +<script> +import StudipActionMenu from './StudipActionMenu.vue'; +import StudipIcon from './StudipIcon.vue'; + +export default { + components: { StudipActionMenu, StudipIcon }, + props: { + icon: { + type: String, + required: false, + }, + items: { + type: Array, + required: false, + }, + title: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/resources/vue/components/StudipUserAvatar.vue b/resources/vue/components/StudipUserAvatar.vue new file mode 100644 index 0000000000000000000000000000000000000000..1020839ab1537396dc62d2cf81e4731ebf2aecbe --- /dev/null +++ b/resources/vue/components/StudipUserAvatar.vue @@ -0,0 +1,38 @@ +<template> + <div class="studip-user-avatar" :class="{ 'studip-user-avatar-small': small }"> + <span> + <img :src="avatarUrl" role="presentation" /> + </span> + <span>{{ formattedName }}</span> + </div> +</template> + +<script> +export default { + props: { + avatarUrl: { + type: String, + required: true, + }, + formattedName: { + type: String, + required: true, + }, + small: { + type: Boolean, + default: false, + }, + }, +}; +</script> + +<style scoped> +.studip-user-avatar { + align-items: center; + display: flex; + gap: 0.25rem; +} +.studip-user-avatar-small img { + width: 1em; +} +</style> diff --git a/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue b/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue index 8b18192c5bf9e7fc863140b7a89b6535c2cb6416..eef8c73601081881305271911fd0bc5d96c94950 100644 --- a/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue @@ -183,22 +183,20 @@ export default { getSolverName(taskId) { const task = this.taskById({ id: taskId }); - if (task === undefined) { - return false; - } - const solver = task.relationships.solver.data; - if (solver.type === 'users') { - const user = this.userById({ id: solver.id }); + if (task) { + const solver = task.relationships.solver.data; + if (solver?.type === 'users') { + const user = this.userById({ id: solver.id }); - return user.attributes['formatted-name']; - } - if (solver.type === 'status-groups') { - const group = this.groupById({ id: solver.id }); + return user.attributes['formatted-name']; + } + if (solver?.type === 'status-groups') { + const group = this.groupById({ id: solver.id }); - return group.attributes.name; + return group.attributes.name; + } } - - return false; + return null; }, }, }; diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue index 3e8ad878746a0a13e1699626e95173d683e1ca8c..bf2af11afbe568027835b6c63a6582b6e626f0d8 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue @@ -44,8 +44,10 @@ :title="structuralElement.attributes.title" > <span>{{ structuralElement.attributes.title || '–' }}</span> - <span v-if="isTask">[ {{ solverName }} ]</span> - <template v-if="!userIsTeacher && inCourse"> + <span v-if="isTask"> + [ {{ (!userIsSolver && userIsReviewer && isPeerReviewAnonymous) ? $gettext('anonym') : solverName }} ] + </span> + <template v-if="inCourse && !(userIsTeacher || userIsReviewer)"> <studip-icon v-if="complete" shape="accept" @@ -134,6 +136,9 @@ </template> </courseware-call-to-action-box> <div v-if="structuralElementLoaded && !isLink" class="cw-companion-box-wrapper"> + <StudipMessageBox v-if="userIsReviewer"> + {{ $gettext('Diese Seite gehört zu einer Aufgabe, die von einer anderen Person bearbeitet wird.') }} + </StudipMessageBox> <courseware-companion-box v-if="!canVisit" mood="sad" @@ -155,6 +160,22 @@ </button> </template> </courseware-companion-box> + <courseware-companion-box + v-for="peerReview in peerReviews" + :key="peerReview.id" + mood="pointing" + :msgCompanion="peerReviewCompanionMessage(peerReview)" + > + <template #companionActions> + <button + class="button" + @click="openPeerReview(peerReview)" + :disabled="!canReadPeerReviewAssessment(peerReview)" + > + {{ peerReviewCompanionAction(peerReview) }} + </button> + </template> + </courseware-companion-box> <courseware-empty-element-box v-if="empty && !showRootLayout" :canEdit="canEdit" @@ -370,6 +391,16 @@ v-if="showPublicLinkDialog && inContent" :structuralElement="structuralElement" /> + <PeerReviewAssessmentDialog + v-model:show="showPeerReviewAssessment" + v-if="selectedPeerReview" + :review="selectedPeerReview" + /> + <PeerReviewResultDialog + v-model:show="showPeerReviewResult" + v-if="selectedPeerReview" + :review="selectedPeerReview" + /> <feedback-dialog v-if="showFeedbackDialog" :feedbackElementId="parseInt(feedbackElementId)" @@ -435,6 +466,9 @@ import CoursewareStructuralElementDiscussion from './CoursewareStructuralElement import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue'; import CoursewareRibbon from "./CoursewareRibbon.vue"; +import PeerReviewAssessmentDialog from '../tasks/peer-review/AssessmentDialog.vue'; +import PeerReviewResultDialog from '../tasks/peer-review/ResultDialog.vue'; +import { getProcessStatus, ProcessStatus } from '../tasks/peer-review/definitions.ts'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import colorMixin from '@/vue/mixins/courseware/colors.js'; @@ -446,6 +480,7 @@ import { FocusTrap } from 'focus-trap-vue'; import FeedbackDialog from '../../feedback/FeedbackDialog.vue'; import FeedbackCreateDialog from '../../feedback/FeedbackCreateDialog.vue'; import StudipFiveStars from '../../feedback/StudipFiveStars.vue'; +import StudipMessageBox from '../../StudipMessageBox.vue'; import StudipProgressIndicator from '../../StudipProgressIndicator.vue'; import draggable from 'vuedraggable'; import containerMixin from '@/vue/mixins/courseware/container.js'; @@ -481,7 +516,10 @@ export default { FeedbackCreateDialog, StudipFiveStars, FocusTrap, + PeerReviewAssessmentDialog, + PeerReviewResultDialog, StudipDialog, + StudipMessageBox, StudipProgressIndicator, draggable, CoursewareRibbon, @@ -511,6 +549,9 @@ export default { consumModeTrap: false, keyboardSelected: null, assistiveLive: '', + showPeerReviewAssessment: false, + showPeerReviewResult: false, + selectedPeerReview: null, displayFeedback: false, showRatingPopup: false, ratingPopupFeedbackElement: null, @@ -535,6 +576,8 @@ export default { context: 'context', containerById: 'courseware-containers/byId', relatedContainers: 'courseware-containers/related', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + relatedPeerReviews: 'courseware-peer-reviews/related', relatedStructuralElements: 'courseware-structural-elements/related', getRelatedFeedback: 'courseware-structural-element-feedback/related', getRelatedComments: 'courseware-structural-element-comments/related', @@ -645,7 +688,7 @@ export default { if (this.context.type === 'courses' && this.currentElement.relationships) { if ( this.currentElement.relationships.course && - this.context.id === this.currentElement.relationships.course.data.id + this.context.id === this.currentElement.relationships.course.data.id ) { return true; } @@ -654,7 +697,7 @@ export default { if (this.context.type === 'users' && this.currentElement.relationships) { if ( this.currentElement.relationships.user && - this.context.id === this.currentElement.relationships.user.data.id + this.context.id === this.currentElement.relationships.user.data.id ) { return true; } @@ -969,10 +1012,10 @@ export default { solver() { if (this.task) { const solver = this.task.relationships.solver.data; - if (solver.type === 'users') { + if (solver?.type === 'users') { return this.userById({ id: solver.id }); } - if (solver.type === 'status-groups') { + if (solver?.type === 'status-groups') { return this.groupById({ id: solver.id }); } } @@ -988,8 +1031,7 @@ export default { return this.solver.attributes.name; } } - - return ''; + return null; }, canAddElements() { if (!this.isTask) { @@ -1042,7 +1084,7 @@ export default { }, elementProgress() { if (this.structuralElementLoaded) { - return this.progressData?.[this.structuralElement.id].progress.self; + return this.progressData?.[this.structuralElement.id].progress?.self ?? 0; } return 0; @@ -1106,6 +1148,30 @@ export default { { length: this.commentsCounter } ); }, + userIsReviewer() { + return this.peerReviews.some((peerReview) => peerReview.attributes['is-reviewer']); + }, + userIsSolver() { + return this.peerReviews.some((peerReview) => peerReview.attributes['is-submitter']); + }, + peerReviews() { + if (this.task) { + return this.relatedPeerReviews({ + parent: { id: this.task.id, type: this.task.type }, + relationship: 'peer-reviews', + }) ?? []; + } + return []; + }, + isPeerReviewAnonymous() { + return this.peerReviews.every(({ id, type }) => { + const process = this.relatedPeerReviewProcesses({ + parent: { id, type }, + relationship: 'process', + }); + return process.attributes.configuration.anonymous; + }); + }, }, methods: { @@ -1366,17 +1432,19 @@ export default { ref.initCurrentData(); } }, - async loadFeedback() { + loadFeedback() { const parent = { type: this.currentElement.type, id: this.currentElement.id, }; - await this.loadRelatedFeedback({ + return this.loadRelatedFeedback({ parent, relationship: 'feedback', options: { include: 'user', }, + }).catch((error) => { + console.error("Could not load feedback"); }); }, keyHandler(e, containerId) { @@ -1605,6 +1673,52 @@ export default { } }); }, + + getPeerReviewProcess(review) { + return this.relatedPeerReviewProcesses({ + parent: { id: review.id, type: review.type }, + relationship: 'process', + }); + }, + canReadPeerReviewAssessment(peerReview) { + if (peerReview.attributes['is-reviewer']) { + return true; + } + const process = this.getPeerReviewProcess(peerReview); + const isAfter = getProcessStatus(process)?.status === ProcessStatus.After; + return (this.userIsTeacher || peerReview.attributes['is-submitter']) && isAfter; + }, + openPeerReview(peerReview) { + this.selectedPeerReview = peerReview; + if (peerReview.attributes['is-reviewer']) { + this.showPeerReviewAssessment = true; + } else { + this.showPeerReviewResult = true; + } + }, + peerReviewCompanionAction(peerReview) { + const process = this.getPeerReviewProcess(peerReview); + if (peerReview.attributes['is-reviewer'] && getProcessStatus(process)?.status === ProcessStatus.Active) { + return this.$gettext('Peer-Review geben'); + } + return this.$gettext('Peer-Review einsehen'); + }, + peerReviewCompanionMessage(peerReview) { + let message; + if (peerReview.attributes['is-reviewer']) { + message = this.$gettext('Sie beurteilen diese Aufgabe im Rahmen eines Peer-Reviews.'); + } else if (peerReview.attributes['is-submitter']) { + message = this.$gettext('Sie haben zu Ihrer Aufgabe ein Peer-Review erhalten.'); + } else { + message = this.$gettext('Diese Aufgabe hat ein Peer-Review erhalten.'); + } + + if (this.canReadPeerReviewAssessment(peerReview)) { + return message; + } + + return `${message} ${this.$gettext('Sie können es jedoch nicht öffnen, da der Bearbeitungszeitraum noch nicht abgelaufen ist.')}`; + }, }, created() { this.pluginManager.registerComponentsLocally(this); diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue index 01e894cf028b268e4ae21be851ef3232e873a310..9ad0eb1d0bb7a5008ddc66c87cbb2565b4da0f9f 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue @@ -32,7 +32,7 @@ <studip-icon shape="edit" /> </button> - <span v-if="task">| {{ solverName }}</span> + <span v-if="task">| {{ solverName ?? $gettext("anonym") }}</span> <span v-if="hasReleaseOrWithdrawDate" class="cw-tree-item-flag-date" @@ -44,7 +44,7 @@ :title="canWriteFlagTitle" ></span> <span v-if="hasNoReadApproval" class="cw-tree-item-flag-cant-read" :title="cantReadFlagTitle"></span> - <template v-if="!userIsTeacher && inCourse"> + <template v-if="!(userIsTeacher || userIsReviewer) && inCourse"> <span v-if="complete" class="cw-tree-item-sequential cw-tree-item-sequential-complete" @@ -408,10 +408,10 @@ export default { solver() { if (this.task) { const solver = this.task.relationships.solver.data; - if (solver.type === 'users') { + if (solver?.type === 'users') { return this.userById({ id: solver.id }); } - if (solver.type === 'status-groups') { + if (solver?.type === 'status-groups') { return this.groupById({ id: solver.id }); } } @@ -428,7 +428,7 @@ export default { } } - return ''; + return null; }, isTask() { return this.element.attributes?.purpose === 'task'; @@ -462,6 +462,9 @@ export default { complete() { return this.itemProgress === 100; }, + userIsReviewer() { + return this.task ? this.task.attributes['can-peer-review'] : false; + }, }, methods: { ...mapActions({ diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue index 889b64d3f4f767eba4e7af19d24c4331b3520249..f38d908ee4991b8d606ad23f3911a51b4739657d 100644 --- a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue @@ -3,7 +3,8 @@ <ContentBar isContentBar> <template #buttons-left> <router-link :to="{ name: 'task-groups-index' }"> - <StudipIcon shape="category-task" :size="24" /> + <StudipIcon shape="category-task" :size="24" aria-role="presentation" /> + <span class="sr-only">{{ $gettext('Aufgaben') }}</span> </router-link> </template> <template #breadcrumb-list> @@ -26,6 +27,7 @@ <th :class="getSortClass('end-date')" @click="sort('end-date')"> {{ $gettext('Bearbeitungszeit') }} </th> + <th></th> <th class="actions">{{ $gettext('Aktionen') }}</th> </tr> </thead> @@ -46,9 +48,13 @@ }}</router-link> </td> <td> - <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - <StudipDate - :date="new Date(taskGroup.attributes['end-date'])" - /> + <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - + <StudipDate :date="new Date(taskGroup.attributes['end-date'])" /> + </td> + <td> + <div v-for="process in peerReviewProcesses(taskGroup)" :key="process.id"> + <PeerReviewProcessStatus :process="process" description :filter="processActive" /> + </div> </td> <td class="actions"> <StudipActionMenu @@ -70,7 +76,11 @@ </template> </CompanionBox> - <TaskGroupsAddSolversDialog v-if="showTaskGroupsAddSolversDialog" :taskGroup="selectedTaskGroup" @newtask="reloadTasks" /> + <TaskGroupsAddSolversDialog + v-if="showTaskGroupsAddSolversDialog" + :taskGroup="selectedTaskGroup" + @newtask="reloadTasks" + /> <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="selectedTaskGroup" /> <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="selectedTaskGroup" /> <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" /> @@ -82,6 +92,7 @@ import _ from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; +import PeerReviewProcessStatus from './peer-review/ProcessStatus.vue'; import StudipActionMenu from '../../StudipActionMenu.vue'; import StudipDate from '../../StudipDate.vue'; import StudipIcon from '../../StudipIcon.vue'; @@ -90,6 +101,7 @@ import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; import { getStatus } from './task-groups-helper.js'; import ContentBar from "../../ContentBar.vue"; +import { ProcessStatus } from './peer-review/definitions.ts'; export default { name: 'courseware-dashboard-students', @@ -97,6 +109,7 @@ export default { ContentBar, CompanionBox, CoursewareTasksDialogDistribute, + PeerReviewProcessStatus, StudipActionMenu, StudipDate, StudipIcon, @@ -105,6 +118,7 @@ export default { TaskGroupsModifyDeadlineDialog, }, data: () => ({ + processActive: ProcessStatus.Active, selectedTaskGroup: null, sortBy: 'end-date', sortAsc: false, @@ -112,6 +126,7 @@ export default { computed: { ...mapGetters({ context: 'context', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog', showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog', showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog', @@ -157,13 +172,13 @@ export default { id: 'add-solvers', label: this.$gettext('Teilnehmende hinzufügen'), icon: 'add', - emit: 'addsolvers' + emit: 'addsolvers', }); menuItems.push({ id: 'modify-deadline', label: this.$gettext('Bearbeitungszeit verlängern'), icon: 'date', - emit: 'deadline' + emit: 'deadline', }); } @@ -188,11 +203,15 @@ export default { this.selectedTaskGroup = taskGroup; this.setShowTaskGroupsModifyDeadlineDialog(true); }, + peerReviewProcesses(parent) { + return this.relatedPeerReviewProcesses({ parent, relationship: 'peer-review-processes' }); + }, reloadTasks() { this.loadAllTasks({ options: { 'filter[cid]': this.context.id, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + include: + 'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes', }, }); }, @@ -212,7 +231,7 @@ export default { th { cursor: pointer; } -th:is(:first-child,:last-child) { +th:is(:first-child, :last-child) { cursor: not-allowed; } </style> diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue index 9bf7b6356577a004831d61cc2cc7c687a8984fa0..09227a957ac3db8d061ea68a8ba8ae28a4205e87 100644 --- a/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue @@ -1,351 +1,20 @@ <template> - <div class="cw-dashboard-tasks-wrapper"> - <table v-if="tasks.length > 0" class="default"> - <colgroup> - <col style="width: 5%" /> - <col style="width: 20%" /> - <col style="width: 10%" /> - <col style="width: 10%" /> - <col style="width: 5%" /> - <col style="width: 15%" /> - <col style="width: 15%" /> - <col style="width: 15%" /> - <col style="width: 5%" /> - </colgroup> - <thead> - <tr> - <th>{{ $gettext('Status') }}</th> - <th>{{ $gettext('Aufgabe') }}</th> - <th>{{ $gettext('bearbeitet') }}</th> - <th>{{ $gettext('Abgabefrist') }}</th> - <th>{{ $gettext('Abgabe') }}</th> - <th class="responsive-hidden">{{ $gettext('Verlängerungsanfrage') }}</th> - <th class="responsive-hidden">{{ $gettext('Für Teilnehmende freigeben') }}</th> - <th class="responsive-hidden">{{ $gettext('Anmerkung') }}</th> - <th class="actions">{{ $gettext('Aktionen') }}</th> - </tr> - </thead> - <tbody> - <tr v-for="{ task, taskGroup, status, element, feedback } in tasks" :key="task.id"> - <td> - <studip-icon - v-if="status.shape !== undefined" - :shape="status.shape" - :role="status.role" - :title="status.description" - /> - </td> - <td> - <a :href="getLinkToElement(element)"> - <studip-icon - v-if="task.attributes['solver-type'] === 'group'" - shape="group2" - :title="$gettext('Gruppenaufgabe')" - /> - {{ taskGroup.attributes.title }} - </a> - </td> - <td>{{ task.attributes?.progress?.toFixed(0) || '-' }}%</td> - <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> - <td> - <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> - </td> - <td class="responsive-hidden"> - <span v-show="task.attributes.renewal === 'declined'"> - <studip-icon shape="decline" role="status-red" /> - {{ $gettext('Anfrage abgelehnt') }} - </span> - <span v-show="task.attributes.renewal === 'pending'"> - <studip-icon shape="date" role="status-yellow" /> - {{ $gettext('Anfrage wird bearbeitet') }} - </span> - <span v-show="task.attributes.renewal === 'granted'"> - {{ $gettext('verlängert bis') }}: {{ getReadableDate(task.attributes['renewal-date']) }} - </span> - </td> - <td class="responsive-hidden"> - <span v-if="task.attributes.submitted"> - <button - class="button" - v-if="!task.attributes.visible" - @click="toggleVisibilityOn(task)" - > - {{ $gettext('Freigeben') }} - </button> - <button - class="button" - v-if="task.attributes.visible" - @click="toggleVisibilityOff(task)"> - {{ $gettext('Freigabe widerrufen') }} - </button> - </span> - </td> - <td class="responsive-hidden"> - <studip-icon - v-if="feedback" - :title="$gettext('Anmerkung anzeigen')" - class="display-feedback" - shape="consultation" - role="clickable" - @click="displayFeedback(feedback)" - /> - </td> - <td class="actions"> - <studip-action-menu - :items="getTaskMenuItems(task, status, element)" - @submitTask="displaySubmitDialog(task)" - @renewalRequest="renewalRequest(task)" - @copyContent="copyContent(taskGroup, element)" - /> - </td> - </tr> - </tbody> - </table> - <div v-else> - <courseware-companion-box - mood="sad" - :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" - /> - </div> - <studip-dialog - v-if="showFeedbackDialog" - :message="currentTaskFeedback" - :title="text.feedbackDialog.title" - @close=" - showFeedbackDialog = false; - currentTaskFeedback = ''; - " - /> - <studip-dialog - v-if="showSubmitDialog" - :title="text.submitDialog.title" - :question="text.submitDialog.question" - height="200" - width="420" - @confirm="submitTask" - @close="closeSubmitDialog" - /> + <div class="courseware-dashboard-tasks"> + <TasksList /> + <ProcessesList /> </div> </template> <script> -import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; -import StudipIcon from '../../StudipIcon.vue'; -import StudipActionMenu from '../../StudipActionMenu.vue'; -import StudipDialog from '../../StudipDialog.vue'; -import taskHelperMixin from '../../../mixins/courseware/task-helper.js'; -import { mapActions, mapGetters } from 'vuex'; +import TasksList from './CoursewareDashboardTasksList.vue'; +import ProcessesList from './peer-review/ProcessesList.vue'; export default { - name: 'courseware-dashboard-tasks', - mixins: [taskHelperMixin], - components: { - CoursewareCompanionBox, - StudipIcon, - StudipActionMenu, - StudipDialog, - }, - data() { - return { - showFeedbackDialog: false, - showSubmitDialog: false, - currentTask: null, - currentTaskFeedback: '', - text: { - feedbackDialog: { - title: this.$gettext('Anmerkung'), - }, - submitDialog: { - title: this.$gettext('Aufgabe abgeben'), - question: this.$gettext( - 'Änderungen sind nach Abgabe nicht mehr möglich. Möchten Sie diese Aufgabe jetzt wirklich abgeben?' - ), - }, - }, - }; - }, - computed: { - ...mapGetters({ - context: 'context', - allTasks: 'courseware-tasks/all', - userId: 'userId', - userById: 'users/byId', - statusGroupById: 'status-groups/byId', - getElementById: 'courseware-structural-elements/byId', - getFeedbackById: 'courseware-task-feedback/byId', - getTaskGroupById: 'courseware-task-groups/byId', - lastCreateCoursewareUnit: 'courseware-units/lastCreated', - }), - tasks() { - return this.allTasks.map((task) => { - const result = { - task, - taskGroup: this.getTaskGroupById({ id: task.relationships['task-group'].data.id }), - status: this.getStatus(task), - element: this.getElementById({ id: task.relationships['structural-element'].data.id }), - feedback: null, - }; - const feedbackId = task.relationships['task-feedback'].data?.id; - if (feedbackId) { - result.feedback = this.getFeedbackById({ id: feedbackId }); - } - - return result; - }); - }, - taskVisibilities() { - let visibilities = []; - for (const task of this.tasks) { - visibilities[`${task.task.id}`] = task.element.attributes.payload['task-visibility']; - } - return visibilities; - } - }, - methods: { - ...mapActions({ - updateTask: 'updateTask', - loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', - copyStructuralElement: 'copyStructuralElement', - companionSuccess: 'companionSuccess', - companionError: 'companionError', - createCoursewareUnit: 'courseware-units/create', - loadStructuralElement: 'courseware-structural-elements/loadById' - }), - getTaskMenuItems(task, status, element) { - let menuItems = []; - if (!task.attributes.submitted && status.canSubmit) { - menuItems.push({ - id: 1, - label: this.$gettext('Aufgabe bearbeiten'), - icon: 'edit', - url: this.getLinkToElement(element), - }); - menuItems.push({ id: 2, label: this.$gettext('Aufgabe abgeben'), icon: 'service', emit: 'submitTask' }); - } - - if (!task.attributes.submitted && !task.attributes.renewal) { - menuItems.push({ - id: 3, - label: this.$gettext('Verlängerung beantragen'), - icon: 'date', - emit: 'renewalRequest', - }); - } - if (task.attributes.submitted) { - menuItems.push({ id: 4, label: this.$gettext('Inhalt auf Arbeitsplatz kopieren'), icon: 'export', emit: 'copyContent' }); - } - - return menuItems; - }, - async renewalRequest(task) { - let attributes = task.attributes; - attributes.renewal = 'pending'; - await this.updateTask({ - attributes: attributes, - taskId: task.id, - }); - this.companionSuccess({ - info: this.$gettext('Ihre Anfrage wurde eingereicht.'), - }); - }, - displaySubmitDialog(task) { - this.showSubmitDialog = true; - this.currentTask = task; - }, - closeSubmitDialog() { - this.showSubmitDialog = false; - this.currentTask = null; - }, - async submitTask() { - const currentTaskGroup = this.getTaskGroupById({ id: this.currentTask.relationships['task-group'].data.id }); - this.showSubmitDialog = false; - let attributes = {}; - attributes.submitted = true; - await this.updateTask({ - attributes: attributes, - taskId: this.currentTask.id, - }); - this.companionSuccess({ - info: '"' + currentTaskGroup.attributes.title + '" ' + this.$gettext('wurde erfolgreich abgegeben.'), - }); - this.currentTask = null; - }, - async copyContent(taskGroup, element) { - const unit = { - attributes: { - title: taskGroup.attributes.title, - purpose: 'content', - payload: { - description: '', - color: 'studip-blue', - license_type: '', - required_time: '', - difficulty_start: '', - difficulty_end: '' - }, - settings: { - 'root-layout': 'classic' - } - }, - relationships: { - range: { - data: { - type: 'users', - id: this.userId - } - } - } - }; - await this.createCoursewareUnit(unit, { root: true }); - const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id - await this.copyStructuralElement({ - parentId: newElementId, - elementId: element.id, - removeType: false, - migrate: true, - }); - this.companionSuccess({ - info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'), - }); - }, - displayFeedback(feedback) { - this.showFeedbackDialog = true; - this.currentTaskFeedback = feedback.attributes.content; - }, - toggleVisibilityOn(task) { - let attributes = task.attributes; - attributes['visible'] = true; - this.toggleVisibility(task, attributes); - }, - toggleVisibilityOff(task) { - let attributes = task.attributes; - attributes['visible'] = false; - this.toggleVisibility(task, attributes); - }, - async toggleVisibility(task, attributes) { - await this.updateTask({ - attributes: attributes, - taskId: task.id, - }); - - const taskGroup = this.getTaskGroupById({ id: task.relationships['task-group'].data.id }); - const taskTitle = taskGroup.attributes.title; - - if (attributes.visible) { - this.companionSuccess({ - info: this.$gettext( - '"%{ title }" wurde freigegeben.', - { title: taskTitle } - ), - }); - } else { - this.companionSuccess({ - info: this.$gettext( - 'Die Freigabe für %{ "title }" wurde zurückgenommen.', - { title: taskTitle } - ), - }); - } - } - }, + components: { ProcessesList, TasksList }, }; </script> + +<style scoped> +.courseware-dashboard-tasks > * + * { + margin-block-start: 2rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue new file mode 100644 index 0000000000000000000000000000000000000000..41ac8736bedf148e5489198074127b8b1bab717a --- /dev/null +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue @@ -0,0 +1,351 @@ +<template> + <div class="cw-dashboard-tasks-wrapper"> + <table v-if="solvableTasks.length > 0" class="default"> + <caption> + {{ $gettext('Aufgaben') }} + </caption> + <colgroup> + <col style="width: 5%" /> + <col style="width: 20%" /> + <col style="width: 10%" /> + <col style="width: 10%" /> + <col style="width: 5%" /> + <col style="width: 15%" /> + <col style="width: 15%" /> + <col style="width: 15%" /> + <col style="width: 5%" /> + </colgroup> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Aufgabe') }}</th> + <th>{{ $gettext('bearbeitet') }}</th> + <th>{{ $gettext('Abgabefrist') }}</th> + <th>{{ $gettext('Abgabe') }}</th> + <th class="responsive-hidden">{{ $gettext('Verlängerungsanfrage') }}</th> + <th class="responsive-hidden">{{ $gettext('Für Teilnehmende freigeben') }}</th> + <th class="responsive-hidden">{{ $gettext('Anmerkung') }}</th> + <th class="actions">{{ $gettext('Aktionen') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="{ task, taskGroup, status, element, feedback } in solvableTasks" :key="task.id"> + <td> + <studip-icon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + /> + </td> + <td> + <a :href="getLinkToElement(element)"> + <studip-icon + v-if="task.attributes['solver-type'] === 'group'" + shape="group2" + :title="$gettext('Gruppenaufgabe')" + /> + {{ taskGroup.attributes.title }} + </a> + </td> + <td>{{ task.attributes?.progress?.toFixed(0) || '-' }}%</td> + <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> + <td> + <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> + </td> + <td class="responsive-hidden"> + <span v-show="task.attributes.renewal === 'declined'"> + <studip-icon shape="decline" role="status-red" /> + {{ $gettext('Anfrage abgelehnt') }} + </span> + <span v-show="task.attributes.renewal === 'pending'"> + <studip-icon shape="date" role="status-yellow" /> + {{ $gettext('Anfrage wird bearbeitet') }} + </span> + <span v-show="task.attributes.renewal === 'granted'"> + {{ $gettext('verlängert bis') }}: {{ getReadableDate(task.attributes['renewal-date']) }} + </span> + </td> + <td class="responsive-hidden"> + <span v-if="task.attributes.submitted"> + <button + class="button" + v-if="!task.attributes.visible" + @click="toggleVisibilityOn(task)" + > + {{ $gettext('Freigeben') }} + </button> + <button + class="button" + v-if="task.attributes.visible" + @click="toggleVisibilityOff(task)"> + {{ $gettext('Freigabe widerrufen') }} + </button> + </span> + </td> + <td class="responsive-hidden"> + <studip-icon + v-if="feedback" + :title="$gettext('Anmerkung anzeigen')" + class="display-feedback" + shape="consultation" + role="clickable" + @click="displayFeedback(feedback)" + /> + </td> + <td class="actions"> + <studip-action-menu + :items="getTaskMenuItems(task, status, element)" + @submitTask="displaySubmitDialog(task)" + @renewalRequest="renewalRequest(task)" + @copyContent="copyContent(taskGroup, element)" + /> + </td> + </tr> + </tbody> + </table> + <div v-else> + <courseware-companion-box + mood="sad" + :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" + /> + </div> + <studip-dialog + v-if="showFeedbackDialog" + :message="currentTaskFeedback" + :title="text.feedbackDialog.title" + @close=" + showFeedbackDialog = false; + currentTaskFeedback = ''; + " + /> + <studip-dialog + v-if="showSubmitDialog" + :title="text.submitDialog.title" + :question="text.submitDialog.question" + height="200" + width="420" + @confirm="submitTask" + @close="closeSubmitDialog" + /> + </div> +</template> +<script> +import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import StudipIcon from '../../StudipIcon.vue'; +import StudipActionMenu from '../../StudipActionMenu.vue'; +import StudipDialog from '../../StudipDialog.vue'; +import taskHelperMixin from '../../../mixins/courseware/task-helper.js'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-dashboard-tasks', + mixins: [taskHelperMixin], + components: { + CoursewareCompanionBox, + StudipIcon, + StudipActionMenu, + StudipDialog, + }, + data() { + return { + showFeedbackDialog: false, + showSubmitDialog: false, + currentTask: null, + currentTaskFeedback: '', + text: { + feedbackDialog: { + title: this.$gettext('Anmerkung'), + }, + submitDialog: { + title: this.$gettext('Aufgabe abgeben'), + question: this.$gettext( + 'Änderungen sind nach Abgabe nicht mehr möglich. Möchten Sie diese Aufgabe jetzt wirklich abgeben?' + ), + }, + }, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + allTasks: 'courseware-tasks/all', + userId: 'userId', + userById: 'users/byId', + statusGroupById: 'status-groups/byId', + getElementById: 'courseware-structural-elements/byId', + getFeedbackById: 'courseware-task-feedback/byId', + getTaskGroupById: 'courseware-task-groups/byId', + lastCreateCoursewareUnit: 'courseware-units/lastCreated', + }), + solvableTasks() { + return this.tasks.filter(({ task }) => task.attributes['can-solve']); + }, + tasks() { + return this.allTasks.map((task) => { + const result = { + task, + taskGroup: this.getTaskGroupById({ id: task.relationships['task-group'].data.id }), + status: this.getStatus(task), + element: this.getElementById({ id: task.relationships['structural-element'].data.id }), + feedback: null, + }; + const feedbackId = task.relationships['task-feedback'].data?.id; + if (feedbackId) { + result.feedback = this.getFeedbackById({ id: feedbackId }); + } + + return result; + }); + }, + taskVisibilities() { + let visibilities = []; + for (const task of this.tasks) { + visibilities[`${task.task.id}`] = task.element.attributes.payload['task-visibility']; + } + return visibilities; + } + }, + methods: { + ...mapActions({ + updateTask: 'updateTask', + loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', + copyStructuralElement: 'copyStructuralElement', + companionSuccess: 'companionSuccess', + companionError: 'companionError', + createCoursewareUnit: 'courseware-units/create', + loadStructuralElement: 'courseware-structural-elements/loadById' + }), + getTaskMenuItems(task, status, element) { + let menuItems = []; + if (!task.attributes.submitted && status.canSubmit) { + menuItems.push({ + id: 1, + label: this.$gettext('Aufgabe bearbeiten'), + icon: 'edit', + url: this.getLinkToElement(element), + }); + menuItems.push({ id: 2, label: this.$gettext('Aufgabe abgeben'), icon: 'service', emit: 'submitTask' }); + } + + if (!task.attributes.submitted && !task.attributes.renewal) { + menuItems.push({ + id: 3, + label: this.$gettext('Verlängerung beantragen'), + icon: 'date', + emit: 'renewalRequest', + }); + } + if (task.attributes.submitted) { + menuItems.push({ id: 4, label: this.$gettext('Inhalt auf Arbeitsplatz kopieren'), icon: 'export', emit: 'copyContent' }); + } + + return menuItems; + }, + async renewalRequest(task) { + let attributes = task.attributes; + attributes.renewal = 'pending'; + await this.updateTask({ + attributes: attributes, + taskId: task.id, + }); + this.companionSuccess({ + info: this.$gettext('Ihre Anfrage wurde eingereicht.'), + }); + }, + displaySubmitDialog(task) { + this.showSubmitDialog = true; + this.currentTask = task; + }, + closeSubmitDialog() { + this.showSubmitDialog = false; + this.currentTask = null; + }, + async submitTask() { + const currentTaskGroup = this.getTaskGroupById({ id: this.currentTask.relationships['task-group'].data.id }); + this.showSubmitDialog = false; + let attributes = {}; + attributes.submitted = true; + await this.updateTask({ + attributes: attributes, + taskId: this.currentTask.id, + }); + this.companionSuccess({ + info: '"' + currentTaskGroup.attributes.title + '" ' + this.$gettext('wurde erfolgreich abgegeben.'), + }); + this.currentTask = null; + }, + async copyContent(taskGroup, element) { + const unit = { + attributes: { + title: taskGroup.attributes.title, + purpose: 'content', + payload: { + description: '', + color: 'studip-blue', + license_type: '', + required_time: '', + difficulty_start: '', + difficulty_end: '' + }, + settings: { + 'root-layout': 'classic' + } + }, + relationships: { + range: { + data: { + type: 'users', + id: this.userId + } + } + } + }; + await this.createCoursewareUnit(unit, { root: true }); + const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id + await this.copyStructuralElement({ + parentId: newElementId, + elementId: element.id, + removeType: false, + migrate: true, + }); + this.companionSuccess({ + info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'), + }); + }, + displayFeedback(feedback) { + this.showFeedbackDialog = true; + this.currentTaskFeedback = feedback.attributes.content; + }, + toggleVisibilityOn(task) { + let attributes = task.attributes; + attributes['visible'] = true; + this.toggleVisibility(task, attributes); + }, + toggleVisibilityOff(task) { + let attributes = task.attributes; + attributes['visible'] = false; + this.toggleVisibility(task, attributes); + }, + async toggleVisibility(task, attributes) { + await this.updateTask({ + attributes: attributes, + taskId: task.id, + }); + + const taskGroup = this.getTaskGroupById({ id: task.relationships['task-group'].data.id }); + const taskTitle = taskGroup.attributes.title; + + if (attributes.visible) { + this.companionSuccess({ + info: this.$gettext('"%{ title }" wurde freigegeben.', { title: taskTitle }), + }); + } else { + this.companionSuccess({ + info: this.$gettext('Die Freigabe für %{ "title }" wurde zurückgenommen.', { title: taskTitle }), + }); + } + } + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue index 525a9c123546f0962e1f698a8997914095b13229..6e7f0343af39095dcedf81f686e57759aedf062c 100644 --- a/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue +++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue @@ -530,15 +530,9 @@ export default { } this.distributing = true; const startDate = new Date(this.startDate); - startDate.setHours(0); - startDate.setMinutes(0); - startDate.setSeconds(0); - startDate.setMilliseconds(0); + startDate.setHours(0, 0, 0, 0); const endDate = new Date(this.endDate); - endDate.setHours(23); - endDate.setMinutes(59); - endDate.setSeconds(59); - endDate.setMilliseconds(999); + endDate.setHours(23, 59, 59, 999); const taskGroup = { attributes: { title: this.taskTitle, diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue index a0ec342e1ca7713cdf9f52f82a2f9639ecf19a0d..f72f8f059df9407a5bd465af97936247eb6dba27 100644 --- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue @@ -1,14 +1,18 @@ <template> <div class="cw-tasks-wrapper"> <Teleport to="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> - <CoursewareTasksActionWidget :taskGroup="taskGroup" /> + <CoursewareTasksActionWidget + :taskGroup="taskGroup" + :hasPeerReviewProcesses="hasPeerReviewProcesses" + @add-peer-review-process="onShowPeerReviewProcessCreate" /> </Teleport> <div v-if="taskGroup" class="cw-tasks-list"> <ContentBar isContentBar> <template #buttons-left> <router-link :to="{ name: 'task-groups-index' }"> - <StudipIcon shape="category-task" :size="24" /> + <StudipIcon shape="category-task" :size="24" aria-role="presentation" /> + <span class="sr-only">{{ $gettext('Aufgaben') }}</span> </router-link> </template> <template #breadcrumb-list> @@ -34,6 +38,7 @@ :taskGroup="taskGroup" :tasks="tasksByGroup[taskGroup.id]" @add-feedback="onShowAddFeedback" + @add-peer-review-process="onShowPeerReviewProcessCreate" @edit-feedback="onShowEditFeedback" @solve-renewal="onShowSolveRenewal" /> @@ -57,6 +62,13 @@ @close="closeDialogs" /> + <PeerReviewProcessCreateDialog + v-if="showPeerReviewProcessCreate" + :taskGroup="taskGroup" + @create="onCreatePeerReviewProcess" + @close="closeDialogs" + /> + <RenewalDialog v-if="renewalTask" :renewalDate="renewalDate" @@ -65,7 +77,11 @@ @close="closeDialogs" /> - <TaskGroupsAddSolversDialog v-if="showTaskGroupsAddSolversDialog" :taskGroup="taskGroup" @newtask="reloadTasks" /> + <TaskGroupsAddSolversDialog + v-if="showTaskGroupsAddSolversDialog" + :taskGroup="taskGroup" + @newtask="reloadTasks" + /> <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="taskGroup" /> <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="taskGroup" /> <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" /> @@ -79,6 +95,7 @@ import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; import EditFeedbackDialog from './EditFeedbackDialog.vue'; +import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue'; import RenewalDialog from './RenewalDialog.vue'; import TaskGroup from './TaskGroup.vue'; import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; @@ -96,6 +113,7 @@ export default { CoursewareTasksActionWidget, CoursewareTasksDialogDistribute, EditFeedbackDialog, + PeerReviewProcessCreateDialog, RenewalDialog, StudipDate, TaskGroup, @@ -109,6 +127,7 @@ export default { currentDialogFeedback: {}, renewalTask: null, showAddFeedbackDialog: false, + showPeerReviewProcessCreate: null, showEditFeedbackDialog: false, }; }, @@ -116,6 +135,7 @@ export default { ...mapGetters({ context: 'context', getTaskGroup: 'courseware-task-groups/byId', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog', showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog', showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog', @@ -124,6 +144,12 @@ export default { tasksLoading: 'courseware-tasks/isLoading', userIsTeacher: 'userIsTeacher', }), + hasPeerReviewProcesses() { + return !!this.peerReviewProcesses; + }, + peerReviewProcesses() { + return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' }); + }, renewalDate() { return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date(); }, @@ -155,6 +181,7 @@ export default { ...mapActions({ companionError: 'companionError', companionSuccess: 'companionSuccess', + createPeerReviewProcess: 'tasks/createPeerReviewProcess', createTaskFeedback: 'createTaskFeedback', deleteTaskFeedback: 'deleteTaskFeedback', loadAllTasks: 'courseware-tasks/loadAll', @@ -165,6 +192,7 @@ export default { closeDialogs() { this.showAddFeedbackDialog = false; this.showEditFeedbackDialog = false; + this.showPeerReviewProcessCreate = false; this.currentDialogFeedback = {}; this.renewalTask = null; @@ -180,6 +208,12 @@ export default { this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback }); this.closeDialogs(); }, + onCreatePeerReviewProcess(options) { + this.createPeerReviewProcess({ taskGroup: this.taskGroup, options }) + .then(() => this.closeDialogs()) + .then(() => this.loadTaskGroup(this.taskGroup)); + + }, onShowAddFeedback(task) { this.currentDialogFeedback = { attributes: { content: '' }, @@ -198,6 +232,9 @@ export default { this.currentDialogFeedback = _.cloneDeep(feedback); this.showEditFeedbackDialog = true; }, + onShowPeerReviewProcessCreate() { + this.showPeerReviewProcessCreate = true; + }, onShowSolveRenewal(task) { this.renewalTask = _.cloneDeep(task); this.renewalTask.attributes['renewal-date'] = new Date().toISOString(); diff --git a/resources/vue/components/courseware/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue index a8dd83221afc5b7d319ff8967859a5f48e074af5..2cd6ecd9244230ed4bc45b71defdc88cc142e1c4 100644 --- a/resources/vue/components/courseware/tasks/RenewalDialog.vue +++ b/resources/vue/components/courseware/tasks/RenewalDialog.vue @@ -50,10 +50,7 @@ export default { }, updateRenewal() { const date = new Date(this.date); - date.setHours(23); - date.setMinutes(59); - date.setSeconds(59); - date.setMilliseconds(999); + date.setHours(23, 59, 59, 999); this.$emit('update', { state: this.state, diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue index 122cc7e53ece735caf153967f25200253966c211..a10a8848b4ca59df04447034b7541b48fdaa2a49 100644 --- a/resources/vue/components/courseware/tasks/TaskGroup.vue +++ b/resources/vue/components/courseware/tasks/TaskGroup.vue @@ -3,7 +3,9 @@ <section v-if="tasks.length > 0"> <table class="default"> <caption> - {{ $gettext('Verteilte Aufgaben') }} + {{ + $gettext('Verteilte Aufgaben') + }} </caption> <thead> <tr> @@ -29,6 +31,12 @@ /> </tbody> </table> + + <PeerReviewProcesses + :taskGroup="taskGroup" + @add-peer-review-process="$emit('add-peer-review-process', taskGroup)" + class="cw-task-group-peer-review-processes" + /> </section> <div v-else> <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" /> @@ -39,10 +47,11 @@ <script> import { mapGetters } from 'vuex'; import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import PeerReviewProcesses from './TaskGroupPeerReviewProcesses.vue'; import TaskItem from './TaskGroupTaskItem.vue'; export default { - components: { CompanionBox, TaskItem }, + components: { CompanionBox, PeerReviewProcesses, TaskItem }, emits: ['add-feedback', 'edit-feedback', 'solve-renewal'], props: ['taskGroup', 'tasks'], computed: { @@ -58,3 +67,9 @@ export default { }, }; </script> + +<style scoped> +.cw-task-group-peer-review-processes { + margin-block-start: 3rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue new file mode 100644 index 0000000000000000000000000000000000000000..c8262b4fae1fe9500aeea54a965cbb2215db6b43 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue @@ -0,0 +1,158 @@ +<template> + <div> + <StudipArticle> + <template #title> {{ $gettext('Peer-Review-Prozess') }} </template> + <template #body> + <CompanionBox + v-if="!hasPeerReviewProcesses" + mood="pointing" + :msgCompanion="$gettext('Für diese Aufgabe wurde noch kein Peer-Review-Prozess aktiviert.')" + :border="false" + > + <template #companionActions> + <button class="button" @click="$emit('add-peer-review-process')"> + {{ $gettext('Peer-Review-Prozess aktivieren') }} + </button> + </template> + </CompanionBox> + <ProcessDetail + v-for="process in peerReviewProcesses" + :key="process.id" + :process="process" + @show-assessment-type-editor="onShowAssessmentTypeEditor(process)" + @show-pairing-editor="onShowPairingEditor(process)" + @change-peer-review-process-duration="onShowPeerReviewProcessDuration(process)" + @edit-peer-review-process="onShowPeerReviewProcessEdit(process)" + /> + </template> + </StudipArticle> + + <AssessmentTypeEditorDialog + v-if="showAssessmentTypeEditor" + v-model:show="showAssessmentTypeEditor" + :process="selectedProcess" + @update="onUpdateAssessmentType" + /> + <PairingEditorDialog v-model:show="showPairingEditor" :process="selectedProcess" @update="onUpdatePairing" /> + <ProcessEditDialog + v-if="showPeerReviewProcessEdit" + :process="selectedProcess" + @update="onUpdatePeerReviewProcess" + @close="showPeerReviewProcessEdit = false" + /> + <ProcessDurationDialog + v-model:show="showPeerReviewProcessDuration" + :process="selectedProcess" + @update="onUpdateDuration" + /> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import AssessmentTypeEditorDialog from './peer-review/AssessmentTypeEditorDialog.vue'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import PairingEditorDialog from './peer-review/PairingEditorDialog.vue'; +import ProcessDetail from './peer-review/ProcessDetail.vue'; +import ProcessDurationDialog from './peer-review/ProcessDurationDialog.vue'; +import ProcessEditDialog from './peer-review/ProcessEditDialog.vue'; +import StudipArticle from '../../StudipArticle.vue'; +import { getStatus } from './task-groups-helper.js'; + +export default { + components: { + AssessmentTypeEditorDialog, + CompanionBox, + PairingEditorDialog, + ProcessDetail, + ProcessDurationDialog, + ProcessEditDialog, + StudipArticle, + }, + props: ['taskGroup'], + data: () => ({ + selectedProcess: null, + showAssessmentTypeEditor: false, + showPairingEditor: false, + showPeerReviewProcessDuration: false, + showPeerReviewProcessEdit: false, + }), + computed: { + ...mapGetters({ + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + }), + hasPeerReviewProcesses() { + return !!this.peerReviewProcesses; + }, + isAfter() { + return new Date() > new Date(this.taskGroup.attributes['end-date']); + }, + peerReviewProcesses() { + return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' }); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + replacePairings: 'tasks/replacePairings', + updatePeerReviewProcess: 'tasks/updatePeerReviewProcess', + }), + loadPeerReviews(process) { + return this.loadRelatedPeerReviews({ + parent: process, + relationship: 'peer-reviews', + options: { include: 'reviewer,task' }, + }); + }, + onShowAssessmentTypeEditor(process) { + this.selectedProcess = process; + this.showAssessmentTypeEditor = true; + }, + onShowPairingEditor(process) { + this.selectedProcess = process; + this.showPairingEditor = true; + }, + onShowPeerReviewProcessDuration(process) { + this.selectedProcess = process; + this.showPeerReviewProcessDuration = true; + }, + onShowPeerReviewProcessEdit(process) { + this.selectedProcess = process; + this.showPeerReviewProcessEdit = true; + }, + onUpdateAssessmentType(payload) { + const configuration = this.selectedProcess.attributes.configuration; + configuration.payload = payload; + + this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => { + this.selectedProcess = null; + this.showAssessmentTypeEditor = false; + }); + }, + onUpdateDuration(duration) { + const configuration = { ...this.selectedProcess.attributes.configuration, duration }; + this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => { + this.selectedProcess = null; + this.showPeerReviewProcessDuration = false; + }); + }, + onUpdatePairing(pairings) { + this.replacePairings({ process: this.selectedProcess, pairings }) + .then(() => this.loadPeerReviews(this.selectedProcess)) + .then(() => { + this.selectedProcess = null; + this.showPairingEditor = false; + }) + .catch((error) => { + console.error('Could not replace pairings.', error); + }); + }, + onUpdatePeerReviewProcess({ configuration }) { + this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => { + this.selectedProcess = null; + this.showPeerReviewProcessEdit = false; + }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue index 1903f6a30a327530fc8f97c7473d4fcc5df58d60..3dde8496b61c6c718bbaf267957dcb80a813b344 100644 --- a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue +++ b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue @@ -48,10 +48,7 @@ import StudipDate from '../../StudipDate.vue'; const midnight = (_date) => { const date = new Date(_date); - date.setHours(0); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); + date.setHours(0, 0, 0, 0); return date; }; diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..1cac7949b71717292e38e322027b33e98412b0b3 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue @@ -0,0 +1,115 @@ +<template> + <StudipDialog + v-if="show" + :title="$gettext('Peer-Review verfassen')" + :confirmText="isActive ? $gettext('Speichern') : ''" + confirmClass="accept" + :closeText="$gettext('Abbrechen')" + closeClass="cancel" + height="700" + width="700" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <CompanionBox + v-if="!isActive" + mood="sad" + :msgCompanion=" + $gettext( + 'Der Peer-Review-Prozess ist abgeschlossen. Sie können das Peer-Review nicht mehr ändern.' + ) + " + /> + <component + v-bind:is="assessmentComponent" + :process="process" + :review="review" + @answer="onAnswer" + ></component> + </template> + </StudipDialog> +</template> + +<script> +import AssessmentTypeForm from './assessment-types/forms/AssessmentTypeForm.vue'; +import AssessmentTypeFreetext from './assessment-types/forms/AssessmentTypeFreetext.vue'; +import AssessmentTypeTable from './assessment-types/forms/AssessmentTypeTable.vue'; +import ResultsTypeForm from './assessment-types/results/Form.vue'; +import ResultsTypeFreetext from './assessment-types/results/Freetext.vue'; +import ResultsTypeTable from './assessment-types/results/Table.vue'; +import { getProcessStatus, ProcessStatus } from './definitions.ts'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + components: { + CompanionBox, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + emits: ['update:show'], + data: () => ({ + assessment: {}, + }), + computed: { + ...mapGetters({ + relatedProcess: 'courseware-peer-review-processes/related', + }), + assessmentComponent() { + switch (this.configuration?.type) { + case 'form': + return this.isActive ? AssessmentTypeForm : ResultsTypeForm; + case 'freetext': + return this.isActive ? AssessmentTypeFreetext : ResultsTypeFreetext; + case 'table': + return this.isActive ? AssessmentTypeTable : ResultsTypeTable; + default: + return null; + } + }, + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + isActive() { + return this.process && getProcessStatus(this.process)?.status === ProcessStatus.Active; + }, + process() { + return this.relatedProcess({ + parent: { id: this.review.id, type: this.review.type }, + relationship: 'process', + }); + }, + }, + methods: { + ...mapActions({ + storeAssessment: 'tasks/storeAssessment', + }), + onAnswer(assessment) { + this.assessment = assessment; + }, + onClose() { + this.$emit('update:show', false); + this.assessment = {}; + }, + onConfirm() { + this.$emit('update:show', false); + this.storeAssessment({ review: this.review, assessment: this.assessment }); + this.globalEmit('push-system-notification', { + type: 'success', + message: this.$gettext('Peer-Review gespeichert.'), + }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..92589dfc66dd91dec867c059f95702e051eaa9da --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue @@ -0,0 +1,65 @@ +<template> + <component v-if="editorComponent" v-bind:is="editorComponent" v-model:payload="payload"></component> + <CompanionBox v-else :msgCompanion="$gettext('Dieses Bewertungssystem kann nicht konfiguriert werden.')" /> +</template> + +<script> +import { mapGetters } from 'vuex'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import EditorForm from './assessment-types/editors/EditorForm.vue'; +import EditorTable from './assessment-types/editors/EditorTable.vue'; +import { ASSESSMENT_TYPES } from './process-configuration'; + +const getPayload = (configuration) => { + const defaultPayload = ASSESSMENT_TYPES[configuration.type].defaultPayload ?? {}; + return _.isEmpty(configuration.payload) ? defaultPayload : configuration.payload; +}; + +const withPayload = (configuration, payload) => { + return { ...configuration, payload }; +}; + +export default { + props: { + configuration: { + type: Object, + default: () => ({}), + }, + }, + components: { CompanionBox }, + data() { + return { localPayload: _.cloneDeep(getPayload(this.configuration)) }; + }, + computed: { + ...mapGetters({}), + editorComponent() { + switch (this.configuration?.type) { + case 'form': + return EditorForm; + case 'freetext': + return null; + case 'table': + return EditorTable; + default: + return null; + } + }, + payload: { + get() { + return getPayload(this.configuration); + }, + set(payload) { + this.updatePayload(payload); + }, + }, + }, + methods: { + updatePayload(payload) { + if (!_.isEqual(this.localPayload, payload)) { + this.localPayload = payload; + this.$emit('update', withPayload(this.configuration, this.localPayload)); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..679e033014055d311d5bc202c024e3756249c170 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue @@ -0,0 +1,84 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Peer-Review-Form ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + width="800" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <component v-bind:is="editorComponent" v-model:payload="payload"></component> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import EditorForm from './assessment-types/editors/EditorForm.vue'; +import EditorTable from './assessment-types/editors/EditorTable.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { ASSESSMENT_TYPES } from './process-configuration'; + +const getConfiguration = (process) => process?.attributes?.configuration ?? {}; +const getPayload = (process) => { + const configuration = getConfiguration(process); + const defaultPayload = ASSESSMENT_TYPES[configuration.type].defaultPayload ?? {}; + return _.isEmpty(configuration.payload) ? defaultPayload : configuration.payload; +}; + +export default { + components: { + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + emits: ['update:show', 'update'], + data() { + return { localPayload: _.cloneDeep(getPayload(this.process)) }; + }, + computed: { + ...mapGetters({}), + editorComponent() { + switch (getConfiguration(this.process)?.type) { + case 'form': + return EditorForm; + case 'freetext': + return null; + case 'table': + return EditorTable; + default: + return null; + } + }, + payload: { + get() { + return getPayload(this.process); + }, + set(payload) { + this.localPayload = payload; + }, + }, + }, + methods: { + onClose() { + this.$emit('update:show', false); + }, + onConfirm(...args) { + this.$emit('update', _.cloneDeep(this.localPayload)); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..09ff0237c66716285c6e42b405f9e635800465b5 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue @@ -0,0 +1,200 @@ +<template> + <div> + <form class="default"> + <div> + <label> + {{ $gettext('Lösung von') }} + <select v-model="selectedSubmitter" size="10"> + <option v-for="solver in selectableSubmitters" :key="solver.id" :value="solver"> + <span v-if="isUser(solver)"> + {{ solver.attributes['formatted-name'] }} + </span> + <span v-if="isStatusGroup(solver)"> + {{ solver.attributes.name }} + </span> + </option> + <option v-if="!selectableSubmitters?.length" disabled>{{ $gettext('--leer--') }}</option> + </select> + </label> + </div> + <div> + <label> + {{ $gettext('Peer-Review von') }} + <select v-model="selectedReviewer" size="10"> + <option + v-for="solver in selectableReviewers" + :key="solver.id" + :value="solver" + :disabled="solver.id === selectedSubmitter?.id" + > + <span v-if="isUser(solver)"> + {{ solver.attributes['formatted-name'] }} + </span> + <span v-if="isStatusGroup(solver)"> + {{ solver.attributes.name }} + </span> + </option> + <option v-if="!selectableReviewers?.length" disabled>{{ $gettext('--leer--') }}</option> + </select> + </label> + </div> + <div> + <div> + <div>{{ $gettext('Paarungen') }}</div> + <div> + <button + class="button" + type="button" + :disabled="!(selectedSubmitter && selectedReviewer)" + @click="onAdd" + > + <span>{{ $gettext('Paarung hinzufügen') }}</span> + </button> + <table> + <tr v-for="({ submitter, reviewer }, index) in localPairings" :key="index"> + <td> + <span v-if="submitter.type === 'users'"> + {{ submitter.attributes['formatted-name'] }} + </span> + <span v-if="submitter.type === 'status-groups'"> + {{ submitter.attributes.name }} + </span> + </td> + + <td><span>»</span></td> + <td> + <span v-if="reviewer.type === 'users'"> + {{ reviewer.attributes['formatted-name'] }} + </span> + <span v-if="reviewer.type === 'status-groups'"> + {{ reviewer.attributes.name }} + </span> + </td> + <td> + <StudipIcon + name="delete" + shape="trash" + :size="20" + :title="$gettext('Paarung entfernen')" + @click.prevent="onTrash(index)" + /> + </td> + </tr> + </table> + </div> + </div> + </div> + </form> + </div> +</template> + +<script> +import _ from 'lodash'; +import { mapGetters } from 'vuex'; +import StudipIcon from '../../../StudipIcon.vue'; + +export default { + components: { StudipIcon }, + props: { + pairings: { + type: Array, + required: true, + }, + solvers: { + type: Array, + default: () => [], + }, + }, + emits: ['update:pairings'], + data() { + return { + localPairings: [], + selectedSubmitter: null, + selectedReviewer: null, + }; + }, + computed: { + selectableReviewers() { + const selected = this.localPairings.map(({ reviewer }) => reviewer.id); + return this.solvers.filter(({ id }) => !selected.includes(id)); + }, + selectableSubmitters() { + const selected = this.localPairings.map(({ submitter }) => submitter.id); + return this.solvers.filter(({ id }) => !selected.includes(id)); + }, + }, + methods: { + isStatusGroup(object) { + return object.type === 'status-groups'; + }, + isUser(object) { + return object.type === 'users'; + }, + onAdd() { + this.localPairings = [ + ...this.localPairings, + { + reviewer: this.selectedReviewer, + submitter: this.selectedSubmitter, + }, + ]; + this.selectedReviewer = null; + this.selectedSubmitter = null; + }, + onTrash(index) { + this.localPairings = [...this.localPairings.slice(0, index), ...this.localPairings.slice(index + 1)]; + }, + resetLocalState() { + this.localPairings = [...this.pairings]; + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + localPairings(newP, oldP) { + if (!_.isEqual(this.localPairings, this.pairings)) { + this.$emit('update:pairings', [...this.localPairings]); + } + }, + pairings() { + if (!_.isEqual(this.localPairings, this.pairings)) { + this.resetLocalState(); + } + }, + selectedReviewer() { + if (this.selectedReviewer === this.selectedSubmitter) { + this.selectedSubmitter = null; + } + }, + selectedSubmitter() { + if (this.selectedReviewer === this.selectedSubmitter) { + this.selectedReviewer = null; + } + }, + }, +}; +</script> + +<style scoped> +form { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +form > :nth-child(-n + 2) { + flex-grow: 0; + min-width: 15rem; +} + +form > :nth-child(3) { + flex-basis: 100%; + flex-grow: 1; +} + +tr > :nth-child(2), +tr > :nth-child(4) { + padding-inline: 0.5rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..29aef1c54341ccb0e8963614094a98e582e520fb --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue @@ -0,0 +1,102 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Zuordnungen festlegen')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :confirmDisabled="!pairings?.length" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="800" + width="800" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <PairingEditor v-if="!storing && pairings" v-model:pairings="pairings" :solvers="solvers" /> + <ProgressIndicator v-if="storing" :description="$gettext('Zuordnungen werden gespeichert …')" /> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import PairingEditor from './PairingEditor.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import ProgressIndicator from '../../../StudipProgressIndicator.vue'; + +const objId = ({ id, type }) => ({ id, type }); + +export default { + components: { + PairingEditor, + ProgressIndicator, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + emits: ['update:show', 'update'], + data() { + return { + pairings: [], + storing: false, + }; + }, + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTaskGroups: 'courseware-task-groups/related', + }), + reviewPairs() { + return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }).map((review) => ({ + reviewer: this.getObject(review.relationships.reviewer.data), + submitter: this.getObject(review.relationships.submitter.data), + })); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map((solver) => this.getObject(solver)); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + }, + methods: { + getObject({ type, id }) { + return this.$store.getters[`${type}/byId`]({ id }); + }, + onClose() { + this.$emit('update:show', false); + }, + onConfirm() { + if (!this.storing) { + this.storing = true; + this.$emit('update', this.pairings); + } + }, + resetLocalState() { + this.storing = false; + }, + }, + mounted() { + this.resetLocalState(); + }, + updated() { + this.resetLocalState(); + }, + watch: { + show() { + if (this.show) { + this.pairings = this.reviewPairs; + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue new file mode 100644 index 0000000000000000000000000000000000000000..c88016a99f6274432b7cba6755678c0e8806f725 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue @@ -0,0 +1,66 @@ +<template> + <div v-if="peerReviews && peerReviews.length > 0"> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext('Aufgabe') }}</th> + <th>{{ $gettext('Lösung von') }}</th> + <th>{{ $gettext('Peer-Review von') }}</th> + <th> </th> + </tr> + </thead> + <tbody> + <PeerReviewListItem + v-for="review in peerReviews" + :review="review" + :key="review.id" + :process="process" + :task-group="taskGroup" + @show-assessment="onShowAssessment(review)" + /> + </tbody> + </table> + <PeerReviewResultDialog v-model:show="showPeerReview" v-if="selectedPeerReview" :review="selectedPeerReview" /> + </div> + <div v-else> + {{ $gettext("Bisher sind noch keine Peer-Review-Paarungen erstellt worden.") }} + </div> +</template> + +<script> +import { mapGetters } from 'vuex'; +import PeerReviewListItem from './PeerReviewListItem.vue'; +import PeerReviewResultDialog from './ResultDialog.vue'; + +export default { + components: { PeerReviewListItem, PeerReviewResultDialog }, + props: { + process: { + type: Object, + required: true, + }, + taskGroup: { + type: Object, + required: true, + }, + }, + data: () => ({ + selectedPeerReview: null, + showPeerReview: false, + }), + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + }), + peerReviews() { + return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + }, + }, + methods: { + onShowAssessment(review) { + this.selectedPeerReview = review; + this.showPeerReview = true; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..d24616e3a83239d4343bab30e3738df4c5822ea0 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue @@ -0,0 +1,134 @@ +<template> + <tr> + <td> + <a :href="getLinkToElement(element)"> + {{ taskGroup.attributes.title }} + </a> + </td> + <td> + <a v-if="isUser(submitter)" :href="userProfile(submitter)"> + <UserAvatar + :avatar-url="submitter.meta.avatar.small" + :formatted-name="submitter.attributes['formatted-name']" + small + /> + </a> + <a v-else :href="statusGroupUrl(submitter)"> + {{ submitter.attributes.name }} + </a> + </td> + <td> + <a v-if="isUser(reviewer)" :href="userProfile(reviewer)"> + <UserAvatar + :avatar-url="reviewer.meta.avatar.small" + :formatted-name="reviewer.attributes['formatted-name']" + small + /> + </a> + <a v-else :href="statusGroupUrl(reviewer)"> + {{ reviewer.attributes.name }} + </a> + </td> + <td> + <template v-if="isPeerReviewAfter"> + <template v-if="review.attributes.assessment"> + <button class="button" @click="onShowAssessment(review)"> + {{ $gettext('Peer-Review anzeigen') }} + </button> + </template> + <template v-else> + {{ $gettext('Kein Peer-Review abgegeben') }} + </template> + </template> + <template v-else> + {{ $gettext('Peer-Review sichtbar ab:') }} + <StudipDate :date="new Date(process.attributes['review-end'])" /> + </template> + </td> + </tr> +</template> + +<script> +import { mapGetters } from 'vuex'; +import StudipDate from '@/vue/components/StudipDate.vue'; +import UserAvatar from '@/vue/components/StudipUserAvatar.vue'; +import taskHelper from '../../../../mixins/courseware/task-helper.js'; +import { getProcessStatus, ProcessStatus } from './definitions'; + +export default { + mixins: [taskHelper], + props: { + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + taskGroup: { + type: Object, + required: true, + }, + }, + components: { StudipDate, UserAvatar }, + computed: { + ...mapGetters({ + context: 'context', + relatedStructuralElement: 'courseware-structural-elements/related', + relatedTasks: 'courseware-tasks/related', + relatedStatusGroups: 'status-groups/related', + relatedUsers: 'users/related', + }), + element() { + const parent = { id: this.task.id, type: this.task.type }; + const relationship = 'structural-element'; + return this.relatedStructuralElement({ parent, relationship }); + }, + isPeerReviewAfter() { + return getProcessStatus(this.process)?.status === ProcessStatus.After; + }, + reviewer() { + const user = this.relatedUsers({ parent: this.review, relationship: 'reviewer' }); + if (user) { + return user; + } + const statusGroup = this.relatedStatusGroups({ parent: this.review, relationship: 'reviewer' }); + return statusGroup; + }, + submitter() { + const user = this.relatedUsers({ parent: this.task, relationship: 'solver' }); + if (user) { + return user; + } + const statusGroup = this.relatedStatusGroups({ parent: this.task, relationship: 'solver' }); + return statusGroup; + }, + task() { + const parent = { id: this.review.id, type: this.review.type }; + const relationship = 'task'; + return this.relatedTasks({ parent, relationship }); + }, + }, + methods: { + isUser(object) { + return object.type === 'users'; + }, + onShowAssessment() { + this.$emit('show-assessment'); + }, + statusGroupUrl(statusGroup) { + const cid = this.context.id; + return window.STUDIP.URLHelper.getURL( + 'dispatch.php/course/statusgroups', + { cid, contentbox_open: statusGroup.id }, + true + ); + }, + userProfile(user) { + const username = user.attributes.username; + return window.STUDIP.URLHelper.getURL('dispatch.php/profile', { username }, true); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue new file mode 100644 index 0000000000000000000000000000000000000000..e973a5003a2cd56cb4aa4fd7d8a1f0c34b76c2f4 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue @@ -0,0 +1,39 @@ +<template> + <ul> + <li v-if="options.anonymous">{{ $gettext('Anonymes Review') }}</li> + <li v-else>{{ $gettext('Offenes Review') }}</li> + + <li> + {{ + $gettextInterpolate($gettext('%{n} Tage Zeit für das Review'), { + n: options.duration, + }) + }} + </li> + + <li> + {{ reviewTypes[options.type].long }} + </li> + + <li v-if="options.automaticPairing"> + {{ $gettext('Zusammenstellung der Review-Paarungen durch das Programm') }} + </li> + <li v-else>{{ $gettext('Zusammenstellung der Review-Paarungen durch die Lehrenden') }}</li> + </ul> +</template> + +<script> +import { ProcessConfiguration, ASSESSMENT_TYPES } from './process-configuration'; + +export default { + props: { + options: { + required: true, + type: Object, + }, + }, + computed: { + reviewTypes: () => ASSESSMENT_TYPES, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..1615107cf832a7c296f817eaf8396567416e7f42 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue @@ -0,0 +1,131 @@ +<template> + <StudipDialog + :title="$gettext('Peer-Review-Prozess anlegen')" + :confirmText="$gettext('Anlegen')" + :confirmDisabled="creating" + :closeText="$gettext('Abbrechen')" + @close="$emit('close')" + @confirm="create" + height="800" + width="800" + > + <template #dialogContent> + <div v-if="!creating" class="with-sidebar"> + <div> + <ul> + <li :class="{ active: selectedSlot === 'configuration' }"> + <a href="#" @click.prevent="selectedSlot = 'configuration'"> + {{ $gettext('Einstellungen') }} + </a> + </li> + <li :class="{ active: selectedSlot === 'assessment' }"> + <a href="#" @click.prevent="selectedSlot = 'assessment'"> + {{ $gettext('Bewertungssystem') }} + </a> + </li> + </ul> + </div> + <div v-if="selectedSlot === 'configuration'"> + <ProcessCreateForm :configuration="configuration" @update="updateConfiguration" /> + </div> + <div v-if="selectedSlot === 'assessment'"> + <AssessmentTypeEditor :configuration="configuration" @update="updateConfiguration" /> + </div> + </div> + <div v-if="creating"> + <CompanionBox :msgCompanion="$gettext('Der Peer-Review-Prozess wird jetzt angelegt.')" /> + </div> + </template> + </StudipDialog> +</template> + +<script> +import AssessmentTypeEditor from './AssessmentTypeEditor.vue'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import ProcessCreateForm from './ProcessCreateForm.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { defaultConfiguration, ProcessConfiguration } from './process-configuration'; + +export default { + components: { AssessmentTypeEditor, CompanionBox, ProcessCreateForm, StudipDialog }, + props: ['taskGroup'], + data: () => ({ + changed: false, + configuration: defaultConfiguration(), + creating: false, + selectedSlot: 'configuration', + }), + methods: { + create() { + if (this.creating) { + return; + } + this.creating = true; + this.$emit('create', { ...this.configuration }); + }, + updateConfiguration(configuration) { + this.changed = true; + this.configuration = configuration; + }, + }, +}; +</script> + +<style scoped lang="scss"> +.with-sidebar { + display: flex; + flex-wrap: wrap; + gap: 1em; +} + +.with-sidebar > :first-child { + flex-grow: 1; +} + +.with-sidebar > :last-child { + flex-basis: 0; + flex-grow: 999; + min-inline-size: 50%; +} + +.with-sidebar > :first-child { + ul { + list-style: none; + padding: 0; + width: 12em; + + > li:has(> a):not(:last-child) { + border-bottom: solid thin var(--color--sidebar-divider); + } + + > li { + padding-block: 2px; + padding-inline-start: 5px; + + a { + display: block; + line-height: 17px; + padding-block: 4px; + padding-inline: 0px; + word-wrap: break-word; + } + + &.active { + background-color: var(--color--sidebar-active); + border-left: solid 4px var(--color--sidebar-marker-active); + margin-left: -4px; + padding-left: 1px; + + a { + color: var(--black); + padding-left: 4px; + } + } + } + + > li.active { + background-color: var(--color--sidebar-active); + } + } +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..6685baa6963f802683b6d1c7afad78d0b57acb3b --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue @@ -0,0 +1,319 @@ +<template> + <form class="default" @submit.prevent=""> + <fieldset class="select_configuration_set"> + <template v-for="(configurationSet, index) in configurationSets" :key="`configuration-set-${index}`"> + <input + :aria-description="'todo'" + :checked="selectedConfigurationSet === index" + :id="`configuration_set_${index}`" + :value="index" + name="selected_configuration_set" + type="radio" + /> + <label @click="selectConfigurationSet(index)"> + <div class="icon"> + <studip-icon + :shape="`radiobutton-${selectedConfigurationSet === index ? 'checked' : 'unchecked'}`" + :size="24" + /> + </div> + <div class="text"> + {{ configurationSet.name }} + </div> + <studip-icon shape="arr_1down" :size="24" class="arrow" /> + <studip-icon shape="check-circle" :size="24" class="check" /> + </label> + <div> + <PeerReviewProcessConfiguration :options="configurationSet.configuration" /> + </div> + </template> + + <input + :aria-description="'todo'" + :checked="selectedConfigurationSet === null" + id="configuration_set_custom" + value="custom" + name="selected_configuration_set" + type="radio" + /> + <label @click="selectConfigurationSet(null)"> + <div class="icon"> + <studip-icon + :shape="`radiobutton-${selectedConfigurationSet === null ? 'checked' : 'unchecked'}`" + :size="24" + /> + </div> + <div class="text"> + {{ $gettext('Eigene Einstellungen') }} + </div> + <studip-icon shape="arr_1down" :size="24" class="arrow" /> + <studip-icon shape="check-circle" :size="24" class="check" /> + </label> + <div class="peer-review-process-create-form-custom-configuration"> + <div class="custom-configuration"> + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-anonymous`" + :label="$gettext('Anonymes oder offenes Review:')" + > + <select + v-model="localConfiguration.anonymous" + :id="`peer-review-process-create-form-${uid}-anonymous`" + @change="update" + > + <option :value="true">{{ $gettext('anonym') }}</option> + <option :value="false">{{ $gettext('offen') }}</option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-duration`" + :label="$gettext('Bearbeitungszeitraum in Tagen:')" + > + <select + v-model.number="localConfiguration.duration" + :id="`peer-review-process-create-form-${uid}-duration`" + @change="update" + > + <option v-for="i in 21" :key="i">{{ i }}</option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-type`" + :label="$gettext('Art des Reviews:')" + > + <select + v-model="localConfiguration.type" + :id="`peer-review-process-create-form-${uid}-type`" + @change="onChangeType" + > + <option v-for="[key, { short }] in Object.entries(reviewTypes)" :key="key" :value="key"> + {{ short }} + </option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-true-process-create-form-${uid}-anonymous`" + :label="$gettext('Review-Paarungen')" + > + <select + v-model="localConfiguration.automaticPairing" + :id="`peer-review-process-create-form-${uid}-automatic-pairing`" + @change="update" + > + <option :value="true">{{ $gettext('Zufall') }}</option> + <option :value="false">{{ $gettext('Manuell') }}</option> + </select> + </LabelRequired> + </div> + </div> + </div> + </fieldset> + </form> +</template> + +<script> +import LabelRequired from '../../../forms/LabelRequired.vue'; +import PeerReviewProcessConfiguration from './ProcessConfiguration.vue'; +import { ASSESSMENT_TYPES, CONFIGURATION_SETS, ProcessConfiguration } from './process-configuration'; + +let nextId = 0; + +export default { + components: { LabelRequired, PeerReviewProcessConfiguration }, + props: { + configuration: { + required: true, + type: Object, + }, + custom: { + type: Boolean, + default: false, + }, + }, + data() { + return { + localConfiguration: { ...this.configuration }, + selectedConfigurationSet: 0, + uid: nextId++, + }; + }, + computed: { + reviewTypes: () => ASSESSMENT_TYPES, + configurationSets: () => CONFIGURATION_SETS, + }, + methods: { + customizeConfiguration() { + this.update(); + }, + findSelectedConfigurationSet() { + const index = this.configurationSets.findIndex(({ configuration }) => + _.isEqual(this.configuration, configuration), + ); + this.selectedConfigurationSet = index === -1 ? null : index; + }, + onChangeType() { + this.localConfiguration.payload = + this.localConfiguration.type === this.configuration.type + ? this.configuration.payload + : ASSESSMENT_TYPES[this.localConfiguration.type].defaultPayload; + this.customizeConfiguration(); + }, + resetData() { + this.localConfiguration = { ...this.configuration }; + // this.findSelectedConfigurationSet(); + }, + selectConfigurationSet(configurationSetIndex) { + this.selectedConfigurationSet = configurationSetIndex; + if (configurationSetIndex in CONFIGURATION_SETS) { + this.localConfiguration = CONFIGURATION_SETS[configurationSetIndex].configuration; + } + this.update(); + }, + update() { + this.$emit('update', this.localConfiguration); + }, + }, + mounted() { + this.findSelectedConfigurationSet(); + }, + watch: { + configuration() { + this.localConfiguration = { ...this.configuration }; + }, + }, +}; +</script> + +<style scoped lang="scss"> +.peer-review-process-create-form-type-cards { + box-sizing: border-box; + width: 100%; + margin-block: 1.5rem 0; + + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + --threshold: 45rem; + + article { + flex-grow: 1; + flex-basis: calc((var(--threshold) - 100%) * 999); + box-sizing: border-box; + padding: 1rem; + border: 2px var(--dark-gray-color-20) solid; + + &.selected { + border-color: var(--dark-gray-color-80); + border-width: 2px; + } + + h2 { + font-weight: bold; + font-size: 1.2rem; + margin-block: 1rem 0; + } + button { + margin-block: 1.5rem; + } + ul { + padding-inline: 1em 0; + } + li { + padding-block: 0.5rem; + } + } + + > :nth-last-child(n + 4), + > :nth-last-child(n + 4) ~ * { + flex-basis: 100%; + } +} + +.peer-review-process-create-form-type-cards + section { + text-align: center; + margin-block-end: 1.5rem; +} + +.peer-review-process-create-form-custom-configuration { + margin-block: 1.5rem; +} + +.custom-configuration { + padding: 1rem; +} + +fieldset.select_configuration_set { + border: none; + padding: 0; + margin: 0; + + > :not(legend) { + margin: 0; + } + + > input[type='radio'] { + opacity: 0; + position: absolute; + &:focus + label { + outline: auto; + } + } + > label { + cursor: pointer; + border: 1px solid var(--content-color-40); + transition: background-color var(--transition-duration); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px 0; + margin: 0; + border-top: none; + > .text { + width: 100%; + margin-left: 10px; + } + > .check { + display: none; + } + + > .icon { + margin-top: 6px; + } + } + > label:first-of-type { + border-top: 1px solid var(--content-color-40); + } + > div { + border: 1px solid var(--content-color-40); + border-top: none; + display: none; + padding: 10px; + } + > input[type='radio']:checked + label { + background-color: var(--content-color-20); + transition: background-color var(--transition-duration); + > .arrow { + display: none; + } + > .check { + display: inline-block; + } + } + > input[type='radio']:checked + label + div { + display: block; + > * { + animation-duration: 400ms; + animation-name: terms_of_use_fadein; + } + } +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..a33cad2ba7d434ef491625d8bb6b05dec4d748a7 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue @@ -0,0 +1,217 @@ +<template> + <div> + <CompanionBox + v-if="isActive" + :msgCompanion=" + $gettext( + 'Der Peer-Review-Prozess hat bereits begonnen. Die Einstellungen können bis auf die Bearbeitungsdauer nicht geändert werden.', + ) + " + /> + + <section> + <article> + <header> + <h4>{{ $gettext('Status') }}</h4> + </header> + <div class="cw-peer-review-processes-status"> + <ProcessStatus :process="process" /> + <span>{{ processStatus.description }}</span> + </div> + <div class="cw-peer-review-processes-duration"> + <span>{{ $gettext('Bearbeitungszeit:') }}</span> + <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + <div v-if="canChangeDurationOnly"> + <button class="button" @click="$emit('change-peer-review-process-duration')"> + {{ $gettext('Bearbeitungszeit verlängern') }} + </button> + </div> + </div> + <div v-if="isBefore"> + <div> + {{ + isAutomaticPairing + ? $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.', + ) + : $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.', + ) + }} + </div> + + <button v-if="!isAutomaticPairing" class="button" @click="$emit('show-pairing-editor')"> + {{ $gettext('Paarungen manuell festlegen') }} + </button> + </div> + </article> + <article> + <header> + <h4>{{ $gettext('Einstellungen') }}</h4> + </header> + <div> + <ProcessConfiguration :options="configuration" /> + </div> + <div> + <button + v-if="canChangeConfiguration" + class="button" + @click="$emit('edit-peer-review-process')" + > + {{ $gettext('Einstellungen ändern') }} + </button> + <button + v-if="configuration.type === 'form' || configuration.type === 'table'" + class="button" + @click="$emit('show-assessment-type-editor')" + :disabled="!canChangeConfiguration" + > + {{ $gettext('Bewertungssystem konfigurieren') }} + </button> + </div> + </article> + + <article> + <header> + <h4>{{ $gettext('Peer-Review-Paarungen') }}</h4> + </header> + <PeerReviewList :process="process" :task-group="taskGroup" /> + </article> + </section> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import PeerReviewList from './PeerReviewList.vue'; +import ProcessConfiguration from './ProcessConfiguration.vue'; +import ProcessStatus from './ProcessStatus.vue'; +import StudipDate from '../../../StudipDate.vue'; +import { getProcessStatus, ProcessStatus as Status } from './definitions'; + +export default { + components: { + CompanionBox, + PeerReviewList, + ProcessConfiguration, + ProcessStatus, + StudipDate, + }, + props: { + process: { + type: Object, + required: true, + }, + }, + emits: [ + 'show-assessment-type-editor', + 'show-pairing-editor', + 'change-peer-review-process-duration', + 'edit-peer-review-process', + ], + data: () => ({}), + computed: { + ...mapGetters({ + getProcess: 'courseware-peer-review-processes/byId', + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTasks: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + relatedUsers: 'users/related', + userIsTeacher: 'userIsTeacher', + }), + canChangeConfiguration() { + return this.isBefore; + }, + canChangeDurationOnly() { + return this.processStatus.status === Status.Active; + }, + configuration() { + return this.process.attributes['configuration']; + }, + endDate() { + return new Date(this.process.attributes['review-end']); + }, + isActive() { + return this.processStatus.status === Status.Active; + }, + isAfter() { + return this.processStatus.status === Status.After; + }, + isBefore() { + return this.processStatus.status === Status.Before; + }, + isAutomaticPairing() { + return this.configuration.automaticPairing; + }, + owner() { + return this.relatedUsers({ parent: this.process, relationship: 'owner' }); + }, + peerReviews() { + const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + return result; + }, + processStatus() { + return getProcessStatus(this.process); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map(({ id, type }) => { + return [id, type]; + }); + }, + startDate() { + return new Date(this.process.attributes['review-start']); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + tasks() { + return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' }); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + }), + loadPeerReviews() { + return this.loadRelatedPeerReviews({ + parent: this.process, + relationship: 'peer-reviews', + options: { include: 'reviewer,task' }, + }); + }, + }, + async mounted() { + await this.loadPeerReviews(); + }, +}; +</script> + +<style scoped> +.cw-peer-review-processes-status { + display: flex; + gap: 0.25rem; +} + +section { + display: flex; + flex-wrap: wrap; + gap: 2em; +} + +section > article:last-child { + grow: 1; + flex-basis: 100%; +} + +section > article:nth-child(-n + 2) { + grow: 0; + flex-basis: 32em; +} + +section > article:first-child { + display: flex; + gap: 1em; + flex-direction: column; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..46eae8f449e5651d9e913ad674559e09048c3e4d --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue @@ -0,0 +1,116 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Bearbeitungszeit ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <form class="default"> + <p> + {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }}) + </p> + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-${uid}`" + :label="$gettext('Bearbeitungszeit verlängern bis zum:')" + /> + <input + :id="`peer-review-process-${uid}`" + name="end-date" + type="date" + v-model="localEndDate" + :min="endDateString" + class="size-l" + required + /> + <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div> + </div> + </form> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import StudipDate from '../../../StudipDate.vue'; +import StudipDialog from '../../../StudipDialog.vue'; + +const midnight = (_date) => { + const date = new Date(_date); + date.setHours(0, 0, 0, 0); + return date; +}; + +const dateString = (date) => + `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`; + +let nextUid = 0; + +export default { + components: { + LabelRequired, + StudipDate, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + emits: ['update:show', 'update'], + data: () => ({ localEndDate: null, uid: nextUid++ }), + computed: { + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + endDate() { + return midnight(this.process?.attributes?.['review-end'] ?? new Date()); + }, + endDateString() { + return dateString(this.endDate); + }, + newDuration() { + return this.localEndDate + ? Math.floor((midnight(this.localEndDate) - midnight(this.startDate)) / (1000 * 60 * 60 * 24)) + : 0; + }, + oldDuration() { + return this.configuration.duration ?? '??'; + }, + startDate() { + return midnight(this.process.attributes['review-start']); + }, + }, + methods: { + onClose() { + this.$emit('update:show', false); + }, + onConfirm(...args) { + this.$emit('update', this.newDuration); + }, + resetLocalVars() { + this.localEndDate = dateString(this.endDate ?? new Date()); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + process() { + this.resetLocalVars(); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..751f95236414db90b36c9735458d0a4cf605d125 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue @@ -0,0 +1,64 @@ +<template> + <StudipDialog + :title="title" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :confirmDisabled="!changed" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="600" + width="800" + @close="$emit('close')" + @confirm="confirm" + > + <template #dialogContent> + <ProcessCreateForm :configuration="process.attributes.configuration" custom @update="updateConfiguration" /> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext'; +import StudipDialog from '../../../StudipDialog.vue'; +import ProcessCreateForm from './ProcessCreateForm.vue'; +import { defaultConfiguration, ProcessConfiguration } from './process-configuration'; + +export default { + components: { ProcessCreateForm, StudipDialog }, + props: ['process'], + data: () => ({ + changed: false, + configuration: defaultConfiguration(), + }), + computed: { + ...mapGetters({ + relatedTaskGroups: 'courseware-task-groups/related', + }), + title() { + const taskGroup = this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + return $gettextInterpolate($gettext('Peer-Review-Prozess anlegen zur Aufgabe "%{title}"'), { + title: taskGroup.attributes.title, + }); + }, + }, + methods: { + confirm() { + this.$emit('update', { + process: this.process, + configuration: { ...this.configuration }, + }); + }, + updateConfiguration(configuration) { + this.changed = true; + this.configuration = configuration; + }, + }, +}; +</script> + +<style scoped> +header { + margin-block-end: 2rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue new file mode 100644 index 0000000000000000000000000000000000000000..666514d8aa7bd10ef1f961ef40cc13904f43b5cc --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue @@ -0,0 +1,47 @@ +<template> + <span class="peer-review-process-status" v-if="!filter || status.status === filter"> + <StudipIcon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + aria-hidden="true" + /> + <span :class="{'sr-only': !description }">{{ status.description }}</span> + </span> +</template> +<script> +import StudipIcon from '../../../StudipIcon.vue'; +import { getProcessStatus, ProcessStatus } from './definitions'; + +export default { + components: { StudipIcon }, + props: { + description: { + type: Boolean, + default: false, + }, + filter: { + type: String, + default: null, + }, + process: { + type: Object, + required: true, + }, + }, + computed: { + status() { + return getProcessStatus(this.process); + }, + }, +}; +</script> + +<style scoped> +.peer-review-process-status { + display: flex; + align-items: center; + gap: 0.5rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue new file mode 100644 index 0000000000000000000000000000000000000000..59c80ffa769744bfbb4fb9c2994b309dda84face --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue @@ -0,0 +1,174 @@ +<template> + <div class="cw-peer-review-processes-wrapper" v-if="!userIsTeacher"> + <table class="default" v-if="peerReviews.length"> + <caption> + {{ $gettext('Peer-Reviews') }} + </caption> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Bearbeitungszeit') }}</th> + <th>{{ $gettext('Aufgabe') }}</th> + <th> + {{ $gettext('Erhaltene Peer-Reviews') }} + </th> + <th> + {{ $gettext('Gegebene Peer-Reviews') }} + </th> + </tr> + </thead> + <tbody> + <tr v-for="process in processes" :key="process.id"> + <td> + <ProcessStatusIcon :process="process" /> + </td> + <td> + <StudipDate :date="new Date(process.attributes['review-start'])" /> + - + <StudipDate :date="new Date(process.attributes['review-end'])" /> + </td> + <td> + {{ taskGroups[process.id].attributes.title }} + </td> + <td> + <div v-for="review in peerReviewsForMe(process)" :key="review.id"> + <template v-if="isPeerReviewProcessAfter(process)"> + <template v-if="review.attributes.assessment"> + <a :href="elementUrls[review.id]" class="button"> + {{ $gettext('Erhaltenes Peer-Review anzeigen') }} + </a> + </template> + </template> + <template v-else> + <button class="button" disabled> + {{ $gettext('Peer-Review noch nicht sichtbar') }} + </button> + </template> + </div> + </td> + <td> + <div v-for="review in peerReviewsFromMe(process)" :key="review.id"> + <template v-if="isPeerReviewProcessActive(process)"> + <a :href="elementUrls[review.id]" class="button"> + {{ $gettext('Peer-Review geben') }} + </a> + </template> + <template v-else-if="review.attributes.assessment"> + <a :href="elementUrls[review.id]" class="button"> + {{ $gettext('Gegebenes Peer-Review anzeigen') }} + </a> + </template> + </div> + </td> + </tr> + </tbody> + </table> + <CompanionBox + v-else-if="!loading" + mood="sad" + :msgCompanion="$gettext('Sie haben noch keine Peer-Reviews erhalten oder gegeben.')" + /> + </div> +</template> + +<script> +import _ from 'lodash'; +import { mapActions, mapGetters } from 'vuex'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import ProcessStatusIcon from './ProcessStatus.vue'; +import StudipDate from '../../../StudipDate.vue'; +import taskHelper from '../../../../mixins/courseware/task-helper.js'; +import { getProcessStatus, ProcessStatus } from './definitions'; + +export default { + components: { + CompanionBox, + ProcessStatusIcon, + StudipDate, + }, + mixins: [taskHelper], + data: () => ({ + loading: true, + }), + computed: { + ...mapGetters({ + context: 'context', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedStructuralElement: 'courseware-structural-elements/related', + relatedTask: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + userIsTeacher: 'userIsTeacher', + }), + elementUrls() { + return this.peerReviews.reduce((memo, review) => { + const task = this.tasks[review.id]; + const element = this.relatedStructuralElement({ parent: task, relationship: 'structural-element' }); + memo[review.id] = this.getLinkToElement(element); + return memo; + }, {}); + }, + peerReviews() { + const course = { type: 'courses', id: this.context.id }; + return this.relatedPeerReviews({ parent: course, relationship: 'courseware-peer-reviews' }) ?? []; + }, + processes() { + return _.reverse( + _.sortBy( + Object.values( + this.peerReviews.reduce((memo, review) => { + const process = this.relatedPeerReviewProcesses({ + parent: review, + relationship: 'process', + }); + memo[process.id] = process; + return memo; + }, {}) + ), + ['attributes.chdate'] + ) + ); + }, + taskGroups() { + return Object.values(this.processes).reduce((memo, process) => { + memo[process.id] = this.relatedTaskGroups({ parent: process, relationship: 'task-group' }); + return memo; + }, {}); + }, + tasks() { + return this.peerReviews.reduce((memo, review) => { + memo[review.id] = this.relatedTask({ parent: review, relationship: 'task' }); + return memo; + }, {}); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + }), + isPeerReviewProcessActive(process) { + return getProcessStatus(process)?.status === ProcessStatus.Active; + }, + isPeerReviewProcessAfter(process) { + return getProcessStatus(process)?.status === ProcessStatus.After; + }, + reviewsOf(process) { + return this.peerReviews.filter((review) => review.relationships.process.data.id === process.id); + }, + peerReviewsFromMe(process) { + return this.reviewsOf(process).filter((process) => process.attributes['is-reviewer']); + }, + peerReviewsForMe(process) { + return this.reviewsOf(process).filter((process) => process.attributes['is-submitter']); + }, + }, + mounted() { + const parent = { type: 'courses', id: this.context.id }; + const relationship = 'courseware-peer-reviews'; + const options = { + include: 'process,task.structural-element,task.task-group,reviewer,submitter', + }; + this.loadRelatedPeerReviews({ parent, relationship, options }).then(() => (this.loading = false)); + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..5dc28df04747df9deffd56995eab84f54c071c94 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue @@ -0,0 +1,71 @@ +<template> + <StudipDialog + v-if="show" + :title="$gettext('Peer-Review einsehen')" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="700" + width="610" + @close="onClose" + > + <template #dialogContent> + <component v-bind:is="assessmentComponent" :process="process" :review="review"></component> + </template> + </StudipDialog> +</template> + +<script> +import ResultForm from './assessment-types/results/Form.vue'; +import ResultFreetext from './assessment-types/results/Freetext.vue'; +import ResultTable from './assessment-types/results/Table.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + components: { + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + emits: ['update:show'], + computed: { + ...mapGetters({ + relatedProcess: 'courseware-peer-review-processes/related', + }), + assessmentComponent() { + switch (this.configuration?.type) { + case 'form': + return ResultForm; + case 'freetext': + return ResultFreetext; + case 'table': + return ResultTable; + default: + return null; + } + }, + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + process() { + return this.relatedProcess({ + parent: { id: this.review.id, type: this.review.type }, + relationship: 'process', + }); + }, + }, + methods: { + onClose() { + this.$emit('update:show', false); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..f6995b1cc1d2bf30fc58fd9cf950d0b6a0a9abf9 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue @@ -0,0 +1,149 @@ +<template> + <CoursewareTabs> + <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-form--editor"> + <form class="default studipform"> + <StudipArticle v-for="(criterium, index) in localCriteria" :key="index" collapsable> + <template #title="{ isOpen }"> + <template v-if="isOpen"> + {{ + $gettextInterpolate($gettext('Kriterium %{ index }: "%{ text }"'), { + index: index + 1, + text: criterium.text, + }) + }} + </template> + <template v-else> + {{ $gettextInterpolate($gettext('Kriterium %{ index }'), { index: index + 1 }) }} + </template> + </template> + <template #titleplus> + <StudipActionMenu :items="actionItems(index)" :collapseAt="2" @trash="removeLine" /> + </template> + <template #body> + <div class="formpart criterium-text"> + <LabelRequired :id="`editor-form-text-${index}`" :label="$gettext('Kriterium')" /> + <input + :id="`editor-form-text-${index}`" + type="text" + v-model="criterium.text" + required + aria-required="true" + /> + </div> + <div class="formpart criterium-description"> + <LabelRequired :id="`editor-form-description-${index}`" :label="$gettext('Beschreibung')" /> + <textarea + :id="`editor-form-description-${index}`" + v-model="criterium.description" + required + aria-required="true" + ></textarea> + </div> + </template> + </StudipArticle> + <div class="formpart"> + <button class="button add" type="button" @click="addLine"> + <span>{{ $gettext('Kriterium hinzufügen') }}</span> + </button> + </div> + </form> + </CoursewareTab> + <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-form--preview"> + <article> + <section v-for="(criterium, index) in nonEmptyCriteria" :key="index"> + <strong>{{ criterium.text }}</strong> + <p>{{ criterium.description }}</p> + <textarea disabled /> + </section> + </article> + </CoursewareTab> + </CoursewareTabs> +</template> +<script> +import StudipActionMenu from '../../../../../StudipActionMenu.vue'; +import StudipArticle from '../../../../../StudipArticle.vue'; +import LabelRequired from '../../../../../forms/LabelRequired.vue'; +import CoursewareTab from '../../../../layouts/CoursewareTab.vue'; +import CoursewareTabs from '../../../../layouts/CoursewareTabs.vue'; +import { EditorFormCriterium, FormAssessmentPayload } from '../../process-configuration'; + +export default { + components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipActionMenu, StudipArticle }, + props: { + payload: { + type: Object + }, + }, + emits: ['update:payload'], + data: () => ({ localCriteria: [] }), + computed: { + criteria() { + return this.payload.criteria; + }, + nonEmptyCriteria() { + return this.localCriteria.filter(({ text }) => text.trim().length); + }, + }, + methods: { + actionItems(index) { + return this.localCriteria.length > 1 + ? [ + { + id: 1, + label: this.$gettext('Kriterium entfernen'), + icon: 'trash', + emit: 'trash', + emitArguments: [index], + }, + ] + : []; + }, + addLine() { + this.localCriteria.push({ text: '', description: '' }); + }, + removeLine(lineNumber) { + this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber); + }, + resetLocalState() { + this.localCriteria = this.criteria.map(({ text, description }) => ({ text, description })); + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + payload() { + this.resetLocalState(); + }, + localCriteria: { + handler() { + this.$emit('update:payload', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) }); + }, + deep: true, + }, + }, +}; +</script> + +<style scoped> +.cw-peer-review-editor-form--editor form input { + max-width: 48em; +} + +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.cw-peer-review-editor-form--preview > article { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.cw-peer-review-editor-form--preview > article > * + * { + border-top: 1px solid var(--light-gray-color-40); + padding-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..96511ad4f7872956f0b7e2110ab401cad070a6e3 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue @@ -0,0 +1,138 @@ +<template> + <CoursewareTabs> + <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-table-editor"> + <form class="studip studipform"> + <div class="formpart" v-for="(criterium, index) in localCriteria" :key="index"> + <LabelRequired :id="`editor-table-text-${index}`" :label="$gettext('Kriterium')" class="sr-only" /> + <input + :id="`editor-table-text-${index}`" + type="text" + v-model="criterium.text" + required + aria-required="true" + /> + <StudipIcon + :disabled="criteria.length === 1" + name="delete" + shape="trash" + :size="20" + :title="$gettext('Kriterium entfernen')" + @click.prevent="removeLine(index)" + /> + </div> + <div class="formpart"> + <button class="button add" type="button" @click="addLine"> + <span>{{ $gettext('Kriterium hinzufügen') }}</span> + </button> + </div> + </form> + </CoursewareTab> + <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-table--preview"> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext('Kriterien') }}</th> + <th>{{ $gettext('Bewertung') }}</th> + <th>{{ $gettext('Kommentar') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(criterium, index) in nonEmptyCriteria" :key="index"> + <td>{{ criterium.text }}</td> + <td> + <label v-for="text in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]" :key="text"> + <input name="rating" type="radio" disabled /> + {{ text }} + </label> + </td> + <td> + <textarea disabled /> + </td> + </tr> + </tbody> + </table> + </CoursewareTab> + </CoursewareTabs> +</template> +<script> +import LabelRequired from '../../../../../forms/LabelRequired.vue'; +import CoursewareTab from '../../../../layouts/CoursewareTab.vue'; +import CoursewareTabs from '../../../../layouts/CoursewareTabs.vue'; +import { EditorTableCriterium, TableAssessmentPayload } from '../../process-configuration'; + +export default { + components: { CoursewareTab, CoursewareTabs, LabelRequired }, + props: { + payload: { + type: Object, + }, + }, + emits: ['update:payload'], + data: () => ({ localCriteria: [] }), + computed: { + criteria() { + return this.payload.criteria; + }, + nonEmptyCriteria() { + return this.localCriteria.filter(({ text }) => text.trim().length); + }, + }, + methods: { + addLine() { + this.localCriteria.push({ text: '' }); + }, + removeLine(lineNumber) { + this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber); + }, + resetLocalState() { + this.localCriteria = this.criteria.map(({ text }) => ({ text })); + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + payload() { + this.resetLocalState(); + }, + localCriteria: { + handler() { + this.$emit('update:payload', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) }); + }, + deep: true, + }, + }, +}; +</script> + +<style scoped> +form button.trash { + min-width: 2em; + width: 2em; +} +form input { + flex-grow: 1; + height: 1.7em; + max-width: 48em; +} + +form .formpart { + display: flex; + align-items: center; + gap: 1em; +} + +.formpart { + margin-block-start: 0.75em; +} + +.cw-peer-review-editor-table--preview label { + display: block; + white-space: nowrap; +} + +.cw-peer-review-editor-table-editor input[name="delete"] { + flex-grow: 0; + padding-inline: 0.7em; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..283031eeb59d0785e5c0df6a6bdff7155f2e050a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue @@ -0,0 +1,70 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart" v-for="(criterium, index) in criteria" :key="index"> + <LabelRequired + :id="`assessment-type-form-${index}`" + :label="criterium.text" + /> + <p>{{ criterium.description }}</p> + <textarea + :id="`assessment-type-form-${index}`" + required + aria-required="true" + :disabled="disabled" + v-model="answers[index]" + @change="changeAnswers" /> + </div> + </form> + </article> +</template> +<script> +import LabelRequired from '../../../../../forms/LabelRequired.vue'; + +export default { + components: { LabelRequired }, + props: { + disabled: { + type: Boolean, + default: false, + }, + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data() { + return { + answers: this.review.attributes.assessment?.answers ?? [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + }, + methods: { + changeAnswers() { + const answers = this.criteria.map((_, index) => this.answers[index] ?? ''); + this.$emit('answer', { answers }); + }, + }, +}; +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue new file mode 100644 index 0000000000000000000000000000000000000000..06cc779323a3c37009072f78fbda30e964c316b3 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue @@ -0,0 +1,64 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart"> + <LabelRequired + id="assessment-type-freetext" + :label="$gettext('Bewertung')" + /> + <textarea + id="assessment-type-freetext" + required + aria-required="true" + rows="17" + :disabled="disabled" + v-model="answer" + @change="changeAnswer" /> + </div> + </form> + </article> +</template> +<script> +import LabelRequired from '../../../../../forms/LabelRequired.vue'; + +export default { + components: { LabelRequired }, + props: { + disabled: { + type: Boolean, + default: false, + }, + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data() { + return { + answer: this.review.attributes.assessment?.answer ?? '', + }; + }, + methods: { + changeAnswer() { + const answer = this.answer ?? ''; + this.$emit('answer', { answer }); + }, + }, +}; +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..e24a5150c80ea82e0ddd2c954f6698a11b53b41c --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue @@ -0,0 +1,114 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart" v-for="(criterium, index) in criteria" :key="index"> + <fieldset> + <legend> + {{ criterium.text }} + </legend> + <section> + <div> + <label v-for="(text, rating) in ratingLevels" :key="text" + ><input + :disabled="disabled" + v-model="answers[index].rating" + :name="`rating-${index}`" + type="radio" + :value="rating + 1" + @change="changeAnswers" + />{{ text }}</label + > + </div> + <label :for="`assessment-type-table-${index}`"> + {{ $gettext('Begründung') }} + </label> + <textarea + :id="`assessment-type-table-${index}`" + :disabled="disabled" + v-model="answers[index].text" + @change="changeAnswers" + /> + </section> + </fieldset> + </div> + </form> + </article> +</template> +<script> +import { $gettext } from '../../../../../../../assets/javascripts/lib/gettext'; + +const emptyAssessment = (criteria) => { + return { + answers: criteria.map((_) => ({ text: '', rating: 0 })), + }; +}; + +export default { + props: { + disabled: { + type: Boolean, + default: false, + }, + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data() { + return { + answers: [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + ratingLevels() { + return [$gettext('gut'), $gettext('ok'), $gettext('schwach')]; + }, + }, + methods: { + changeAnswers() { + this.$emit('answer', { answers: this.answers }); + }, + }, + beforeMount() { + if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) { + this.answers = this.review.attributes.assessment.answers; + } else { + this.answers = emptyAssessment(this.criteria).answers; + } + }, +}; +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 2rem; +} + +form.default .formpart section { + padding-block-start: 0; +} + +.formpart section div { + display: flex; + gap: 1em; +} + +.formpart section label { + display: inline-block; + white-space: nowrap; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue new file mode 100644 index 0000000000000000000000000000000000000000..783259ad1f2bc81cf7ce29ae92776b4ce66082cd --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue @@ -0,0 +1,54 @@ +<template> + <article> + <section v-for="(criterium, index) in criteria" :key="index" class="criterium"> + <header>{{ criterium.text }}</header> + + <p class="criterium-description">{{ criterium.description }}</p> + + <p class="criterium-text">{{ answers[index] }}</p> + </section> + </article> +</template> + +<script> +export default { + props: { + process: { type: Object, required: true }, + review: { type: Object, required: true }, + }, + data() { + return { + answers: this.review.attributes.assessment?.answers ?? [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + }, +}; +</script> + +<style scoped> +article { + padding-inline: 1rem; +} + +.criterium + .criterium { + margin-block-start: 2rem; +} + +.criterium header { + font-weight: bold; + margin-block: 1em; +} + +.criterium-description { + font-style: italic; +} + +.criterium-text { + line-height: 1.65; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue new file mode 100644 index 0000000000000000000000000000000000000000..c3c71ddfc9efd0672fcc524ca586d91748f889f2 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue @@ -0,0 +1,42 @@ +<template> + <article> + <section class="criterium"> + <header>{{ $gettext('Bewertung') }}</header> + + <p class="criterium-text">{{ answer }}</p> + </section> + </article> +</template> + +<script> +export default { + props: { + process: { type: Object, required: true }, + review: { type: Object, required: true }, + }, + data() { + return { + answer: this.review.attributes.assessment?.answer ?? '', + }; + }, +}; +</script> + +<style scoped> +article { + padding-inline: 1em; +} + +.criterium { + margin-block-end: 2em; +} + +.criterium header { + font-weight: bold; + margin-block: 1em; +} + +.criterium-text { + line-height: 1.65; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue new file mode 100644 index 0000000000000000000000000000000000000000..99d54e16c40a8ddb7916d710938f4e6f934cc83a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue @@ -0,0 +1,71 @@ +<template> + <article> + <section v-for="(criterium, index) in criteria" :key="index" class="criterium"> + <header>{{ criterium.text }}</header> + + <div class="criterium-rating"> + <div>{{ $gettext('Bewertung') }}</div> + <p>{{ ratingLevels[answers[index].rating - 1] }}</p> + </div> + + <p class="criterium-text">{{ answers[index].text }}</p> + </section> + </article> +</template> + +<script> +const emptyAssessment = (criteria) => ({ + answers: criteria.map((_) => ({ text: '', rating: 0 })), +}); + +export default { + props: { + process: { type: Object, required: true }, + review: { type: Object, required: true }, + }, + data() { + return { + answers: [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + ratingLevels() { + return [this.$gettext('gut'), this.$gettext('ok'), this.$gettext('schwach')]; + }, + }, + beforeMount() { + if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) { + this.answers = this.review.attributes.assessment.answers; + } else { + this.answers = emptyAssessment(this.criteria).answers; + } + }, +}; +</script> + +<style scoped> +article { + padding-inline: 1rem; +} + +.criterium + .criterium { + margin-block-start: 2rem; +} + +.criterium header { + font-weight: bold; + margin-block: 1em; +} + +.criterium-rating > div { + font-weight: bold; +} + +.criterium-text { + line-height: 1.65; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/definitions.ts b/resources/vue/components/courseware/tasks/peer-review/definitions.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f24623501673fa260a3146b6c04e01b986ebe7f --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/definitions.ts @@ -0,0 +1,57 @@ +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; + +export enum ProcessStatus { + Before = 'before', + After = 'after', + Active = 'active', +} + +export interface StatusDescriptor { + status: ProcessStatus; + shape: string; + role: string; + description: string; +} + +interface StringDict { + [key: string]: string; +} + +export interface JsonApiSchema { + id?: string; + type: string; + attributes: StringDict; + meta?: StringDict; + relationships?: StringDict; +} + +export function getProcessStatus(process: JsonApiSchema): StatusDescriptor { + const now = new Date(); + const startDate = new Date(process.attributes['review-start']); + const endDate = new Date(process.attributes['review-end']); + + if (now < startDate) { + return { + status: ProcessStatus.Before, + shape: 'span-empty', + role: 'status-yellow', + description: $gettext('Peer-Review-Prozess noch nicht aktiv'), + }; + } + + if (endDate < now) { + return { + status: ProcessStatus.After, + shape: 'span-full', + role: 'status-green', + description: $gettext('Peer-Review-Prozess beendet'), + }; + } + + return { + status: ProcessStatus.Active, + shape: 'span-empty', + role: 'status-green', + description: $gettext('Peer-Review-Prozess aktiv'), + }; +} diff --git a/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts new file mode 100644 index 0000000000000000000000000000000000000000..440fe9dcc0a846b0aaf1f6d7f2fe8ebd495afaf2 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts @@ -0,0 +1,129 @@ +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; + +export enum AssessmentType { + Form = 'form', + Freetext = 'freetext', + Table = 'table', +} + +export interface EditorFormCriterium { + text: string; + description: string; +} + +export interface EditorTableCriterium { + text: string; +} + +export type FormAssessmentPayload = { criteria: EditorFormCriterium[] }; +export type TableAssessmentPayload = { criteria: EditorTableCriterium[] }; +export type FreetextAssessmentPayload = {}; + +export type ProcessConfigurationPayload = FormAssessmentPayload | FreetextAssessmentPayload | TableAssessmentPayload; + +export interface ProcessConfiguration { + anonymous: boolean; + duration: number; + automaticPairing: boolean; + type: AssessmentType; + payload?: ProcessConfigurationPayload; +} + +export interface ConfigurationSet { + name: string; + configuration: ProcessConfiguration; +} + +export const ASSESSMENT_TYPES = { + [AssessmentType.Form]: { + short: $gettext('Formular'), + long: $gettext('Strukturiertes Bewertungssystem mit detailierten Fragen zur Begutachtung'), + defaultPayload: { criteria: defaultCriteriaForm() }, + }, + [AssessmentType.Freetext]: { + short: $gettext('Freitext'), + long: $gettext('Freitextliche Begutachtung'), + defaultPayload: { }, + }, + [AssessmentType.Table]: { + short: $gettext('Tabelle'), + long: $gettext('Einfaches Bewertungssystem mit 3 Bewertungsnoten und kurzer Erläuterung'), + defaultPayload: { criteria: defaultCriteriaTable() }, + }, +}; + +export const CONFIGURATION_SETS: Array<ConfigurationSet> = [ + { + name: $gettext('Kurz und bündig'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Table, + payload: ASSESSMENT_TYPES[AssessmentType.Table].defaultPayload, + }, + }, + { + name: $gettext('Strukturiert begleitet'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Form, + payload: ASSESSMENT_TYPES[AssessmentType.Form].defaultPayload, + }, + }, + { + name: $gettext('Selbstbestimmt'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Freetext, + payload: ASSESSMENT_TYPES[AssessmentType.Freetext].defaultPayload, + }, + }, +]; + +export function defaultConfiguration(): ProcessConfiguration { + return CONFIGURATION_SETS[0].configuration; +} + +function defaultCriteriaForm() { + return [ + { + text: $gettext('Aufbau'), + description: $gettext( + 'Wo sind die grundlegenden Abschnitte (Einführung, Schlussfolgerung, Literatur, Zitate, usw.) und sind sie angemessen? Wenn nicht, was fehlt?\nHat der Schreiber verschiedene Überschriftenstile verwendet um die Abschnitte klar zu kennzeichnen? Kurze Erklärung.\nWie wurde der Inhalt geordnet? War er logisch, klar und leicht zu folgen? Kurze Erklärung.' + ), + }, + { + text: $gettext('Grammatik und Stil'), + description: $gettext( + 'Gibt es grammatische oder orthografische Probleme?\nIst der Schreibstil klar? Sind die Absätze und die enthaltenen Sätze zusammengehörig?' + ), + }, + { + text: $gettext('Inhalt'), + description: $gettext( + 'Hat der Autor hinreichend verdichtet und die Aufgabe diskutiert? Kurze Erklärung.\nHat der Autor umfassend Material aus Standardquellen benutzt? Wenn nicht, was fehlt?\nHat der Autor auch eigene Gedanken beigetragen, oder hat er mehrheitlich Zusammenfassungen von Veröffentlichungen/Daten zusammengetragen? Kurze Erklärung.' + ), + }, + { + text: $gettext('Zitate'), + description: $gettext( + 'Hat der Autor Zitatquellen passend und korrekt angegeben? Notiere unkorrekte Formatierungen.\nSind alle Zitate auch in dem Literaturhinweis zu finden? Notiere die Unstimmigkeiten.' + ), + }, + ]; +} + +function defaultCriteriaTable() { + return [ + { text: $gettext('These: Klarheit, Bedeutung') }, + { text: $gettext('Belege: Relevanz, Glaubwürdigkeit, Aussagekraft') }, + { text: $gettext('Aufbau: Anordnung des Inhalts, Nachvollziehbarkeit') }, + { text: $gettext('Handwerk: Orthografie, Grammatik, Zeichensetzung') }, + { text: $gettext('Gesamtwirkung') }, + ]; +} diff --git a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue index c0e7d92f5553fd79feb33852c52437470fd072ec..3ba88654ed2a44a870ffa56a753f7d8a9055eab6 100644 --- a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue +++ b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue @@ -24,6 +24,12 @@ {{ $gettext('Aufgabe verteilen') }} </button> </li> + <li v-if="taskGroup && !hasPeerReviewProcesses" class="cw-action-widget-add"> + <button @click="$emit('add-peer-review-process')"> + {{ $gettext('Peer-Review-Verfahren aktivieren') }} + </button> + </li> + </ul> </template> </sidebar-widget> @@ -39,7 +45,7 @@ export default { components: { SidebarWidget, }, - props: ['taskGroup'], + props: ['hasPeerReviewProcesses', 'taskGroup'], computed: { isBeforeEndDate() { return this.taskGroup && new Date() < new Date(this.taskGroup.attributes['end-date']); diff --git a/resources/vue/components/forms/LabelRequired.vue b/resources/vue/components/forms/LabelRequired.vue new file mode 100644 index 0000000000000000000000000000000000000000..7a123776c79c2e9cf9985ef719e94f70aca01f12 --- /dev/null +++ b/resources/vue/components/forms/LabelRequired.vue @@ -0,0 +1,22 @@ +<template> + <label class="studiprequired" :for="id"> + <span class="textlabel">{{ label }}</span> + <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span> + <slot></slot> + </label> +</template> + +<script> +export default { + props: { + id: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js index b6412d9c4d048e1905cdf91e280ff60e718cd6f9..483073015be679aff0044198f7d9fdcab6e08c64 100644 --- a/resources/vue/store/courseware/courseware-tasks.module.js +++ b/resources/vue/store/courseware/courseware-tasks.module.js @@ -1,3 +1,5 @@ +import { ASSESSMENT_TYPES } from '../../components/courseware/tasks/peer-review/process-configuration'; + const getDefaultState = () => { return { showTaskGroupsAddSolversDialog: false, @@ -25,7 +27,7 @@ const getters = { taskGroupsByCid(state, getters, rootState, rootGetters) { return (cid) => { return rootGetters['courseware-task-groups/all'].filter( - (taskGroup) => taskGroup.relationships.course.data.id === cid + (taskGroup) => taskGroup.relationships.course.data.id === cid, ); }; }, @@ -34,7 +36,7 @@ const getters = { const taskGroupIds = getters.taskGroupsByCid(cid).map(({ id }) => id); return rootGetters['courseware-tasks/all'].filter((task) => - taskGroupIds.includes(task.relationships['task-group'].data.id) + taskGroupIds.includes(task.relationships['task-group'].data.id), ); }; }, @@ -61,14 +63,15 @@ export const actions = { loadTasksOfCourse({ dispatch }, { cid }) { const options = { 'filter[cid]': cid, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + include: + 'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes', }; return dispatch('courseware-tasks/loadAll', { options }, { root: true }); }, loadTaskGroup({ dispatch }, { id }) { const options = { - include: 'lecturer', + include: 'lecturer, peer-review-processes', }; return dispatch('courseware-task-groups/loadById', { id, options }, { root: true }); }, @@ -84,6 +87,87 @@ export const actions = { data: solvers, }); }, + + createPeerReviewProcess({ dispatch }, { taskGroup, options }) { + const { anonymous, duration, automaticPairing, type, payload } = options; + + const taskGroupEndDate = new Date(taskGroup.attributes['end-date']); + taskGroupEndDate.setSeconds(taskGroupEndDate.getSeconds() + 1); + + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const startDate = taskGroupEndDate > now ? taskGroupEndDate : tomorrow; + + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + duration); + + const data = { + attributes: { + configuration: { anonymous, duration, automaticPairing, type, payload }, + 'review-start': startDate.toISOString(), + 'review-end': endDate.toISOString(), + }, + relationships: { + 'task-group': { + data: { + type: taskGroup.type, + id: taskGroup.id, + }, + }, + }, + }; + + return dispatch('courseware-peer-review-processes/create', data, { root: true }); + }, + + replacePairings({ dispatch, rootGetters }, { process, pairings }) { + const reviews = rootGetters['courseware-peer-reviews/related']({ + parent: process, + relationship: 'peer-reviews', + }); + const relation = ({ id, type }) => ({ data: { id, type } }); + const deleteReview = (review) => dispatch('courseware-peer-reviews/delete', review, { root: true }); + const createReview = (pairing) => + dispatch( + 'courseware-peer-reviews/create', + { + type: 'courseware-peer-reviews', + attributes: {}, + relationships: { + process: relation(process), + submitter: relation(pairing.submitter), + reviewer: relation(pairing.reviewer), + }, + }, + { root: true }, + ); + + return Promise.all(reviews.map(deleteReview)).then(() => Promise.all(pairings.map(createReview))); + }, + + updatePeerReviewProcess({ dispatch }, { process, configuration }) { + const startDate = new Date(process.attributes['review-start']); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + configuration.duration); + + if (_.isEmpty(configuration.payload)) { + configuration.payload = ASSESSMENT_TYPES[configuration.type].defaultPayload; + } + + process.attributes.configuration = configuration; + process.attributes['review-start'] = startDate.toISOString(); + process.attributes['review-end'] = endDate.toISOString(); + + return dispatch('courseware-peer-review-processes/update', process, { root: true }); + }, + + storeAssessment({ dispatch }, { review, assessment }) { + review.attributes.assessment = assessment; + return dispatch('courseware-peer-reviews/update', review, { root: true }); + }, }; export const mutations = { diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 9e935313357dcb81a658d5bd5db63f69125fb2f3..8aacdda0ac7ba31b4cbce6e4b2e8cc94e37eb28d 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -1278,7 +1278,7 @@ export const actions = { { id: taskId, options: { - include: 'solver,task-group,task-group.lecturer', + include: 'solver,task-group,task-group.lecturer,peer-reviews.process', }, }, { root: true } diff --git a/webpack.common.js b/webpack.common.js index e8f4106ea73efd6d87a145ced9e5b04359ac17b2..bded2cfe862eff0fc64a59f983824ec8bcd78beb 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -104,7 +104,8 @@ module.exports = { new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: "stylesheets/[name].css", - chunkFilename: "stylesheets/[name].css?h=[chunkhash]" + chunkFilename: "stylesheets/[name].css?h=[chunkhash]", + ignoreOrder: true, }), new ESLintPlugin({ configType: 'flat',