diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index af7d0e912fd6cfa8a90eaffd55ee95d4bcffe1fb..c1421f486d22130f636ad405cc7efd39110f4028 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -79,11 +79,16 @@ class Course_CoursewareController extends CoursewareController } } - public function tasks_action(): void + public function tasks_action($route = null): void { - global $perm, $user; - $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id); + $this->is_teacher = $GLOBALS['perm']->have_studip_perm( + 'tutor', + Context::getId(), + $GLOBALS['user']->id + ); + Navigation::activateItem('course/courseware/tasks'); + PageLayout::setTitle(_('Courseware: Aufgaben')); $this->setTasksSidebar(); } diff --git a/db/migrations/5.5.12_add_dates_to_cw_task_groups.php b/db/migrations/5.5.12_add_dates_to_cw_task_groups.php new file mode 100644 index 0000000000000000000000000000000000000000..aba5ea99f8395cc4d2ca57f07daca3fadc945af6 --- /dev/null +++ b/db/migrations/5.5.12_add_dates_to_cw_task_groups.php @@ -0,0 +1,35 @@ +<?php +class AddDatesToCwTaskGroups extends Migration +{ + public function description() + { + return 'Add start_date and end_date to table cw_task_groups.'; + } + + public function up() + { + $dbm = \DBManager::get(); + $dbm->exec( + "ALTER TABLE `cw_task_groups` + ADD `start_date` INT NOT NULL AFTER `title`, + ADD `end_date` INT NOT NULL AFTER `start_date`" + ); + $dbm->exec('UPDATE `cw_task_groups` SET `start_date`=`mkdate`'); + $dbm->exec( + 'UPDATE `cw_task_groups` AS tg SET tg.`end_date` = ( SELECT MAX(t.`submission_date`) FROM `cw_tasks` t WHERE t.`task_group_id` = tg.`id` )' + ); + $dbm->exec('ALTER TABLE `cw_tasks` DROP `submission_date`'); + } + + public function down() + { + $dbm = \DBManager::get(); + $dbm->exec("ALTER TABLE `cw_tasks` ADD `submission_date` int(11) NOT NULL AFTER `solver_type`"); + $dbm->exec('UPDATE `cw_tasks` AS t INNER JOIN cw_task_groups tg ON t.`task_group_id` = tg.`id` SET t.`submission_date` = tg.`end_date`'); + $dbm->exec( + 'ALTER TABLE `cw_task_groups` + DROP `start_date`, + DROP `end_date`' + ); + } +} diff --git a/db/migrations/5.5.12_new_external_pages.php b/db/migrations/5.5.24_new_external_pages.php similarity index 100% rename from db/migrations/5.5.12_new_external_pages.php rename to db/migrations/5.5.24_new_external_pages.php diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index d4d5bbb21210e48392e1f8f75e08ce8025a4f4f8..4f441651ba70870a8277e2e5e4c00647b00ef100 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -501,6 +501,13 @@ class RouteMap $group->get('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsShow::class); $group->post('/courseware-task-groups', Routes\Courseware\TaskGroupsCreate::class); + $group->patch('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsUpdate::class); + $group->delete('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsDelete::class); + $this->addRelationship( + $group, + '/courseware-task-groups/{id}/relationships/solvers', + Routes\Courseware\Rel\SolversOfTaskGroup::class + ); $group->get('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackShow::class); $group->post('/courseware-task-feedback', Routes\Courseware\TaskFeedbackCreate::class); diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 88eb3df36b80d7da67fe596ea4b0e5b3687555fd..2acf83e3a6ba54ce389dfb7c7ffac0fd226614e3 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -23,7 +23,13 @@ use User; use Course; /** + * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * @SuppressWarnings(PHPMD.CamelCaseVariableName) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.ExcessivePublicCount) + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.TooManyMethods) * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ @@ -306,6 +312,16 @@ class Authority return $resource['lecturer_id'] === $user->id; } + public static function canUpdateTaskGroup(User $user, TaskGroup $resource): bool + { + return self::canCreateTasks($user, $resource->target); + } + + public static function canDeleteTaskGroup(User $user, TaskGroup $resource): bool + { + return self::canUpdateTaskGroup($user, $resource); + } + public static function canShowTask(User $user, Task $resource): bool { return self::canUpdateTask($user, $resource); @@ -332,6 +348,11 @@ class Authority return self::canCreateTasks($user, $resource->structural_element) && !$resource->userIsASolver($user); } + public static function canRenewTask(User $user, Task $resource): bool + { + return self::canDeleteTask($user, $resource); + } + public static function canCreateTaskFeedback(User $user, Task $resource): bool { return self::canCreateTasks($user, $resource->structural_element); @@ -352,7 +373,6 @@ class Authority return self::canCreateTaskFeedback($user, $resource); } - public static function canIndexStructuralElementComments(User $user, StructuralElement $resource) { return self::canShowStructuralElement($user, $resource); @@ -407,7 +427,8 @@ class Authority public static function canShowStructuralElementFeedback(User $user, StructuralElementFeedback $resource) { - return $resource->user_id === $user->id || self::canUpdateStructuralElement($user, $resource->structural_element); + return $resource->user_id === $user->id || + self::canUpdateStructuralElement($user, $resource->structural_element); } public static function canDeleteStructuralElementFeedback(User $user, StructuralElementFeedback $resource) @@ -415,7 +436,6 @@ class Authority return self::canUpdateStructuralElementFeedback($user, $resource); } - public static function canShowTemplate(User $user, Template $resource) { // templates are for everybody, aren't they? @@ -430,7 +450,7 @@ class Authority public static function canCreateTemplate(User $user) { - return $GLOBALS['perm']->have_perm('admin'); + return $GLOBALS['perm']->have_perm('admin', $user->id); } public static function canUpdateTemplate(User $user, Template $resource) @@ -490,7 +510,7 @@ class Authority if ($user->id === $range->id) { return true; } - return $GLOBALS['perm']->have_studip_perm('tutor', $range->id ,$user->id); + return $GLOBALS['perm']->have_studip_perm('tutor', $range->id, $user->id); } public static function canSortUnit(User $user, \Range $range): bool @@ -518,7 +538,6 @@ class Authority return $request_user->id === $user->id; } - public static function canShowClipboard(User $user, Clipboard $resource): bool { return $resource->user_id === $user->id; @@ -541,7 +560,7 @@ class Authority } else { $structural_element = $resource->getStructuralElement(); } - + return $structural_element->canEdit($user); } diff --git a/lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php b/lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php new file mode 100644 index 0000000000000000000000000000000000000000..2ab5ffa6da1513ab849743890c74fb14107cfad5 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php @@ -0,0 +1,207 @@ +<?php + +namespace JsonApi\Routes\Courseware\Rel; + +use Courseware\StructuralElement; +use Courseware\Task; +use Courseware\TaskGroup; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\RelationshipsController; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use JsonApi\Schemas\StatusGroup as StatusGroupSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ServerRequestInterface as Request; +use Statusgruppen; +use User; + +/** + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class SolversOfTaskGroup extends RelationshipsController +{ + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function fetchRelationship(Request $request, $related) + { + $solvers = $related->getSolvers(); + $total = count($solvers); + + return $this->getPaginatedIdentifiersResponse(array_slice($solvers, ...$this->getOffsetAndLimit()), $total); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function addToRelationship(Request $request, $related) + { + $this->createTaskFor( + $related, + array_filter($this->validateSolvers($related, $this->validate($request)), function ($solver) use ( + $related + ) { + return !$related->findTaskBySolver($solver); + }) + ); + + return $this->getCodeResponse(204); + } + + protected function findRelated(array $args) + { + $related = TaskGroup::find($args['id']); + if (!$related) { + throw new RecordNotFoundException(); + } + + return $related; + } + + protected function authorize(Request $request, $resource) + { + switch ($request->getMethod()) { + case 'GET': + return Authority::canShowTaskGroup($this->getUser($request), $resource); + case 'POST': + return Authority::canUpdateTaskGroup($this->getUser($request), $resource); + + default: + return false; + } + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function getRelationshipSelfLink($resource, $schema, $userData) + { + return $schema->getRelationshipSelfLink($resource, TaskGroupSchema::REL_SOLVERS); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function getRelationshipRelatedLink($resource, $schema, $userData) + { + return $schema->getRelationshipRelatedLink($resource, TaskGroupSchema::REL_SOLVERS); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + $data = self::arrayGet($json, 'data'); + + if (!is_array($data)) { + return 'Document´s `data` must be an array.'; + } + + foreach ($data as $item) { + if (!in_array(self::arrayGet($item, 'type'), [UserSchema::TYPE, StatusGroupSchema::TYPE])) { + return 'Wrong `type` in document´s `data`.'; + } + + if (!self::arrayGet($item, 'id')) { + return 'Missing `id` of document´s `data`.'; + } + } + } + + private function validateSolvers(TaskGroup $taskGroup, iterable $json): iterable + { + if (!$taskGroup->course) { + return []; + } + $solvers = []; + foreach ($json['data'] as $item) { + $solver = $this->findSolver($item); + if (!$solver) { + throw new RecordNotFoundException(); + } + if (!$this->validateSolver($taskGroup, $solver)) { + throw new UnprocessableEntityException(); + } + $solvers[] = $solver; + } + return $solvers; + } + + /** + * @return Statusgruppen|User|null + */ + private function findSolver($json) + { + switch ($json['type']) { + case 'status-groups': + return Statusgruppen::find($json['id']); + case 'users': + return User::find($json['id']); + } + return null; + } + + /** + * @param Statusgruppen|User $solver + * + * @SuppressWarnings(PHPMD.Superglobals) + */ + private function validateSolver(TaskGroup $taskGroup, $solver): bool + { + if ($solver instanceof User) { + return $GLOBALS['perm']->have_studip_perm('autor', $taskGroup->course->id, $solver->id); + } + if ($solver instanceof Statusgruppen) { + return $taskGroup->course->id === $solver->range_id; + } + + return false; + } + + /** + * @param array<User|Statusgruppen> $solvers + */ + private function createTaskFor(TaskGroup $taskGroup, $solvers): void + { + $template = $this->getTaskTemplate($taskGroup); + if (!$template) { + throw new RuntimeException(); + } + + foreach ($solvers as $solver) { + $task = Task::build([ + 'task_group_id' => $taskGroup->id, + 'solver_id' => $solver->id, + 'solver_type' => $this->getSolverType($solver), + ]); + + $taskElement = $template->copy($taskGroup->lecturer, $taskGroup->target, 'task'); + $taskElement->title = $taskGroup->title; + $taskElement->store(); + + $task['structural_element_id'] = $taskElement->id; + $task->store(); + } + } + + private function getTaskTemplate(TaskGroup $taskGroup): StructuralElement + { + return StructuralElement::find($taskGroup->task_template_id); + } + + /** + * @param User|Statusgruppen $solver + */ + private function getSolverType($solver): string + { + $solverTypes = [\User::class => 'autor', \Statusgruppen::class => 'group']; + + return $solverTypes[get_class($solver)]; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php index 28c4e9ce65cb2da05b5de46309bc491669854237..f7357a43fe4a6d5dc4f0fbe35408aab1943e6a3f 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php @@ -65,14 +65,20 @@ class TaskGroupsCreate extends JsonApiController if (!self::arrayHas($json, 'data.attributes.title')) { return 'Missing `title` attribute.'; } - if (!self::arrayHas($json, 'data.attributes.submission-date')) { - return 'Missing `submission-date` attribute.'; + if (!self::arrayHas($json, 'data.attributes.start-date')) { + return 'Missing `start-date` attribute.'; } - $submissionDate = self::arrayGet($json, 'data.attributes.submission-date'); - if (!self::isValidTimestamp($submissionDate)) { - return '`submission-date` is not an ISO 8601 timestamp.'; + $startDate = self::arrayGet($json, 'data.attributes.start-date'); + if (!self::isValidTimestamp($startDate)) { + return '`start-date` is not an ISO 8601 timestamp.'; + } + if (!self::arrayHas($json, 'data.attributes.end-date')) { + return 'Missing `end-date` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.end-date'); + if (!self::isValidTimestamp($endDate)) { + return '`end-date` is not an ISO 8601 timestamp.'; } - if (!self::arrayHas($json, 'data.relationships.target')) { return 'Missing `target` relationship.'; } @@ -165,8 +171,8 @@ class TaskGroupsCreate extends JsonApiController $target = $this->getTargetFromJson($json); $solverMayAddBlocks = self::arrayGet($json, 'data.attributes.solver-may-add-blocks', ''); - $submissionDate = self::arrayGet($json, 'data.attributes.submission-date', ''); - $submissionDate = self::fromISO8601($submissionDate); + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.start-date', '')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date', '')); $title = self::arrayGet($json, 'data.attributes.title', ''); /** @var TaskGroup $taskGroup */ @@ -177,6 +183,8 @@ class TaskGroupsCreate extends JsonApiController 'task_template_id' => $taskTemplate->getId(), 'solver_may_add_blocks' => $solverMayAddBlocks, 'title' => $title, + 'start_date' => $startDate->getTimestamp(), + 'end_date' => $endDate->getTimestamp(), ]); foreach ($solvers as $solver) { @@ -184,7 +192,6 @@ class TaskGroupsCreate extends JsonApiController 'task_group_id' => $taskGroup->getId(), 'solver_id' => $solver->getId(), 'solver_type' => $this->getSolverType($solver), - 'submission_date' => $submissionDate->getTimestamp(), ]); // copy task template diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..2faf7783bc2c2a6bf9d2bc586ce3230ea747b4b2 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php @@ -0,0 +1,38 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one TaskGroup. + */ +class TaskGroupsDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?TaskGroup $resource */ + $resource = TaskGroup::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteTaskGroup($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..8662b71ea80330fc240022ae3f5b6df3595c09a2 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php @@ -0,0 +1,99 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +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 TaskGroup. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class TaskGroupsUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\TaskGroup $resource */ + $resource = TaskGroup::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdateTaskGroup($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $process = $this->update($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 (TaskGroupSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.start-date')) { + return 'Missing `start-date` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.start-date'); + if (!self::isValidTimestamp($startDate)) { + return '`start-date` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.end-date')) { + return 'Missing `end-date` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.end-date'); + if (!self::isValidTimestamp($endDate)) { + return '`end-date` is not an ISO 8601 timestamp.'; + } + + if (self::fromISO8601($startDate) > self::fromISO8601($endDate)) { + return '`start-date` is later than `end-date`'; + } + } + + private function update(TaskGroup $taskGroup, array $json): TaskGroup + { + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.start-date')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date')); + + $taskGroup->start_date = $startDate->getTimestamp(); + $taskGroup->end_date = $endDate->getTimestamp(); + + $taskGroup->store(); + + return $taskGroup; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php index f0b2ce9a53af8b869e8c04b04e818977808274fe..26a021c9682052271e9995c07867d91fded6d555 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php @@ -77,9 +77,10 @@ class TasksIndex extends JsonApiController } } - private function findTasksByCourse(\Course $course): \SimpleCollection + private function findTasksByCourse(\Course $course, bool $showNotYetActive = true): \SimpleCollection { - $taskGroups = TaskGroup::findBySQL('seminar_id = ?', [$course->getId()]); + $whereClause = $showNotYetActive ? 'seminar_id = ?' : 'start_date <= UNIX_TIMESTAMP() AND seminar_id = ?'; + $taskGroups = TaskGroup::findBySQL($whereClause, [$course->getId()]); $tasks = []; foreach ($taskGroups as $taskGroup) { @@ -98,7 +99,7 @@ class TasksIndex extends JsonApiController }) ->pluck('id'); - return $this->findTasksByCourse($course)->filter(function ($task) use ($user, $groupIds) { + return $this->findTasksByCourse($course, false)->filter(function ($task) use ($user, $groupIds) { return ('autor' === $task['solver_type'] && $task['solver_id'] === $user->getId()) || ('group' === $task['solver_type'] && in_array($task['solver_id'], $groupIds)); }); diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php index 3728dba9a6b378712a9fb666b9404e7f042d099b..33b51ad1ae8b50f5ab9f2647b5a027c981f1c542 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php @@ -13,6 +13,8 @@ use Psr\Http\Message\ServerRequestInterface as Request; /** * Update one Task. + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class TasksUpdate extends JsonApiController { @@ -32,7 +34,8 @@ class TasksUpdate extends JsonApiController throw new RecordNotFoundException(); } $json = $this->validate($request, $resource); - if (!Authority::canUpdateTask($user = $this->getUser($request), $resource)) { + $user = $this->getUser($request); + if (!Authority::canUpdateTask($user, $resource)) { throw new AuthorizationFailedException(); } $resource = $this->updateTask($user, $resource, $json); @@ -66,53 +69,35 @@ class TasksUpdate extends JsonApiController private function updateTask(\User $user, Task $resource, array $json): Task { - if (Authority::canDeleteTask($user, $resource)) { - if (self::arrayHas($json, 'data.attributes.renewal')) { - $newRenewalState = self::arrayGet($json, 'data.attributes.renewal'); - if ('declined' === $newRenewalState) { - $resource->renewal = $newRenewalState; - } - if ('granted' === $newRenewalState && self::arrayHas($json, 'data.attributes.renewal-date')) { - $renewalDate = self::arrayGet($json, 'data.attributes.renewal-date', ''); - $renewalDate = self::fromISO8601($renewalDate); + if (Authority::canRenewTask($user, $resource)) { + return $this->renewTask($resource, $json); + } - $resource->renewal = $newRenewalState; - $resource->renewal_date = $renewalDate->getTimestamp(); - } - } - } else { - if (self::arrayHas($json, 'data.attributes.submitted')) { - $newSubmittedState = self::arrayGet($json, 'data.attributes.submitted'); - if ($this->canSubmit($resource, $newSubmittedState)) { - $resource->submitted = $newSubmittedState; - if ('pending' === $resource->renewal) { - $resource->renewal = ''; - } - } - } - if (self::arrayHas($json, 'data.attributes.renewal')) { - $newRenewalState = self::arrayGet($json, 'data.attributes.renewal'); - if ('pending' === $newRenewalState) { - $resource->renewal = $newRenewalState; - } - } + if (self::arrayGet($json, 'data.attributes.submitted') === true && $resource->canSubmit()) { + $resource->submitTask(); } - $resource->store(); + if (self::arrayGet($json, 'data.attributes.renewal') === 'pending') { + $resource->requestRenewal(); + } return $resource; } - private function canSubmit(Task $resource, string $newSubmittedState): bool + private function renewTask(Task $resource, array $json): Task { - $now = time(); - if (1 === (int) $resource->submitted || !$newSubmittedState) { - return false; - } - if ('granted' === $resource->renewal) { - return $now <= $resource->renewal_date; - } else { - return $now <= $resource->submission_date; + switch (self::arrayGet($json, 'data.attributes.renewal')) { + case 'declined': + $resource->declineRenewalRequest(); + break; + + case 'granted': + $resource->grantRenewalRequest( + self::fromISO8601(self::arrayGet($json, 'data.attributes.renewal-date')) + ); + break; } + + return $resource; } } diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index dd74bc9bc2a20957c438c5bb3d7832a3d7a5e208..71aadf7124a99463ac5de3ca0e962e9c1d8c2d1f 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -59,17 +59,17 @@ class SchemaMap \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class, \Courseware\Container::class => Schemas\Courseware\Container::class, \Courseware\Instance::class => Schemas\Courseware\Instance::class, + \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class, \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class, \Courseware\StructuralElementFeedback::class => Schemas\Courseware\StructuralElementFeedback::class, - \Courseware\Unit::class => Schemas\Courseware\Unit::class, - \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, - \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, \Courseware\Task::class => Schemas\Courseware\Task::class, - \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class, \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class, + \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class, \Courseware\Template::class => Schemas\Courseware\Template::class, - \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, + \Courseware\Unit::class => Schemas\Courseware\Unit::class, + \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, + \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, ]; } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php index a0605e609d86b7cf23518d382cff267c3ca01121..81c7a0d18a55f55985022c35be32bd192641a6b7 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Task.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php @@ -2,6 +2,8 @@ namespace JsonApi\Schemas\Courseware; +use Courseware\Task as TaskModel; +use JsonApi\Routes\Courseware\Authority as CoursewareAuthority; use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Link; diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php index 12dbc6c5855e110437481997261e3fea450e2a6e..c950671ea47bac8821dcf11bc523eb6c09f6d3a7 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php +++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php @@ -3,6 +3,7 @@ namespace JsonApi\Schemas\Courseware; use Courseware\StructuralElement; +use Courseware\TaskGroup as TaskGroupModel; use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Identifier; @@ -35,6 +36,8 @@ class TaskGroup extends SchemaProvider return [ 'solver-may-add-blocks' => (bool) $resource['solver_may_add_blocks'], 'title' => (string) $resource->title, + 'start-date' => date('c', $resource['start_date']), + 'end-date' => date('c', $resource['end_date']), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), ]; diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php index 3a68d3e4ffef6c0062ef873221d2161e2a4ea2b2..d409676ca3cfad784b1738029da5f0e6d4b7a400 100644 --- a/lib/models/Courseware/Task.php +++ b/lib/models/Courseware/Task.php @@ -31,7 +31,9 @@ use User; * @property \Statusgruppen $group belongs_to \Statusgruppen * @property \Course $course belongs_to \Course * @property TaskFeedback|null $task_feedback belongs_to TaskFeedback - * @property mixed $solver additional field + * @property-read \User|\Statusgruppen|null $solver additional field + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class Task extends \SimpleORMap { @@ -80,6 +82,10 @@ class Task extends \SimpleORMap 'get' => 'getSolver', 'set' => false, ]; + $config['additional_fields']['submission_date'] = [ + 'get' => 'getSubmissionDate', + 'set' => false, + ]; parent::configure($config); } @@ -171,6 +177,11 @@ class Task extends \SimpleORMap return null; } + public function getSubmissionDate(): int + { + return $this->task_group['end_date']; + } + public function getTaskProgress(): float { $children = $this->structural_element->findDescendants(); @@ -185,6 +196,45 @@ class Task extends \SimpleORMap return $progress * 100; } + public function canSubmit(): bool + { + return !$this->submitted + && time() <= ('granted' === $this->renewal ? $this->renewal_date : $this->submission_date); + } + + public function submitTask(): void + { + $this->submitted = 1; + if ('pending' === $this->renewal) { + $this->renewal = ''; + } + $this->store(); + } + + public function isRenewed(): bool + { + return $this->renewal === 'granted'; + } + + public function requestRenewal(): void + { + $this->renewal = 'pending'; + $this->store(); + } + + public function declineRenewalRequest(): void + { + $this->renewal = 'declined'; + $this->store(); + } + + public function grantRenewalRequest(\DateTime $renewalDate): void + { + $this->renewal = 'granted'; + $this->renewal_date = $renewalDate->getTimestamp(); + $this->store(); + } + 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 092edf644a44583d8a84798a92b7211ff0322429..6902cb36a678fb2f39db89663263f3412d913ef6 100644 --- a/lib/models/Courseware/TaskGroup.php +++ b/lib/models/Courseware/TaskGroup.php @@ -2,6 +2,8 @@ namespace Courseware; +use DBManager; +use Statusgruppen; use User; /** @@ -19,11 +21,17 @@ use User; * @property int $task_template_id database column * @property int $solver_may_add_blocks database column * @property string $title database column + * @property int $start_date database column + * @property int $end_date database column * @property int $mkdate database column * @property int $chdate database column * @property \SimpleORMapCollection|Task[] $tasks has_many Task * @property \User $lecturer belongs_to \User * @property \Course $course belongs_to \Course + * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement + * @property \SimpleORMapCollection $tasks has_many Courseware\Task + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class TaskGroup extends \SimpleORMap implements \PrivacyObject { @@ -41,6 +49,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject 'foreign_key' => 'seminar_id', ]; + $config['belongs_to']['target'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'target_id', + ]; + $config['has_many']['tasks'] = [ 'class_name' => Task::class, 'assoc_foreign_key' => 'task_group_id', @@ -52,6 +65,22 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject parent::configure($config); } + /** + * Export available data of a given user into a storage object + * (an instance of the StoredUserData class) for that user. + * + * @param StoredUserData $storage object to store data into + */ + public static function exportUserData(\StoredUserData $storage) + { + $task_groups = DBManager::get()->fetchAll('SELECT * FROM cw_task_groups WHERE lecturer_id = ?', [ + $storage->user_id, + ]); + if ($task_groups) { + $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups); + } + } + public function getSolvers(): iterable { $solvers = $this->tasks->pluck('solver'); @@ -60,20 +89,45 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject } /** - * Export available data of a given user into a storage object - * (an instance of the StoredUserData class) for that user. + * Returns all submitters of this TaskGroup. * - * @param StoredUserData $storage object to store data into + * @returns iterable all the submitters of this TaskGroup. */ - public static function exportUserData(\StoredUserData $storage) + public function getSubmitters(): iterable { - $task_groups = \DBManager::get()->fetchAll( - 'SELECT * FROM cw_task_groups WHERE lecturer_id = ?', - [$storage->user_id] + return DBManager::get()->fetchAll( + 'SELECT solver_id, solver_type FROM cw_tasks WHERE task_group_id = ? AND submitted = 1', + [$this->getId()], + function ($row) { + switch ($row['solver_type']) { + case 'autor': + return \User::find($row['solver_id']); + case 'group': + return \Statusgruppen::find($row['solver_id']); + } + } ); - if ($task_groups) { - $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups); - } - } + + /** + * Returns the task of this TaskGroup given to $solver. + * + * @param User|Statusgruppen $solver + * + * @return Task|null + */ + public function findTaskBySolver($solver) + { + $row = DBManager::get()->fetchOne( + 'SELECT id FROM cw_tasks WHERE task_group_id = ? AND solver_id = ? AND solver_type = ?', + [ + $this->getId(), + $solver->getId(), + $solver instanceof User ? 'autor' : 'group', + ] + ); + + return empty($row) ? null : Task::find($row['id']); + } + } diff --git a/lib/models/Statusgruppen.php b/lib/models/Statusgruppen.php index e58611084f081c331d1be2943d308c57db3d5fcd..e0d2575595e0d398a36aa1e8a9575065c96ae3e9 100644 --- a/lib/models/Statusgruppen.php +++ b/lib/models/Statusgruppen.php @@ -740,4 +740,17 @@ class Statusgruppen extends SimpleORMap implements PrivacyObject } } } + + /** + * Checks if a user is a member of a group. + * + * @param string $user_id The user id + * @return boolean <b>true</b> if user is a member of this group + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public static function isMemberOf(string $gruppenId, string $userId): bool + { + return StatusgruppeUser::countBySql('statusgruppe_id = ? AND user_id = ?', [$gruppenId, $userId]) !== 0; + } } diff --git a/resources/assets/javascripts/bootstrap/application.js b/resources/assets/javascripts/bootstrap/application.js index c831261868732305bb44686faa815987384bdc56..a9f53dfb2c5d608ec027ba680acc62e593c57129 100644 --- a/resources/assets/javascripts/bootstrap/application.js +++ b/resources/assets/javascripts/bootstrap/application.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; import eventBus from "../lib/event-bus.ts"; /* ------------------------------------------------------------------------ diff --git a/resources/assets/javascripts/bootstrap/consultations.js b/resources/assets/javascripts/bootstrap/consultations.js index dec8f4aee8676d652a38b6d7f85261fd0f52a1eb..ef79d9ca1e6760be1c2c493c2add338636f3d895 100644 --- a/resources/assets/javascripts/bootstrap/consultations.js +++ b/resources/assets/javascripts/bootstrap/consultations.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; $(document).on('click', '.consultation-delete-check:not(.ignore)', event => { const form = $(event.target).closest('form'); diff --git a/resources/assets/javascripts/bootstrap/copyable_links.js b/resources/assets/javascripts/bootstrap/copyable_links.js index d3675ed7a82cf9721960af94f53ceb3e9b5835ea..521eae4c5ff75e84dc36ad5400375a6d6825a5f8 100644 --- a/resources/assets/javascripts/bootstrap/copyable_links.js +++ b/resources/assets/javascripts/bootstrap/copyable_links.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; $(document).on('click', 'a.copyable-link', function (event) { event.preventDefault(); diff --git a/resources/assets/javascripts/bootstrap/data_secure.js b/resources/assets/javascripts/bootstrap/data_secure.js index a1a5ac7ae95be669376452ac5d9b2c278864dc90..1b3b7a1072e84276758766321a2d668e04891d1c 100644 --- a/resources/assets/javascripts/bootstrap/data_secure.js +++ b/resources/assets/javascripts/bootstrap/data_secure.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; /** * Secure forms or form elements by displaying a warning on page unload if diff --git a/resources/assets/javascripts/bootstrap/forms.js b/resources/assets/javascripts/bootstrap/forms.js index bbc3d8a01ed8c08797e7973812d6252496937dc6..1f4937d19c1710903bfff99130adacf767232a27 100644 --- a/resources/assets/javascripts/bootstrap/forms.js +++ b/resources/assets/javascripts/bootstrap/forms.js @@ -1,4 +1,4 @@ -import { $gettext, $gettextInterpolate } from '../lib/gettext.js'; +import { $gettext, $gettextInterpolate } from '../lib/gettext'; // Allow fieldsets to collapse $(document).on( diff --git a/resources/assets/javascripts/bootstrap/multi_select.js b/resources/assets/javascripts/bootstrap/multi_select.js index 9e817b8f6a419c72a5fbecf30654fb6b16d9cea5..5996bd7001e653d33952b23d0f460b95245dc3ef 100644 --- a/resources/assets/javascripts/bootstrap/multi_select.js +++ b/resources/assets/javascripts/bootstrap/multi_select.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; import eventBus from "../lib/event-bus.ts"; eventBus.on('studip:set-locale', () => { diff --git a/resources/assets/javascripts/bootstrap/mvv_difflog.js b/resources/assets/javascripts/bootstrap/mvv_difflog.js index f21c3680e6e5d7d058baa2e1738c1ee3a2660ab3..8ade9181925dd45cd2980a6c81cd719e686ca9ed 100644 --- a/resources/assets/javascripts/bootstrap/mvv_difflog.js +++ b/resources/assets/javascripts/bootstrap/mvv_difflog.js @@ -1,4 +1,4 @@ -import { $gettext, $gettextInterpolate } from '../lib/gettext.js'; +import { $gettext, $gettextInterpolate } from '../lib/gettext'; STUDIP.domReady(() => { $('del.diffdel').each(function() { diff --git a/resources/assets/javascripts/bootstrap/raumzeit.js b/resources/assets/javascripts/bootstrap/raumzeit.js index 2140497d57c14ed96893accee97e84fbcdc3e8db..241105b84e450b43268ead16aafcd54161feeadc 100644 --- a/resources/assets/javascripts/bootstrap/raumzeit.js +++ b/resources/assets/javascripts/bootstrap/raumzeit.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; STUDIP.Dialog.handlers.header['X-Raumzeit-Update-Times'] = function(json) { var info = $.parseJSON(json); diff --git a/resources/assets/javascripts/bootstrap/resources.js b/resources/assets/javascripts/bootstrap/resources.js index 388f47576e565c071d0822e462ffeba9d0bf5682..25582d43bdf25c66c6484aec55ae8f2be1ff0c32 100644 --- a/resources/assets/javascripts/bootstrap/resources.js +++ b/resources/assets/javascripts/bootstrap/resources.js @@ -1,4 +1,4 @@ -import {$gettext} from '../lib/gettext.js'; +import {$gettext} from '../lib/gettext'; STUDIP.ready(function () { //Event definitions: diff --git a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js index 8f96dcbe480b34505ddaae49e6177851ad283210..c106de3dcd7ac10b6963e61786b630e2f2f04476 100644 --- a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js +++ b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; /** * This file provides a set of global handlers. diff --git a/resources/assets/javascripts/chunks/tablesorter.js b/resources/assets/javascripts/chunks/tablesorter.js index 9cc8b0df3e931ef002f1d7620f1ed520ebc9e796..047c7ce0899084f4d0e4794c2a0ffb080b8792f2 100644 --- a/resources/assets/javascripts/chunks/tablesorter.js +++ b/resources/assets/javascripts/chunks/tablesorter.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js' +import { $gettext } from '../lib/gettext' import "tablesorter/dist/js/jquery.tablesorter" import "tablesorter/dist/js/extras/jquery.tablesorter.pager.min.js" diff --git a/resources/assets/javascripts/chunks/vue.js b/resources/assets/javascripts/chunks/vue.js index cf95ed3c5f73c5fbf9766ddc2b64ec2c2b5baf75..b98cc2707b5c5bc8dbfdd2595ead77d19d71add8 100644 --- a/resources/assets/javascripts/chunks/vue.js +++ b/resources/assets/javascripts/chunks/vue.js @@ -3,7 +3,7 @@ import Vuex from 'vuex'; import Router from "vue-router"; import eventBus from '../lib/event-bus.ts'; import GetTextPlugin from 'vue-gettext'; -import { getLocale, getVueConfig } from '../lib/gettext.js'; +import { getLocale, getVueConfig } from '../lib/gettext'; import PortalVue from 'portal-vue'; import BaseComponents from '../../../vue/base-components.js'; import BaseDirectives from "../../../vue/base-directives.js"; diff --git a/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js b/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js index 814e931e67195daa068b514f1a3959a58d85c11c..41195b02f20a929fc508d94ff6bede469a647177 100644 --- a/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js +++ b/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js @@ -1,6 +1,6 @@ import { Plugin } from '@ckeditor/ckeditor5-core'; import { add } from '@ckeditor/ckeditor5-utils/src/translation-service'; -import { $gettext } from '../../lib/gettext.js'; +import { $gettext } from '../../lib/gettext'; import A11YDialogEditing from './editing.js'; import A11YDialogUI from './ui.js'; diff --git a/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js b/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js index a2d207fc5f1f29924b59a727c3ce3bc2e910d658..f80d70317052f7c9129ecb0a4b82aa9f02613291 100644 --- a/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js +++ b/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js @@ -1,6 +1,6 @@ import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import { Plugin } from '@ckeditor/ckeditor5-core'; -import { $gettext } from '../../lib/gettext.js'; +import { $gettext } from '../../lib/gettext'; const a11yIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54"><path d="M32.5,43h-11a1.5,1.5,0,0,0,0,3h11a1.5,1.5,0,0,0,0-3Z"/><path d="M31.5,48h-9a1.5,1.5,0,0,0,0,3h9a1.5,1.5,0,0,0,0-3Z"/><path d="M27,3a18.54,18.54,0,0,0-2,.11,17,17,0,0,0-6.95,31.37A2,2,0,0,1,19,36.13v3.34A1.5,1.5,0,0,0,20.5,41h13a1.5,1.5,0,0,0,1.5-1.5V36.12a2,2,0,0,1,.9-1.67A17,17,0,0,0,27,3Zm7.33,28.92A5,5,0,0,0,32,36.12V38H22V36.13a5,5,0,0,0-2.33-4.24,14,14,0,0,1,5.7-25.83A14.84,14.84,0,0,1,27,6a14,14,0,0,1,7.33,25.92Z"/><path d="M32.39,9.05A12.51,12.51,0,0,0,27.24,8a12.66,12.66,0,0,0-10.37,5.4,1.73,1.73,0,0,0,.42,2.41,1.69,1.69,0,0,0,1,.32,1.73,1.73,0,0,0,1.42-.74,9.21,9.21,0,0,1,7.54-3.93,9.08,9.08,0,0,1,3.74.8,1.73,1.73,0,1,0,1.41-3.16Z"/><path d="M17,16.31A1.73,1.73,0,0,0,15,17.58a12.38,12.38,0,0,0-.37,3,12.68,12.68,0,0,0,.28,2.67,1.74,1.74,0,0,0,1.69,1.36,1.55,1.55,0,0,0,.37,0,1.74,1.74,0,0,0,1.33-2.06A8.92,8.92,0,0,1,18,20.61a9.08,9.08,0,0,1,.27-2.2A1.74,1.74,0,0,0,17,16.31Z"/></svg>'; diff --git a/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js b/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js index e50f8c64d8dcf1685c00c16ecd6821d0cea08969..0cd43e9d87d1839362bf02fa562b606962c80519 100644 --- a/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js +++ b/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js @@ -1,6 +1,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import { $gettext } from '../../lib/gettext.js'; +import { $gettext } from '../../lib/gettext'; import { Command, icons } from '@ckeditor/ckeditor5-core'; const divideIcon = diff --git a/resources/assets/javascripts/cke/wiki-link/formview.js b/resources/assets/javascripts/cke/wiki-link/formview.js index 8d82e25eb204994d36c4a4feb19bd47ae0092aae..8a1525a00e1c8388b060c869cd7add095f08784d 100644 --- a/resources/assets/javascripts/cke/wiki-link/formview.js +++ b/resources/assets/javascripts/cke/wiki-link/formview.js @@ -12,7 +12,7 @@ import { addListToDropdown, } from '@ckeditor/ckeditor5-ui'; import { FocusTracker, KeystrokeHandler, Collection, Rect, isVisible } from '@ckeditor/ckeditor5-utils'; -import { $gettext } from '../../lib/gettext.js'; +import { $gettext } from '../../lib/gettext'; export default class WikiLinkFormView extends View { constructor(locale) { diff --git a/resources/assets/javascripts/cke/wiki-link/ui.js b/resources/assets/javascripts/cke/wiki-link/ui.js index a8e5f89c019046aac6b025d14ee4b963e0cae4f7..dba6b82e994ada85ca2a8fc76d63164d20ae821b 100644 --- a/resources/assets/javascripts/cke/wiki-link/ui.js +++ b/resources/assets/javascripts/cke/wiki-link/ui.js @@ -1,7 +1,7 @@ import { Plugin } from '@ckeditor/ckeditor5-core'; import { createDropdown } from '@ckeditor/ckeditor5-ui'; import WikiLinkFormView from './formview.js'; -import { $gettext } from '../../lib/gettext.js'; +import { $gettext } from '../../lib/gettext'; const wikiIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54"><path class="cls-1" d="M49.83,15a15.17,15.17,0,0,1-10.17,7.9,31.41,31.41,0,0,1,3.45,11.38C46.63,32.05,53.82,25.94,49.83,15ZM4.17,15c-4,10.94,3.2,17,6.72,19.28A31.41,31.41,0,0,1,14.34,22.9,15.17,15.17,0,0,1,4.17,15ZM27,16c-7.1,0-12.85,10.31-12.85,23h25.7C39.85,26.29,34.1,16,27,16Z"/></svg>'; diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js index e824775ae920dd601b946045f7f2c91ef2fed413..8981e950ef3b3e128a172e755fcda3354f3e5bc7 100644 --- a/resources/assets/javascripts/init.js +++ b/resources/assets/javascripts/init.js @@ -77,7 +77,7 @@ import Table from './lib/table.js'; import TableOfContents from './lib/table-of-contents.js'; import Tooltip from './lib/tooltip.js'; import Tour from './lib/tour.js'; -import * as Gettext from './lib/gettext.js'; +import * as Gettext from './lib/gettext'; import UserFilter from './lib/user_filter.js'; import wysiwyg from './lib/wysiwyg.js'; import ScrollToTop from './lib/scroll_to_top.js'; diff --git a/resources/assets/javascripts/jquery-bundle.js b/resources/assets/javascripts/jquery-bundle.js index bdee32de161aef5d3d2e7a105e219ae06fef9a2c..bd1642260eae74610ced75bd88d8b8d1c3b54b13 100644 --- a/resources/assets/javascripts/jquery-bundle.js +++ b/resources/assets/javascripts/jquery-bundle.js @@ -1,6 +1,6 @@ import 'expose-loader?exposes[]=$&exposes[]=jQuery!jquery'; -import { setLocale } from './lib/gettext.js'; +import { setLocale } from './lib/gettext'; import 'jquery-ui/ui/widget.js'; import 'jquery-ui/ui/position.js'; @@ -76,7 +76,7 @@ import 'blueimp-file-upload/js/jquery.iframe-transport.js'; import './jquery/autoresize.jquery.min.js'; -import { $gettext } from './lib/gettext.js'; +import { $gettext } from './lib/gettext'; // Create jQuery "plugin" that just reverses the elements' order. This is // neccessary since the navigation is built and afterwards, we need to diff --git a/resources/assets/javascripts/lib/admission.js b/resources/assets/javascripts/lib/admission.js index 7cf8c880d3e2c20c4a9bb37a40bf9f1c67141488..df62bbe830dc4c7c7b7ac80061bec5fdd64b871a 100644 --- a/resources/assets/javascripts/lib/admission.js +++ b/resources/assets/javascripts/lib/admission.js @@ -1,7 +1,7 @@ /* ------------------------------------------------------------------------ * Anmeldeverfahren und -sets * ------------------------------------------------------------------------ */ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; const Admission = { diff --git a/resources/assets/javascripts/lib/big_image_handler.js b/resources/assets/javascripts/lib/big_image_handler.js index 51309979916f923a98b26c08d0c65c86249a3cb3..55e9b38246c389bf299106bb09adffafbd197f7e 100644 --- a/resources/assets/javascripts/lib/big_image_handler.js +++ b/resources/assets/javascripts/lib/big_image_handler.js @@ -18,7 +18,7 @@ * @license GPL2 or any later version * @since Stud.IP 3.4 */ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; var pixelRatio = window.devicePixelRatio || 1, dataAttribute = 'big-image-handled'; diff --git a/resources/assets/javascripts/lib/calendar.js b/resources/assets/javascripts/lib/calendar.js index 2f1cd672a40ef757880dc72dda9930d0c6234def..2d995b5f5f81717ad646d656a098c14ad64a25b3 100644 --- a/resources/assets/javascripts/lib/calendar.js +++ b/resources/assets/javascripts/lib/calendar.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; import eventBus from "./event-bus.ts"; eventBus.on('studip:set-locale', () => { diff --git a/resources/assets/javascripts/lib/dialog.js b/resources/assets/javascripts/lib/dialog.js index 8c22d1ced3aaf8758385be676c067c2b73fe5c0d..b5cab540604f3d3572af290d6d5d2a8126680393 100644 --- a/resources/assets/javascripts/lib/dialog.js +++ b/resources/assets/javascripts/lib/dialog.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; import parseOptions from './parse_options.js'; import extractCallback from './extract_callback.js'; import Overlay from './overlay.js'; diff --git a/resources/assets/javascripts/lib/files.js b/resources/assets/javascripts/lib/files.js index 7b628f60311b17521a723319dac4120c3095ecc6..d05112decd89b1a6d9cd1154e38790a4ad1c7cfa 100644 --- a/resources/assets/javascripts/lib/files.js +++ b/resources/assets/javascripts/lib/files.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; import FilesTable from '../../../vue/components/FilesTable.vue'; diff --git a/resources/assets/javascripts/lib/folders.js b/resources/assets/javascripts/lib/folders.js index ced430d0747e573cb9bb37ac08870b79c07f81a8..6cd23c5fa3045e0ef739d6025fca3bd2b39b8c98 100644 --- a/resources/assets/javascripts/lib/folders.js +++ b/resources/assets/javascripts/lib/folders.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; const Folders = { diff --git a/resources/assets/javascripts/lib/forum.js b/resources/assets/javascripts/lib/forum.js index c2f0d7c0cbf98b7ae10dfcc8fe00b0df9577b998..385ec12b0b3e346061d8a8cbac9bcadcd335c972 100644 --- a/resources/assets/javascripts/lib/forum.js +++ b/resources/assets/javascripts/lib/forum.js @@ -1,4 +1,4 @@ -import { $gettext } from "./gettext.js"; +import { $gettext } from "./gettext"; import eventBus from "./event-bus.ts"; eventBus.on('studip:set-locale', () => { diff --git a/resources/assets/javascripts/lib/gettext.js b/resources/assets/javascripts/lib/gettext.ts similarity index 73% rename from resources/assets/javascripts/lib/gettext.js rename to resources/assets/javascripts/lib/gettext.ts index 5742466a60ccc441436666fca1d7d3a70b946a7b..23daaaa075e371bd14b74cf7d4fb048792539b90 100644 --- a/resources/assets/javascripts/lib/gettext.js +++ b/resources/assets/javascripts/lib/gettext.ts @@ -1,6 +1,25 @@ import { translate } from 'vue-gettext'; -import defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json'; -import eventBus from './event-bus.ts'; +import * as defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json'; +import eventBus from './event-bus'; + +interface StringDict { + [key: string]: string; +} + +interface InstalledLanguage { + name: string; + selected: boolean; +} + +interface InstalledLanguages { + [key: string]: InstalledLanguage; +} + +type TranslationDict = StringDict; + +interface TranslationDicts { + [key: string]: TranslationDict | null; +} const DEFAULT_LANG = 'de_DE'; const DEFAULT_LANG_NAME = 'Deutsch'; @@ -24,7 +43,7 @@ async function setLocale(locale = getInitialLocale()) { state.locale = locale; if (state.translations[state.locale] === null) { - const translations = await getTranslations(state.locale); + const translations: TranslationDict = await getTranslations(state.locale); state.translations[state.locale] = translations; } @@ -43,7 +62,7 @@ function getVueConfig() { memo[lang] = name; return memo; - }, {}); + }, {} as StringDict); return { availableLanguages, @@ -55,11 +74,11 @@ function getVueConfig() { } function getInitialState() { - const translations = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => { + const translations: TranslationDicts = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => { memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : null; return memo; - }, {}); + }, {} as TranslationDicts); return { locale: DEFAULT_LANG, @@ -77,11 +96,11 @@ function getInitialLocale() { return DEFAULT_LANG; } -function getInstalledLanguages() { +function getInstalledLanguages(): InstalledLanguages { return window?.STUDIP?.INSTALLED_LANGUAGES ?? { [DEFAULT_LANG]: { name: DEFAULT_LANG_NAME, selected: true } }; } -async function getTranslations(locale) { +async function getTranslations(locale: string): Promise<TranslationDict> { try { const language = locale.split(/[_-]/)[0]; const translation = await import(`../../../../locale/${language}/LC_MESSAGES/js-resources.json`); diff --git a/resources/assets/javascripts/lib/instschedule.js b/resources/assets/javascripts/lib/instschedule.js index af438c29d9cb60c2a74c7a5dd4eaa446ba17f2a5..d925bfbc0a959713fe7eafa9360f991721727b32 100644 --- a/resources/assets/javascripts/lib/instschedule.js +++ b/resources/assets/javascripts/lib/instschedule.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; const Instschedule = { diff --git a/resources/assets/javascripts/lib/jsupdater.js b/resources/assets/javascripts/lib/jsupdater.js index 7888f29218bbf1bdfd9993ec90fbe34fe82191dd..5069af0be099d3feda102da851289a7d11693f30 100644 --- a/resources/assets/javascripts/lib/jsupdater.js +++ b/resources/assets/javascripts/lib/jsupdater.js @@ -10,7 +10,7 @@ * * Refer to the according function definitions for further info. * ------------------------------------------------------------------------ */ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; let active = false; diff --git a/resources/assets/javascripts/lib/lightbox.js b/resources/assets/javascripts/lib/lightbox.js index 134cfca85864d856d82cd8873e9cf5ffaa9af47f..09bfda29e6794ede101fe479351854c75a2e99b9 100644 --- a/resources/assets/javascripts/lib/lightbox.js +++ b/resources/assets/javascripts/lib/lightbox.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; function sprintf(string) { diff --git a/resources/assets/javascripts/lib/messages.js b/resources/assets/javascripts/lib/messages.js index dbb27939607594db6ab3cff6c081fe82870f5a6e..7ce5328b25407553d8fd472b0af4c001df8ab0db 100644 --- a/resources/assets/javascripts/lib/messages.js +++ b/resources/assets/javascripts/lib/messages.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Markup from './markup.js'; const Messages = { diff --git a/resources/assets/javascripts/lib/multi_person_search.js b/resources/assets/javascripts/lib/multi_person_search.js index f5ba046c164dde7da73ff9f5672bdf1a6002347e..b876bc979fc2d06221060d6a39e5b4abd5ab98bf 100644 --- a/resources/assets/javascripts/lib/multi_person_search.js +++ b/resources/assets/javascripts/lib/multi_person_search.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; const MultiPersonSearch = { init: function() { diff --git a/resources/assets/javascripts/lib/multi_select.js b/resources/assets/javascripts/lib/multi_select.js index b4abeb9c1ed4f5ddf14e89a5bddc386771aa9288..6c1b3875166c6e3967423dc9930230906d525c18 100644 --- a/resources/assets/javascripts/lib/multi_select.js +++ b/resources/assets/javascripts/lib/multi_select.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; /** * Turns a select-box into an easy to use multiple select-box diff --git a/resources/assets/javascripts/lib/oer.js b/resources/assets/javascripts/lib/oer.js index 112d155a5ce2c064bfc1698a7cb7eedc67563df7..17f0186ad114c1d9b8ab050ff0a7ea0f249a636c 100644 --- a/resources/assets/javascripts/lib/oer.js +++ b/resources/assets/javascripts/lib/oer.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; const OER = { periodicalPushData: function () { diff --git a/resources/assets/javascripts/lib/overlapping.js b/resources/assets/javascripts/lib/overlapping.js index 73ab32fa52e04300b17501fe764d6f272cca4053..a6aa4b8ae385407427c0f39d7fcc94daa9ff616c 100644 --- a/resources/assets/javascripts/lib/overlapping.js +++ b/resources/assets/javascripts/lib/overlapping.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; const Overlapping = { @@ -91,4 +91,4 @@ const Overlapping = { } }; -export default Overlapping; \ No newline at end of file +export default Overlapping; diff --git a/resources/assets/javascripts/lib/overlay.js b/resources/assets/javascripts/lib/overlay.js index 52d1c949d5b5f1fa8db0421b74e15f4da569b02e..ffe8ed144fe1c85561caeb54080f814e43a43bc2 100644 --- a/resources/assets/javascripts/lib/overlay.js +++ b/resources/assets/javascripts/lib/overlay.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; const Overlay = { delay: 300, diff --git a/resources/assets/javascripts/lib/qr_code.js b/resources/assets/javascripts/lib/qr_code.js index 3db4fd8703c226fb82657f3007ce31552daccd01..ada0cb33f558f081aa0da7a917c045654e577aad 100644 --- a/resources/assets/javascripts/lib/qr_code.js +++ b/resources/assets/javascripts/lib/qr_code.js @@ -1,4 +1,4 @@ -import { $gettext } from "./gettext.js"; +import { $gettext } from "./gettext"; import Dialog from "./dialog.js"; const QRCode = { diff --git a/resources/assets/javascripts/lib/questionnaire.js b/resources/assets/javascripts/lib/questionnaire.js index 8fbbbb0775f31921bb566ef4f10f18f5c1ddd2e5..2bca8c628aa1c0b42ccf6f1d3cfdf93c670afe14 100644 --- a/resources/assets/javascripts/lib/questionnaire.js +++ b/resources/assets/javascripts/lib/questionnaire.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; import md5 from 'md5'; //import html2canvas from "html2canvas"; //import {jsPDF} from "jspdf"; diff --git a/resources/assets/javascripts/lib/quick_search.js b/resources/assets/javascripts/lib/quick_search.js index 806debd5f50d7da6b1c35f71b26a2de2d8d69ee6..627bffacb391d31504d18232e9dcb689f94d77fc 100644 --- a/resources/assets/javascripts/lib/quick_search.js +++ b/resources/assets/javascripts/lib/quick_search.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; /* ------------------------------------------------------------------------ * QuickSearch inputs diff --git a/resources/assets/javascripts/lib/raumzeit.js b/resources/assets/javascripts/lib/raumzeit.js index 5cd5e55118fcaf2e0a06fe7c1d3b8a38d6b4b9d3..c28dbae280dfc910d1d6ba769f0f989f4fea9122 100644 --- a/resources/assets/javascripts/lib/raumzeit.js +++ b/resources/assets/javascripts/lib/raumzeit.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; const Raumzeit = { disableBookableRooms: function(icon) { diff --git a/resources/assets/javascripts/lib/register.js b/resources/assets/javascripts/lib/register.js index da81132aa91f83cda7fbf78d71bde969de78b907..de7b66684dc1359d41ebfe7399cddb1deea336f9 100644 --- a/resources/assets/javascripts/lib/register.js +++ b/resources/assets/javascripts/lib/register.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; const register = { re_username: null, diff --git a/resources/assets/javascripts/lib/resources.js b/resources/assets/javascripts/lib/resources.js index 2375eeeab17fe450264410cd3a0e4da909070421..9acb2e375b844082503805a5f116b31c8f8be35d 100644 --- a/resources/assets/javascripts/lib/resources.js +++ b/resources/assets/javascripts/lib/resources.js @@ -1,4 +1,4 @@ -import { $gettext } from '../lib/gettext.js'; +import { $gettext } from '../lib/gettext'; class Resources { diff --git a/resources/assets/javascripts/lib/schedule.js b/resources/assets/javascripts/lib/schedule.js index b7c9d3700345717a7075871513e6d4f20b036ec8..f3e5123fd45cc5d3ba86ba31e9c57dc83f4d254b 100644 --- a/resources/assets/javascripts/lib/schedule.js +++ b/resources/assets/javascripts/lib/schedule.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Calendar from './calendar.js'; import Dialog from './dialog.js'; diff --git a/resources/assets/javascripts/lib/tour.js b/resources/assets/javascripts/lib/tour.js index b93be078395bfe8356718d09117166c1b1d85559..8094b2b08a693cfabfb02e0f31b3585975eb8c46 100644 --- a/resources/assets/javascripts/lib/tour.js +++ b/resources/assets/javascripts/lib/tour.js @@ -1,4 +1,4 @@ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; /* ------------------------------------------------------------------------ * Stud.IP Tour diff --git a/resources/assets/javascripts/lib/user_filter.js b/resources/assets/javascripts/lib/user_filter.js index 450af348e0e85a74f6fa73adc56fe69800bbc3b8..25b8488e0e246c39a0ba689164332c3895e3c063 100644 --- a/resources/assets/javascripts/lib/user_filter.js +++ b/resources/assets/javascripts/lib/user_filter.js @@ -1,7 +1,7 @@ /* ------------------------------------------------------------------------ * Bedingungen zur Auswahl von Stud.IP-Nutzern * ------------------------------------------------------------------------ */ -import { $gettext } from './gettext.js'; +import { $gettext } from './gettext'; import Dialog from './dialog.js'; const UserFilter = { diff --git a/resources/assets/javascripts/mvv.js b/resources/assets/javascripts/mvv.js index 12d26532665403cae4fbe6e475695395f16d42c8..a339624b60864457817341cf5185f3cfe6c0dbc1 100644 --- a/resources/assets/javascripts/mvv.js +++ b/resources/assets/javascripts/mvv.js @@ -1,4 +1,4 @@ -import { $gettext } from './lib/gettext.js'; +import { $gettext } from './lib/gettext'; jQuery(function ($) { $(document).on('click', 'a.mvv-load-in-new-row', function () { diff --git a/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js b/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js index 2462ff990f1539137bddccacb81a8c8ca3a561ba..adde0ceeb14262f6db098b41f3a3aee9bfe6cc1e 100644 --- a/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js +++ b/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js @@ -1,4 +1,4 @@ -import { $gettext } from './lib/gettext.js'; +import { $gettext } from './lib/gettext'; /** diff --git a/resources/assets/javascripts/studip-ui.js b/resources/assets/javascripts/studip-ui.js index 60a3cfaf65627e32bd45ade5f97656e067fbe0ff..f5812952a850cb886879d775bff288fc7fb8dca0 100644 --- a/resources/assets/javascripts/studip-ui.js +++ b/resources/assets/javascripts/studip-ui.js @@ -1,4 +1,4 @@ -import { $gettext } from './lib/gettext.js'; +import { $gettext } from './lib/gettext'; import eventBus from "./lib/event-bus.ts"; /** diff --git a/resources/vue-gettext.d.ts b/resources/vue-gettext.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3f4c6611cb250c2f9bf0f020cb43ab7439f9ac3 --- /dev/null +++ b/resources/vue-gettext.d.ts @@ -0,0 +1,17 @@ +declare module "vue-gettext" { + import GettextPlugin from 'vue-gettext'; + + declare namespace translate { + function getTranslation(msgid: any, n?: number, context?: any, defaultPlural?: any, language?: string): any; + function gettext(msgid: any, language?: string): any; + function pgettext(context: any, msgid: any, language?: string): any; + function ngettext(msgid: any, plural: any, n: any, language?: string): any; + function npgettext(context: any, msgid: any, plural: any, n: any, language?: string): any; + function initTranslations(translations: any, config: any): void; + const gettextInterpolate: any; + } + + export { translate }; + + export default GettextPlugin; +} diff --git a/resources/vue/components/StudipDate.vue b/resources/vue/components/StudipDate.vue new file mode 100644 index 0000000000000000000000000000000000000000..2e30b9d250a5853d6dd23926119a8f7332f98001 --- /dev/null +++ b/resources/vue/components/StudipDate.vue @@ -0,0 +1,27 @@ +<template> + <time :datetime="date.toISOString()">{{ formatted }}</time> +</template> + +<script> +function formatDate(date) { + return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + date.getFullYear(); +} + +function pad(what) { + return what.toString().padStart(2, '0'); +} + +export default { + props: { + date: { + type: Date, + required: true, + }, + }, + computed: { + formatted() { + return formatDate(this.date); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue deleted file mode 100644 index bac31a6f0fc93541a1e3d9d55a815bc5c3b2b2f3..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue +++ /dev/null @@ -1,481 +0,0 @@ -<template> - <div class="cw-dashboard-students-wrapper"> - <table v-if="tasks.length > 0" class="default"> - <colgroup> - <col /> - </colgroup> - <thead> - <tr class="sortable"> - <th>{{ $gettext('Status') }}</th> - <th :class="getSortClass('task-title')" @click="sort('task-title')"> - {{ $gettext('Aufgabentitel') }} - </th> - <th :class="getSortClass('solver-name')" @click="sort('solver-name')"> - {{ $gettext('Teilnehmende/Gruppen') }} - </th> - <th class="responsive-hidden" :class="getSortClass('page-title')" @click="sort('page-title')"> - {{ $gettext('Seite') }} - </th> - <th :class="getSortClass('progress')" @click="sort('progress')"> - {{ $gettext('bearbeitet') }} - </th> - <th :class="getSortClass('submission-date')" @click="sort('submission-date')"> - {{ $gettext('Abgabefrist') }} - </th> - <th>{{ $gettext('Abgabe') }}</th> - <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th> - <th class="responsive-hidden feedback">{{ $gettext('Anmerkungen') }}</th> - </tr> - </thead> - <tbody> - <tr v-for="{ task, taskGroup, status, element, user, group, feedback } in tasks" :key="task.id"> - <td> - <studip-icon - v-if="status.shape !== undefined" - :shape="status.shape" - :role="status.role" - :title="status.description" - aria-hidden="true" - /> - <span class="sr-only">{{ status.description }}</span> - </td> - <td> - {{ taskGroup && taskGroup.attributes.title }} - </td> - <td> - <span v-if="user"> - <studip-icon - shape="person2" - role="info" - aria-hidden="true" - :title="$gettext('Teilnehmende Person')" - /> - <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span> - {{ user.attributes['formatted-name'] }} - - </span> - <span v-if="group"> - <studip-icon - shape="group2" - role="info" - aria-hidden="true" - :title="$gettext('Gruppe')" - /> - <span class="sr-only">{{ $gettext('Gruppe') }}</span> - {{ group.attributes['name'] }} - - </span> - </td> - <td class="responsive-hidden"> - <a v-if="task.attributes.submitted" :href="getLinkToElement(element)"> - {{ element.attributes.title }} - </a> - <span v-else>{{ element.attributes.title }}</span> - </td> - <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</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"> - <button - v-show="task.attributes.renewal === 'pending'" - class="button" - @click="solveRenewalRequest(task)" - > - {{ $gettext('Anfrage bearbeiten') }} - </button> - <span v-show="task.attributes.renewal === 'declined'"> - <studip-icon shape="decline" role="status-red" /> - {{ $gettext('Anfrage abgelehnt') }} - </span> - <span v-show="task.attributes.renewal === 'granted'"> - {{ $gettext('verlängert bis') }}: - {{ getReadableDate(task.attributes['renewal-date']) }} - </span> - <studip-icon - v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'" - :title="$gettext('Anfrage bearbeiten')" - class="edit" - shape="edit" - role="clickable" - @click="solveRenewalRequest(task)" - /> - </td> - <td class="responsive-hidden"> - <span - v-if="feedback" - :title=" - $gettext('Anmerkung geschrieben am:') + - ' ' + - getReadableDate(feedback.attributes['chdate']) - " - > - <studip-icon shape="accept" role="status-green" /> - {{ $gettext('Anmerkung gegeben') }} - <studip-icon - :title="$gettext('Anmerkung bearbeiten')" - class="edit" - shape="edit" - role="clickable" - @click="editFeedback(feedback)" - /> - </span> - - <button - v-show="!feedback && task.attributes.submitted" - class="button" - @click="addFeedback(task)" - > - {{ $gettext('Anmerkung geben') }} - </button> - </td> - </tr> - </tbody> - </table> - <div v-else> - <courseware-companion-box - mood="pointing" - :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" - > - </courseware-companion-box> - </div> - <studip-dialog - v-if="showRenewalDialog" - :title="text.renewalDialog.title" - :confirmText="text.renewalDialog.confirm" - confirmClass="accept" - :closeText="text.renewalDialog.close" - closeClass="cancel" - height="350" - @close=" - showRenewalDialog = false; - currentDialogTask = {}; - " - @confirm="updateRenewal" - > - <template v-slot:dialogContent> - <form class="default" @submit.prevent=""> - <label> - {{ $gettext('Fristverlängerung') }} - <select v-model="currentDialogTask.attributes.renewal"> - <option value="declined"> - {{ $gettext('ablehnen') }} - </option> - <option value="granted"> - {{ $gettext('gewähren') }} - </option> - </select> - </label> - <label v-if="currentDialogTask.attributes.renewal === 'granted'"> - {{ $gettext('neue Frist') }} - <courseware-date-input v-model="currentDialogTask.attributes['renewal-date']" class="size-l" /> - </label> - </form> - </template> - </studip-dialog> - <studip-dialog - v-if="showEditFeedbackDialog" - :title="text.editFeedbackDialog.title" - :confirmText="text.editFeedbackDialog.confirm" - confirmClass="accept" - :closeText="text.editFeedbackDialog.close" - closeClass="cancel" - height="420" - @close=" - showEditFeedbackDialog = false; - currentDialogFeedback = {}; - " - @confirm="updateFeedback" - > - <template v-slot:dialogContent> - <courseware-companion-box - v-if="currentDialogFeedback.attributes.content === ''" - mood="pointing" - :msgCompanion=" - $gettext('Sie haben keine Anmerkungen geschrieben, beim Speichern wird diese Anmerkung gelöscht!') - " - /> - <form class="default" @submit.prevent=""> - <label> - {{ $gettext('Anmerkung') }} - <textarea v-model="currentDialogFeedback.attributes.content" /> - </label> - </form> - </template> - </studip-dialog> - <studip-dialog - v-if="showAddFeedbackDialog" - :title="text.addFeedbackDialog.title" - :confirmText="text.addFeedbackDialog.confirm" - confirmClass="accept" - :closeText="text.addFeedbackDialog.close" - closeClass="cancel" - @close=" - showAddFeedbackDialog = false; - currentDialogFeedback = {}; - " - @confirm="createFeedback" - > - <template v-slot:dialogContent> - <form class="default" @submit.prevent=""> - <label> - {{ $gettext('Anmerkung') }} - <textarea v-model="currentDialogFeedback.attributes.content" /> - </label> - </form> - </template> - </studip-dialog> - <courseware-tasks-dialog-distribute v-if="showTasksDistributeDialog" @newtask="reloadTasks"/> - </div> -</template> - -<script> -import StudipIcon from './../StudipIcon.vue'; -import StudipDialog from './../StudipDialog.vue'; -import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue'; -import CoursewareDateInput from './layouts/CoursewareDateInput.vue'; -import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; -import taskHelperMixin from '../../mixins/courseware/task-helper.js'; -import { mapActions, mapGetters } from 'vuex'; - - -export default { - name: 'courseware-dashboard-students', - mixins: [taskHelperMixin], - components: { - CoursewareCompanionBox, - CoursewareDateInput, - StudipIcon, - StudipDialog, - CoursewareTasksDialogDistribute, - }, - data() { - return { - showRenewalDialog: false, - showAddFeedbackDialog: false, - showEditFeedbackDialog: false, - currentDialogTask: {}, - currentDialogFeedback: {}, - text: { - renewalDialog: { - title: this.$gettext('Verlängerungsanfrage bearbeiten'), - confirm: this.$gettext('Speichern'), - close: this.$gettext('Schließen'), - }, - editFeedbackDialog: { - title: this.$gettext('Anmerkung zur Aufgabe ändern'), - confirm: this.$gettext('Speichern'), - close: this.$gettext('Schließen'), - }, - addFeedbackDialog: { - title: this.$gettext('Anmerkung zur Aufgabe erstellen'), - confirm: this.$gettext('Speichern'), - close: this.$gettext('Schließen'), - }, - }, - sortBy: 'task-title', - sortASC: true, - }; - }, - computed: { - ...mapGetters({ - context: 'context', - allTasks: 'courseware-tasks/all', - userById: 'users/byId', - statusGroupById: 'status-groups/byId', - getElementById: 'courseware-structural-elements/byId', - getFeedbackById: 'courseware-task-feedback/byId', - relatedTaskGroups: 'courseware-task-groups/related', - showTasksDistributeDialog: 'showTasksDistributeDialog' - }), - tasks() { - const tasks = this.allTasks.map((task) => { - const result = { - task, - taskGroup: this.relatedTaskGroups({ parent: task, relationship: 'task-group' }), - status: this.getStatus(task), - element: this.getElementById({ id: task.relationships['structural-element'].data.id }), - user: null, - group: null, - feedback: null, - solverName: null - }; - let solver = task.relationships.solver.data; - if (solver.type === 'users') { - result.user = this.userById({ id: solver.id }); - result.solverName = result.user.attributes['formatted-name']; - } - if (solver.type === 'status-groups') { - result.group = this.statusGroupById({ id: solver.id }); - result.solverName = result.group.attributes['name']; - } - - const feedbackId = task.relationships['task-feedback'].data?.id; - if (feedbackId) { - result.feedback = this.getFeedbackById({ id: feedbackId }); - } - - return result; - }); - - return this.sortTasks(tasks); - }, - managerUrl() { - return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/manager', {cid: this.context.id}); - } - }, - methods: { - ...mapActions({ - updateTask: 'updateTask', - createTaskFeedback: 'createTaskFeedback', - updateTaskFeedback: 'updateTaskFeedback', - deleteTaskFeedback: 'deleteTaskFeedback', - loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', - copyStructuralElement: 'copyStructuralElement', - companionSuccess: 'companionSuccess', - companionError: 'companionError', - loadAllTasks: 'courseware-tasks/loadAll' - }), - addFeedback(task) { - this.currentDialogFeedback.attributes = {}; - this.currentDialogFeedback.attributes.content = ''; - this.currentDialogFeedback.relationships = {}; - this.currentDialogFeedback.relationships.task = {}; - this.currentDialogFeedback.relationships.task.data = {}; - this.currentDialogFeedback.relationships.task.data.id = task.id; - this.currentDialogFeedback.relationships.task.data.type = task.type; - this.showAddFeedbackDialog = true; - }, - createFeedback() { - if (this.currentDialogFeedback.attributes.content === '') { - this.companionError({ - info: this.$gettext('Bitte schreiben Sie eine Anmerkung.'), - }); - return false; - } - this.showAddFeedbackDialog = false; - this.createTaskFeedback({ - taskFeedback: this.currentDialogFeedback, - }); - this.currentDialogFeedback = {}; - }, - editFeedback(feedback) { - this.currentDialogFeedback = _.cloneDeep(feedback); - this.showEditFeedbackDialog = true; - }, - async updateFeedback() { - this.showEditFeedbackDialog = false; - let attributes = {}; - attributes.content = this.currentDialogFeedback.attributes.content; - if (attributes.content === '') { - await this.deleteTaskFeedback({ - taskFeedbackId: this.currentDialogFeedback.id, - }); - this.companionSuccess({ - info: this.$gettext('Anmerkung wurde gelöscht.'), - }); - } else { - await this.updateTaskFeedback({ - attributes: attributes, - taskFeedbackId: this.currentDialogFeedback.id, - }); - this.companionSuccess({ - info: this.$gettext('Anmerkung wurde gespeichert.'), - }); - } - - this.currentDialogFeedback = {}; - }, - solveRenewalRequest(task) { - this.currentDialogTask = _.cloneDeep(task); - this.currentDialogTask.attributes['renewal-date'] = new Date().toISOString(); - this.showRenewalDialog = true; - }, - updateRenewal() { - this.showRenewalDialog = false; - let attributes = {}; - attributes.renewal = this.currentDialogTask.attributes.renewal; - if (attributes.renewal === 'granted') { - attributes['renewal-date'] = new Date(this.currentDialogTask.attributes['renewal-date'] || Date.now()).toISOString(); - } - - this.updateTask({ - attributes: attributes, - taskId: this.currentDialogTask.id, - }); - this.currentDialogTask = {}; - }, - reloadTasks() { - this.loadAllTasks({ - options: { - 'filter[cid]': this.context.id, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer' - } - }); - }, - getSortClass(col) { - if (col === this.sortBy) { - return this.sortASC ? 'sortasc' : 'sortdesc'; - } - }, - sort(sortBy) { - if (this.sortBy === sortBy) { - this.sortASC = !this.sortASC; - } else { - this.sortBy = sortBy; - } - }, - sortTasks(tasks) { - switch (this.sortBy) { - case 'task-title': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.taskGroup.attributes.title < b.taskGroup.attributes.title ? -1 : 1; - } else { - return a.taskGroup.attributes.title > b.taskGroup.attributes.title ? -1 : 1; - } - }); - break; - case 'solver-name': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.solverName < b.solverName ? -1 : 1; - } else { - return a.solverName > b.solverName ? -1 : 1; - } - }); - break; - case 'page-title': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.element.attributes.title < b.element.attributes.title ? -1 : 1; - } else { - return a.element.attributes.title > b.element.attributes.title ? -1 : 1; - } - }); - break; - case 'progress': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.task.attributes.progress < b.task.attributes.progress ? -1 : 1; - } else { - return a.task.attributes.progress > b.task.attributes.progress ? -1 : 1; - } - }); - break; - case 'submission-date': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return new Date(a.task.attributes['submission-date']) - new Date(b.task.attributes['submission-date']); - } else { - return new Date(b.task.attributes['submission-date']) - new Date(a.task.attributes['submission-date']); - } - }); - break; - } - return tasks; - }, - }, -}; -</script> diff --git a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue index eefc0fb5d0e289641543b17cf5a1619155507951..e79df40ffeaec2bbc19aa0900dca8da3d72e6ccf 100644 --- a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue +++ b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue @@ -2,7 +2,8 @@ <div class="cw-collapsible" :class="{ 'cw-collapsible-open': isOpen }"> <a href="#" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen"> <header :class="{ 'cw-collapsible-open': isOpen }" class="cw-collapsible-title"> - <studip-icon v-if="icon" :shape="icon" /> {{ title }} + <studip-icon v-if="icon" :shape="icon" /> + <slot name="title" :is-open="isOpen">{{ title }}</slot> </header> </a> <div v-if="isOpen" class="cw-collapsible-content" :class="{ 'cw-collapsible-content-open': isOpen }"> diff --git a/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d7c28104e9f7bb9be195f6016bbad0ea44bb6bc --- /dev/null +++ b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue @@ -0,0 +1,48 @@ +<template> + <studip-dialog + :title="$gettext('Feedback zur Aufgabe geben')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + @close="$emit('close')" + @confirm="create" + > + <template #dialogContent> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Feedback') }} + <textarea v-model="localContent" /> + </label> + </form> + </template> + </studip-dialog> +</template> + +<script> +export default { + props: ['content'], + data: () => ({ + localContent: '', + }), + methods: { + resetLocalVars() { + this.localContent = this.content; + }, + create() { + this.$emit('create', { content: this.localContent }); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + content(newValue) { + if (newValue !== this.localContent) { + this.resetLocalVars(); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue new file mode 100644 index 0000000000000000000000000000000000000000..426b0cbff4b36668e11434772c41d1fb5fb4bcd6 --- /dev/null +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue @@ -0,0 +1,222 @@ +<template> + <div class="cw-dashboard-students-wrapper"> + <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> + <template #buttons> + <router-link :to="{ name: 'task-groups-index' }"> + <StudipIcon shape="category-task" :size="24" /> + </router-link> + </template> + <template #breadcrumbList> + <li> + {{ $gettext('Aufgaben') }} + </li> + </template> + </CoursewareRibbon> + <table class="default" v-if="taskGroups.length"> + <thead> + <tr class="sortable"> + <th> + {{ $gettext('Status') }} + </th> + <th :class="getSortClass('task-group-title')" @click="sort('task-group-title')"> + {{ $gettext('Titel') }} + </th> + <th :class="getSortClass('end-date')" @click="sort('end-date')"> + {{ $gettext('Bearbeitungszeit') }} + </th> + <th class="actions">{{ $gettext('Aktionen') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(taskGroup, index) in sortedTaskGroups" :key="index"> + <td> + <StudipIcon + :shape="status(taskGroup).shape" + :role="status(taskGroup).role" + :title="status(taskGroup).description" + aria-hidden="true" + /> + <span class="sr-only">{{ status(taskGroup).description }}</span> + </td> + <td> + <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }">{{ + taskGroup.attributes.title + }}</router-link> + </td> + <td> + <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - <StudipDate + :date="new Date(taskGroup.attributes['end-date'])" + /> + </td> + <td class="actions"> + <StudipActionMenu + :items="getTaskGroupMenuItems(taskGroup)" + @addsolvers="onShowAddSolvers(taskGroup)" + @deadline="onShowModifyDeadline(taskGroup)" + @delete="onShowDeleteDialog(taskGroup)" + /> + </td> + </tr> + </tbody> + </table> + + <CompanionBox v-else-if="!tasksLoading" :msgCompanion="$gettext('Es wurden noch keine Aufgaben verteilt.')"> + <template #companionActions> + <button @click="setShowTasksDistributeDialog(true)" type="button" class="button"> + {{ $gettext('Aufgabe verteilen') }} + </button> + </template> + </CompanionBox> + + <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" /> + </div> +</template> + +<script> +import _ from 'lodash'; +import { mapActions, mapGetters } from 'vuex'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; +import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; +import StudipActionMenu from '../../StudipActionMenu.vue'; +import StudipDate from '../../StudipDate.vue'; +import StudipIcon from '../../StudipIcon.vue'; +import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; +import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; +import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; +import { getStatus } from './task-groups-helper.js'; + +export default { + name: 'courseware-dashboard-students', + components: { + CompanionBox, + CoursewareRibbon, + CoursewareTasksDialogDistribute, + StudipActionMenu, + StudipDate, + StudipIcon, + TaskGroupsAddSolversDialog, + TaskGroupsDeleteDialog, + TaskGroupsModifyDeadlineDialog, + }, + data: () => ({ + selectedTaskGroup: null, + sortBy: 'end-date', + sortAsc: false, + }), + computed: { + ...mapGetters({ + context: 'context', + showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog', + showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog', + showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog', + showTasksDistributeDialog: 'tasks/showTasksDistributeDialog', + taskGroupsByCid: 'tasks/taskGroupsByCid', + tasksLoading: 'courseware-tasks/isLoading', + }), + sortedTaskGroups() { + const sorters = { + 'task-group-title': (taskGroup) => taskGroup.attributes.title, + 'end-date': (taskGroup) => new Date(taskGroup.attributes['end-date']), + }; + + return _.chain(this.taskGroups) + .sortBy([sorters[this.sortBy]]) + .thru((sorted) => (this.sortAsc ? sorted : _.reverse(sorted))) + .value(); + }, + taskGroups() { + return this.taskGroupsByCid(this.context.id); + }, + }, + methods: { + ...mapActions({ + loadAllTasks: 'courseware-tasks/loadAll', + setShowTaskGroupsAddSolversDialog: 'tasks/setShowTaskGroupsAddSolversDialog', + setShowTaskGroupsDeleteDialog: 'tasks/setShowTaskGroupsDeleteDialog', + setShowTaskGroupsModifyDeadlineDialog: 'tasks/setShowTaskGroupsModifyDeadlineDialog', + setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog', + }), + getSortClass(col) { + if (col === this.sortBy) { + return this.sortAsc ? 'sortasc' : 'sortdesc'; + } + return ''; + }, + getTaskGroupMenuItems(taskGroup) { + let menuItems = []; + + const isBeforeEndDate = new Date() < new Date(taskGroup.attributes['end-date']); + if (isBeforeEndDate) { + menuItems.push({ + id: 'add-solvers', + label: this.$gettext('Teilnehmende hinzufügen'), + icon: 'add', + emit: 'addsolvers' + }); + menuItems.push({ + id: 'modify-deadline', + label: this.$gettext('Bearbeitungszeit verlängern'), + icon: 'date', + emit: 'deadline' + }); + } + + menuItems.push({ + id: 'delete', + label: this.$gettext('Aufgabe löschen'), + icon: 'trash', + emit: 'delete', + }); + + return menuItems; + }, + onShowAddSolvers(taskGroup) { + this.selectedTaskGroup = taskGroup; + this.setShowTaskGroupsAddSolversDialog(true); + }, + onShowDeleteDialog(taskGroup) { + this.selectedTaskGroup = taskGroup; + this.setShowTaskGroupsDeleteDialog(true); + }, + onShowModifyDeadline(taskGroup) { + this.selectedTaskGroup = taskGroup; + this.setShowTaskGroupsModifyDeadlineDialog(true); + }, + reloadTasks() { + this.loadAllTasks({ + options: { + 'filter[cid]': this.context.id, + include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + }, + }); + }, + sort(sortBy) { + if (this.sortBy === sortBy) { + this.sortAsc = !this.sortAsc; + } else { + this.sortBy = sortBy; + } + }, + status: getStatus, + }, +}; +</script> + +<style scoped> +.cw-dashboard-students-wrapper >>> .cw-ribbon-nav { + min-width: 24px; + padding: 0 1em; + height: 24px; + margin-top: 2px; +} +th { + cursor: pointer; +} +th:is(:first-child,:last-child) { + cursor: not-allowed; +} +</style> diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue similarity index 97% rename from resources/vue/components/courseware/CoursewareDashboardTasks.vue rename to resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue index 6de9c13882b6bdf3aa1a39c84cd65df2c65a4487..9c9e298eff2488f73875431d4b339dfc38465238 100644 --- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue @@ -102,11 +102,11 @@ </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 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 { diff --git a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue similarity index 92% rename from resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue rename to resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue index 79c8cacac60578d18d6737764254feb12e8fec4b..e88bd04da57729b2bb38f88147e44396381f8fa7 100644 --- a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue +++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue @@ -27,10 +27,10 @@ :key="'label-' + unit.id" :for="'cw-task-dist-source-unit' + unit.id" > - <div class="icon"><studip-icon shape="courseware" size="32" /></div> + <div class="icon"><studip-icon shape="courseware" :size="32" /></div> <div class="text">{{ unit.element.attributes.title }}</div> - <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> - <studip-icon shape="check-circle" size="24" class="check" /> + <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" /> + <studip-icon shape="check-circle" :size="24" class="check" /> </label> </template> </fieldset> @@ -62,10 +62,15 @@ <span aria-hidden="true" class="wizard-required">*</span> <input type="text" v-model="taskTitle" required /> </label> + <label> + <span>{{ $gettext('Startdatum') }}</span> + <span aria-hidden="true" class="wizard-required">*</span> + <input type="date" v-model="startDate" required /> + </label> <label> <span>{{ $gettext('Abgabefrist') }}</span> <span aria-hidden="true" class="wizard-required">*</span> - <input type="date" v-model="submissionDate" /> + <input type="date" v-model="endDate" :min="startDate" required /> </label> <label> {{ $gettext('Inhalte ergänzen') }} @@ -99,10 +104,10 @@ :key="'label-' + unit.id" :for="'cw-task-dist-target-unit' + unit.id" > - <div class="icon"><studip-icon shape="courseware" size="32" /></div> + <div class="icon"><studip-icon shape="courseware" :size="32" /></div> <div class="text">{{ unit.element.attributes.title }}</div> - <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> - <studip-icon shape="check-circle" size="24" class="check" /> + <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" /> + <studip-icon shape="check-circle" :size="24" class="check" /> </label> </template> </fieldset> @@ -237,12 +242,15 @@ </template> <script> -import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue'; -import CoursewareStructuralElementSelector from './structural-element/CoursewareStructuralElementSelector.vue'; -import StudipWizardDialog from '../StudipWizardDialog.vue'; +import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import CoursewareStructuralElementSelector from '../structural-element/CoursewareStructuralElementSelector.vue'; +import StudipWizardDialog from '../../StudipWizardDialog.vue'; import { mapActions, mapGetters } from 'vuex'; +const dateString = (date) => + `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`; + export default { name: 'courseware-tasks-dialog-distribute', components: { @@ -316,7 +324,8 @@ export default { ], selectedSourceUnit: null, taskTitle: '', - submissionDate: '', + startDate: dateString(new Date()), + endDate: '', solverMayAddBlocks: true, selectedTask: null, selectedTargetUnit: null, @@ -488,7 +497,7 @@ export default { }, methods: { ...mapActions({ - setShowTasksDistributeDialog: 'setShowTasksDistributeDialog', + setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog', loadCourseUnits: 'loadCourseUnits', loadUserUnits: 'loadUserUnits', loadStructuralElement: 'courseware-structural-elements/loadById', @@ -522,10 +531,21 @@ export default { return; } this.distributing = true; + const startDate = new Date(this.startDate); + startDate.setHours(0); + startDate.setMinutes(0); + startDate.setSeconds(0); + startDate.setMilliseconds(0); + const endDate = new Date(this.endDate); + endDate.setHours(23); + endDate.setMinutes(59); + endDate.setSeconds(59); + endDate.setMilliseconds(999); const taskGroup = { attributes: { title: this.taskTitle, - 'submission-date': new Date(this.submissionDate).toISOString(), + 'start-date': startDate.toISOString(), + 'end-date': endDate.toISOString(), 'solver-may-add-blocks': this.solverMayAddBlocks, }, relationships: { @@ -560,7 +580,7 @@ export default { this.$emit('newtask'); this.distributing = false; this.setShowTasksDistributeDialog(false); - + }, validateSolvers() { if ( @@ -575,7 +595,7 @@ export default { return this.wizardSlots[5].valid; }, validateTaskSettings() { - if (this.taskTitle !== '' && this.submissionDate !== '') { + if (this.taskTitle !== '' && this.endDate !== '') { this.wizardSlots[2].valid = true; } else { this.wizardSlots[2].valid = false; @@ -651,7 +671,14 @@ export default { taskTitle() { this.validate(); }, - submissionDate() { + startDate() { + if (new Date(this.startDate) > new Date(this.endDate)) { + const endDate = new Date(this.startDate); + endDate.setDate(endDate.getDate() + 1); + this.endDate = dateString(endDate); + } + }, + endDate() { this.validate(); }, selectedAutors() { diff --git a/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..a07356dfc68d186a676cbbb31fc78bbd711d13ca --- /dev/null +++ b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue @@ -0,0 +1,60 @@ +<template> + <studip-dialog + :title="$gettext('Feedback zur Aufgabe ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + @close="$emit('close')" + @confirm="update" + > + <template #dialogContent> + <CompanionBox + v-if="localContent === ''" + mood="pointing" + :msgCompanion=" + $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!') + " + /> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Feedback') }} + <textarea v-model="localContent" /> + </label> + </form> + </template> + </studip-dialog> +</template> + +<script> +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; + +export default { + props: ['content'], + components: { + CompanionBox, + }, + data: () => ({ + localContent: '', + }), + methods: { + resetLocalVars() { + this.localContent = this.content; + }, + update() { + this.$emit('update', { content: this.localContent }); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + content(newValue) { + if (newValue !== this.localContent) { + this.resetLocalVars(); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/TasksApp.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue similarity index 70% rename from resources/vue/components/courseware/TasksApp.vue rename to resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue index 8a406d8086407df5cd567c38e86aca1c79f8c3a5..5701580d4894e20790de9294ce407a6ceb0f6f43 100644 --- a/resources/vue/components/courseware/TasksApp.vue +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue @@ -1,21 +1,21 @@ <template> <div class="cw-tasks-wrapper"> <div class="cw-tasks-list"> - <courseware-dashboard-students v-if="userIsTeacher" /> - <courseware-dashboard-tasks v-else /> + <CoursewareDashboardStudents v-if="userIsTeacher" /> + <CoursewareDashboardTasks v-else /> </div> <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> - <courseware-tasks-action-widget /> + <CoursewareTasksActionWidget /> </MountingPortal> <courseware-companion-overlay /> </div> </template> <script> -import CoursewareTasksActionWidget from './widgets/CoursewareTasksActionWidget.vue'; +import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; import CoursewareDashboardTasks from './CoursewareDashboardTasks.vue'; import CoursewareDashboardStudents from './CoursewareDashboardStudents.vue'; -import CoursewareCompanionOverlay from './layouts/CoursewareCompanionOverlay.vue'; +import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue'; import { mapGetters } from 'vuex'; export default { diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue new file mode 100644 index 0000000000000000000000000000000000000000..e17d18e60a4701d77f26ac69c0072fb5ee7e40e3 --- /dev/null +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue @@ -0,0 +1,224 @@ +<template> + <div class="cw-tasks-wrapper"> + <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> + <CoursewareTasksActionWidget :taskGroup="taskGroup" /> + </MountingPortal> + + <div v-if="taskGroup" class="cw-tasks-list"> + <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> + <template #buttons> + <router-link :to="{ name: 'task-groups-index' }"> + <StudipIcon shape="category-task" :size="24" /> + </router-link> + </template> + <template #breadcrumbList> + <li> + <router-link :to="{ name: 'task-groups-index' }"> + {{ $gettext('Aufgaben') }} + </router-link> + </li> + <li>{{ taskGroup.attributes['title'] }}</li> + </template> + </CoursewareRibbon> + + <TaskGroup + :taskGroup="taskGroup" + :tasks="tasksByGroup[taskGroup.id]" + @add-feedback="onShowAddFeedback" + @edit-feedback="onShowEditFeedback" + @solve-renewal="onShowSolveRenewal" + /> + </div> + <CompanionBox + v-else-if="!tasksLoading" + :msgCompanion="$gettext('Diese Courseware-Aufgabe konnte nicht gefunden werden.')" + /> + + <AddFeedbackDialog + v-if="showAddFeedbackDialog" + :content="currentDialogFeedback.attributes.content" + @create="createFeedback" + @close="closeDialogs" + /> + + <EditFeedbackDialog + v-if="showEditFeedbackDialog" + :content="currentDialogFeedback.attributes.content" + @update="updateFeedback" + @close="closeDialogs" + /> + + <RenewalDialog + v-if="renewalTask" + :renewalDate="renewalDate" + :renewalState="renewalTask.attributes.renewal" + @update="updateRenewal" + @close="closeDialogs" + /> + + <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" /> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import AddFeedbackDialog from './AddFeedbackDialog.vue'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; +import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; +import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; +import EditFeedbackDialog from './EditFeedbackDialog.vue'; +import RenewalDialog from './RenewalDialog.vue'; +import TaskGroup from './TaskGroup.vue'; +import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; +import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; +import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; + +export default { + components: { + AddFeedbackDialog, + CompanionBox, + CoursewareRibbon, + CoursewareTasksActionWidget, + CoursewareTasksDialogDistribute, + EditFeedbackDialog, + RenewalDialog, + TaskGroup, + TaskGroupsAddSolversDialog, + TaskGroupsDeleteDialog, + TaskGroupsModifyDeadlineDialog, + }, + props: ['id'], + data() { + return { + currentDialogFeedback: {}, + renewalTask: null, + showAddFeedbackDialog: false, + showEditFeedbackDialog: false, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + getTaskGroup: 'courseware-task-groups/byId', + showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog', + showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog', + showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog', + showTasksDistributeDialog: 'tasks/showTasksDistributeDialog', + tasksByCid: 'tasks/tasksByCid', + tasksLoading: 'courseware-tasks/isLoading', + userIsTeacher: 'userIsTeacher', + }), + renewalDate() { + return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date(); + }, + taskGroup() { + return this.getTaskGroup({ id: this.id }); + }, + tasksByGroup() { + return this.tasksByCid(this.context.id).reduce((memo, task) => { + const key = task.relationships['task-group'].data.id; + (memo[key] || (memo[key] = [])).push(task); + + return memo; + }, {}); + }, + }, + methods: { + ...mapActions({ + companionError: 'companionError', + companionSuccess: 'companionSuccess', + createTaskFeedback: 'createTaskFeedback', + deleteTaskFeedback: 'deleteTaskFeedback', + loadAllTasks: 'courseware-tasks/loadAll', + loadTaskGroup: 'tasks/loadTaskGroup', + updateTask: 'updateTask', + updateTaskFeedback: 'updateTaskFeedback', + }), + closeDialogs() { + this.showAddFeedbackDialog = false; + this.showEditFeedbackDialog = false; + + this.currentDialogFeedback = {}; + this.renewalTask = null; + }, + createFeedback({ content }) { + if (content === '') { + this.companionError({ + info: this.$gettext('Bitte schreiben Sie ein Feedback.'), + }); + return false; + } + this.currentDialogFeedback.attributes.content = content; + this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback }); + this.closeDialogs(); + }, + onShowAddFeedback(task) { + this.currentDialogFeedback = { + attributes: { content: '' }, + relationships: { + task: { + data: { + id: task.id, + type: task.type, + }, + }, + }, + }; + this.showAddFeedbackDialog = true; + }, + onShowEditFeedback(feedback) { + this.currentDialogFeedback = _.cloneDeep(feedback); + this.showEditFeedbackDialog = true; + }, + onShowSolveRenewal(task) { + this.renewalTask = _.cloneDeep(task); + this.renewalTask.attributes['renewal-date'] = new Date().toISOString(); + }, + reloadTasks() { + this.loadAllTasks({ + options: { + 'filter[cid]': this.context.id, + include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + }, + }); + }, + updateRenewal({ state, date }) { + const attributes = { renewal: state }; + if (date) { + attributes['renewal-date'] = date.toISOString(); + } + + this.updateTask({ attributes, taskId: this.renewalTask.id }); + this.closeDialogs(); + }, + async updateFeedback({ content }) { + if (content === '') { + await this.deleteTaskFeedback({ taskFeedbackId: this.currentDialogFeedback.id }); + this.companionSuccess({ info: this.$gettext('Feedback wurde gelöscht.') }); + } else { + await this.updateTaskFeedback({ + attributes: { content }, + taskFeedbackId: this.currentDialogFeedback.id, + }); + this.companionSuccess({ + info: this.$gettext('Feedback wurde gespeichert.'), + }); + } + this.closeDialogs(); + }, + }, +}; +</script> + +<style scoped> +.cw-tasks-wrapper >>> .cw-ribbon-nav { + min-width: 24px; + padding: 0 1em; + height: 24px; + margin-top: 2px; +} +</style> diff --git a/resources/vue/components/courseware/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..f08719e22d2d13d17f059a13298545e0052334f5 --- /dev/null +++ b/resources/vue/components/courseware/tasks/RenewalDialog.vue @@ -0,0 +1,79 @@ +<template> + <studip-dialog + :title="$gettext('Verlängerungsanfrage bearbeiten')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="350" + @close="$emit('close')" + @confirm="updateRenewal" + > + <template #dialogContent> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Fristverlängerung') }} + <select v-model="state"> + <option value="declined"> + {{ $gettext('ablehnen') }} + </option> + <option value="granted"> + {{ $gettext('gewähren') }} + </option> + </select> + </label> + <label v-if="state === 'granted'"> + {{ $gettext('neue Frist') }} + <DateInput v-model="date" class="size-l" /> + </label> + </form> + </template> + </studip-dialog> +</template> + +<script> +import DateInput from '../layouts/CoursewareDateInput.vue'; +export default { + props: ['renewalDate', 'renewalState'], + components: { + DateInput, + }, + data: () => ({ + date: null, + state: null, + }), + methods: { + resetLocalVars() { + this.date = this.renewalDate ?? null; + this.state = this.renewalState; + }, + updateRenewal() { + const date = new Date(this.date); + date.setHours(23); + date.setMinutes(59); + date.setSeconds(59); + date.setMilliseconds(999); + + this.$emit('update', { + state: this.state, + date: this.state === 'granted' ? date || Date.now() : null, + }); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + renewalDate(newValue) { + if (newValue !== this.date) { + this.resetLocalVars(); + } + }, + renewalState(newValue) { + if (newValue !== this.state) { + this.resetLocalVars(); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..62449f1c03533044faf0dea0f94c1424f4323d4a --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroup.vue @@ -0,0 +1,84 @@ +<template> + <div> + <CompanionBox :msgCompanion="statusMessage"> + <template #companionActions> + <span> + {{ $gettext('Bearbeitungszeit') }} + <StudipDate :date="startDate" /> - <StudipDate :date="endDate" /> + </span> + </template> + </CompanionBox> + + <section v-if="tasks.length > 0"> + <table class="default"> + <caption> + {{ $gettext('Verteilte Aufgaben') }} + </caption> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Teilnehmende/Gruppen') }}</th> + <th class="responsive-hidden">{{ $gettext('Seite') }}</th> + <th>{{ $gettext('bearbeitet') }}</th> + <th>{{ $gettext('Abgabefrist') }}</th> + <th>{{ $gettext('Abgabe') }}</th> + <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th> + <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th> + </tr> + </thead> + <tbody> + <TaskItem + v-for="task in tasks" + :task="task" + :taskGroup="taskGroup" + :key="task.id" + @add-feedback="(task) => $emit('add-feedback', task)" + @edit-feedback="(feedback) => $emit('edit-feedback', feedback)" + @solve-renewal="(task) => $emit('solve-renewal', task)" + /> + </tbody> + </table> + </section> + <div v-else> + <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" /> + </div> + </div> +</template> + +<script> +import { mapGetters } from 'vuex'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import StudipDate from '../../StudipDate.vue'; +import TaskItem from './TaskGroupTaskItem.vue'; +import { getStatus } from './task-groups-helper.js'; + +export default { + components: { CompanionBox, StudipDate, TaskItem }, + props: ['taskGroup', 'tasks'], + computed: { + ...mapGetters({ + coursewareContext: 'context', + }), + actionMenuContext() { + return this.$gettextInterpolate(this.$gettext('Courseware-Aufgabe "%{ taskGroup }"'), { + taskGroup: this.taskGroup.attributes.title, + }); + }, + endDate() { + return new Date(this.taskGroup.attributes['end-date']); + }, + isAfter() { + return new Date() > this.endDate; + }, + startDate() { + return new Date(this.taskGroup.attributes['start-date']); + }, + status() { + return getStatus(this.taskGroup); + }, + statusMessage() { + return this.status.description; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..b684f105b658596ab7c7aaab90f13936aaabfce0 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue @@ -0,0 +1,118 @@ +<template> + <tr> + <td> + <studip-icon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + aria-hidden="true" + /> + <span class="sr-only">{{ status.description }}</span> + </td> + <td> + <span v-if="user"> + <studip-icon shape="person2" role="info" aria-hidden="true" :title="$gettext('Teilnehmende Person')" /> + <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span> + {{ user.attributes['formatted-name'] }} + </span> + <span v-if="group"> + <studip-icon shape="group2" role="info" aria-hidden="true" :title="$gettext('Gruppe')" /> + <span class="sr-only">{{ $gettext('Gruppe') }}</span> + {{ group.attributes['name'] }} + </span> + </td> + <td class="responsive-hidden"> + <a v-if="task.attributes.submitted" :href="getLinkToElement(element)"> + {{ element.attributes.title }} + </a> + <span v-else>{{ element.attributes.title }}</span> + </td> + <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</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"> + <button v-show="task.attributes.renewal === 'pending'" class="button" @click="$emit('solve-renewal', task)"> + {{ $gettext('Anfrage bearbeiten') }} + </button> + <span v-show="task.attributes.renewal === 'declined'"> + <studip-icon shape="decline" role="status-red" /> + {{ $gettext('Anfrage abgelehnt') }} + </span> + <span v-show="task.attributes.renewal === 'granted'"> + {{ $gettext('verlängert bis') }}: + {{ getReadableDate(task.attributes['renewal-date']) }} + </span> + <studip-icon + v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'" + :title="$gettext('Anfrage bearbeiten')" + class="edit" + shape="edit" + @click="$emit('solve-renewal', task)" + /> + </td> + <td class="responsive-hidden"> + <span + v-if="feedback" + :title=" + $gettextInterpolate($gettext('Feedback geschrieben am: %{ date }'), { + date: getReadableDate(feedback.attributes['chdate']), + }) + " + > + <studip-icon shape="accept" role="status-green" /> + {{ $gettext('Feedback gegeben') }} + <studip-icon + :title="$gettext('Feedback bearbeiten')" + class="edit" + shape="edit" + @click="$emit('edit-feedback', feedback)" + /> + </span> + + <button v-show="!feedback && task.attributes.submitted" class="button" @click="$emit('add-feedback', task)"> + {{ $gettext('Feedback geben') }} + </button> + </td> + </tr> +</template> +<script> +import taskHelper from '../../../mixins/courseware/task-helper.js'; +import { mapGetters } from 'vuex'; + +export default { + mixins: [taskHelper], + props: ['task', 'taskGroup'], + computed: { + ...mapGetters({ + elementById: 'courseware-structural-elements/byId', + feedbackById: 'courseware-task-feedback/byId', + statusGroupById: 'status-groups/byId', + userById: 'users/byId', + }), + element() { + return this.elementById({ id: this.task.relationships['structural-element'].data.id }); + }, + feedback() { + const id = this.task.relationships['task-feedback'].data?.id; + return id ? this.feedbackById({ id }) : null; + }, + group() { + const { id, type } = this.solver; + return type === 'status-groups' ? this.statusGroupById({ id }) : null; + }, + solver() { + return this.task.relationships.solver.data; + }, + status() { + return this.getStatus(this.task); + }, + user() { + const { id, type } = this.solver; + return type === 'users' ? this.userById({ id }) : null; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..a84334169a23cef4ea6782bca765defd6fc510e9 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue @@ -0,0 +1,224 @@ +<template> + <studip-dialog + :title="$gettext('Teilnehmende hinzufügen')" + :confirmText="$gettext('Hinzufügen')" + confirmClass="accept" + :confirmDisabled="!taskSolverType" + :closeText="$gettext('Abbrechen')" + closeClass="cancel" + @close="onClose" + @confirm="onConfirm" + width="700" + > + <template #dialogContent> + <form class="default"> + <label> + {{ $gettext('Verteilen an') }} + <select v-model="taskSolverType"> + <option value="users">{{ $gettext('Studierende') }}</option> + <option value="status-groups">{{ $gettext('Gruppen') }}</option> + </select> + </label> + + <template v-if="taskSolverType === 'users'"> + <CoursewareCompanion + v-show="autor_members.length === 0" + :msgCompanion="$gettext('Es wurden keine Studierenden in dieser Veranstaltung gefunden.')" + mood="pointing" + /> + <table v-show="autor_members.length > 0" class="default"> + <thead> + <tr> + <th></th> + <th>{{ $gettext('Name') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="user in autor_members" :key="user.user_id"> + <td> + <input + type="checkbox" + v-model="selectedAutors" + :disabled="isSolver(user.user_id)" + :value="user.user_id" + :aria-label=" + $gettextInterpolate($gettext('%{userName} auswählen'), { + userName: user.formattedname, + }) + " + /> + </td> + <td>{{ user.formattedname }}</td> + </tr> + </tbody> + </table> + </template> + <template v-if="taskSolverType === 'status-groups'"> + <CoursewareCompanion + v-show="groups.length === 0" + :msgCompanion="$gettext('Es wurden keine Gruppen in dieser Veranstaltung gefunden.')" + mood="pointing" + /> + <table v-show="groups.length > 0" class="default"> + <thead> + <tr> + <th></th> + <th>{{ $gettext('Gruppenname') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="group in groups" :key="group.id"> + <td> + <input + type="checkbox" + v-model="selectedGroups" + :disabled="isSolver(group.id)" + :value="group.id" + :aria-label=" + $gettextInterpolate($gettext('%{groupName} auswählen'), { + groupName: group.name, + }) + " + /> + </td> + <td>{{ group.name }}</td> + </tr> + </tbody> + </table> + </template> + </form> + </template> + </studip-dialog> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import CoursewareCompanion from '../layouts/CoursewareCompanionBox.vue'; + +export default { + props: ['taskGroup'], + components: { + CoursewareCompanion, + }, + data: () => ({ + selectedAutors: [], + selectedGroups: [], + storing: false, + taskSolverType: null, + }), + computed: { + ...mapGetters({ + context: 'context', + relatedCourseMemberships: 'course-memberships/related', + relatedCourseStatusGroups: 'status-groups/related', + relatedUser: 'users/related', + tasksByCid: 'tasks/tasksByCid', + }), + autor_members() { + return Object.keys(this.users).length === 0 && this.users.constructor === Object + ? [] + : this.users.filter(({ perm }) => perm === 'autor').map((obj) => ({ ...obj, active: false })); + }, + groups() { + return ( + this.relatedCourseStatusGroups({ + parent: { type: 'courses', id: this.context.id }, + relationship: 'status-groups', + })?.map(({ id, attributes: { name } }) => ({ id, name })) ?? [] + ); + }, + solversById() { + return new Map(this.solvers.map(({ id, type }) => [id, { id, type }])); + }, + solvers() { + return this.tasks.map((task) => task.relationships.solver.data); + }, + tasks() { + return this.tasksByCid(this.context.id).filter( + (task) => task.relationships['task-group'].data.id === this.taskGroup.id + ); + }, + users() { + const memberships = this.relatedCourseMemberships({ + parent: { type: 'courses', id: this.context.id }, + relationship: 'memberships', + }); + + return ( + memberships?.map(({ type, id, attributes: { permission } }) => { + const member = this.relatedUser({ parent: { type, id }, relationship: 'user' }); + + return { + user_id: member.id, + formattedname: member.attributes['formatted-name'], + username: member.attributes['username'], + perm: permission, + }; + }) ?? [] + ); + }, + }, + methods: { + ...mapActions({ + addSolversToTaskGroup: 'tasks/addSolversToTaskGroup', + loadCourseMemberships: 'course-memberships/loadRelated', + loadCourseStatusGroups: 'status-groups/loadRelated', + setShowDialog: 'tasks/setShowTaskGroupsAddSolversDialog', + }), + isSolver(id) { + return !!this.solvers.find((solver) => solver.id === id); + }, + onClose() { + this.setShowDialog(false); + }, + onConfirm() { + if (!this.taskSolverType || this.storing) { + return; + } + this.storing = true; + + const solvers = this[this.taskSolverType === 'users' ? 'selectedAutors' : 'selectedGroups']; + const ids = solvers.filter((id) => !this.solversById.has(id)); + this.addSolversToTaskGroup({ + taskGroup: this.taskGroup, + solvers: ids.map((id) => ({ id, type: this.taskSolverType })), + }) + .then(() => { + this.$emit('newtask'); + this.onClose(); + }) + .finally(() => (this.storing = false)); + }, + resetLocalVars() { + this.selectedAutors = this.solvers.filter(({ type }) => type === 'users').map(({ id }) => id); + this.selectedGroups = this.solvers.filter(({ type }) => type === 'status-groups').map(({ id }) => id); + this.taskSolverType = this.selectedAutors.length + ? 'users' + : this.selectedGroups.length + ? 'status-groups' + : null; + }, + }, + mounted() { + this.resetLocalVars(); + + const parent = { type: 'courses', id: this.context.id }; + this.loadCourseMemberships({ + parent, + relationship: 'memberships', + options: { + include: 'user', + 'page[offset]': 0, + 'page[limit]': 10000, + 'filter[permission]': 'autor', + }, + }); + this.loadCourseStatusGroups({ parent, relationship: 'status-groups' }); + }, + watch: { + taskGroup() { + this.resetLocalVars(); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..b1a151d8d96dc2af0f7ab102b23579deb5ded01d --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue @@ -0,0 +1,33 @@ +<template> + <studip-dialog + :title="$gettext('Aufgabe löschen')" + :question="$gettext('Möchten Sie die Aufgabe wirklich löschen?')" + height="200" + @close="onClose" + @confirm="onConfirm" + > + </studip-dialog> +</template> + +<script> +import { mapActions } from 'vuex'; + +export default { + props: ['taskGroup'], + methods: { + ...mapActions({ + deleteTaskGroup: 'courseware-task-groups/delete', + setShowTaskGroupsDeleteDialog: 'tasks/setShowTaskGroupsDeleteDialog' + }), + onClose() { + this.setShowTaskGroupsDeleteDialog(false); + }, + onConfirm() { + this.deleteTaskGroup(this.taskGroup).then(() => { + this.onClose(); + this.$router.push({ name: 'task-groups-index' }); + }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..39198af5965358d45db02ae25cf5a9b4e44003a9 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue @@ -0,0 +1,117 @@ +<template> + <studip-dialog + :title="$gettext('Bearbeitungszeit verlängern')" + :confirmText="$gettext('Verlängern')" + confirmClass="accept" + :closeText="$gettext('Abbrechen')" + 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"> + <label class="studiprequired"> + <span class="textlabel">{{ $gettext('Bearbeitungszeit verlängern bis zum') }}</span> + <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span> + <input + :id="`task-groups-${uid}`" + name="end-date" + type="date" + v-model="localEndDate" + :min="endDateString" + class="size-l" + required + /> + </label> + </div> + <p> + {{ $gettext('Verlängerte Bearbeitungszeit:') }} <StudipDate :date="startDate" /> - <StudipDate + :date="newEndDate" + /> + ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }}) + </p> + </form> + </template> + </studip-dialog> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +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); + return date; +}; + +const dateString = (date) => + `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`; + +let nextUid = 0; + +export default { + props: ['taskGroup'], + components: { + StudipDate, + }, + data: () => ({ localEndDate: null, uid: nextUid++ }), + computed: { + endDate() { + return midnight(this.taskGroup?.attributes?.['end-date'] ?? new Date()); + }, + endDateString() { + return dateString(this.endDate); + }, + newDuration() { + return this.localEndDate + ? Math.floor((midnight(this.localEndDate) - this.startDate) / (1000 * 60 * 60 * 24)) + : 0; + }, + newEndDate() { + return this.localEndDate ? midnight(this.localEndDate) : this.endDate; + }, + oldDuration() { + return Math.floor((this.endDate - this.startDate) / (1000 * 60 * 60 * 24)); + }, + startDate() { + return midnight(this.taskGroup.attributes['start-date']); + }, + }, + methods: { + ...mapActions({ + modifyDeadline: 'tasks/modifyDeadlineOfTaskGroup', + setShowDialog: 'tasks/setShowTaskGroupsModifyDeadlineDialog', + }), + onClose() { + this.setShowDialog(false); + }, + onConfirm() { + const endDate = midnight(this.localEndDate); + this.modifyDeadline({ taskGroup: this.taskGroup, endDate }); + this.onClose(); + }, + resetLocalVars() { + this.localEndDate = dateString(this.endDate ?? new Date()); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + taskGroup() { + this.resetLocalVars(); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/task-groups-helper.js b/resources/vue/components/courseware/tasks/task-groups-helper.js new file mode 100644 index 0000000000000000000000000000000000000000..8a9e46972298b7b94d653eba984860bb56ba94fc --- /dev/null +++ b/resources/vue/components/courseware/tasks/task-groups-helper.js @@ -0,0 +1,31 @@ +import { $gettext } from '../../../../assets/javascripts/lib/gettext'; + +export function getStatus(taskGroup) { + const now = new Date(); + const startDate = new Date(taskGroup.attributes['start-date']); + const endDate = new Date(taskGroup.attributes['end-date']); + + if (startDate <= now && now <= endDate) { + return { + shape: 'span-3quarter', + role: 'status-green', + description: $gettext('Die Bearbeitungszeit hat begonnen.'), + }; + } + + if (now < startDate) { + return { + shape: 'span-empty', + role: 'status-yellow', + description: $gettext('Die Bearbeitungszeit hat noch nicht begonnen.'), + }; + } + + if (endDate < now) { + return { + shape: 'span-full', + role: 'status-red', + description: $gettext('Die Bearbeitungszeit ist beendet.'), + }; + } +} diff --git a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue index c2f54e9171daca7b0658070ba4bf0d4e0435f1a7..cf37c6f6861f4dfbea61328c6c827564681c4d72 100644 --- a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue +++ b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue @@ -2,7 +2,24 @@ <sidebar-widget id="courseware-action-widget" :title="$gettext('Aktionen')"> <template #content> <ul class="widget-list widget-links cw-action-widget"> - <li class="cw-action-widget-add"> + <template v-if="taskGroup"> + <li v-if="isBeforeEndDate" class="cw-action-widget-task-groups-deadline"> + <button @click="modifyDeadline(taskGroup)"> + {{ $gettext('Bearbeitungszeit verlängern') }} + </button> + </li> + <li v-if="isBeforeEndDate" class="cw-action-widget-task-groups-add-solvers"> + <button @click="addSolvers(taskGroup)"> + {{ $gettext('Teilnehmende hinzufügen') }} + </button> + </li> + <li class="cw-action-widget-task-groups-delete"> + <button @click="deleteTaskGroup(taskGroup)"> + {{ $gettext('Aufgabe löschen') }} + </button> + </li> + </template> + <li v-else class="cw-action-widget-add"> <button @click="setShowTasksDistributeDialog(true)"> {{ $gettext('Aufgabe verteilen') }} </button> @@ -22,10 +39,34 @@ export default { components: { SidebarWidget, }, + props: ['taskGroup'], + computed: { + isBeforeEndDate() { + return this.taskGroup && new Date() < new Date(this.taskGroup.attributes['end-date']); + }, + }, methods: { ...mapActions({ - setShowTasksDistributeDialog: 'setShowTasksDistributeDialog', + addSolvers: 'tasks/setShowTaskGroupsAddSolversDialog', + deleteTaskGroup: 'tasks/setShowTaskGroupsDeleteDialog', + modifyDeadline: 'tasks/setShowTaskGroupsModifyDeadlineDialog', + setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog', }), - } + }, +}; +</script> + +<style scoped> +.cw-action-widget-task-groups-add-solvers { + background-image: url('../images/icons/blue/add.svg'); + background-size: 16px; +} +.cw-action-widget-task-groups-deadline { + background-image: url('../images/icons/blue/date.svg'); + background-size: 16px; +} +.cw-action-widget-task-groups-delete { + background-image: url('../images/icons/blue/trash.svg'); + background-size: 16px; } -</script> \ No newline at end of file +</style> diff --git a/resources/vue/components/stock-images/colors.js b/resources/vue/components/stock-images/colors.js index 4ba138cdea02386d1082f30cfec6ae54bb7e444c..910b1435d308de8fa209e5c3d0d618a166f4e2e7 100644 --- a/resources/vue/components/stock-images/colors.js +++ b/resources/vue/components/stock-images/colors.js @@ -1,4 +1,4 @@ -import { $gettext } from '@/assets/javascripts/lib/gettext.js'; +import { $gettext } from '@/assets/javascripts/lib/gettext'; const colors = [ { name: $gettext('Schwarz'), hex: '#000000' }, diff --git a/resources/vue/components/stock-images/filters.js b/resources/vue/components/stock-images/filters.js index 55cf72697baa42b1f8ea5613c5e8913a8c49121e..42de27dc6fc346f817271d885b8ab65d6165c170 100644 --- a/resources/vue/components/stock-images/filters.js +++ b/resources/vue/components/stock-images/filters.js @@ -1,4 +1,4 @@ -import { $gettext } from '@/assets/javascripts/lib/gettext.js'; +import { $gettext } from '@/assets/javascripts/lib/gettext'; import { fromHex, rgbToCIELab, cie94 } from 'colorpare'; const SQUARE_DELTA = 1.1; diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index 191385f47c3bc944e3681a1b73f2193a31a7f06f..59c0ebca22535a3af35e73c175bf8e8c9178785a 100644 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -2,6 +2,7 @@ import CoursewareModule from './store/courseware/courseware.module'; import CoursewareStructureModule from './store/courseware/structure.module'; import FileChooserStore from './store/file-chooser.js'; import CoursewareStructuralElement from './components/courseware/structural-element/CoursewareStructuralElement.vue'; +import CoursewareTasksModule from './store/courseware/courseware-tasks.module'; import IndexApp from './components/courseware/IndexApp.vue'; import PluginManager from './components/courseware/plugin-manager.js'; import Vue from 'vue'; @@ -89,6 +90,7 @@ const mountApp = async (STUDIP, createApp, element) => { courseware: CoursewareModule, 'courseware-structure': CoursewareStructureModule, 'file-chooser': FileChooserStore, + 'tasks': CoursewareTasksModule, ...mapResourceModules({ names: [ 'courses', diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js index 2f332466d796aba116efa98372a04ae9ba3883a9..9c01b7190f22bad0b06bdf078bf1b5a6aefcc83a 100644 --- a/resources/vue/courseware-tasks-app.js +++ b/resources/vue/courseware-tasks-app.js @@ -1,5 +1,7 @@ -import TasksApp from './components/courseware/TasksApp.vue'; +import TaskGroupsIndex from './components/courseware/tasks/PagesTaskGroupsIndex.vue'; +import TaskGroupsShow from './components/courseware/tasks/PagesTaskGroupsShow.vue'; import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import VueRouter, { RouterView } from 'vue-router'; import Vuex from 'vuex'; import CoursewareModule from './store/courseware/courseware.module'; import CoursewareTasksModule from './store/courseware/courseware-tasks.module'; @@ -17,6 +19,40 @@ const mountApp = async (STUDIP, createApp, element) => { const httpClient = getHttpClient(); + const routes = [ + { + path: '/', + name: 'task-groups-index', + component: TaskGroupsIndex, + }, + { + path: '/task-groups/:id', + name: 'task-groups-show', + component: TaskGroupsShow, + props: true, + }, + ]; + + const base = new URL( + window.STUDIP.URLHelper.getURL( + 'dispatch.php/course/courseware/tasks', + { cid: STUDIP.URLHelper.parameters.cid }, + true + ) + ); + const router = new VueRouter({ + base: base.pathname, + mode: 'history', + routes, + }); + router.beforeEach((to, from, next) => { + if ('cid' in to?.query) { + next(); + } else { + next({ ...to, query: { ...to.query, cid: window.STUDIP.URLHelper.parameters.cid } }); + } + }); + const store = new Vuex.Store({ modules: { courseware: CoursewareModule, @@ -71,22 +107,18 @@ const mountApp = async (STUDIP, createApp, element) => { } store.dispatch('setUserId', STUDIP.USER_ID); - await store.dispatch('users/loadById', {id: STUDIP.USER_ID}); + await store.dispatch('users/loadById', { id: STUDIP.USER_ID }); store.dispatch('setHttpClient', httpClient); store.dispatch('coursewareContext', { id: entry_id, type: entry_type, }); await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); - store.dispatch('courseware-tasks/loadAll', { - options: { - 'filter[cid]': entry_id, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', - }, - }); + await store.dispatch('tasks/loadTasksOfCourse', { cid: entry_id }); const app = createApp({ - render: (h) => h(TasksApp), + render: (h) => h(RouterView), + router, store, }); diff --git a/resources/vue/mixins/courseware/task-helper.js b/resources/vue/mixins/courseware/task-helper.js index 0bc694c12260b0e4449ae385a526f16154cf4b0c..a0510f7697dd4433f9d6cf965b03a5d21a55cf14 100644 --- a/resources/vue/mixins/courseware/task-helper.js +++ b/resources/vue/mixins/courseware/task-helper.js @@ -8,7 +8,7 @@ export default { limit.setDate(now.getDate() + 3); status.canSubmit = true; - if (now < submissionDate) { + if (now <= submissionDate) { status.shape = 'span-empty'; status.role = 'status-green'; status.description = this.$gettext('Aufgabe bereit'); @@ -20,7 +20,7 @@ export default { status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); } - if (now >= submissionDate) { + if (now > submissionDate) { status.canSubmit = false; status.shape = 'span-full'; status.role = 'status-red'; @@ -34,7 +34,7 @@ export default { status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); } - if (now >= renewalDate) { + if (now > renewalDate) { status.canSubmit = false; status.shape = 'span-full'; status.role = 'status-red'; diff --git a/resources/vue/store/AdminCoursesStore.js b/resources/vue/store/AdminCoursesStore.js index 5b20e7013cbcb9641dcd3823db2510e7ca076672..509239b91ba29455b9710120076735aad34378db 100644 --- a/resources/vue/store/AdminCoursesStore.js +++ b/resources/vue/store/AdminCoursesStore.js @@ -1,5 +1,5 @@ import Screenreader from '../../assets/javascripts/lib/screenreader.js'; -import { $gettext } from '../../assets/javascripts/lib/gettext.js'; +import { $gettext } from '../../assets/javascripts/lib/gettext'; export default { namespaced: true, diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js index fd5152dfa839f569e6ec2ed62f93372056ad25f5..06224524dadabeee524c4c1b8398e1fe01b8ae00 100644 --- a/resources/vue/store/courseware/courseware-tasks.module.js +++ b/resources/vue/store/courseware/courseware-tasks.module.js @@ -1,5 +1,8 @@ const getDefaultState = () => { return { + showTaskGroupsAddSolversDialog: false, + showTaskGroupsDeleteDialog: false, + showTaskGroupsModifyDeadlineDialog: false, showTasksDistributeDialog: false, }; }; @@ -7,29 +10,99 @@ const getDefaultState = () => { const initialState = getDefaultState(); const getters = { + showTaskGroupsAddSolversDialog(state) { + return state.showTaskGroupsAddSolversDialog; + }, + showTaskGroupsDeleteDialog(state) { + return state.showTaskGroupsDeleteDialog; + }, + showTaskGroupsModifyDeadlineDialog(state) { + return state.showTaskGroupsModifyDeadlineDialog; + }, showTasksDistributeDialog(state) { return state.showTasksDistributeDialog; }, + taskGroupsByCid(state, getters, rootState, rootGetters) { + return (cid) => { + return rootGetters['courseware-task-groups/all'].filter( + (taskGroup) => taskGroup.relationships.course.data.id === cid + ); + }; + }, + tasksByCid(state, getters, rootState, rootGetters) { + return (cid) => { + const taskGroupIds = getters.taskGroupsByCid(cid).map(({ id }) => id); + + return rootGetters['courseware-tasks/all'].filter((task) => + taskGroupIds.includes(task.relationships['task-group'].data.id) + ); + }; + }, }; export const state = { ...initialState }; export const actions = { // setters + setShowTaskGroupsAddSolversDialog({ commit }, context) { + commit('setShowTaskGroupsAddSolversDialog', context); + }, + setShowTaskGroupsDeleteDialog({ commit }, context) { + commit('setShowTaskGroupsDeleteDialog', context); + }, + setShowTaskGroupsModifyDeadlineDialog({ commit }, context) { + commit('setShowTaskGroupsModifyDeadlineDialog', context); + }, setShowTasksDistributeDialog({ commit }, context) { commit('setShowTasksDistributeDialog', context); }, // other actions + loadTasksOfCourse({ dispatch }, { cid }) { + const options = { + 'filter[cid]': cid, + include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + }; + return dispatch('courseware-tasks/loadAll', { options }, { root: true }); + }, + + loadTaskGroup({ dispatch }, { id }) { + const options = { + include: 'lecturer', + }; + return dispatch('courseware-task-groups/loadById', { id, options }, { root: true }); + }, + + modifyDeadlineOfTaskGroup({ dispatch }, { taskGroup, endDate }) { + taskGroup.attributes['end-date'] = endDate.toISOString(); + + return dispatch('courseware-task-groups/update', taskGroup, { root: true }); + }, + + addSolversToTaskGroup({ dispatch, rootGetters }, { taskGroup, solvers }) { + return rootGetters.httpClient.post(`courseware-task-groups/${+taskGroup.id}/relationships/solvers`, { + data: solvers, + }); + }, }; export const mutations = { - setShowTasksDistributeDialog(state, data){ + setShowTaskGroupsAddSolversDialog(state, data) { + state.showTaskGroupsAddSolversDialog = data; + }, + setShowTasksDistributeDialog(state, data) { state.showTasksDistributeDialog = data; }, + setShowTaskGroupsDeleteDialog(state, data) { + state.showTaskGroupsDeleteDialog = data; + }, + setShowTaskGroupsModifyDeadlineDialog(state, data) { + state.showTaskGroupsModifyDeadlineDialog = data; + }, }; export default { + namespaced: true, state, actions, mutations, diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 8b2f8da0c455937a420273e6c56ecb1a85b3093a..056802f7b8e9e7876c55a9adae0612b7ffc51450 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -575,7 +575,7 @@ export const actions = { element.attributes.commentable = true; const updatedElement = await dispatch('setStructuralElementComments', { element: element }); - + return updatedElement; }, @@ -584,7 +584,7 @@ export const actions = { element.attributes.commentable = false; const updatedElement = await dispatch('setStructuralElementComments', { element: element }); - + return updatedElement; }, @@ -678,7 +678,7 @@ export const actions = { block.attributes.commentable = true; const updatedBlock = await dispatch('setBlockComments', { block: block }); - + return updatedBlock; }, @@ -687,7 +687,7 @@ export const actions = { block.attributes.commentable = false; const updatedBlock = await dispatch('setBlockComments', { block: block }); - + return updatedBlock; }, diff --git a/tsconfig.json b/tsconfig.json index 55b45dc2e0fa2d508c766a69d453be0517c3410f..2ada63c6be2a057f1c10d48d058d1d1f4c1fe37e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { "compilerOptions": { - "target": "es2015", + "allowJs": true, + "module": "es2020", + "moduleResolution": "node", + "resolveJsonModule": true, "strict": true, - "module": "es2015", - "moduleResolution": "node" + "target": "es2020" }, "include": ["resources/**/*.ts", "resources/**/*.vue"], "exclude": ["node_modules"] diff --git a/webpack.common.js b/webpack.common.js index 3dd376d6f9391133a510ff002cb261a3b23ecf2e..8edc9c6b1500bb3f1ba1fc5fb763c86345a4b7ee 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -172,6 +172,7 @@ module.exports = { 'jquery-ui/widgets/resizable': 'jquery-ui/ui/widgets/resizable', '@': path.resolve(__dirname, 'resources') }, + extensions: ['.ts', '.vue', '.js'], fallback: { 'stream': require.resolve("stream-browserify"), 'buffer': require.resolve("buffer/") diff --git a/webpack.dev.js b/webpack.dev.js index c0cee71c597ad4a0339f2a0cf83af1b7c25b5cc0..92fb8a8e147aff31793ed0a29d2803f50d92c3ef 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -1,3 +1,4 @@ +const webpack = require('webpack'); const { merge } = require('webpack-merge'); const common = require('./webpack.common.js'); const WebpackNotifierPlugin = require('webpack-notifier'); @@ -10,8 +11,13 @@ const statusesPaths = { module.exports = merge(common, { mode: 'development', - devtool: 'eval', + devtool: 'eval-cheap-module-source-map', plugins: [ + new webpack.WatchIgnorePlugin({ + paths:[ + /\.d\.[cm]ts$/ + ] + }), new WebpackNotifierPlugin({ appID: 'Stud.IP Webpack', title: function (params) {